From 1bcd8555420c1d461eedf15e7319e268f2df02d4 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 28 Apr 2026 13:29:22 +0800 Subject: [PATCH 01/19] fix: flashcards dialog closed immediately after finishing one card --- src/main/frontend/handler/events/rtc.cljs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/frontend/handler/events/rtc.cljs b/src/main/frontend/handler/events/rtc.cljs index 1b4100a2cc..170483684b 100644 --- a/src/main/frontend/handler/events/rtc.cljs +++ b/src/main/frontend/handler/events/rtc.cljs @@ -12,15 +12,9 @@ [missionary.core :as m] [promesa.core :as p])) -(defn rtc-collaborators-dialog? - [] - (= :rtc-collaborators (state/get-modal-id))) - (defmethod events/handle :rtc/decrypt-user-e2ee-private-key [[_ encrypted-private-key]] (let [private-key-promise (p/deferred) refresh-token (str (state/get-auth-refresh-token))] - (when-not (rtc-collaborators-dialog?) - (shui/dialog-close-all!)) (-> (p/let [{:keys [password]} (state/ Date: Tue, 28 Apr 2026 15:17:55 +0800 Subject: [PATCH 02/19] enhance(plugins): custom block renderer (#12533) * enhance(plugins): add block properties and block renderers with conditional rendering * chore(libs): bump version * enhance(docs): update Experiments API guide with detailed usage examples and best practices * enhance(ui): implement plugin renderer effects and enhance action bar visibility * enhance(plugin): improve router refresh mechanism for dynamic plugin routes * chore(libs): rename development notes and add SKILL.md for plugin SDK * enhance(plugin): enhance PluginLogger with structured log entries and UI for viewing logs * fix: lint * Update src/main/frontend/components/block.cljs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/frontend/handler/plugin.cljs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/frontend/handler/plugin.cljs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update libs/src/modules/LSPlugin.Experiments.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/frontend/handler/plugin.cljs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * enhance(plugin): add rich text interpolation to i18n translation * fix: lint * fix: lint * fix: remove unnecessary newlines and clean up code formatting * fix: lint * fix(libs): incorrect entry path for js mode * enhance(docs): add custom theme plugin guide and update SKILL.md * enhance(plugin): add 'open logs' option in multiple languages --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .i18n-lint.toml | 1 + libs/SKILL.md | 133 +++ .../experiments_api_guide.md | 460 ---------- libs/{development-notes => guides}/AGENTS.md | 0 libs/guides/custom_theme_guide.md | 595 +++++++++++++ .../db_properties_guide.md | 0 .../db_properties_references.md} | 0 .../db_query_guide.md | 0 .../db_tag_property_idents_guide.md} | 0 libs/guides/experiments_api_guide.md | 784 +++++++++++++++++ .../starter_guide.md | 0 libs/package.json | 2 +- libs/src/LSPlugin.core.ts | 134 ++- libs/src/common.ts | 154 +++- libs/src/modules/LSPlugin.Experiments.ts | 129 ++- src/main/frontend/components/block.cljs | 444 +++++++--- src/main/frontend/components/block.css | 18 + src/main/frontend/components/plugin_logs.cljs | 169 ++++ src/main/frontend/components/plugins.cljs | 2 + src/main/frontend/components/property.cljs | 62 +- src/main/frontend/core.cljs | 33 +- src/main/frontend/handler/plugin.cljs | 803 +++++++++++------- src/main/logseq/sdk/experiments.cljs | 75 +- src/resources/dicts/af.edn | 2 +- src/resources/dicts/ar.edn | 2 +- src/resources/dicts/ca.edn | 2 +- src/resources/dicts/cs.edn | 2 +- src/resources/dicts/de.edn | 2 +- src/resources/dicts/en.edn | 12 +- src/resources/dicts/es.edn | 2 +- src/resources/dicts/fa.edn | 2 +- src/resources/dicts/fr.edn | 2 +- src/resources/dicts/id.edn | 2 +- src/resources/dicts/it.edn | 2 +- src/resources/dicts/ja.edn | 2 +- src/resources/dicts/ko.edn | 2 +- src/resources/dicts/nb-no.edn | 2 +- src/resources/dicts/nl.edn | 2 +- src/resources/dicts/pl.edn | 2 +- src/resources/dicts/pt-br.edn | 2 +- src/resources/dicts/pt-pt.edn | 2 +- src/resources/dicts/ru.edn | 2 +- src/resources/dicts/sk.edn | 2 +- src/resources/dicts/tr.edn | 2 +- src/resources/dicts/uk.edn | 2 +- src/resources/dicts/zh-cn.edn | 2 +- src/resources/dicts/zh-hant.edn | 2 +- 47 files changed, 3065 insertions(+), 991 deletions(-) create mode 100644 libs/SKILL.md delete mode 100644 libs/development-notes/experiments_api_guide.md rename libs/{development-notes => guides}/AGENTS.md (100%) create mode 100644 libs/guides/custom_theme_guide.md rename libs/{development-notes => guides}/db_properties_guide.md (100%) rename libs/{development-notes/db_properties_skill.md => guides/db_properties_references.md} (100%) rename libs/{development-notes => guides}/db_query_guide.md (100%) rename libs/{development-notes/db_tag_property_idents_notes.md => guides/db_tag_property_idents_guide.md} (100%) create mode 100644 libs/guides/experiments_api_guide.md rename libs/{development-notes => guides}/starter_guide.md (100%) create mode 100644 src/main/frontend/components/plugin_logs.cljs diff --git a/.i18n-lint.toml b/.i18n-lint.toml index a1bb609afb..d82123c3f2 100644 --- a/.i18n-lint.toml +++ b/.i18n-lint.toml @@ -106,6 +106,7 @@ exclude_patterns = [ "src/main/frontend/handler/shell.cljs", # Run shell command "src/main/frontend/undo_redo/debug_ui.cljs", # Developer undo/redo tool "src/main/frontend/worker/commands.cljs", # Internal command identifier strings + "src/main/frontend/**/plugin*", # Plugin namespaces — may contain hardcoded strings that are not ] # Maximum character length of the text preview in output. diff --git a/libs/SKILL.md b/libs/SKILL.md new file mode 100644 index 0000000000..cdbb076f0a --- /dev/null +++ b/libs/SKILL.md @@ -0,0 +1,133 @@ +--- +name: logseq-plugin-sdk +description: Build, debug, or review Logseq plugins with the `@logseq/libs` SDK (TypeScript/JavaScript, iframe/shadow sandboxed). Use when the task involves writing plugin entry code, registering slash/command/UI items, provideUI/provideStyle/provideModel, settings schema, macro renderers, DB-graph properties & tags, Datascript/DSL queries, experimental APIs, theme plugins, or the `logseq/*` CLJS facade generated under this package. +--- + +# Logseq Plugin SDK Skill + +This skill governs work inside `libs/` — the source of the npm package [`@logseq/libs`](./package.json) and its CLJS facade under [`cljs-sdk/`](./cljs-sdk). Use it whenever the user is authoring, upgrading, or debugging a Logseq plugin, or extending the SDK itself. + +## When to use + +Trigger this skill when the task mentions any of: + +- `@logseq/libs`, `logseq.App`, `logseq.Editor`, `logseq.DB`, `logseq.UI`, `logseq.Assets`, `logseq.Git`, `logseq.Experiments` +- `provideUI` / `provideStyle` / `provideModel` / `useSettingsSchema` / `onMacroRendererSlotted` +- `registerSlashCommand`, `registerBlockContextMenuItem`, `registerCommandPalette`, `registerUIItem` +- Plugin `package.json` `logseq` block, themes, `effect` plugins, iframe/shadow sandbox +- DB-graph properties, tags/classes, property idents (`:logseq.property/*`, `:plugin.property./*`) +- Datascript / DSL queries through `logseq.DB.q` / `logseq.DB.datascriptQuery` +- Regenerating the CLJS SDK (`yarn run generate:schema`, `bb libs:generate-cljs-sdk`) + +If the user is editing core Logseq app code (not a plugin), prefer the repo-root `AGENTS.md` instead. + +## Golden rules + +1. **Always `await logseq.ready(main)`** before touching any API. Most SDK calls are async RPC over postMessage. +2. **Detect graph mode** before using DB-only APIs: `await logseq.App.checkCurrentIsDbGraph()`. `IBatchBlock.properties` is **not** supported for DB graphs — use `Editor.upsertBlockProperty` / `upsertProperty` instead. +3. **Clean up listeners** in `logseq.beforeunload` (collect the `off` functions returned by every `onXxx` hook). +4. **Batch mutations** (`Editor.insertBatchBlock`) and **debounce** `DB.onChanged` / `onBlockChanged` handlers — they fire on every keystroke. +5. **Prefer CSS variables** (`--ls-primary-text-color`, `--ls-primary-background-color`, `--ls-border-color`, …) over hard-coded colors so plugins follow the active theme. +6. **Unique plugin id** in `package.json > logseq.id`; keep it lowercase-kebab. `main`/`entry` must point at a built HTML file. +7. **Experimental APIs (`logseq.Experiments.*`) are unstable** — only use when no stable API exists and document the reason. +8. **Idents are identity.** For built-in or cross-graph stable references, use idents (`:logseq.property/created-at`, `:plugin.property./`) instead of display names. + +## Canonical plugin skeleton + +```ts +import '@logseq/libs' + +const offHooks: Array<() => void> = [] + +async function main() { + logseq.useSettingsSchema([ + { key: 'enabled', type: 'boolean', default: true, title: 'Enabled', description: '' }, + ]) + + logseq.Editor.registerSlashCommand('My Command', async () => { + await logseq.Editor.insertAtEditingCursor('Hello from my plugin!') + }) + + offHooks.push( + logseq.DB.onChanged(({ blocks }) => { + // debounce in real code + }), + ) + + logseq.beforeunload(async () => { + offHooks.forEach((off) => off()) + }) +} + +logseq.ready(main).catch(console.error) +``` + +## Workflow + +1. **Scope the request.** Is it a new plugin, a change to an existing plugin, SDK-internal work, or the CLJS facade? +2. **Load the right reference file(s)** from [`./guides/`](./guides) (see table below) before proposing code. +3. **For SDK-internal changes**, open the matching TypeScript under [`./src/`](./src) (`LSPlugin.ts` for types, `LSPlugin.user.ts` for the proxy implementation, `modules/` for Experiments/Storage/Request). +4. **For CLJS facade changes**, regenerate with: + ```bash + yarn run generate:schema # dist/logseq-sdk-schema.json + bb libs:generate-cljs-sdk # target/generated-cljs/logseq/*.cljs + ``` + Non-proxy methods land in `logseq.core`; each `IXxxProxy` gets its own namespace (`logseq.app`, `logseq.editor`, …). +5. **Validate.** Build the plugin (`npm run build` / `parcel build`) and load it via Settings → Developer mode → `t p` → *Load unpacked plugin*. Use DevTools (`Cmd+Shift+I`) and `logseq.UI.showMsg` for quick feedback. +6. **Respect the package.json rules** (see [`guides/AGENTS.md`](./guides/AGENTS.md) §Configuration Fields). + +## Reference map (`./guides/`) + +Load these on demand — do not dump their full contents unless needed: + +| File | Load when… | +|------|------------| +| [`guides/AGENTS.md`](./guides/AGENTS.md) | Authoritative overview of SDK namespaces, `package.json > logseq` schema, theme plugins, UI injection, macro renderers, lifecycle. Start here for most plugin tasks. | +| [`guides/custom_theme_guide.md`](./guides/custom_theme_guide.md) | Building or reviewing Logseq theme plugins, custom theme CSS, `logseq.themes`, `provideTheme`, theme variables, light/dark mode styling, or UI selector/theme-token guidance. | +| [`guides/starter_guide.md`](./guides/starter_guide.md) | Bootstrapping a new plugin project (Node/TS toolchain, desktop dev-mode loading, hello-world). | +| [`guides/db_properties_guide.md`](./guides/db_properties_guide.md) | Conceptual model: file-graph vs DB-graph properties, schema vs values, tag/class modeling. | +| [`guides/db_properties_references.md`](./guides/db_properties_references.md) | API reference for `upsertProperty`, `upsertBlockProperty`, property schemas/types/cardinality. | +| [`guides/db_tag_property_idents_guide.md`](./guides/db_tag_property_idents_guide.md) | Ident naming rules (`:logseq.property/*`, `:logseq.class/*`, `:plugin.property./*`, `:plugin.class./*`) and when to use them. | +| [`guides/db_query_guide.md`](./guides/db_query_guide.md) | DSL (`logseq.DB.q`) vs Datascript (`logseq.DB.datascriptQuery`) queries, parameters, change watchers. | +| [`guides/experiments_api_guide.md`](./guides/experiments_api_guide.md) | `logseq.Experiments.*` — React/ReactDOM reuse, internal components, CLJS interop, custom fenced-code / route / sidebar / property / block-body renderers. | + +## Core API quick index + +Full code examples live in [`guides/AGENTS.md`](./guides/AGENTS.md) — use this table to jump to the right namespace: + +- `logseq.App` — info, graph, navigation, `registerUIItem`, `registerCommandPalette`, lifecycle hooks (`onCurrentGraphChanged`, `onThemeModeChanged`, `onRouteChanged`, `onMacroRendererSlotted`), `checkCurrentIsDbGraph`. +- `logseq.Editor` — slash & context-menu commands, block CRUD, `insertBatchBlock`, pages, cursor/selection, `upsertBlockProperty` / `getBlockProperties` (DB). +- `logseq.DB` — `q`, `datascriptQuery`, `onChanged`, `onBlockChanged`, `getFileContent` / `setFileContent`. +- `logseq.UI` — `showMsg`, `closeMsg`, `queryElementRect`, `queryElementById`. +- `logseq.Assets` — `listFilesOfCurrentGraph`, `makeSandboxStorage`, `makeUrl`, `builtInOpen`. +- `logseq.Git` — `execCommand`, `loadIgnoreFile`, `saveIgnoreFile` (**file graphs / desktop only**). +- `logseq.Experiments` — unstable; see the Experiments guide before using. +- Top-level — `provideUI`, `provideStyle`, `provideModel`, `useSettingsSchema`, `onSettingsChanged`, `updateSettings`, `showMainUI` / `hideMainUI` / `toggleMainUI` / `setMainUIInlineStyle`, `beforeunload`, `ready`. + +## Common pitfalls + +- Forgetting `await` — nearly every API is async. +- Using `IBatchBlock.properties` in a DB graph (silently ignored). +- Treating `block.content` as current — it is deprecated; use `block.title`. +- Registering the same `key` twice in `provideUI` / `provideStyle` without intending to replace. +- Hard-coding colors instead of `--ls-*` CSS variables. +- Leaking listeners (no cleanup in `beforeunload`). +- Shipping plugins without `logseq.id` or with a non-unique id. +- Assuming Git APIs exist on mobile / DB graphs. + +## When editing SDK source + +- Type definitions: [`src/LSPlugin.ts`](./src/LSPlugin.ts). Keep `IAppProxy`, `IEditorProxy`, `IDBProxy`, `IUIProxy`, `IAssetsProxy`, `IGitProxy`, `IExperimentsProxy` and the `ILSPluginUser` surface in sync. +- User proxy implementation: [`src/LSPlugin.user.ts`](./src/LSPlugin.user.ts). +- Modules: [`src/modules/`](./src/modules) (Experiments, Storage, Request). +- After changing the public surface, regenerate the CLJS facade (see Workflow step 4) and update [`CHANGELOG.md`](./CHANGELOG.md). +- Follow the repo commit style: short imperative subjects, optional scope (e.g. `enhance(libs): …`, `fix(libs): …`). + +## Resources + +- API docs: +- Samples: +- CLJS template: +- TS template: +- Discord: + diff --git a/libs/development-notes/experiments_api_guide.md b/libs/development-notes/experiments_api_guide.md deleted file mode 100644 index 72329db74f..0000000000 --- a/libs/development-notes/experiments_api_guide.md +++ /dev/null @@ -1,460 +0,0 @@ -# Logseq Experiments API Guide - -This guide covers the **experimental APIs** available in the Logseq Plugin SDK. These APIs provide advanced functionality for creating custom renderers, loading external scripts, and accessing internal utilities. - -> **⚠️ WARNING**: These are experimental features that may change at any time. Plugins using these APIs may not be supported on the Marketplace temporarily. - ---- - -## Overview - -The Experiments API is accessed via `logseq.Experiments` and provides: - -1. **React Integration** - Access to React and ReactDOM from the host -2. **Custom Renderers** - Register custom code block, route, and daemon renderers -3. **Component Access** - Access to internal Logseq components -4. **Utilities** - ClojureScript interop utilities (toClj, toJs, etc.) -5. **Script Loading** - Dynamic loading of external scripts -6. **Extension Enhancers** - Enhance libraries like KaTeX and CodeMirror - ---- - -## 1. React Integration - -Access React and ReactDOM from the Logseq host environment. - -### Properties - -#### `logseq.Experiments.React` - -Returns the React instance from the host scope. - -```typescript -const React = logseq.Experiments.React -``` - -#### `logseq.Experiments.ReactDOM` - -Returns the ReactDOM instance from the host scope. - -```typescript -const ReactDOM = logseq.Experiments.ReactDOM -``` - -### Example Usage - -```typescript -const React = logseq.Experiments.React -const ReactDOM = logseq.Experiments.ReactDOM - -// Use React to create components -const MyComponent = React.createElement('div', null, 'Hello from plugin!') -``` - ---- - -## 2. Components - -Access internal Logseq components for advanced UI integration. - -### `logseq.Experiments.Components.Editor` - -A page editor component that can render Logseq page content. - -**Type**: `(props: { page: string } & any) => any` - -**Parameters**: -- `page` (string): The page name to render - -```typescript -const Editor = logseq.Experiments.Components.Editor - -// Render a page editor -const editor = Editor({ page: 'My Page Name' }) -``` - ---- - -## 3. Utilities - -ClojureScript interop utilities for data conversion between JavaScript and ClojureScript. - -### `logseq.Experiments.Utils` - -Provides conversion utilities: - -#### `toClj(input: any)` - -Convert JavaScript data to ClojureScript data structures. - -```typescript -const cljData = logseq.Experiments.Utils.toClj({ key: 'value' }) -``` - -#### `jsxToClj(input: any)` - -Convert JSX/JavaScript objects to ClojureScript, preserving JSX structures. - -```typescript -const cljData = logseq.Experiments.Utils.jsxToClj(
Content
) -``` - -#### `toJs(input: any)` - -Convert ClojureScript data structures to JavaScript. - -```typescript -const jsData = logseq.Experiments.Utils.toJs(cljData) -``` - -#### `toKeyword(input: any)` - -Convert a string to a ClojureScript keyword. - -```typescript -const keyword = logseq.Experiments.Utils.toKeyword('my-key') -``` - -#### `toSymbol(input: any)` - -Convert a string to a ClojureScript symbol. - -```typescript -const symbol = logseq.Experiments.Utils.toSymbol('my-symbol') -``` - ---- - -## 4. Script Loading - -### `logseq.Experiments.loadScripts(...scripts: string[])` - -Dynamically load external scripts into the Logseq environment. - -**Parameters**: -- `scripts` (string[]): Array of script URLs or relative paths - -**Returns**: `Promise` - -**Behavior**: -- Relative paths are resolved using the plugin's resource path -- HTTP/HTTPS URLs are loaded directly -- Scripts are loaded in order - -```typescript -// Load external library -await logseq.Experiments.loadScripts( - 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js' -) - -// Load local script from plugin resources -await logseq.Experiments.loadScripts('./my-script.js') - -// Load multiple scripts -await logseq.Experiments.loadScripts( - 'https://cdn.example.com/lib1.js', - 'https://cdn.example.com/lib2.js', - './local-script.js' -) -``` - ---- - -## 5. Custom Renderers - -### 5.1 Fenced Code Renderer - -Register a custom renderer for code blocks with specific language tags. - -#### `logseq.Experiments.registerFencedCodeRenderer(lang: string, opts: object)` - -**Parameters**: -- `lang` (string): The language identifier for the code block (e.g., 'mermaid', 'chart') -- `opts` (object): - - `render` (function, required): Render function that receives props - - `edit` (boolean, optional): Whether the block is editable - - `before` (function, optional): Async function to run before rendering - - `subs` (string[], optional): Subscriptions to state changes - -**Render Props**: -- `content` (string): The content of the code block - -```typescript -// Register a custom code block renderer -logseq.Experiments.registerFencedCodeRenderer('my-chart', { - edit: false, - before: async () => { - // Load dependencies before rendering - await logseq.Experiments.loadScripts( - 'https://cdn.jsdelivr.net/npm/chart.js' - ) - }, - render: (props) => { - const React = logseq.Experiments.React - - return React.createElement('div', { - ref: (el) => { - if (el) { - // Parse content and render chart - const config = JSON.parse(props.content) - new Chart(el, config) - } - } - }) - } -}) -``` - -**Usage in Logseq**: -````markdown -```my-chart -{ - "type": "bar", - "data": { - "labels": ["A", "B", "C"], - "datasets": [{"data": [10, 20, 30]}] - } -} -``` -```` - -### 5.2 Daemon Renderer - -Register a renderer that runs continuously in the background (daemon). - -#### `logseq.Experiments.registerDaemonRenderer(key: string, opts: object)` - -**Parameters**: -- `key` (string): Unique identifier for the daemon renderer -- `opts` (object): - - `render` (function, required): Render function - - `sub` (string[], optional): Subscriptions to state changes - -```typescript -// Register a daemon renderer for persistent UI -logseq.Experiments.registerDaemonRenderer('my-status-bar', { - sub: ['ui/theme', 'ui/sidebar-open'], - render: (props) => { - const React = logseq.Experiments.React - - return React.createElement('div', { - style: { - position: 'fixed', - bottom: 0, - right: 0, - padding: '10px', - background: '#333', - color: '#fff' - } - }, 'Status: Active') - } -}) -``` - -### 5.3 Route Renderer - -Register a custom renderer for specific routes in Logseq. - -#### `logseq.Experiments.registerRouteRenderer(key: string, opts: object)` - -**Parameters**: -- `key` (string): Unique identifier for the route renderer -- `opts` (object): - - `path` (string, required): Route path (e.g., '/my-plugin-page') - - `render` (function, required): Render function - - `name` (string, optional): Display name for the route - - `subs` (string[], optional): Subscriptions to state changes - -```typescript -// Register a custom route -logseq.Experiments.registerRouteRenderer('my-custom-page', { - path: '/my-plugin-dashboard', - name: 'Dashboard', - subs: ['ui/theme'], - render: (props) => { - const React = logseq.Experiments.React - - return React.createElement('div', { - className: 'my-plugin-dashboard' - }, [ - React.createElement('h1', null, 'Plugin Dashboard'), - React.createElement('p', null, 'Custom content here') - ]) - } -}) - -// Navigate to the route -logseq.App.pushState('page', { name: 'my-plugin-dashboard' }) -``` - ---- - -## 6. Extension Enhancers - -Enhance external libraries that Logseq uses (like KaTeX for math rendering). - -### `logseq.Experiments.registerExtensionsEnhancer(type: string, enhancer: function)` - -**Parameters**: -- `type` ('katex' | 'codemirror'): The extension type to enhance -- `enhancer` (function): Async function that receives the library instance and can modify it - -**Returns**: `Promise` - -```typescript -// Enhance KaTeX with custom macros -logseq.Experiments.registerExtensionsEnhancer('katex', async (katex) => { - // Add custom KaTeX macros - katex.macros = { - ...katex.macros, - '\\RR': '\\mathbb{R}', - '\\NN': '\\mathbb{N}', - '\\ZZ': '\\mathbb{Z}' - } - - console.log('KaTeX enhanced with custom macros') -}) -``` - ---- - -## 7. Plugin Local Access - -### `logseq.Experiments.pluginLocal` - -Access the internal plugin instance (PluginLocal) for advanced operations. - -**Type**: `PluginLocal` - -```typescript -const pluginLocal = logseq.Experiments.pluginLocal - -// Access plugin-specific internal state -console.log('Plugin ID:', pluginLocal.id) -``` - ---- - -## 8. Advanced: Invoke Experimental Methods - -### `logseq.Experiments.invokeExperMethod(type: string, ...args: any[])` - -Directly invoke experimental methods from the host scope. - -**Parameters**: -- `type` (string): Method name (converted to snake_case) -- `...args`: Arguments to pass to the method - -**Returns**: `any` - -```typescript -// Invoke a custom experimental method -const result = logseq.Experiments.invokeExperMethod( - 'someExperimentalFeature', - arg1, - arg2 -) -``` - ---- - -## Complete Example: Custom Chart Renderer - -Here's a complete example combining multiple APIs: - -```typescript -import '@logseq/libs' - -async function main() { - console.log('Chart Plugin Loaded') - - // Register fenced code renderer for charts - logseq.Experiments.registerFencedCodeRenderer('chart', { - edit: false, - before: async () => { - // Load Chart.js before rendering - await logseq.Experiments.loadScripts( - 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js' - ) - }, - render: (props) => { - const React = logseq.Experiments.React - - return React.createElement('div', null, [ - React.createElement('canvas', { - ref: (canvas) => { - if (canvas && window.Chart) { - try { - const config = JSON.parse(props.content) - new window.Chart(canvas, config) - } catch (e) { - console.error('Chart rendering error:', e) - } - } - } - }) - ]) - } - }) -} - -logseq.ready(main).catch(console.error) -``` - -**Usage**: -````markdown -```chart -{ - "type": "line", - "data": { - "labels": ["Jan", "Feb", "Mar", "Apr"], - "datasets": [{ - "label": "Sales", - "data": [10, 20, 15, 30], - "borderColor": "rgb(75, 192, 192)" - }] - } -} -``` -```` - ---- - -## Best Practices - -1. **Check Host Scope**: Always ensure the host scope is accessible before using experimental APIs -2. **Error Handling**: Wrap experimental API calls in try-catch blocks -3. **Dependencies**: Load external scripts in `before` hooks to ensure they're ready -4. **Memory Management**: Clean up event listeners and subscriptions in daemon renderers -5. **Compatibility**: Test thoroughly as these APIs may change between Logseq versions -6. **Documentation**: Document which experimental APIs your plugin uses -7. **Marketplace**: Be aware that plugins using these APIs may not be accepted on the Marketplace - ---- - -## Limitations - -- **Experimental Status**: These APIs are not stable and may change without notice -- **Marketplace Support**: Plugins using experimental APIs may not be approved for the Marketplace -- **Security**: Be cautious when loading external scripts or accessing host scope -- **Performance**: Custom renderers can impact performance if not optimized -- **Compatibility**: Limited backwards compatibility guarantees - ---- - -## See Also - -- [Starter Guide](./starter_guide.md) - Getting started with plugin development -- [DB Properties Guide](./db_properties_guide.md) - Working with database properties -- [DB Query Guide](./db_query_guide.md) - Querying the Logseq database - ---- - -## Support - -For questions and issues: -- [Logseq Discord](https://discord.gg/logseq) - #plugin-dev channel -- [GitHub Discussions](https://github.com/logseq/logseq/discussions) -- [Plugin API Documentation](https://plugins-doc.logseq.com/) - -Remember: These are experimental features. Use at your own risk and always test thoroughly! diff --git a/libs/development-notes/AGENTS.md b/libs/guides/AGENTS.md similarity index 100% rename from libs/development-notes/AGENTS.md rename to libs/guides/AGENTS.md diff --git a/libs/guides/custom_theme_guide.md b/libs/guides/custom_theme_guide.md new file mode 100644 index 0000000000..d35fe54321 --- /dev/null +++ b/libs/guides/custom_theme_guide.md @@ -0,0 +1,595 @@ +# Custom Theme Plugin Guide + +This guide summarizes Logseq's current UI theme architecture and gives a practical workflow for building a Logseq **theme plugin** with `@logseq/libs`. + +It is based on the current repository structure and these theme-related entry points: + +- CSS load order: `tailwind.all.css` +- Design tokens and theme variables: `packages/ui/src/radix.css`, `packages/ui/src/radix-hsl.css`, `packages/ui/src/vars-classic.css`, `packages/ui/src/colors.css` +- Shared shui/Radix component CSS: `resources/css/shui.css` +- CodeMirror theme bridge: `resources/css/codemirror.lsradix.css` +- Frontend component CSS: `src/main/frontend/**/*.css` +- Theme runtime attributes: `src/main/frontend/components/theme.cljs`, `src/main/frontend/state.cljs`, `src/main/frontend/ui.cljs` +- Plugin theme registration/injection: `libs/src/LSPlugin.ts`, `libs/src/LSPlugin.core.ts`, `libs/src/LSPlugin.user.ts`, `libs/src/common.ts` + +## Mental model + +Logseq themes are mostly CSS-variable based. A theme plugin should first override the semantic variables that the app already consumes, and only then add targeted selector overrides for areas that are not fully tokenized. + +### CSS loading order + +The main stylesheet imports theme foundations before app component CSS: + +1. `packages/ui/src/radix.css` — Radix color scales as `--rx-*` variables, for example `--rx-gray-01` through `--rx-gray-12` and alpha variants. +2. `packages/ui/src/radix-hsl.css` — HSL forms such as `--rx-gray-01-hsl` for Tailwind/shui tokens. +3. `packages/ui/src/vars-classic.css` — Logseq semantic variables, layout variables, default light/dark values for `data-color=logseq`. +4. `packages/ui/src/colors.css` — accent palettes selected by `html[data-color=...]`, mapping `--lx-accent-*`, `--lx-gray-*`, shui tokens, and many `--ls-*` values. +5. `packages/ui/src/index.css` — Tailwind base. +6. `resources/css/shui.css` — shared UI components built on shui/Radix/Tailwind tokens. +7. Third-party CSS: Inter, PhotoSwipe, KaTeX, CodeMirror, PDF.js, Tabler, `codemirror.lsradix.css`. +8. `src/main/frontend/**/[!_]*.css` — component and extension CSS. +9. A selected plugin theme is injected later as a ``, so it can override earlier rules when specificity is equal or higher. + +### Runtime attributes and classes + +Theme CSS should scope by attributes on `html` and compatibility classes on `body`: + +| Hook | Set by | Meaning | +| --- | --- | --- | +| `html[data-theme="light"]` / `html[data-theme="dark"]` | `frontend.components.theme/container` | Current light/dark mode. Prefer this for theme CSS. | +| `html.dark` | same component | Tailwind dark-mode hook. | +| `body.light-theme`, `body.white-theme`, `body.dark-theme` | same component | Backward-compatible hooks for older custom CSS/themes. | +| `html[data-color="logseq"]` and other colors | same component | Accent color selection. A theme plugin can override or ignore this. | +| `html[data-font="serif"|"mono"]`, `html[data-font-global="true"]` | same component | Editor/global font preferences. Avoid fighting these unless your theme intentionally owns typography. | +| Platform classes such as `html.is-mobile`, `html.is-electron`, `html.is-mac`, `html.is-native-iphone` | `frontend.ui/inject-document-devices-envs!` | Useful for responsive/mobile-safe fixes. | + +### Plugin theme lifecycle + +A theme can be registered in either of two ways: + +1. Declaratively in `package.json > logseq.themes`. During package preparation, Logseq resolves relative `url` values to plugin resources, registers each theme, and shows them in the theme selector. +2. Programmatically with `logseq.provideTheme(theme)`. This is useful for generated themes or settings-driven variants. + +When a user selects a theme, Logseq: + +- removes the previously injected custom theme link; +- injects the selected theme CSS link into `document.head`; +- persists the selected theme separately for `light` and `dark` modes; +- updates `:plugin/selected-theme` and emits plugin app hooks such as `:theme-changed`. + +## Minimal theme plugin + +### Directory layout + +```text +logseq-my-theme/ +├── package.json +├── index.html +└── themes/ + ├── my-theme-light.css + └── my-theme-dark.css +``` + +### `package.json` + +Use a stable lowercase-kebab `logseq.id`. For a theme-only plugin, set `effect: true` and declare each CSS file in `themes`. + +```json +{ + "name": "logseq-my-theme", + "version": "0.1.0", + "description": "A custom Logseq theme pack", + "license": "MIT", + "main": "index.html", + "logseq": { + "id": "my-theme", + "main": "index.html", + "title": "My Theme", + "icon": "./icon.png", + "effect": true, + "themes": [ + { + "name": "My Theme Light", + "url": "./themes/my-theme-light.css", + "mode": "light", + "description": "Light variant of My Theme" + }, + { + "name": "My Theme Dark", + "url": "./themes/my-theme-dark.css", + "mode": "dark", + "description": "Dark variant of My Theme" + } + ] + }, + "devDependencies": { + "@logseq/libs": "^0.0.17" + } +} +``` + +### `index.html` + +Declarative themes do not need runtime code, but keeping a tiny entry file makes the plugin package explicit and easy to extend later. + +```html + + + + + My Logseq Theme + + + +``` + +### Programmatic variant with `provideTheme` + +If the theme URL or metadata is generated at runtime, use the SDK. Always wait for Logseq to be ready. + +```ts +import '@logseq/libs' + +async function main() { + logseq.provideTheme({ + name: 'My Generated Dark Theme', + url: './themes/generated-dark.css', + mode: 'dark', + description: 'Generated from plugin settings', + }) +} + +logseq.ready(main).catch(console.error) +``` + +## CSS strategy + +Start with variables. Add selector overrides only for UI that cannot be changed via variables. + +### Dark theme starter + +```css +/* themes/my-theme-dark.css */ + +html[data-theme="dark"] { + color-scheme: dark; + + /* Core backgrounds */ + --ls-primary-background-color: #111827; + --ls-secondary-background-color: #172033; + --ls-tertiary-background-color: #202b42; + --ls-quaternary-background-color: #2a3650; + --ls-table-tr-even-background-color: var(--ls-secondary-background-color); + --ls-slide-background-color: var(--ls-primary-background-color); + + /* Text */ + --ls-primary-text-color: #d6deeb; + --ls-secondary-text-color: #eef2ff; + --ls-title-text-color: #f8fafc; + --ls-left-sidebar-text-color: #cbd5e1; + + /* Links and refs */ + --ls-link-text-color: #7dd3fc; + --ls-link-text-hover-color: #bae6fd; + --ls-link-ref-text-color: var(--ls-link-text-color); + --ls-link-ref-text-hover-color: var(--ls-link-text-hover-color); + --ls-block-ref-link-text-color: #38bdf8; + --ls-tag-text-color: #93c5fd; + --ls-tag-text-hover-color: #bfdbfe; + + /* Borders, guidelines, focus */ + --ls-border-color: #334155; + --ls-secondary-border-color: #475569; + --ls-tertiary-border-color: rgb(148 163 184 / 0.18); + --ls-guideline-color: rgb(148 163 184 / 0.18); + --ls-focus-ring-color: rgb(56 189 248 / 0.45); + + /* Blocks and properties */ + --ls-block-properties-background-color: #1e293b; + --ls-page-properties-background-color: #1e293b; + --ls-block-bullet-color: #64748b; + --ls-block-bullet-border-color: #475569; + --ls-block-highlight-color: rgb(14 165 233 / 0.22); + --ls-a-chosen-bg: rgb(56 189 248 / 0.16); + --ls-menu-hover-color: var(--ls-a-chosen-bg); + + /* Selection, checkbox, quote, mark, inline code */ + --ls-selection-background-color: rgb(56 189 248 / 0.32); + --ls-selection-text-color: #f8fafc; + --ls-page-checkbox-color: #64748b; + --ls-page-checkbox-border-color: #475569; + --ls-page-blockquote-color: var(--ls-primary-text-color); + --ls-page-blockquote-bg-color: #172033; + --ls-page-blockquote-border-color: #38bdf8; + --ls-page-mark-color: #111827; + --ls-page-mark-bg-color: #fde68a; + --ls-page-inline-code-color: #e0f2fe; + --ls-page-inline-code-bg-color: #0f172a; + + /* Scrollbars and notifications */ + --ls-scrollbar-foreground-color: rgb(148 163 184 / 0.35); + --ls-scrollbar-background-color: rgb(15 23 42 / 0.35); + --ls-scrollbar-thumb-hover-color: rgb(148 163 184 / 0.55); + --ls-notification-background: #1e293b; + --ls-notification-text-color: #f8fafc; + + /* shui/Tailwind HSL tokens: use space-separated HSL channels, not hsl(). */ + --background: 222 47% 11%; + --foreground: 210 40% 96%; + --card: 222 47% 13%; + --card-foreground: 210 40% 96%; + --popover: 222 47% 10%; + --popover-foreground: 210 40% 96%; + --primary: 199 89% 48%; + --primary-foreground: 210 40% 98%; + --secondary: 217 33% 18%; + --secondary-foreground: 210 40% 96%; + --muted: 217 33% 16%; + --border: 217 33% 24%; + --input: 217 33% 24%; + --ring: 199 89% 48%; + --accent: 199 89% 48%; + --accent-foreground: 210 40% 98%; +} + +html[data-theme="dark"] body { + background: var(--ls-primary-background-color); + color: var(--ls-primary-text-color); +} + +html[data-theme="dark"] ::selection { + background: var(--ls-selection-background-color); + color: var(--ls-selection-text-color); +} +``` + +### Light theme starter + +```css +/* themes/my-theme-light.css */ + +html[data-theme="light"] { + color-scheme: light; + + --ls-primary-background-color: #ffffff; + --ls-secondary-background-color: #f8fafc; + --ls-tertiary-background-color: #eef2f7; + --ls-quaternary-background-color: #e2e8f0; + + --ls-primary-text-color: #1e293b; + --ls-secondary-text-color: #0f172a; + --ls-title-text-color: #0f172a; + --ls-left-sidebar-text-color: #334155; + + --ls-link-text-color: #0369a1; + --ls-link-text-hover-color: #075985; + --ls-link-ref-text-color: var(--ls-link-text-color); + --ls-link-ref-text-hover-color: var(--ls-link-text-hover-color); + --ls-block-ref-link-text-color: #0284c7; + --ls-tag-text-color: #0369a1; + --ls-tag-text-hover-color: #075985; + + --ls-border-color: #cbd5e1; + --ls-secondary-border-color: #e2e8f0; + --ls-tertiary-border-color: rgb(15 23 42 / 0.08); + --ls-guideline-color: rgb(15 23 42 / 0.08); + --ls-focus-ring-color: rgb(14 165 233 / 0.35); + + --ls-block-properties-background-color: #f1f5f9; + --ls-page-properties-background-color: #f1f5f9; + --ls-block-bullet-color: #94a3b8; + --ls-block-bullet-border-color: #cbd5e1; + --ls-block-highlight-color: #e0f2fe; + --ls-a-chosen-bg: #e0f2fe; + --ls-menu-hover-color: var(--ls-a-chosen-bg); + + --ls-selection-background-color: #dbeafe; + --ls-selection-text-color: #0f172a; + --ls-page-checkbox-color: #94a3b8; + --ls-page-checkbox-border-color: #94a3b8; + --ls-page-blockquote-color: var(--ls-primary-text-color); + --ls-page-blockquote-bg-color: #f8fafc; + --ls-page-blockquote-border-color: #38bdf8; + --ls-page-mark-color: #0f172a; + --ls-page-mark-bg-color: #fef3c7; + --ls-page-inline-code-color: #0f172a; + --ls-page-inline-code-bg-color: #f1f5f9; + + --ls-scrollbar-foreground-color: rgb(15 23 42 / 0.12); + --ls-scrollbar-background-color: rgb(15 23 42 / 0.05); + --ls-scrollbar-thumb-hover-color: rgb(15 23 42 / 0.22); + + --background: 0 0% 100%; + --foreground: 222 47% 11%; + --card: 0 0% 100%; + --card-foreground: 222 47% 11%; + --popover: 0 0% 100%; + --popover-foreground: 222 47% 11%; + --primary: 199 89% 40%; + --primary-foreground: 0 0% 100%; + --secondary: 210 40% 96%; + --secondary-foreground: 222 47% 11%; + --muted: 210 40% 96%; + --border: 214 32% 91%; + --input: 214 32% 91%; + --ring: 199 89% 40%; + --accent: 199 89% 40%; + --accent-foreground: 0 0% 100%; +} +``` + +## Important variable groups + +Prefer these variables before reaching for selectors. + +### Global layout and typography + +| Variable | Purpose | +| --- | --- | +| `--ls-page-text-size` | Base page text size under `#root`. | +| `--ls-page-title-size` | Page title size. Mobile may override title layout. | +| `--ls-main-content-max-width`, `--ls-main-content-max-width-wide` | Main content width. | +| `--ls-font-family` | Global app font when not overridden by user font settings. | +| `--ls-scrollbar-width` | Custom scrollbar width. | +| `--ls-border-radius-low`, `--ls-border-radius-medium` | Classic radius tokens. | +| `--ls-headbar-height`, `--ls-headbar-inner-top-padding` | Header sizing. | +| `--ls-left-sidebar-width`, `--ls-left-sidebar-sm-width`, `--ls-left-sidebar-nav-btn-size` | Left sidebar sizing. | + +### Logseq semantic colors + +| Variable | Purpose | +| --- | --- | +| `--ls-primary-background-color` | Main background. | +| `--ls-secondary-background-color` | Secondary surfaces, editors, menus. | +| `--ls-tertiary-background-color` | Nested/raised surfaces. | +| `--ls-quaternary-background-color` | Active/hover surfaces. | +| `--ls-primary-text-color`, `--ls-secondary-text-color`, `--ls-title-text-color` | Body, stronger text, titles. | +| `--ls-border-color`, `--ls-secondary-border-color`, `--ls-tertiary-border-color` | Borders. | +| `--ls-guideline-color` | Block indentation guide lines. | +| `--ls-active-primary-color`, `--ls-active-secondary-color` | Active states. | +| `--ls-a-chosen-bg`, `--ls-menu-hover-color` | Chosen menu/list item backgrounds. | +| `--ls-focus-ring-color` | Focus rings. | + +### Links, references, tags, and blocks + +| Variable | Purpose | +| --- | --- | +| `--ls-link-text-color`, `--ls-link-text-hover-color` | General links. | +| `--ls-link-ref-text-color`, `--ls-link-ref-text-hover-color` | Page references. | +| `--ls-block-ref-link-text-color` | Block references. | +| `--ls-tag-text-color`, `--ls-tag-text-hover-color`, `--ls-tag-text-opacity`, `--ls-tag-text-hover-opacity` | Tags. | +| `--ls-block-bullet-color`, `--ls-block-bullet-border-color`, `--ls-block-bullet-active-color` | Block bullets. | +| `--ls-block-highlight-color` | Block highlight. | +| `--ls-block-properties-background-color`, `--ls-page-properties-background-color` | Property panels. | + +### Content tokens + +| Variable | Purpose | +| --- | --- | +| `--ls-selection-background-color`, `--ls-selection-text-color` | Text selection. | +| `--ls-page-checkbox-color`, `--ls-page-checkbox-border-color` | Markdown task checkboxes/radios. | +| `--ls-page-blockquote-color`, `--ls-page-blockquote-bg-color`, `--ls-page-blockquote-border-color` | Blockquotes. | +| `--ls-page-mark-color`, `--ls-page-mark-bg-color` | Highlight/mark text. | +| `--ls-page-inline-code-color`, `--ls-page-inline-code-bg-color` | Inline code. | +| `--ls-table-tr-even-background-color` | Alternating table rows. | +| `--ls-cloze-text-color` | Cloze text. | +| `--ls-slide-background-color` | Slide mode background. | + +### shui/Tailwind HSL tokens + +Newer shared UI components use HSL channel tokens, often through Tailwind classes such as `bg-background`, `bg-popover`, `border`, `text-foreground`, and `bg-primary`. + +Set these as space-separated HSL channels: + +```css +html[data-theme="dark"] { + --background: 222 47% 11%; + --foreground: 210 40% 96%; + --card: 222 47% 13%; + --card-foreground: 210 40% 96%; + --popover: 222 47% 10%; + --popover-foreground: 210 40% 96%; + --primary: 199 89% 48%; + --primary-foreground: 210 40% 98%; + --secondary: 217 33% 18%; + --secondary-foreground: 210 40% 96%; + --muted: 217 33% 16%; + --border: 217 33% 24%; + --input: 217 33% 24%; + --ring: 199 89% 48%; + --accent: 199 89% 48%; + --accent-foreground: 210 40% 98%; +} +``` + +Do not write `--primary: hsl(199 89% 48%)`; consumers call `hsl(var(--primary))`. + +### `--lx-*` and `--rx-*` color scales + +- `--rx-*` variables are Radix-style raw palettes. They are broad and already available. +- `--lx-gray-01` through `--lx-gray-12` and `--lx-gray-*-alpha` represent the active neutral scale. +- `--lx-accent-01` through `--lx-accent-12` and `--lx-accent-*-alpha` represent the active accent scale. + +If your theme is a complete palette, map `--lx-gray-*` and `--lx-accent-*` as well as `--ls-*`. This makes Radix/shui-heavy UI more consistent: + +```css +html[data-theme="dark"] { + --lx-gray-01: #0f172a; + --lx-gray-02: #111827; + --lx-gray-03: #1e293b; + --lx-gray-04: #273449; + --lx-gray-05: #334155; + --lx-gray-06: #475569; + --lx-gray-07: #64748b; + --lx-gray-08: #94a3b8; + --lx-gray-09: #cbd5e1; + --lx-gray-10: #dbeafe; + --lx-gray-11: #e2e8f0; + --lx-gray-12: #f8fafc; + + --lx-accent-09: #0ea5e9; + --lx-accent-10: #0284c7; + --lx-accent-11: #38bdf8; + --lx-accent-12: #e0f2fe; +} +``` + +## UI selector map + +Use this as a last-mile map after variables. Keep overrides narrow and prefer `:where(...)` to avoid specificity wars. + +### App shell and layout + +| Area | Useful selectors | Notes | +| --- | --- | --- | +| Root/app | `#root`, `#app-container`, `#root-container.theme-container`, `main.theme-container-inner` | `#root` uses `--ls-page-text-size`; `main.theme-container-inner` defines `--left-sidebar-bg-color`. | +| Main content | `#main-container`, `#main-content`, `#main-content-container`, `.page-blocks-inner` | Avoid hard-coded viewport hacks; test desktop and mobile. | +| Header | `.cp__header`, `.head`, `.button`, `.ui__button` | Mostly tokenized through shui/Tailwind variables. | +| Left sidebar | `.left-sidebar-inner`, `.sidebar-header-container`, `.sidebar-contents-container`, `.sidebar-content-group`, `.sidebar-navigations` | Prefer `--left-sidebar-bg-color`, `--ls-left-sidebar-text-color`, and `--ls-left-sidebar-width`. | +| Right sidebar | `#right-sidebar`, `.cp__right-sidebar-inner`, `.sidebar-item`, `.references-blocks-item` | Nested reference cards may need extra contrast. | + +### Pages, blocks, and editor + +| Area | Useful selectors | Notes | +| --- | --- | --- | +| Page title | `.ls-page-title`, `.page-title`, `.journal-title`, `.page-title-sizer-wrapper` | Prefer `--ls-page-title-size` and title text variables. | +| Blocks | `.ls-block`, `.block-main-container`, `.block-content-wrapper`, `.block-content`, `.block-body` | Avoid altering core layout unless necessary. | +| Block tree guides | `.block-children-container`, `.block-children`, `.block-children-left-border` | Governed by `--ls-guideline-color` and related border variables. | +| Bullets/control | `.block-control-wrap`, `.block-control`, `.bullet-container`, `.bullet-link-wrap` | Governed by bullet variables; keep hit areas accessible. | +| Page refs | `.page-ref`, `.page-reference`, `.breadcrumb` | Use link/reference variables first. | +| Block refs | `.block-ref`, `.block-ref-no-title`, `.open-block-ref-link` | Use `--ls-block-ref-link-text-color`, property surface variables. | +| Properties | `.block-properties`, `.page-properties`, `.property-value-inner`, `.property-key` | Use property background variables. | +| Editor textarea | `.editor-inner textarea`, `.edit-input`, `.non-block-editor textarea` | Background usually comes from `--ls-secondary-background-color`. | +| Autocomplete/slash popup | `#ui__ac-inner`, `.menu-link`, `.absolute-modal[data-modal-name]`, `.cp__commands-slash` | Use menu hover and popover tokens. | + +### Shared UI, dialogs, command palette + +| Area | Useful selectors | Notes | +| --- | --- | --- | +| Buttons | `.ui__button`, `.button`, `.ui__toggle`, `.ui__toggle-background-on`, `.ui__toggle-background-off` | Set `--primary`, `--primary-foreground`, `--accent`, `--ring`. | +| Dialogs/modals | `.ui__modal`, `.ui__modal-panel`, `.ui__dialog-overlay`, `.ui__dialog-content`, `.ui__alert-dialog-content` | `--ls-modal-overlay-gradient-start/end` can control modal overlays. | +| Dropdowns/popovers | `.ui__dropdown-menu-content`, `.ui__popover-content`, `div[data-radix-menu-content]`, `div[data-radix-popper-content-wrapper]` | Usually driven by `--popover`, `--border`, `--accent`, `--lx-popover-bg`. | +| Select/calendar | `.ui__select-content`, `.ui__calendar`, `.rc-datepicker` | shui tokens and `--accent` matter. | +| Notifications | `.ui__notifications .notification-area` | Use `--ls-notification-background`, `--ls-notification-text-color`. | +| Command palette | `.cp__cmdk` | Border and hint colors use `--ls-border-color`, `--lx-gray-*`, `--accent`. | +| Context menu | `#custom-context-menu`, `.ls-context-menu-content`, `.menu-links-wrapper` | Popover/menu variables usually cover it. | + +### Extensions and special surfaces + +| Area | Useful selectors/variables | Notes | +| --- | --- | --- | +| CodeMirror | `.cm-s-lsradix`, `.cm-s-lsradix.cm-s-dark`, `.cm-s-lsradix.cm-s-light`, `.CodeMirror-*`, `.cm-*` | `resources/css/codemirror.lsradix.css` already bridges many `--lx-*`/`--ls-*` variables. Override only syntax colors if needed. | +| PDF | `.extensions__pdf-container`, `.extensions__pdf-toolbar`, `.extensions__pdf-outline`, `--ph-highlight-color-*`, `--ph-link-color`, `--ph-view-container-width`, `--lx-pdf-container-dark-bg` | PDF highlight colors are `--ph-*`, not `--ls-highlight-color-*`. | +| Graph | `.graph-layout`, `.graph-filters`, graph extension CSS under `src/main/frontend/extensions/graph.css` | Some graph styles are canvas/SVG-driven; inspect DOM before overriding. | +| Whiteboard/tldraw | `.tl-container`, `.tl-button`, `--ls-wb-stroke-color-default`, `--ls-wb-background-color-default`, `--ls-wb-text-color-default` | Accent colors in `colors.css` set default whiteboard tokens. | +| Tables | `.table-wrapper`, `.table-auto`, table rows | Start with `--ls-table-tr-even-background-color`, borders, text tokens. | +| Cards/flashcards | `.ls-card`, `.ui__dialog-content[label=flashcards__cp]` | Check dialog contrast and card minimum sizes. | +| Plugins UI | `.cp__plugins-page`, `.cp__themes-installed` | Useful when styling the installed theme picker itself. | + +## Scoped overrides: examples + +### Keep overrides mode-specific + +```text +html[data-theme="dark"] :where(.cp__right-sidebar-inner .references-blocks-item) { + background: color-mix(in srgb, var(--ls-secondary-background-color) 88%, white 12%); +} +``` + +### Prefer low specificity for optional polish + +```text +html[data-theme="light"] :where(.page-reference:hover) { + background: rgb(14 165 233 / 0.12); +} +``` + +### Avoid global resets + +Avoid rules like this in a theme plugin: + +```css +/* Avoid */ +* { + transition: all 200ms ease; +} +``` + +They can slow editing, affect CodeMirror/PDF.js, and break subtle interaction states. + +## Mobile and desktop considerations + +- Test `html.is-mobile`, `html.is-native-iphone`, `html.is-native-android`, and `html.is-electron` layouts if your theme changes spacing or header/sidebar dimensions. +- Respect safe-area insets in mobile-specific layout. Existing CSS uses `env(safe-area-inset-*)` in some places. +- Avoid changing `-webkit-app-region` on Electron headers/PDF toolbars unless you are fixing a drag-region issue. +- Do not shrink block control or bullet hit areas below the existing touch-friendly sizes. +- Avoid fixed pixel widths for the main content unless they are variables such as `--ls-main-content-max-width`. + +## Assets and fonts + +Prefer local assets packaged with the plugin. Relative URLs inside the selected theme CSS are resolved relative to the CSS file by the browser. + +```css +@font-face { + font-family: "MyThemeText"; + src: url("../fonts/MyThemeText.woff2") format("woff2"); + font-display: swap; +} + +html[data-theme="dark"] { + --ls-font-family: "MyThemeText", Inter, ui-sans-serif, system-ui, sans-serif; +} +``` + +Be careful with remote `@import` URLs: they can slow startup, fail offline, and leak network requests from a local-first app. + +## Debugging workflow + +1. Enable Logseq Developer mode. +2. Load the unpacked theme plugin. +3. Open DevTools and inspect `document.documentElement`: + - `data-theme` + - `data-color` + - `data-font` + - platform classes +4. In the theme selector, choose each declared light/dark theme. +5. Verify the selected CSS appears as a `` in `document.head`. +6. Inspect computed variables on `html`, `body`, `.theme-container`, and the specific UI area you are styling. +7. If a variable is not taking effect, check whether the component is using `--lx-*`, shui HSL tokens, or a targeted class instead of `--ls-*`. + +## Validation checklist + +Before publishing a theme plugin, test at least these screens and states: + +- [ ] Light and dark mode selection; switching between modes keeps each custom theme selection. +- [ ] Default accent color and at least one non-default `data-color` accent. +- [ ] Page title, journal title, normal blocks, nested blocks, block refs, page refs, tags, properties. +- [ ] Editing state: textarea, slash command menu, autocomplete, date picker, block context menu. +- [ ] Left sidebar, right sidebar, search, command palette, settings dialogs. +- [ ] Notifications, confirmation dialogs, dropdowns, popovers, tooltips. +- [ ] Code blocks / CodeMirror, inline code, marks, blockquotes, tables. +- [ ] PDF viewer highlights and toolbar if PDF is in scope. +- [ ] Graph and whiteboard/tldraw surfaces if your palette changes accents broadly. +- [ ] Desktop Electron and at least one mobile/narrow viewport if the theme changes layout/spacing. +- [ ] High contrast of text against every background; visible focus rings; visible selected/hover states. +- [ ] Reduced-motion friendliness if you add animations. + +## Common pitfalls + +- Overriding only `--ls-*` while newer shui components still read `--background`, `--popover`, `--border`, `--primary`, or `--lx-*` variables. +- Writing HSL tokens as full CSS colors instead of channel values. +- Using high-specificity selectors or `!important` everywhere, making user custom CSS and future Logseq changes harder to coexist with. +- Styling by generated Tailwind class names instead of stable Logseq/Radix selectors and variables. +- Forgetting to scope a dark theme to `html[data-theme="dark"]` or a light theme to `html[data-theme="light"]`. +- Hard-coding external font/image URLs without offline fallback. +- Changing editor layout, block bullets, or mobile sidebars without testing touch interactions. + +## Quick reference: priority order for theme authors + +1. Set `--ls-*` semantic variables for Logseq-specific UI. +2. Set shui/Tailwind HSL tokens (`--background`, `--foreground`, `--popover`, `--border`, `--primary`, etc.). +3. Map `--lx-gray-*` and `--lx-accent-*` for newer shared UI consistency. +4. Add extension variables such as `--ph-*` and `--ls-wb-*` only if your theme covers PDF/whiteboard. +5. Add low-specificity, mode-scoped selector overrides for the remaining gaps. + + diff --git a/libs/development-notes/db_properties_guide.md b/libs/guides/db_properties_guide.md similarity index 100% rename from libs/development-notes/db_properties_guide.md rename to libs/guides/db_properties_guide.md diff --git a/libs/development-notes/db_properties_skill.md b/libs/guides/db_properties_references.md similarity index 100% rename from libs/development-notes/db_properties_skill.md rename to libs/guides/db_properties_references.md diff --git a/libs/development-notes/db_query_guide.md b/libs/guides/db_query_guide.md similarity index 100% rename from libs/development-notes/db_query_guide.md rename to libs/guides/db_query_guide.md diff --git a/libs/development-notes/db_tag_property_idents_notes.md b/libs/guides/db_tag_property_idents_guide.md similarity index 100% rename from libs/development-notes/db_tag_property_idents_notes.md rename to libs/guides/db_tag_property_idents_guide.md diff --git a/libs/guides/experiments_api_guide.md b/libs/guides/experiments_api_guide.md new file mode 100644 index 0000000000..db1f79224e --- /dev/null +++ b/libs/guides/experiments_api_guide.md @@ -0,0 +1,784 @@ +# Logseq Experiments API Guide + +This guide covers the **experimental APIs** exposed as `logseq.Experiments` in the Logseq Plugin SDK. + +These APIs are intentionally lower-level than the stable SDK. They let plugins: + +- reuse host React/ReactDOM +- render internal Logseq components +- convert between JS and ClojureScript data structures +- load scripts dynamically +- register custom renderers for fenced code, routes, sidebars, properties, and block bodies +- hook internal extensions such as KaTeX +- access host/plugin internals when absolutely necessary + +> **⚠️ Warning** +> +> Everything in `logseq.Experiments` is unstable. Signatures, render props, and behaviors may change without a normal deprecation window. Plugins using these APIs may be temporarily unsupported on the Marketplace. + +--- + +## Overview + +The current experimental surface includes: + +1. **React integration**: `React`, `ReactDOM` +2. **Internal components**: `Components.Editor` +3. **Interop utilities**: `Utils.toClj`, `toJs`, `jsxToClj`, `toKeyword`, `toSymbol` +4. **Script loading**: `loadScripts(...)` +5. **Renderer registration**: + - `registerFencedCodeRenderer(...)` + - `registerDaemonRenderer(...)` + - `registerRouteRenderer(...)` + - `registerHostedRenderer(...)` + - `registerSidebarRenderer(...)` + - `registerBlockPropertiesRenderer(...)` + - `registerBlockRenderer(...)` +6. **Extension enhancers**: `registerExtensionsEnhancer(...)` +7. **Host/plugin internals**: + - `pluginLocal` + - `ensureHostScope()` + - `invokeExperMethod(...)` + +--- + +## 1. React Integration + +Use the host's React runtime instead of bundling your own copy. + +### `logseq.Experiments.React` + +Returns the React instance from the host scope. + +```typescript +const React = logseq.Experiments.React +``` + +### `logseq.Experiments.ReactDOM` + +Returns the ReactDOM instance from the host scope. + +```typescript +const ReactDOM = logseq.Experiments.ReactDOM +``` + +### Example + +```typescript +const React = logseq.Experiments.React + +const MyComponent = React.createElement( + 'div', + { className: 'my-plugin-card' }, + 'Hello from a host React tree' +) +``` + +--- + +## 2. Components + +### `logseq.Experiments.Components.Editor` + +Renders Logseq page content using an internal page editor component. + +**Type** + +```typescript +(props: { page: string } & Record) => any +``` + +**Parameters** + +- `page`: page name to render + +```typescript +const Editor = logseq.Experiments.Components.Editor + +const preview = Editor({ page: 'My Page Name' }) +``` + +--- + +## 3. Utilities + +`logseq.Experiments.Utils` exposes host interop helpers. + +### `toClj(input: any)` + +Convert JavaScript data into ClojureScript data structures. + +```typescript +const cljData = logseq.Experiments.Utils.toClj({ key: 'value' }) +``` + +### `jsxToClj(input: any)` + +Convert JS/JSX-style input to ClojureScript while preserving JSX-ish structures better than a plain conversion. + +```typescript +const view = { type: 'div', props: { children: 'Content' } } +const cljView = logseq.Experiments.Utils.jsxToClj(view) +``` + +### `toJs(input: any)` + +Convert ClojureScript values back into plain JavaScript. + +```typescript +const jsData = logseq.Experiments.Utils.toJs(cljData) +``` + +### `toKeyword(input: any)` + +Convert a string into a ClojureScript keyword. + +```typescript +const keyword = logseq.Experiments.Utils.toKeyword('my-key') +``` + +### `toSymbol(input: any)` + +Convert a string into a ClojureScript symbol. + +```typescript +const symbol = logseq.Experiments.Utils.toSymbol('my-symbol') +``` + +--- + +## 4. Script Loading + +### `logseq.Experiments.loadScripts(...scripts: string[])` + +Dynamically load scripts into the host environment. + +**Parameters** + +- `scripts`: HTTP(S) URLs or relative plugin resource paths + +**Returns** + +```typescript +Promise +``` + +**Behavior** + +- relative paths are resolved against the current plugin resource root +- HTTP/HTTPS URLs are used as-is +- scripts are loaded in the given order + +```typescript +await logseq.Experiments.loadScripts( + 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.umd.min.js' +) + +await logseq.Experiments.loadScripts('./vendor/local-helper.js') + +await logseq.Experiments.loadScripts( + 'https://cdn.example.com/lib1.js', + 'https://cdn.example.com/lib2.js', + './local-script.js' +) +``` + +--- + +## 5. Custom Renderers + +Experimental renderers are where most of the newer APIs live. + +## 5.1 Fenced Code Renderer + +Register a custom renderer for fenced code blocks such as: + +````markdown +```my-lang +... +``` +```` + +### `logseq.Experiments.registerFencedCodeRenderer(lang, opts)` + +```text +registerFencedCodeRenderer( + lang: string, + opts: { + edit?: boolean + before?: () => Promise + subs?: string[] + render: (props: { content: string }) => any + } +): any +``` + +**Options** + +- `edit`: whether the fenced block remains editable +- `before`: async preload hook, usually for loading scripts/assets +- `subs`: experimental subscription list +- `render`: React renderer receiving `{ content }` + +```typescript +logseq.Experiments.registerFencedCodeRenderer('my-chart', { + edit: false, + before: async () => { + await logseq.Experiments.loadScripts( + 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.umd.min.js' + ) + }, + render: ({ content }) => { + const React = logseq.Experiments.React + + return React.createElement('canvas', { + ref: (canvas: HTMLCanvasElement | null) => { + if (!canvas || !window.Chart) return + + try { + const config = JSON.parse(content) + new window.Chart(canvas, config) + } catch (error) { + console.error('Chart renderer error', error) + } + }, + }) + }, +}) +``` + +## 5.2 Daemon Renderer + +Register a renderer that stays mounted in a global daemon container. + +### `logseq.Experiments.registerDaemonRenderer(key, opts)` + +```text +registerDaemonRenderer( + key: string, + opts: { + before?: () => Promise + subs?: string[] + render: (props: {}) => any + } +): any +``` + +**Notes** + +- use `subs`, not `sub` +- `before` is supported by the host even if older typings may not show it yet +- daemon renderers are useful for lightweight always-on UI, not large app shells + +```typescript +logseq.Experiments.registerDaemonRenderer('my-status-bar', { + subs: ['ui/theme'], + render: () => { + const React = logseq.Experiments.React + + return React.createElement( + 'div', + { + style: { + position: 'fixed', + right: 12, + bottom: 12, + padding: '6px 10px', + borderRadius: 8, + background: 'var(--ls-secondary-background-color)', + }, + }, + 'Plugin active' + ) + }, +}) +``` + +## 5.3 Route Renderer + +Register a custom route view. + +### `logseq.Experiments.registerRouteRenderer(key, opts)` + +```text +registerRouteRenderer( + key: string, + opts: { + path: string + name?: string + subs?: string[] + render: (props: {}) => any + } +): any +``` + +**Options** + +- `path`: route path, e.g. `'/my-plugin-dashboard'` +- `name`: optional display name; if omitted, the internal key is reused +- `subs`: experimental subscription list +- `render`: route component + +```typescript +logseq.Experiments.registerRouteRenderer('my-custom-page', { + path: '/my-plugin-dashboard', + name: 'Dashboard', + render: () => { + const React = logseq.Experiments.React + + return React.createElement('div', { className: 'my-plugin-dashboard' }, [ + React.createElement('h1', { key: 'title' }, 'Plugin Dashboard'), + React.createElement('p', { key: 'body' }, 'Custom content here'), + ]) + }, +}) +``` + +> Route navigation is handled by Logseq's router. In docs and examples, prefer describing the registered `path` rather than relying on page navigation APIs, which are not the same thing. + +## 5.4 Hosted Renderer + +Low-level API for host-managed render targets. + +Today, the main built-in consumer is the right sidebar, so most plugins should prefer `registerSidebarRenderer(...)` unless they specifically need the lower-level primitive. + +### `logseq.Experiments.registerHostedRenderer(key, opts)` + +```text +registerHostedRenderer( + key: string, + opts: { + title?: string + mode?: string + type?: string + subs?: string[] + render: (props: {}) => any + } +): any +``` + +**Options** + +- `title`: display title when the host surfaces the renderer +- `type`: host-specific placement type +- `mode`: host-specific placement mode +- `subs`: experimental subscription list +- `render`: React renderer + +The host currently passes the registered renderer record back into the render function in some placements. Treat that as implementation detail, not a stable contract. + +## 5.5 Sidebar Renderer + +Convenience wrapper over `registerHostedRenderer(...)` for right-sidebar tools. + +### `logseq.Experiments.registerSidebarRenderer(key, opts)` + +```text +registerSidebarRenderer( + key: string, + opts: { + title?: string + subs?: string[] + render: (props: {}) => any + [key: string]: any + } +): any +``` + +**Behavior** + +- your key is automatically namespaced internally as `_sidebar.${key}` +- `type` is forced to `'sidebar'` +- the renderer appears in the right-sidebar plugin menu + +```typescript +logseq.Experiments.registerSidebarRenderer('inspector', { + title: 'Inspector', + render: () => { + const React = logseq.Experiments.React + + return React.createElement('div', null, 'Hello from the sidebar renderer') + }, +}) +``` + +## 5.6 Block Properties Renderer + +Render custom UI inside a block's properties area. + +### `logseq.Experiments.registerBlockPropertiesRenderer(key, opts)` + +```text +type BlockPropertiesCondition = + | { has: string } + | { equals: [string, any] } + | { in: [string, any[]] } + | { not: BlockPropertiesCondition } + | { any: BlockPropertiesCondition[] } + | { all: BlockPropertiesCondition[] } + +type BlockPropertiesRendererProps = { + blockId: string + properties: Record +} + +registerBlockPropertiesRenderer( + key: string, + opts: { + when?: BlockPropertiesCondition | ((props: BlockPropertiesRendererProps) => boolean) + mode?: 'prepend' | 'append' | 'replace' + priority?: number + subs?: string[] + render: (props: BlockPropertiesRendererProps) => any + } +): any +``` + +**Behavior** + +- `when` may be omitted, a declarative condition, or a synchronous predicate +- `mode` controls placement in the properties area: + - `prepend`: before native properties + - `append`: after native properties + - `replace`: replace native properties UI +- higher `priority` wins for conflicts +- for `replace`, the highest-priority matching replace renderer wins +- for `prepend`/`append`, all matching renderers are rendered in priority order + +**Render props** + +- `blockId`: block UUID string +- `properties`: plain JS object keyed by property names without the leading `:` + +**Property serialization details** + +Before data is passed into plugins, Logseq normalizes some values: + +- keywords become strings like `'logseq.property/status'` +- UUIDs become strings +- entity references become small objects such as `{ uuid, title }` +- sets / collections of entity references become arrays of those objects + +```typescript +logseq.Experiments.registerBlockPropertiesRenderer('priority-pill', { + when: { has: 'priority' }, + mode: 'prepend', + priority: 10, + render: ({ properties }) => { + const React = logseq.Experiments.React + const priority = properties.priority + + if (!priority) return null + + return React.createElement( + 'span', + { + style: { + display: 'inline-flex', + marginRight: 8, + padding: '2px 8px', + borderRadius: 9999, + fontSize: 12, + background: 'var(--ls-tertiary-background-color)', + }, + }, + `Priority: ${priority}` + ) + }, +}) +``` + +## 5.7 Block Renderer + +Replace a block's main outline body with plugin UI. + +### `logseq.Experiments.registerBlockRenderer(key, opts)` + +```text +type BlockRendererChild = Record & { + children?: BlockRendererChild[] +} + +type BlockRendererProps = { + blockId: string + properties: Record + uuid?: string + page?: string + content?: string + format?: string + children?: BlockRendererChild[] +} + +registerBlockRenderer( + key: string, + opts: { + when?: (props: BlockRendererProps) => boolean + includeChildren?: boolean + priority?: number + subs?: string[] + render: (props: BlockRendererProps) => any + } +): any +``` + +**Behavior** + +- `when` must be a **synchronous predicate function** if provided +- declarative conditions are **not** supported here +- highest `priority` match wins +- when the plugin renderer is active, users can switch back to the native outline view via built-in UI on that block +- when `includeChildren` is `true`, Logseq passes a recursive child tree and hides native outline children while the plugin renderer is active + +**Render props** + +- `blockId`: block UUID string +- `uuid`: same block UUID +- `page`: page title +- `content`: block content/title text +- `format`: `'markdown'`, `'org'`, etc. +- `properties`: normalized property object +- `children`: recursive normalized child tree when `includeChildren` is enabled + +```typescript +logseq.Experiments.registerBlockRenderer('kanban-card', { + when: ({ properties }) => properties.view === 'kanban-card', + includeChildren: true, + priority: 20, + render: ({ content, children = [] }) => { + const React = logseq.Experiments.React + + return React.createElement('section', { className: 'my-kanban-card' }, [ + React.createElement('h3', { key: 'title' }, content || 'Untitled'), + React.createElement( + 'ul', + { key: 'children' }, + children.map((child, index) => + React.createElement('li', { key: child.uuid || index }, child.title || child.content) + ) + ), + ]) + }, +}) +``` + +--- + +## 6. Extension Enhancers + +### `logseq.Experiments.registerExtensionsEnhancer(type, enhancer)` + +Enhance host libraries such as KaTeX. + +```text +registerExtensionsEnhancer( + type: 'katex' | 'codemirror', + enhancer: (value: any) => Promise +): any +``` + +For `katex`, the host immediately invokes the enhancer if KaTeX is already present. + +```typescript +logseq.Experiments.registerExtensionsEnhancer('katex', async (katex) => { + katex.macros = { + ...katex.macros, + '\\RR': '\\mathbb{R}', + '\\NN': '\\mathbb{N}', + '\\ZZ': '\\mathbb{Z}', + } +}) +``` + +--- + +## 7. Host / Plugin Internals + +## 7.1 `logseq.Experiments.pluginLocal` + +Returns the internal `PluginLocal` instance for the current plugin. + +```typescript +const pluginLocal = logseq.Experiments.pluginLocal +console.log(pluginLocal.id) +``` + +Use this sparingly. It is intentionally internal. + +## 7.2 `logseq.Experiments.ensureHostScope()` + +Returns the host scope, currently `window.top`, after attempting an access check. + +```typescript +const host = logseq.Experiments.ensureHostScope() +``` + +This is mostly useful when you need direct access to host globals and understand the risks. + +## 7.3 `logseq.Experiments.invokeExperMethod(type, ...args)` + +Direct escape hatch for calling experimental host methods. + +```typescript +const result = logseq.Experiments.invokeExperMethod( + 'someExperimentalFeature', + arg1, + arg2 +) +``` + +`type` is normalized to snake_case before resolution. + +--- + +## 8. Complete Example: Fenced Code Renderer + +```typescript +import '@logseq/libs' + +async function main() { + logseq.Experiments.registerFencedCodeRenderer('chart', { + edit: false, + before: async () => { + await logseq.Experiments.loadScripts( + 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.umd.min.js' + ) + }, + render: ({ content }) => { + const React = logseq.Experiments.React + + return React.createElement('canvas', { + ref: (canvas: HTMLCanvasElement | null) => { + if (!canvas || !window.Chart) return + + try { + const config = JSON.parse(content) + new window.Chart(canvas, config) + } catch (error) { + console.error('Chart rendering error:', error) + } + }, + }) + }, + }) +} + +logseq.ready(main).catch(console.error) +``` + +**Usage** + +````markdown +```chart +{ + "type": "line", + "data": { + "labels": ["Jan", "Feb", "Mar", "Apr"], + "datasets": [{ + "label": "Sales", + "data": [10, 20, 15, 30], + "borderColor": "rgb(75, 192, 192)" + }] + } +} +``` +```` + +--- + +## 9. Complete Example: Block Properties Badge + +```typescript +import '@logseq/libs' + +async function main() { + logseq.Experiments.registerBlockPropertiesRenderer('task-status-chip', { + when: { + any: [ + { equals: ['status', 'todo'] }, + { equals: ['status', 'doing'] }, + ], + }, + mode: 'append', + priority: 5, + render: ({ properties }) => { + const React = logseq.Experiments.React + const value = properties.status + + return React.createElement( + 'span', + { + style: { + marginLeft: 8, + padding: '2px 8px', + borderRadius: 9999, + fontSize: 12, + background: 'var(--ls-secondary-background-color)', + }, + }, + `Status: ${value}` + ) + }, + }) +} + +logseq.ready(main).catch(console.error) +``` + +--- + +## 10. Best Practices + +1. **Prefer stable APIs first**. Only use `Experiments` when the stable SDK cannot solve the problem. +2. **Use host React**. Avoid bundling a second React runtime into the same tree. +3. **Keep `when` predicates synchronous**. This is especially important for `registerBlockRenderer(...)`. +4. **Use `before` to preload dependencies** instead of doing ad hoc script injection inside render. +5. **Treat `subs` as experimental**. Reactive semantics may change. +6. **Keep renderers lightweight**. Block and daemon renderers can affect overall app responsiveness. +7. **Handle bad input defensively**. Render props often contain user-authored content and properties. +8. **Document your experimental usage** in the plugin README so users understand the risk. +9. **Prefer `registerSidebarRenderer(...)` over raw hosted renderers** when your goal is a right-sidebar tool. +10. **Test against real graphs**. Property values, references, and child trees can vary a lot. + +--- + +## 11. Limitations and Notes + +- **Experimental status**: no stability guarantee +- **Marketplace support**: may be restricted temporarily +- **Security**: be careful with external scripts and direct host access +- **Performance**: custom renderers run inside the app UI, so poor implementations are noticeable +- **Typings may lag behavior**: some newer runtime options can land before every generated wrapper/type is refreshed + +### ClojureScript SDK note + +The generated ClojureScript wrapper namespace `com.logseq.experiments` currently includes wrappers for: + +- `load-scripts` +- `register-fenced-code-renderer` +- `register-daemon-renderer` +- `register-hosted-renderer` +- `register-sidebar-renderer` +- `register-route-renderer` +- `register-extensions-enhancer` + +At the time of writing, `register-block-properties-renderer` and `register-block-renderer` are not yet present in that generated wrapper, so ClojureScript plugins may need to call them via `invoke-exper-method` until the wrapper is regenerated. + +--- + +## See Also + +- [Starter Guide](./starter_guide.md) - getting started with plugin development +- [DB Properties Guide](./db_properties_guide.md) - working with database properties +- [DB Query Guide](./db_query_guide.md) - querying the Logseq database + +--- + +## Support + +For questions and issues: + +- [Logseq Discord](https://discord.gg/logseq) - `#plugin-dev` +- [GitHub Discussions](https://github.com/logseq/logseq/discussions) +- [Plugin API Documentation](https://plugins-doc.logseq.com/) + +Remember: these are experimental features. Use them carefully and test thoroughly. diff --git a/libs/development-notes/starter_guide.md b/libs/guides/starter_guide.md similarity index 100% rename from libs/development-notes/starter_guide.md rename to libs/guides/starter_guide.md diff --git a/libs/package.json b/libs/package.json index 29381bbe6b..e764ac7284 100644 --- a/libs/package.json +++ b/libs/package.json @@ -1,7 +1,7 @@ { "name": "@logseq/libs", "packageManager": "pnpm@10.33.0", - "version": "0.3.2", + "version": "0.3.3", "description": "Logseq SDK libraries", "main": "dist/lsplugin.user.js", "typings": "index.d.ts", diff --git a/libs/src/LSPlugin.core.ts b/libs/src/LSPlugin.core.ts index f06d44ec4a..288fdee08b 100644 --- a/libs/src/LSPlugin.core.ts +++ b/libs/src/LSPlugin.core.ts @@ -51,6 +51,46 @@ import { const debug = Debug('LSPlugin:core') const DIR_PLUGINS = 'plugins' +/** + * Compact one-line diff of two plain-object settings snapshots. + * Returns e.g. `foo: 1 -> 2, bar: "x" -> "y", +baz: true, -qux`. + * Returns null when no changes are detected. + */ +function diffSettings( + prev: Record | undefined, + next: Record | undefined +): string | null { + prev = prev || {} + next = next || {} + const keys = new Set([...Object.keys(prev), ...Object.keys(next)]) + const parts: string[] = [] + const fmt = (v: any) => { + if (v === undefined) return 'undefined' + try { + const s = JSON.stringify(v) + return s && s.length > 80 ? s.slice(0, 77) + '...' : s + } catch (_e) { return String(v) } + } + for (const k of keys) { + const a = prev[k], b = next[k] + if (a === b) continue + if (!(k in prev)) { + parts.push(`+${k}: ${fmt(b)}`) + continue + } + if (!(k in next)) { + parts.push(`-${k}`) + continue + } + // deep equality fallback via JSON to skip ref-only changes + try { + if (JSON.stringify(a) === JSON.stringify(b)) continue + } catch (_e) { /* fallthrough */ } + parts.push(`${k}: ${fmt(a)} -> ${fmt(b)}`) + } + return parts.length ? parts.join(', ') : null +} + declare global { interface Window { LSPluginCore: LSPluginCore @@ -487,7 +527,12 @@ class PluginLocal extends EventEmitter< async _setupUserSettings(reload?: boolean) { const { _options } = this - const logger = (this._logger = new PluginLogger(`Loader:${this.debugTag}`)) + // Reuse the existing logger to preserve history across reloads; + // only update the tag. + if (!this._logger) { + this._logger = new PluginLogger(`Loader`) + } + const logger = this._logger if (_options.settings && !reload && this._disposeSettingsObserver) { return @@ -512,8 +557,9 @@ class PluginLocal extends EventEmitter< settings.replace(userSettings) } - const handler = async (a) => { - debug('Settings changed', this.debugTag, a) + const handler = async (a, b) => { + const changed = diffSettings(b, a) + if (changed) logger.debug('settings changed', changed) if (a) { invokeHostExportedApi('save_plugin_user_settings', this.id, a) @@ -533,7 +579,7 @@ class PluginLocal extends EventEmitter< this._disposeSettingsObserver = disposeSettingsObserver } catch (e) { debug('[load plugin user settings Error]', e) - logger?.error(e) + logger?.error('load user settings failed', e) } } @@ -580,6 +626,7 @@ class PluginLocal extends EventEmitter< packageConfigError = 'Can not resolve package config location' } else { debug('prepare package root', url) + this._logger?.debug('prepare package root', url) try { pkg = await invokeHostExportedApi('load_plugin_config', url) @@ -667,6 +714,7 @@ class PluginLocal extends EventEmitter< }) } catch (e) { debug('[save plugin ID Error] ', e) + this._logger?.warn('save plugin id failed', e) } } } @@ -689,6 +737,7 @@ class PluginLocal extends EventEmitter< } } catch (e) { debug('[prepare package effect Error]', e) + this._logger?.error('prepare package effect failed', e) } } } @@ -736,10 +785,14 @@ class PluginLocal extends EventEmitter< dirPathInstalled ) - entry = convertToLSPResource( - withFileProtocol(path.normalize(entryPath)), - this.dotPluginsRoot - ) + entry = withFileProtocol(path.normalize(entryPath)) + + if (!this._options.effect) { + entry = convertToLSPResource( + entry, + this.dotPluginsRoot + ) + } this._options.entry = entry } @@ -901,9 +954,14 @@ class PluginLocal extends EventEmitter< }> ) { if (this.pending || this.loaded) { + this._logger?.debug('load skipped', + this.pending ? '(pending)' : '(already loaded)') return } + const t0 = performance.now() + this._logger?.info('load:start', opts?.reload ? '(reload)' : '') + this._transitionStatus(PluginLocalLoadStatus.LOADING, [ PluginLocalLoadStatus.UNLOADED, PluginLocalLoadStatus.ERROR, @@ -922,16 +980,23 @@ class PluginLocal extends EventEmitter< await installPackageThemes.call(null) } - if (this.disabled || !this.options.entry) { + if (this.disabled) { + this._logger?.info('load:skip (disabled)') + return + } + if (!this.options.entry) { + this._logger?.info('load:skip (no entry - theme-only package)') return } this._ctx.emit('beforeload', this) await this._tryToNormalizeEntry() + this._logger?.debug('entry normalized', this.options.entry) this._caller = new LSPluginCaller(this) await this._caller.connectToChild() + this._logger?.debug('sandbox connected') const readyFn = () => { this._caller?.callUserModel(LSPMSG_READY, { pid: this.id }) @@ -950,8 +1015,10 @@ class PluginLocal extends EventEmitter< this._dispose(cleanInjectedScripts.bind(this)) this._ctx.emit('loadeded', this) + this._logger?.info( + `load:done in ${(performance.now() - t0).toFixed(1)}ms`) } catch (e) { - this.logger.error('load', e, true) + this.logger.error('load:failed', e, true) this.disposeRuntime().catch(null) this._status = PluginLocalLoadStatus.ERROR @@ -970,9 +1037,11 @@ class PluginLocal extends EventEmitter< async reload() { if (this.pending) { + this._logger?.debug('reload skipped (pending)') return } + this._logger?.info('reload:start') this._ctx.emit('beforereload', this) if (this.loaded) { @@ -981,6 +1050,7 @@ class PluginLocal extends EventEmitter< await this.load({ reload: true }) this._ctx.emit('reloaded', this) + this._logger?.info('reload:done') } /** @@ -988,6 +1058,7 @@ class PluginLocal extends EventEmitter< */ async unload(unregister: boolean = false) { if (this.pending) { + this._logger?.debug('unload skipped (pending)') return } @@ -997,6 +1068,7 @@ class PluginLocal extends EventEmitter< } if (unregister) { + this._logger?.info('unregister:start') await this.unload() await this.disposeRegistration() @@ -1004,9 +1076,11 @@ class PluginLocal extends EventEmitter< this._ctx.emit('unlink-plugin', this.id) } + this._logger?.info('unregister:done') return } + this._logger?.info('unload:start') try { const eventBeforeUnload = { unregister } @@ -1022,15 +1096,16 @@ class PluginLocal extends EventEmitter< ) this.emit('beforeunload', eventBeforeUnload) } catch (e) { - this.logger.error('beforeunload', e) + this.logger.error('beforeunload hook failed', e) } await this.disposeRuntime() } this.emit('unloaded') + this._logger?.info('unload:done') } catch (e) { - this.logger.error('unload', e) + this.logger.error('unload failed', e) } finally { this._status = PluginLocalLoadStatus.UNLOADED } @@ -1041,7 +1116,7 @@ class PluginLocal extends EventEmitter< try { fn && (await fn()) } catch (e) { - console.error(this.debugTag, 'dispose Error', e) + this._logger?.error('dispose failed', e) } } } @@ -1437,6 +1512,7 @@ class LSPluginCore if (loadErr) { debug('[Failed LOAD Plugin] #', pluginOptions) + pluginLocal.logger?.error('register: load failed', loadErr, true) this.emit('error', loadErr) @@ -1497,6 +1573,10 @@ class LSPluginCore const p = this.ensurePlugin(identity) await p.reload() } catch (e) { + try { + this.getPluginLogger(identity) + ?.error('reload failed', e) + } catch (_) { /* unknown plugin */ } debug(e) } } @@ -1538,12 +1618,14 @@ class LSPluginCore if (p.pending) return if (!p.disabled && p.loaded) return + p.logger?.info('enable:start') this.emit('beforeenable') p.settings?.set('disabled', false) await p.load() this.emit('enabled', p.id) + p.logger?.info('enable:done') } async disable(plugin: PluginLocalIdentity) { @@ -1551,12 +1633,14 @@ class LSPluginCore if (p.pending) return if (p.disabled && !p.loaded) return + p.logger?.info('disable:start') this.emit('beforedisable') p.settings?.set('disabled', true) await p.unload() this.emit('disabled', p.id) + p.logger?.info('disable:done') } async _hook(ns: string, type: string, payload?: any, pid?: string) { @@ -1637,6 +1721,28 @@ class LSPluginCore return p } + /** + * Return the {@link PluginLogger} of the given plugin (if any). + * Returns undefined when the plugin is unknown. + */ + getPluginLogger(id: PluginLocalIdentity) { + try { + return this.ensurePlugin(id)?.logger + } catch (_e) { + return undefined + } + } + + /** Return structured log entries for the given plugin. */ + getPluginLogs(id: PluginLocalIdentity) { + return this.getPluginLogger(id)?.getEntries() || [] + } + + /** Clear log entries for the given plugin. */ + clearPluginLogs(id: PluginLocalIdentity) { + this.getPluginLogger(id)?.clear() + } + hostMounted() { this._hostMountedActor.resolve() } @@ -1695,6 +1801,8 @@ class LSPluginCore themes.push(opt) this.emit('themes-changed', this.themes, { id, ...opt }) + this.getPluginLogger(id) + ?.debug('theme registered', opt?.name || (opt as any)?.url || '') } async selectTheme( diff --git a/libs/src/common.ts b/libs/src/common.ts index 077dc643f2..ad72bbbbfe 100644 --- a/libs/src/common.ts +++ b/libs/src/common.ts @@ -57,65 +57,155 @@ export function deepMerge(a: Partial, b: Partial): T { return merge(a, b, { arrayMerge: overwriteArrayMerge }) } -export class PluginLogger extends EventEmitter<'change'> { - private _logs: Array<[type: string, payload: any]> = [] +export type PluginLogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' + +export interface PluginLogEntry { + ts: number + level: PluginLogLevel + tag: string + message: string +} + +export interface PluginLoggerOptions { + console?: boolean + maxSize?: number + level?: PluginLogLevel +} + +const LOG_LEVEL_WEIGHT: Record = { + DEBUG: 10, + INFO: 20, + WARN: 30, + ERROR: 40, +} + +const DEFAULT_LOG_MAX_SIZE = 500 + +function safeStringifyArg(it: any): string { + if (it == null) return String(it) + if (it instanceof Error) return `${it.message}${it.stack ? '\n' + it.stack : ''}` + if (typeof it === 'string') return it + if (typeof it === 'object') { + try { + const seen = new WeakSet() + return JSON.stringify(it, (_k, v) => { + if (typeof v === 'object' && v !== null) { + if (seen.has(v)) return '[Circular]' + seen.add(v) + } + return v + }) + } catch (_e) { + try { return String(it) } catch (_) { return '[Unserializable]' } + } + } + try { return String(it) } catch (_e) { return '[Unserializable]' } +} + +export class PluginLogger extends EventEmitter<'change' | 'append' | 'clear'> { + private _logs: PluginLogEntry[] = [] + private _maxSize: number + private _level: PluginLogLevel constructor( private _tag?: string, - private _opts?: { - console: boolean - } + private _opts?: PluginLoggerOptions ) { super() + this._maxSize = Math.max(50, _opts?.maxSize ?? DEFAULT_LOG_MAX_SIZE) + this._level = _opts?.level ?? 'DEBUG' } - write(type: string, payload: any[], inConsole?: boolean) { - if (payload?.length && true === payload[payload.length - 1]) { + /** + * Write a log entry. + * Backwards compatible: the legacy boolean tail flag in `payload` to force + * console output is still honored. + */ + write(level: PluginLogLevel | string, payload: any[], inConsole?: boolean) { + // back-compat: trailing boolean === true means "force console" + if (Array.isArray(payload) && payload.length && + payload[payload.length - 1] === true) { inConsole = true - payload.pop() + payload = payload.slice(0, -1) } - const msg = payload.reduce((ac, it) => { - if (it && it instanceof Error) { - ac += `${it.message} ${it.stack}` - } else { - ac += it.toString() - } - return ac - }, `[${this._tag}][${new Date().toLocaleTimeString()}] `) + const lvl = (typeof level === 'string' + ? (level.toUpperCase() as PluginLogLevel) + : level) as PluginLogLevel + const normalizedLevel: PluginLogLevel = + lvl in LOG_LEVEL_WEIGHT ? lvl : 'INFO' - this._logs.push([type, msg]) + // level filtering + if (LOG_LEVEL_WEIGHT[normalizedLevel] < LOG_LEVEL_WEIGHT[this._level]) { + return + } + + const message = (payload || []).map(safeStringifyArg).join(' ') + const entry: PluginLogEntry = { + ts: Date.now(), + level: normalizedLevel, + tag: this._tag || '', + message, + } + + this._logs.push(entry) + // ring buffer + if (this._logs.length > this._maxSize) { + this._logs.splice(0, this._logs.length - this._maxSize) + } if (inConsole || this._opts?.console) { - console?.['ERROR' === type ? 'error' : 'debug'](`${type}: ${msg}`) + const fn = normalizedLevel === 'ERROR' + ? 'error' + : normalizedLevel === 'WARN' + ? 'warn' + : normalizedLevel === 'DEBUG' ? 'debug' : 'info' + try { + // eslint-disable-next-line no-console + console[fn](`[${entry.tag}][${new Date(entry.ts).toLocaleTimeString()}] ${normalizedLevel}: ${message}`) + } catch (_e) { /* noop */ } } + this.emit('append', entry) this.emit('change') } clear() { this._logs = [] + this.emit('clear') this.emit('change') } - info(...args: any[]) { - this.write('INFO', args) + debug(...args: any[]) { this.write('DEBUG', args) } + info(...args: any[]) { this.write('INFO', args) } + warn(...args: any[]) { this.write('WARN', args) } + error(...args: any[]) { this.write('ERROR', args) } + + setTag(s: string) { this._tag = s } + getTag() { return this._tag } + + setLevel(l: PluginLogLevel) { + if (l in LOG_LEVEL_WEIGHT) this._level = l + } + getLevel(): PluginLogLevel { return this._level } + + setMaxSize(n: number) { + this._maxSize = Math.max(50, n | 0) + if (this._logs.length > this._maxSize) { + this._logs.splice(0, this._logs.length - this._maxSize) + this.emit('change') + } } - error(...args: any[]) { - this.write('ERROR', args) + /** Structured entries (preferred). */ + getEntries(): PluginLogEntry[] { + return this._logs.slice() } - warn(...args: any[]) { - this.write('WARN', args) - } - - setTag(s: string) { - this._tag = s - } - - toJSON() { - return this._logs + /** Legacy tuple format kept for backwards compatibility. */ + toJSON(): Array<[PluginLogLevel, string]> { + return this._logs.map((e) => [e.level, + `[${e.tag}][${new Date(e.ts).toLocaleTimeString()}] ${e.message}`]) } } diff --git a/libs/src/modules/LSPlugin.Experiments.ts b/libs/src/modules/LSPlugin.Experiments.ts index b032107643..5379ff423a 100644 --- a/libs/src/modules/LSPlugin.Experiments.ts +++ b/libs/src/modules/LSPlugin.Experiments.ts @@ -2,6 +2,43 @@ import { LSPluginUser } from '../LSPlugin.user' import { PluginLocal } from '../LSPlugin.core' import { safeSnakeCase } from '../common' +/** + * Declarative condition for matching a block's properties map. + * Operators: has, equals, in, not, any, all. + */ +export type BlockPropertiesCondition = + | { has: string } + | { equals: [string, any] } + | { in: [string, Array] } + | { not: BlockPropertiesCondition } + | { any: Array } + | { all: Array } + +export type BlockPropertiesRendererProps = { + blockId: string + properties: Record +} + +export type BlockRendererChild = Record & { + children?: Array +} + +export type BlockRendererProps = BlockPropertiesRendererProps & { + uuid?: string + page?: string + content?: string + format?: string + children?: Array +} + +export type BlockPropertiesPredicate = ( + props: BlockPropertiesRendererProps +) => boolean + +export type BlockRendererPredicate = ( + props: BlockRendererProps +) => boolean + /** * WARN: These are some experience features and might be adjusted at any time. * These unofficial plugins that use these APIs are temporarily @@ -21,19 +58,20 @@ export class LSPluginExperiments { get Components() { const exper = this.ensureHostScope().logseq.sdk.experiments return { - Editor: exper.cp_page_editor as (props: { page: string } & any) => any + Editor: exper.cp_page_editor as (props: { page: string } & any) => any, } } get Utils() { const utils = this.ensureHostScope().logseq.sdk.utils - const withCall = (name: string): (input: any) => any => utils[safeSnakeCase(name)] + const withCall = (name: string): ((input: any) => any) => + utils[safeSnakeCase(name)] return { toClj: withCall('toClj'), jsxToClj: withCall('jsxToClj'), toJs: withCall('toJs'), toKeyword: withCall('toKeyword'), - toSymbol: withCall('toSymbol') + toSymbol: withCall('toSymbol'), } } @@ -46,7 +84,8 @@ export class LSPluginExperiments { public invokeExperMethod(type: string, ...args: Array) { const host = this.ensureHostScope() type = safeSnakeCase(type)?.toLowerCase() - const fn = host.logseq.api['exper_' + type] || host.logseq.sdk.experiments[type] + const fn = + host.logseq.api['exper_' + type] || host.logseq.sdk.experiments[type] return fn?.apply(host, args) } @@ -83,7 +122,8 @@ export class LSPluginExperiments { registerDaemonRenderer( key: string, opts: { - sub?: Array, + before?: () => Promise + subs?: Array render: (props: {}) => any } ) { @@ -98,9 +138,9 @@ export class LSPluginExperiments { registerHostedRenderer( key: string, opts: { - title?: string, + title?: string subs?: Array - type?: string, + type?: string render: (props: {}) => any } ) { @@ -115,9 +155,9 @@ export class LSPluginExperiments { registerSidebarRenderer( key: string, opts: { - title?: string, + title?: string subs?: Array - render: (props: {}) => any, + render: (props: {}) => any [k: string]: any } ) { @@ -129,9 +169,9 @@ export class LSPluginExperiments { registerRouteRenderer( key: string, opts: { - name?: string, + name?: string subs?: Array - path: string, + path: string render: (props: {}) => any } ) { @@ -143,6 +183,71 @@ export class LSPluginExperiments { ) } + /** + * Register a custom renderer for the block properties area. + * The renderer is shown when the block's properties match the `when` condition. + * `when` may be either a declarative condition object or a synchronous predicate. + * + * @param key Unique key for this renderer (scoped to the plugin). + * @param opts Renderer options. + * @param opts.when Optional condition or synchronous predicate; if omitted, always matches. + * @param opts.mode "prepend" | "append" (default) | "replace". + * @param opts.priority Higher number wins when multiple replace renderers match. + * @param opts.subs Reserved subscription list for future reactive updates. + * @param opts.render React function component receiving `{ blockId, properties }`. + */ + registerBlockPropertiesRenderer( + key: string, + opts: { + when?: BlockPropertiesCondition | BlockPropertiesPredicate + mode?: 'prepend' | 'append' | 'replace' + priority?: number + subs?: Array + render: (props: BlockPropertiesRendererProps) => any + } + ) { + return this.invokeExperMethod( + 'registerBlockPropertiesRenderer', + this.ctx.baseInfo.id, + key, + opts + ) + } + + /** + * Register a custom renderer for the block body. + * When the synchronous predicate matches, the plugin renderer replaces the + * default outline view by default. Users can switch back to outline view via + * an explicit UI toggle on each matched block. + * + * @param key Unique key for this renderer (scoped to the plugin). + * @param opts Renderer options. + * @param opts.when Optional synchronous predicate; if omitted, always matches. + * @param opts.includeChildren When true, passes the block's recursive children + * tree to the renderer and hides native outline children while the plugin + * renderer is active. + * @param opts.priority Higher number wins when multiple block renderers match. + * @param opts.subs Reserved subscription list for future reactive updates. + * @param opts.render React function component receiving block renderer props. + */ + registerBlockRenderer( + key: string, + opts: { + when?: BlockRendererPredicate + includeChildren?: boolean + priority?: number + subs?: Array + render: (props: BlockRendererProps) => any + } + ) { + return this.invokeExperMethod( + 'registerBlockRenderer', + this.ctx.baseInfo.id, + key, + opts + ) + } + registerExtensionsEnhancer( type: 'katex' | 'codemirror', enhancer: (v: T) => Promise @@ -168,7 +273,7 @@ export class LSPluginExperiments { ensureHostScope(): any { try { - const _ = window.top?.document + window.top?.document } catch (_e) { console.error('Can not access host scope!') } diff --git a/src/main/frontend/components/block.cljs b/src/main/frontend/components/block.cljs index 17173068fd..47133ed9de 100644 --- a/src/main/frontend/components/block.cljs +++ b/src/main/frontend/components/block.cljs @@ -81,6 +81,7 @@ [logseq.graph-parser.mldoc :as gp-mldoc] [logseq.graph-parser.text :as text] [logseq.outliner.property :as outliner-property] + [logseq.sdk.utils :as sdk-util] [logseq.shui.dialog.core :as shui-dialog] [logseq.shui.hooks :as hooks] [logseq.shui.ui :as shui] @@ -3252,6 +3253,134 @@ [block* result] [nil result]))) +(defn- build-block-renderer-children-props + [block] + (when-let [block-uuid (:block/uuid block)] + (let [repo (state/get-current-repo) + blocks (some->> (db/get-block-and-children repo block-uuid) + (map (fn [child-block] + (dissoc (db/pull (:db/id child-block)) :block.temp/load-status))))] + (or (some-> blocks + (tree/blocks->vec-tree block-uuid) + first + :block/children + sdk-util/normalize-keyword-for-json) + [])))) + +(defn- build-block-renderer-match-context + ([block] + (build-block-renderer-match-context block false)) + ([block include-children?] + (let [uuid-str (some-> (:block/uuid block) str) + page-title (or (some-> (:block/page block) :block/title) + (when (ldb/page? block) (:block/title block))) + properties-map (if-let [db-id (:db/id block)] + (->> (outliner-property/get-block-full-properties (db/get-db) db-id) + (map :db/ident) + (remove #(= % :logseq.property.class/properties)) + (map (fn [property-id] [property-id (get block property-id)])) + (into {})) + (->> (:block/properties block) + (remove (fn [[property-id _]] (= property-id :logseq.property.class/properties))) + (into {}))) + children (when include-children? + (build-block-renderer-children-props block)) + props (cond-> {:blockId uuid-str + :properties (into {} (map (fn [[k v]] + [(subs (str k) 1) + (plugin-handler/serialize-property-value-for-plugin v)]) + properties-map))} + uuid-str (assoc :uuid uuid-str) + page-title (assoc :page page-title) + (:block/title block) (assoc :content (:block/title block)) + (get block :block/format :markdown) (assoc :format (name (get block :block/format :markdown))) + include-children? (assoc :children children))] + {:block-id uuid-str + :uuid uuid-str + :page page-title + :content (:block/title block) + :format (some-> (get block :block/format :markdown) name) + :properties-map properties-map + :props (clj->js props)}))) + +(defn- block-renderer-supported-view? + [{:keys [sidebar?]} property? table?] + (and (not sidebar?) + (not property?) + (not table?))) + +(defn- block-renderer-display-mode + [{:keys [matched-block-renderer use-plugin-renderer? editing? plugin-renderer-error?]}] + (if (and matched-block-renderer use-plugin-renderer? (not editing?) (not plugin-renderer-error?)) + :plugin + :outline)) + +(defn- show-block-renderer-plugin-toggle? + [display-mode {:keys [matched-block-renderer editing?]}] + (boolean (and matched-block-renderer (not editing?) (= :outline display-mode)))) + +(defn- show-block-renderer-outline-toggle? + [display-mode] + (= :plugin display-mode)) + +(rum/defc setup-plugin-renderer-effects! + [editing? switch-to-plugin-renderer!] + (let [*previous-editing? (hooks/use-ref editing?)] + (hooks/use-effect! + (fn [] + (let [previous-editing? (.-current *previous-editing?)] + (when (and previous-editing? (not editing?)) + (switch-to-plugin-renderer!)) + (set! (.-current *previous-editing?) editing?)) + (fn [])) + [editing?]) + [:<>])) + +(defn- block-renderer-hides-outline-children? + [display-mode {:keys [matched-block-renderer]}] + (boolean (and (= :plugin display-mode) + (true? (:include-children matched-block-renderer))))) + +(defn- block-renderer-outline-view + [config block uuid title table? property? edit-input-id editing? refs-count *hide-block-refs? *show-query? page-icon block-id collapsed?] + [:div.flex.flex-col.w-full + [:div.block-main-content.flex.flex-row.gap-2 + (when page-icon + page-icon) + + [:div.flex.flex-col.w-full + (let [parsed-block (merge block (block/parse-title-and-body uuid (get block :block/format :markdown) title)) + hide-block-refs-count? (or (and (:embed? config) + (= (:block/uuid parsed-block) (:embed-id config))) + table?)] + (block-content-or-editor config + parsed-block + {:edit-input-id edit-input-id + :block-id block-id + :edit? editing? + :refs-count refs-count + :*hide-block-refs? *hide-block-refs? + :hide-block-refs-count? hide-block-refs-count? + :*show-query? *show-query?}))]] + + (when (and (not collapsed?) (not (or table? property?))) + (block-positioned-properties config block :block-below)) + + (when-not (or (:table? config) (:property? config)) + (block-reactions block))]) + +(rum/defcs block-renderer-error-boundary + < {:init (fn [state] + (assoc state ::on-error (some-> state :rum/args first :on-error))) + :did-catch (fn [state error _info] + (when-let [on-error (::on-error state)] + (on-error error)) + (assoc state ::error error))} + [{error ::error} {:keys [fallback-view]} view] + (if (some? error) + fallback-view + view)) + (rum/defcs ^:large-vars/cleanup-todo block-container-inner-aux < rum/reactive db-mixins/query {:init (fn [state] (let [*ref (atom nil) @@ -3270,18 +3399,31 @@ ::ref *ref ::hide-block-refs? (atom default-hide?) ::show-query? (atom false) - ::refs-count *refs-count)))} + ::refs-count *refs-count + ::plugin-renderer-error? (atom false) + ::use-plugin-renderer? (atom true))))} (mixins/event-mixin (fn [state] (let [*ref (::ref state)] - ;; React doesn't let us directly control passive via onTouchMove - ;; So here we listen `touchmove` on the block node + ;; React doesn't let us directly control passive via onTouchMove + ;; So here we listen `touchmove` on the block node (mixins/listen state @*ref "touchmove" block-handler/on-touch-move)))) [state container-state repo config* block {:keys [navigating-block navigated? editing? selected?] :as opts}] (let [*ref (::ref state) *hide-block-refs? (get state ::hide-block-refs?) *show-query? (get state ::show-query?) show-query? (rum/react *show-query?) + *plugin-renderer-error? (get state ::plugin-renderer-error?) + *use-plugin-renderer? (get state ::use-plugin-renderer?) + plugin-renderer-error? (rum/react *plugin-renderer-error?) + use-plugin-renderer? (rum/react *use-plugin-renderer?) + switch-to-plugin-renderer! (fn [] + (reset! *plugin-renderer-error? false) + (reset! *use-plugin-renderer? true)) + switch-to-outline-view! (fn [] + (reset! *plugin-renderer-error? false) + (reset! *use-plugin-renderer? false)) + set-plugin-renderer-error! #(reset! *plugin-renderer-error? %) *refs-count (get state ::refs-count) hide-block-refs? (rum/react *hide-block-refs?) refs-count (rum/react *refs-count) @@ -3307,9 +3449,9 @@ false (or ref-or-custom-query? - (:view? config) - (root-block? config block) - (and (or (ldb/class? block) (ldb/property? block)) (:page-title? config))) + (:view? config) + (root-block? config block) + (and (or (ldb/class? block) (ldb/property? block)) (:page-title? config))) (state/sub-block-collapsed uuid container-id) :else @@ -3331,82 +3473,125 @@ page-icon (when (:page-title? config) (let [icon' (get block :logseq.property/icon)] (when-let [icon (and (ldb/page? block) - (or icon' - (some :logseq.property/icon (:block/tags block)) - (when (ldb/class? block) - {:type :tabler-icon - :id "hash"}) - (when (ldb/property? block) - {:type :tabler-icon - :id "letter-p"})))] + (or icon' + (some :logseq.property/icon (:block/tags block)) + (when (ldb/class? block) + {:type :tabler-icon + :id "hash"}) + (when (ldb/property? block) + {:type :tabler-icon + :id "letter-p"})))] [:div.ls-page-icon.flex.self-start (icon-component/icon-picker icon - {:on-chosen (fn [_e icon] - (if icon - (db-property-handler/set-block-property! - (:db/id block) - :logseq.property/icon - (select-keys icon [:id :type :color])) - ;; del - (db-property-handler/remove-block-property! - (:db/id block) - :logseq.property/icon))) - :del-btn? (boolean icon') - :icon-props {:style {:width "1lh" - :height "1lh" - :font-size (cond - (and (util/mobile?) (:page-title? config)) 24 - (:page-title? config) 38 - :else 18)}}})])))] + {:on-chosen (fn [_e icon] + (if icon + (db-property-handler/set-block-property! + (:db/id block) + :logseq.property/icon + (select-keys icon [:id :type :color])) + ;; del + (db-property-handler/remove-block-property! + (:db/id block) + :logseq.property/icon))) + :del-btn? (boolean icon') + :icon-props {:style {:width "1lh" + :height "1lh" + :font-size (cond + (and (util/mobile?) (:page-title? config)) 24 + (:page-title? config) 38 + :else 18)}}})]))) + ;; --- block renderer (full-block plugin replacement) --- + block-renderer-base-match-context + (when (and config/lsp-enabled? + (plugin-handler/any-block-renderers?) + (block-renderer-supported-view? config property? table?)) + (build-block-renderer-match-context block false)) + matched-block-renderer + (when (and (:props block-renderer-base-match-context) (not editing?)) + (plugin-handler/get-matched-block-renderer block-renderer-base-match-context)) + block-renderer-match-context + (if (true? (:include-children matched-block-renderer)) + (build-block-renderer-match-context block true) + block-renderer-base-match-context) + block-renderer-props-js (:props block-renderer-match-context) + renderer-display-mode + (block-renderer-display-mode {:matched-block-renderer matched-block-renderer + :use-plugin-renderer? use-plugin-renderer? + :editing? editing? + :plugin-renderer-error? plugin-renderer-error?}) + switch-to-plugin-renderer-title + (t (if plugin-renderer-error? + :block/retry-plugin-renderer + :block/switch-to-plugin-renderer)) + switch-to-outline-view-title (t :block/switch-to-outline-view) + outline-view-cp + [:div.flex.flex-col.w-full + (block-renderer-outline-view config block uuid title table? property? edit-input-id editing? refs-count *hide-block-refs? *show-query? page-icon block-id collapsed?) + (when (show-block-renderer-plugin-toggle? + renderer-display-mode + {:matched-block-renderer matched-block-renderer + :editing? editing?}) + (shui/button + {:variant :ghost + :size :icon + :class "self-start h-5 w-5 opacity-20 hover:opacity-70" + :title switch-to-plugin-renderer-title + :aria-label switch-to-plugin-renderer-title + :on-pointer-down util/stop + :on-click (fn [e] + (util/stop e) + (switch-to-plugin-renderer!))} + (shui/tabler-icon "puzzle-piece" {:size 13})))]] + [:div.ls-block.swipe-item (cond-> - {:id (str "ls-block-" - ;; container-id "-" - uuid) - :blockid (str uuid) - :containerid container-id - :data-is-property (ldb/property? block) - :ref #(when (nil? @*ref) (reset! *ref %)) - :data-collapsed (and collapsed? has-child?) - :class (str (when selected? "selected") - (when (ldb/recycled? block) " line-through opacity-70") - (when order-list? " is-order-list") - (when (string/blank? title) " is-blank") - (when original-block " embed-block")) - :haschild (str (boolean has-child?)) - :on-touch-start (fn [event uuid] - (when-not (or @*dragging? (state/editing?)) - (block-handler/on-touch-start event uuid))) - :on-touch-end (fn [event] - (when-not @*dragging? - (block-handler/on-touch-end event)) - (reset! *dragging? false)) - :on-touch-cancel (fn [e] - (block-handler/on-touch-cancel e))} + {:id (str "ls-block-" + ;; container-id "-" + uuid) + :blockid (str uuid) + :containerid container-id + :data-is-property (ldb/property? block) + :ref #(when (nil? @*ref) (reset! *ref %)) + :data-collapsed (and collapsed? has-child?) + :class (str (when selected? "selected") + (when (ldb/recycled? block) " line-through opacity-70") + (when order-list? " is-order-list") + (when (string/blank? title) " is-blank") + (when original-block " embed-block")) + :haschild (str (boolean has-child?)) + :on-touch-start (fn [event uuid] + (when-not (or @*dragging? (state/editing?)) + (block-handler/on-touch-start event uuid))) + :on-touch-end (fn [event] + (when-not @*dragging? + (block-handler/on-touch-end event)) + (reset! *dragging? false)) + :on-touch-cancel (fn [e] + (block-handler/on-touch-cancel e))} (and (util/capacitor?) (not (ldb/page? block))) (assoc - :draggable true - :on-drag-start - (fn [event] - (when-not (state/editing?) - (util/stop-propagation event) - (let [target ^js (.-target event) - blocks (or (seq (state/get-selection-blocks)) [target]) - multiple? (> (count blocks) 1) - element (when multiple? - (let [element (dom/create-element "div")] - (-> element - (dom/set-attr! "id" "dragging-ghost-element") - (dom/set-text! (t :editor/moving-blocks-count (count blocks))) - (dom/set-class! "p-2 rounded text-sm")) - element))] - (doseq [block blocks] - (dom/add-class! block "dragging")) - (on-drag-start event block block-id) - (when element - (dom/append! js/document.body element) - (dnd/set-drag-image! event element (/ (.-offsetWidth target) 2) (/ (.-offsetHeight target) 2))))))) + :draggable true + :on-drag-start + (fn [event] + (when-not (state/editing?) + (util/stop-propagation event) + (let [target ^js (.-target event) + blocks (or (seq (state/get-selection-blocks)) [target]) + multiple? (> (count blocks) 1) + element (when multiple? + (let [element (dom/create-element "div")] + (-> element + (dom/set-attr! "id" "dragging-ghost-element") + (dom/set-text! (t :editor/moving-blocks-count (count blocks))) + (dom/set-class! "p-2 rounded text-sm")) + element))] + (doseq [block blocks] + (dom/add-class! block "dragging")) + (on-drag-start event block block-id) + (when element + (dom/append! js/document.body element) + (dnd/set-drag-image! event element (/ (.-offsetWidth target) 2) (/ (.-offsetHeight target) 2))))))) (:property-default-value? config) (assoc :data-is-property-default-value (:property-default-value? config)) @@ -3456,48 +3641,49 @@ (when (and (not property?) (not (:table-block-title? config))) (let [edit? (or editing? - (= uuid (:block/uuid (state/get-edit-block))))] + (= uuid (:block/uuid (state/get-edit-block))))] (block-control (assoc config :hide-bullet? (:page-title? config)) - block - (merge opts - {:uuid uuid - :block-id block-id - :collapsed? collapsed? - :*control-show? *control-show? - :edit? edit?})))) + block + (merge opts + {:uuid uuid + :block-id block-id + :collapsed? collapsed? + :*control-show? *control-show? + :edit? edit?})))) - [:div.flex.flex-col.w-full - [:div.block-main-content.flex.flex-row.gap-2 - (when page-icon - page-icon) + (if (= :plugin renderer-display-mode) + ;; --- Plugin renderer: full-block replacement --- + [:div.block-renderer-container.flex.flex-col.w-full + (when (show-block-renderer-outline-toggle? renderer-display-mode) + [:div.block-renderer-action-bar + (shui/button + {:variant :outline + :class "block-renderer-action-btn h-6 w-6" + :title switch-to-outline-view-title + :aria-label switch-to-outline-view-title + :on-pointer-down util/stop + :on-click (fn [e] + (util/stop e) + (switch-to-outline-view!))} + (shui/tabler-icon "list" {:size 13}))]) + [:div.ls-block-plugin-renderer + (rum/with-key + (block-renderer-error-boundary + {:on-error (fn [_error] + (set-plugin-renderer-error! true)) + :fallback-view outline-view-cp} + (when-some [renderer (:render matched-block-renderer)] + (js/React.createElement renderer block-renderer-props-js))) + (str "block-renderer-" (:key matched-block-renderer) "-" uuid))]] - ;; Not embed self - [:div.flex.flex-col.w-full - (let [block (merge block (block/parse-title-and-body uuid (get block :block/format :markdown) title)) - hide-block-refs-count? (or (and (:embed? config) - (= (:block/uuid block) (:embed-id config))) - table?)] - (block-content-or-editor config - block - {:edit-input-id edit-input-id - :block-id block-id - :edit? editing? - :refs-count refs-count - :*hide-block-refs? *hide-block-refs? - :hide-block-refs-count? hide-block-refs-count? - :*show-query? *show-query?}))]] - - (when (and (not collapsed?) (not (or table? property?))) - (block-positioned-properties config block :block-below)) - - (when-not (or (:table? config) (:property? config)) - (block-reactions block))]]) + ;; --- Original outline --- + outline-view-cp)]) (when (and (not (:library? config)) - (or (:tag-dialog? config) - (and - (not collapsed?) - (not (or table? property?))))) + (or (:tag-dialog? config) + (and + (not collapsed?) + (not (or table? property?))))) [:div (when-not (:page-title? config) {:style {:padding-left (if (util/mobile?) 12 45)}}) (db-properties-cp config block {:in-block-container? true})]) @@ -3513,15 +3699,15 @@ (block-container config query)])])) (when (and (not (or (:table? config) (:property? config))) - (not hide-block-refs?) - (> refs-count 0) - (not (:page-title? config))) + (not hide-block-refs?) + (> refs-count 0) + (not (:page-title? config))) (when-let [refs-cp (state/get-component :block/linked-references)] [:div.px-4.py-2.border.rounded.my-2.shadow-xs {:style {:margin-left 42}} (refs-cp block {})])) (when (and (not collapsed?) (not (or table? property?)) - (ldb/class-instance? (entity-plus/entity-memoized (db/get-db) :logseq.class/Query) block)) + (ldb/class-instance? (entity-plus/entity-memoized (db/get-db) :logseq.class/Query) block)) (let [query-block (:logseq.property/query (db/entity (:db/id block))) query-block (if query-block (db/sub-block (:db/id query-block)) query-block) query (:block/title query-block) @@ -3530,20 +3716,28 @@ (when query-block [:div {:style {:padding-left 42}} (query/custom-query (wrap-query-components (assoc config - :dsl-query? (not advanced-query?) - :cards? (ldb/class-instance? (entity-plus/entity-memoized - (db/get-db) - :logseq.class/Cards) block))) - (if advanced-query? result {:builder nil - :query (query-builder-component/sanitize-q query)}))]))) + :dsl-query? (not advanced-query?) + :cards? (ldb/class-instance? (entity-plus/entity-memoized + (db/get-db) + :logseq.class/Cards) block))) + (if advanced-query? result {:builder nil + :query (query-builder-component/sanitize-q query)}))]))) - (when-not (or (:hide-children? config) table? property?) + (when-not (or (:hide-children? config) + table? + property? + (block-renderer-hides-outline-children? + renderer-display-mode + {:matched-block-renderer matched-block-renderer})) (let [config' (-> (update config :level inc) - (dissoc :original-block :data))] + (dissoc :original-block :data))] (block-children config' block children collapsed?))) (when-not (or table? property?) - (dnd-separator-wrapper block block-id false))])) + (dnd-separator-wrapper block block-id false)) + + (when config/lsp-enabled? + (setup-plugin-renderer-effects! editing? switch-to-plugin-renderer!))])) (rum/defc block-container-inner [container-state repo config* block opts] diff --git a/src/main/frontend/components/block.css b/src/main/frontend/components/block.css index 62933023ca..206f7f201c 100644 --- a/src/main/frontend/components/block.css +++ b/src/main/frontend/components/block.css @@ -1299,6 +1299,24 @@ html.is-mac { @apply relative inline-block w-full; } +.block-renderer-container { + @apply relative; + + .block-renderer-action-bar { + @apply top-1 right-1 absolute z-10 flex items-center opacity-0 transition-opacity; + } + + .block-renderer-action-btn { + @apply m-1 p-0.5 opacity-70 select-none hover:opacity-90 active:opacity-60; + } + + &:hover, &:active, &:focus-within { + .block-renderer-action-bar { + @apply opacity-100; + } + } +} + .asset-transfer-placeholder { @apply text-sm text-gray-11 mt-2; } diff --git a/src/main/frontend/components/plugin_logs.cljs b/src/main/frontend/components/plugin_logs.cljs new file mode 100644 index 0000000000..95d22e8919 --- /dev/null +++ b/src/main/frontend/components/plugin_logs.cljs @@ -0,0 +1,169 @@ +(ns frontend.components.plugin-logs + "Viewer panel for a single plugin's logs powered by PluginLogger." + (:require [cljs-bean.core :as bean] + [clojure.string :as string] + [frontend.context.i18n :refer [t]] + [frontend.handler.notification :as notification] + [frontend.ui :as ui] + [frontend.util :as util] + [logseq.shui.hooks :as hooks] + [logseq.shui.ui :as shui] + [rum.core :as rum])) + +(def ^:private levels ["DEBUG" "INFO" "WARN" "ERROR"]) + +(defn- ^js get-plugin-logger + [pid] + (when-let [^js core (and pid (.-LSPluginCore js/window))] + (try (.getPluginLogger core pid) (catch :default _ nil)))) + +(defn- get-entries + [pid] + (when-let [^js logger (get-plugin-logger pid)] + (->> (.getEntries logger) + (bean/->clj)))) + +(defn- format-time + [ts] + (let [d (js/Date. ts)] + (.toLocaleTimeString d))) + +(defn- entry->text + [{:keys [ts level tag message]}] + (str "[" (format-time ts) "] " level " [" tag "] " message)) + +(defn- copy-all! + [entries] + (-> (string/join "\n" (map entry->text entries)) + (util/copy-to-clipboard!)) + (notification/show! (t :plugin/logs-copied) :success)) + +(rum/defc ^:large-vars/cleanup-todo plugin-logs-panel + [{:keys [pid name]}] + (let [[entries set-entries!] (rum/use-state (or (get-entries pid) [])) + [level-filter set-level-filter!] (rum/use-state nil) + [keyword-filter set-keyword-filter!] (rum/use-state "") + ^js logger (get-plugin-logger pid) + refresh! (hooks/use-callback + (fn [] (set-entries! (or (get-entries pid) []))) + [pid])] + + (hooks/use-effect! + (fn [] + (when logger + (let [h (fn [] (refresh!))] + (.on logger "change" h) + #(.off logger "change" h)))) + [logger refresh!]) + + (let [filtered (cond->> entries + (seq level-filter) + (filter #(= (:level %) level-filter)) + + (not (string/blank? keyword-filter)) + (filter #(string/includes? + (string/lower-case (str (:message %))) + (string/lower-case keyword-filter))) + + ;; newest first + :always reverse)] + [:div.cp__plugins-logs.flex.flex-col.gap-3 + {:style {:min-width "720px" :max-width "960px"}} + + ;; Row 1 - title + meta + [:div.cp__plugins-logs-head.flex.items-center.justify-between.gap-3.flex-wrap + [:h1.text-lg.font-semibold.flex.items-center.gap-2.m-0 + (ui/icon "file-description") + [:span (t :plugin/logs-title)] + (when name + [:code.opacity-70.text-xs.px-1.5.py-0.5.rounded.bg-gray-03 name])] + + [:div.flex.items-center.gap-3.text-xs.opacity-70 + [:span (str (count filtered) " / " (count entries))] + (when logger + [:span.flex.items-center.gap-1 + "level:" [:code (.getLevel logger)]])]] + + ;; Row 2 - controls + [:div.cp__plugins-logs-toolbar.flex.items-center.gap-2.flex-wrap + ;; keyword filter (flex-grow) + [:div.relative.flex-1.min-w-48.flex.items-center + [:span.absolute.opacity-50.pointer-events-none.flex.items-center + {:style {:left "8px" :top "0" :bottom "0"}} + (ui/icon "search" {:size 14})] + [:input.form-input.text-xs.h-8.w-full + {:style {:paddingLeft "28px"} + :placeholder (t :plugin/logs-filter-placeholder) + :value keyword-filter + :on-change #(set-keyword-filter! (util/evalue %))}]] + + ;; level filter + (shui/select + {:value (or level-filter "*") + :on-value-change (fn [v] (set-level-filter! (if (= v "*") nil v)))} + (shui/select-trigger + {:class "w-32 h-8 text-xs shrink-0"} + (shui/select-value {:placeholder (t :plugin/logs-level-all)})) + (shui/select-content + (shui/select-item {:value "*"} (t :plugin/logs-level-all)) + (for [lvl levels] + (shui/select-item {:value lvl :key lvl} lvl)))) + + [:div.h-5.w-px.bg-gray-05.shrink-0] + + ;; copy (chronological order for readability when pasted) + (shui/button + {:size :sm :variant :outline + :class "h-8 shrink-0" + :on-click #(copy-all! (reverse filtered)) + :title (t :plugin/logs-copy)} + (ui/icon "copy" {:size 14}) + [:span.ml-1.5 (t :plugin/logs-copy)]) + + ;; clear + (shui/button + {:size :sm :variant :outline + :class "h-8 shrink-0" + :on-click (fn [] + (when logger + (.clear logger) + (refresh!))) + :title (t :plugin/logs-clear)} + (ui/icon "trash" {:size 14}) + [:span.ml-1.5 (t :plugin/logs-clear)])] + + ;; Body - log list + [:div.cp__plugins-logs-body.text-xs.font-mono.rounded.border + {:style {:height "60vh" :overflow "auto" :padding "8px" + :background "var(--ls-secondary-background-color)"}} + (if (empty? filtered) + [:div.opacity-60.p-4.text-center (t :plugin/logs-empty)] + (for [[idx e] (map-indexed vector filtered)] + [:div.cp__plugins-logs-row.flex.gap-1.py-0.5.items-start + {:key (str idx "-" (:ts e)) + :data-level (:level e)} + [:span.opacity-60.shrink-0 {:style {:width "80px"}} + (format-time (:ts e))] + [:span.shrink-0.font-bold + {:style {:width "56px" + :color (case (:level e) + "ERROR" "var(--ls-error-text-color, #ef4444)" + "WARN" "#d97706" + "INFO" "var(--ls-active-primary-color)" + "DEBUG" "var(--ls-icon-color)" + nil)}} + (:level e)] + [:span.opacity-70.shrink-0 (str "[" (:tag e) "]")] + [:span.whitespace-pre-wrap.break-all.flex-1 (:message e)]]))]]))) + +(defn open-plugin-logs! + [{:keys [_pid _name] :as opts}] + (shui/dialog-open! + (fn [] (plugin-logs-panel opts)) + {:label "plugin-logs-modal" + :class "lsp-plugin-logs-dialog" + :content-props {:on-open-auto-focus #(.preventDefault %)}})) + + + + diff --git a/src/main/frontend/components/plugins.cljs b/src/main/frontend/components/plugins.cljs index cde8bf2f05..8a852b9573 100644 --- a/src/main/frontend/components/plugins.cljs +++ b/src/main/frontend/components/plugins.cljs @@ -3,6 +3,7 @@ [clojure.string :as string] [electron.ipc :as ipc] [frontend.components.plugins-settings :as plugins-settings] + [frontend.components.plugin-logs :as plugin-logs] [frontend.components.svg :as svg] [frontend.config :as config] [frontend.context.i18n :refer [interpolate-rich-text interpolate-rich-text-node t]] @@ -258,6 +259,7 @@ [:strong (ui/icon "settings")] [:ul.menu-list [:li {:on-click #(plugin-handler/open-plugin-settings! id false)} (t :plugin/open-settings)] + [:li {:on-click #(plugin-logs/open-plugin-logs! {:pid id :name name})} (t :plugin/open-logs)] (when (util/electron?) [:li {:on-click #(js/apis.openPath url)} (t :plugin/open-package)]) [:li {:on-click #(plugin-handler/open-report-modal! id name)} (t :plugin/report-security)] diff --git a/src/main/frontend/components/property.cljs b/src/main/frontend/components/property.cljs index eecc1a98af..683c57743b 100644 --- a/src/main/frontend/components/property.cljs +++ b/src/main/frontend/components/property.cljs @@ -16,6 +16,7 @@ [frontend.db.model :as db-model] [frontend.handler.db-based.property :as db-property-handler] [frontend.handler.notification :as notification] + [frontend.handler.plugin :as plugin-handler] [frontend.handler.property :as property-handler] [frontend.handler.route :as route-handler] [frontend.mixins :as mixins] @@ -65,6 +66,13 @@ property) (notification/show! (t :property.validation/invalid-name) :error))))) +(defn- enable-block-properties-renderers? + [{:keys [sidebar? sidebar-properties?]} class?] + (and config/lsp-enabled? + (not class?) + (not sidebar?) + (not sidebar-properties?))) + ;; TODO: This component should be cleaned up as it's only used for new properties and used to be used for existing properties (rum/defcs property-type-select < shortcut/disable-all-shortcuts @@ -827,28 +835,66 @@ :else (let [remove-properties #{:logseq.property/icon :logseq.property/query} properties' (->> (remove (fn [[k _v]] (contains? remove-properties k)) - full-properties) - (remove (fn [[k _v]] (= k :logseq.property.class/properties)))) + full-properties) + (remove (fn [[k _v]] (= k :logseq.property.class/properties)))) page? (entity-util/page? block) - class? (entity-util/class? block)] + class? (entity-util/class? block) + plugin-properties (->> (concat full-properties hidden-properties) + (remove (fn [[k _v]] (= k :logseq.property.class/properties))) + (into {})) + props-for-plugin (when (enable-block-properties-renderers? opts class?) + (clj->js {:blockId (str (:block/uuid block)) + :properties (into {} (map (fn [[k v]] + [(subs (str k) 1) + (plugin-handler/serialize-property-value-for-plugin v)]) + plugin-properties))})) + plugin-renderers (when props-for-plugin + (plugin-handler/get-matched-block-properties-renderers + {:block-id (str (:block/uuid block)) + :properties-map plugin-properties + :props props-for-plugin})) + prepend-renderers (filter #(= "prepend" (:mode %)) plugin-renderers) + replace-renderer (first (filter #(= "replace" (:mode %)) plugin-renderers)) + append-renderers (remove #(contains? #{"prepend" "replace"} (:mode %)) plugin-renderers)] + [:div.ls-properties-area {:id id :class (util/classnames [{:ls-page-properties page?}]) :tab-index 0} [:<> - (properties-section block properties' opts) - (bidirectional-properties-section bidirectional-properties) + (mapv (fn [r] + (when (fn? (:render r)) + (rum/with-key + (js/React.createElement (:render r) props-for-plugin) + (str "plugin-prepend-" (:key r))))) + prepend-renderers) + + (if replace-renderer + (when (fn? (:render replace-renderer)) + (rum/with-key + (js/React.createElement (:render replace-renderer) props-for-plugin) + (str "plugin-replace-" (:key replace-renderer)))) + [:<> + (properties-section block properties' opts) + (bidirectional-properties-section bidirectional-properties)]) (when-not class? (hidden-properties-cp block hidden-properties - (assoc opts :root-block? root-block?))) + (assoc opts :root-block? root-block?))) (when (and page? (not class?)) (rum/with-key (new-property block opts) (str id "-add-property"))) + (mapv (fn [r] + (when (fn? (:render r)) + (rum/with-key + (js/React.createElement (:render r) props-for-plugin) + (str "plugin-append-" (:key r))))) + append-renderers) + (when class? (let [properties (->> (:logseq.property.class/properties block) - (map (fn [e] [(:db/ident e)]))) + (map (fn [e] [(:db/ident e)]))) opts' (assoc opts :class-schema? true)] [:div.flex.flex-col.gap-1 [:div {:style {:font-size 15}} @@ -860,5 +906,5 @@ [:div.ml-4 (properties-section block properties opts') (hidden-properties-cp block hidden-properties - (assoc opts :root-block? root-block?)) + (assoc opts :root-block? root-block?)) (rum/with-key (new-property block opts') (str id "-class-add-property"))]]))]])))])) diff --git a/src/main/frontend/core.cljs b/src/main/frontend/core.cljs index cff73af1fe..018d71230e 100644 --- a/src/main/frontend/core.cljs +++ b/src/main/frontend/core.cljs @@ -22,19 +22,36 @@ [reitit.frontend :as rf] [reitit.frontend.easy :as rfe])) -(defn set-router! +(defn- build-router [] - (.addEventListener js/window "popstate" route-handler/restore-scroll-pos) - (rfe/start! - (rf/router (plugins/hook-custom-routes routes/routes) nil) - (fn [route] - (route-handler/set-route-match! route) - (plugin-handler/hook-plugin-app - :route-changed (select-keys route [:template :path :parameters]))) + (rf/router (plugins/hook-custom-routes routes/routes) nil)) +(defn- on-navigate + [route] + (route-handler/set-route-match! route) + (plugin-handler/hook-plugin-app + :route-changed (select-keys route [:template :path :parameters]))) + +(defn refresh-router! + "Rebuilds the reitit router so route renderers registered by plugins after + the app started take effect. Safe to call repeatedly; `rfe/start!` stops the + previous history instance internally." + [] + (rfe/start! + (build-router) + on-navigate ;; set to false to enable HistoryAPI {:use-fragment true})) +(defonce ^:private *popstate-installed? (atom false)) + +(defn set-router! + [] + (when (compare-and-set! *popstate-installed? false true) + (.addEventListener js/window "popstate" route-handler/restore-scroll-pos)) + (refresh-router!) + (plugin-handler/set-route-renderer-refresh-fn! refresh-router!)) + (defn display-welcome-message [] (js/console.log diff --git a/src/main/frontend/handler/plugin.cljs b/src/main/frontend/handler/plugin.cljs index 6266a2c5be..6d6bc6500f 100644 --- a/src/main/frontend/handler/plugin.cljs +++ b/src/main/frontend/handler/plugin.cljs @@ -4,10 +4,11 @@ [cljs-bean.core :as bean] [clojure.string :as string] [clojure.walk :as walk] + [datascript.impl.entity :as de] [electron.ipc :as ipc] [frontend.components.svg :as svg] [frontend.config :as config] - [frontend.context.i18n :refer [interpolate-rich-text t]] + [frontend.context.i18n :refer [t interpolate-rich-text]] [frontend.format :as format] [frontend.fs :as fs] [frontend.handler.common.plugin :as plugin-common-handler] @@ -30,18 +31,11 @@ (when input (let [f (fn [[k v]] (if (keyword? k) [(csk/->camelCase (name k)) v] [k v]))] (walk/postwalk - (fn [x] - (cond - (map? x) (into {} (map f x)) - (uuid? x) (str x) - :else x)) input)))) - -(defn- normalize-user-key-without-ns - [k] - (some-> k (name) - (string/replace "/" "$") - (string/replace " " "_") - (string/replace #"^[:_\s]+" ""))) + (fn [x] + (cond + (map? x) (into {} (map f x)) + (uuid? x) (str x) + :else x)) input)))) (defn invoke-exported-api [type & args] @@ -71,12 +65,12 @@ (let [name (some-> e (aget "name")) message (some-> e (aget "message")) url (or (some-> e (aget "url")) - (some->> message (re-matches illegal-plugin-package-error-pattern) second)) + (some->> message (re-matches illegal-plugin-package-error-pattern) second)) url (some-> url util/node-path.normalize) package-json-path (or (some-> e (aget "packageJsonPath")) - (some-> url (util/node-path.join "package.json")))] + (some-> url (util/node-path.join "package.json")))] (when (and (= "IllegalPluginPackageError" name) - (not (string/blank? url))) + (not (string/blank? url))) {:url url :package-json-path package-json-path}))) @@ -86,7 +80,7 @@ dotroot (some-> (get-ls-dotdir-root) util/node-path.normalize) dotplugins-root (some-> dotroot (util/node-path.join "plugins"))] (if (and dotplugins-root - (string/starts-with? url dotplugins-root)) + (string/starts-with? url dotplugins-root)) {:type :installed :id (util/node-path.basename url) :url url} @@ -103,7 +97,7 @@ removed? (not= externals updated-externals) _ (when removed? (invoke-exported-api :save_user_preferences - (clj->js (assoc prefs :externals updated-externals))))] + (clj->js (assoc prefs :externals updated-externals))))] removed?)) (defn- remove-problematic-plugin! @@ -114,8 +108,8 @@ (p/let [_ (when (util/electron?) (ipc/ipc :uninstallMarketPlugin id)) _ (-> (plugin-config-handler/remove-plugin id) - (p/catch (fn [error] - (log/warn :remove-broken-plugin-config-error error))))] + (p/catch (fn [error] + (log/warn :remove-broken-plugin-config-error error))))] source) :external @@ -128,37 +122,37 @@ (let [{:keys [type id]} (problematic-plugin-source url) uid (keyword (str "plugin-illegal-package-error-" (hash url)))] (notification/show! - [:div.flex.flex-col.gap-2 - [:div (t :plugin.package-config/parse-error)] - [:div.text-xs.opacity-70.break-all package-json-path] - (when (= type :external) - [:div.text-xs.opacity-70 - (t :plugin.package-config/detach-desc)]) - [:div.flex.items-center.gap-2.pt-1 - (shui/button - {:size :sm - :on-click (fn [] - (-> (remove-problematic-plugin! url) + [:div.flex.flex-col.gap-2 + [:div (t :plugin.package-config/parse-error)] + [:div.text-xs.opacity-70.break-all package-json-path] + (when (= type :external) + [:div.text-xs.opacity-70 + (t :plugin.package-config/detach-desc)]) + [:div.flex.items-center.gap-2.pt-1 + (shui/button + {:size :sm + :on-click (fn [] + (-> (remove-problematic-plugin! url) (p/then (fn [_] (notification/clear! uid) (notification/show! - (if (= type :installed) - (t :plugin.package-config/remove-installed-success id) - (t :plugin.package-config/remove-external-success)) - :success))) - (p/catch (fn [error] + (if (= type :installed) + (t :plugin.package-config/remove-installed-success id) + (t :plugin.package-config/remove-external-success)) + :success))) + (p/catch (fn [_error] (notification/show! - (str (t :plugin.package-config/remove-error) "\n" error) - :error)))))} - (t :plugin/uninstall))]] - :error false uid) + (t :plugin.package-config/remove-error) + :error)))))} + (t :plugin/uninstall))]] + :error false uid) true) (notification/show! (t :plugin/invalid-package) :error))) (defn setup-global-apis-for-web! [] (when (and util/web-platform? - (nil? js/window.apis)) + (nil? js/window.apis)) (let [^js e (js/window.EventEmitter3.)] (set! (. js/window -apis) e)))) @@ -171,25 +165,25 @@ [theme] (when theme (cond-> theme - (util/electron?) - (update :url #(some-> % (string/replace-first "assets://" "file://")))))) + (util/electron?) + (update :url #(some-> % (string/replace-first "assets://" "file://")))))) (defn load-plugin-preferences [] (-> (invoke-exported-api :load_user_preferences) - (p/then #(bean/->clj %)) - (p/then #(state/set-state! :plugin/preferences %)) - (p/catch - #(js/console.error %)))) + (p/then #(bean/->clj %)) + (p/then #(state/set-state! :plugin/preferences %)) + (p/catch + #(js/console.error %)))) (defn save-plugin-preferences! ([input] (save-plugin-preferences! input true)) ([input reload-state?] (when-let [^js input (and (map? input) (bean/->js input))] (p/then - (js/LSPluginCore.saveUserPreferences input) - #(when reload-state? - (load-plugin-preferences)))))) + (js/LSPluginCore.saveUserPreferences input) + #(when reload-state? + (load-plugin-preferences)))))) (defn gh-repo-url [repo] (str "https://github.com/" repo)) @@ -203,66 +197,66 @@ [refresh?] (if (or refresh? (nil? (:plugin/marketplace-pkgs @state/state))) (p/create - (fn [resolve reject] - (let [on-ok (fn [res] - (if-let [res (and res (bean/->clj res))] - (let [pkgs (:packages res) - pkgs (if (util/electron?) pkgs - (some->> pkgs (filterv #(or (true? (:web %)) (not (true? (:effect %)))))))] - (state/set-state! :plugin/marketplace-pkgs pkgs) - (resolve pkgs)) - (reject nil)))] - (if (state/http-proxy-enabled-or-val?) - (-> (ipc/ipc :httpFetchJSON plugins-url) - (p/then on-ok) - (p/catch reject)) - (util/fetch plugins-url on-ok reject))))) + (fn [resolve reject] + (let [on-ok (fn [res] + (if-let [res (and res (bean/->clj res))] + (let [pkgs (:packages res) + pkgs (if (util/electron?) pkgs + (some->> pkgs (filterv #(or (true? (:web %)) (not (true? (:effect %)))))))] + (state/set-state! :plugin/marketplace-pkgs pkgs) + (resolve pkgs)) + (reject nil)))] + (if (state/http-proxy-enabled-or-val?) + (-> (ipc/ipc :httpFetchJSON plugins-url) + (p/then on-ok) + (p/catch reject)) + (util/fetch plugins-url on-ok 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] - (let [on-ok (fn [^js res] - (if-let [res (and res (bean/->clj res))] - (do - (state/set-state! - :plugin/marketplace-stats - (into {} (map (fn [[k stat]] - [k (assoc stat - :total_downloads - (reduce (fn [a b] (+ a (get b 2))) 0 (:releases stat)))]) - res))) - (resolve nil)) - (reject nil)))] - (if (state/http-proxy-enabled-or-val?) - (-> (ipc/ipc :httpFetchJSON stats-url) - (p/then on-ok) - (p/catch reject)) - (util/fetch stats-url on-ok reject))))) + (fn [resolve reject] + (let [on-ok (fn [^js res] + (if-let [res (and res (bean/->clj res))] + (do + (state/set-state! + :plugin/marketplace-stats + (into {} (map (fn [[k stat]] + [k (assoc stat + :total_downloads + (reduce (fn [a b] (+ a (get b 2))) 0 (:releases stat)))]) + res))) + (resolve nil)) + (reject nil)))] + (if (state/http-proxy-enabled-or-val?) + (-> (ipc/ipc :httpFetchJSON stats-url) + (p/then on-ok) + (p/catch reject)) + (util/fetch stats-url on-ok reject))))) (p/resolved nil))) (defn check-or-update-marketplace-plugin! [{:keys [id] :as pkg} error-handler] (when-not (and (:plugin/installing @state/state) - (not (plugin-common-handler/installed? id))) + (not (plugin-common-handler/installed? id))) (state/set-state! :plugin/installing pkg) (-> (load-marketplace-plugins false) - (p/then (fn [manifests] - (let [mft (some #(when (= (:id %) id) %) manifests) - opts (merge (dissoc pkg :logger) mft)] - ;;TODO: (throw (js/Error. [:not-found-in-marketplace id])) - (if (util/electron?) - (ipc/ipc :updateMarketPlugin opts) - (plugin-common-handler/async-install-or-update-for-web! opts))) - true)) - (p/catch (fn [^js e] - (state/reset-all-updates-state) - (error-handler e) - (state/set-state! :plugin/installing nil) - (js/console.error e)))))) + (p/then (fn [manifests] + (let [mft (some #(when (= (:id %) id) %) manifests) + opts (merge (dissoc pkg :logger) mft)] + ;;TODO: (throw (js/Error. [:not-found-in-marketplace id])) + (if (util/electron?) + (ipc/ipc :updateMarketPlugin opts) + (plugin-common-handler/async-install-or-update-for-web! opts))) + true)) + (p/catch (fn [^js e] + (state/reset-all-updates-state) + (error-handler e) + (state/set-state! :plugin/installing nil) + (js/console.error e)))))) (defn get-plugin-inst [pid] @@ -283,17 +277,17 @@ (when-let [matched (medley/find-first #(= (:key (second %)) key) commands)] (let [[_ cmd action pid] matched] (state/pub-event! - [:exec-plugin-cmd {:type type :key key :pid pid :cmd (assoc cmd :args args) :action action}]))))) + [:exec-plugin-cmd {:type type :key key :pid pid :cmd (assoc cmd :args args) :action action}]))))) (defn open-updates-downloading [] (when (and (not (:plugin/updates-downloading? @state/state)) - (seq (state/all-available-coming-updates))) + (seq (state/all-available-coming-updates))) (->> (:plugin/updates-coming @state/state) - (map #(if (state/coming-update-new-version? (second %1)) - (update % 1 dissoc :error-code) %1)) - (into {}) - (state/set-state! :plugin/updates-coming)) + (map #(if (state/coming-update-new-version? (second %1)) + (update % 1 dissoc :error-code) %1)) + (into {}) + (state/set-state! :plugin/updates-coming)) (state/set-state! :plugin/updates-downloading? true))) (defn close-updates-downloading @@ -320,34 +314,34 @@ (case (keyword status) :completed (let [{:keys [id dst name title theme web-pkg]} payload - name (or title name (t :ui/untitled))] + name (or title name "Untitled")] (if only-check (state/consume-updates-from-coming-plugin! payload false) (if (plugin-common-handler/installed? id) ;; update plugin (when-let [^js pl (get-plugin-inst id)] (p/then - (.reload pl) - #(do - ;;(if theme (select-a-plugin-theme id)) - (when (not (util/electron?)) - (set! (.-version (.-options pl)) (:version web-pkg)) - (set! (.-webPkg (.-options pl)) (bean/->js web-pkg)) - (invoke-exported-api :save_installed_web_plugin (.toJSON pl false))) - (notification/show! - (t :plugin/update-plugin name (.-version (.-options pl))) :success) - (state/consume-updates-from-coming-plugin! payload true)))) + (.reload pl) + #(do + ;;(if theme (select-a-plugin-theme id)) + (when (not (util/electron?)) + (set! (.-version (.-options pl)) (:version web-pkg)) + (set! (.-webPkg (.-options pl)) (bean/->js web-pkg)) + (invoke-exported-api :save_installed_web_plugin (.toJSON pl false))) + (notification/show! + (t :plugin/update-plugin name (.-version (.-options pl))) :success) + (state/consume-updates-from-coming-plugin! payload true)))) ;; register plugin (-> (js/LSPluginCore.register (bean/->js {:key id :url dst :webPkg web-pkg})) - (p/then (fn [] - (when-let [^js pl (get-plugin-inst id)] - (when theme (js/setTimeout #(select-a-plugin-theme id) 300)) - (when (.-isWebPlugin pl) - (invoke-exported-api :save_installed_web_plugin (.toJSON pl false))) - (notification/show! - (t :plugin/installed-plugin name) :success)))) - (p/catch (fn [^js e] - (notification/show! + (p/then (fn [] + (when-let [^js pl (get-plugin-inst id)] + (when theme (js/setTimeout #(select-a-plugin-theme id) 300)) + (when (.-isWebPlugin pl) + (invoke-exported-api :save_installed_web_plugin (.toJSON pl false))) + (notification/show! + (t :plugin/installed-plugin name) :success)))) + (p/catch (fn [^js e] + (notification/show! (t :plugin/install-error name (.-message e)) :error))))))) @@ -360,9 +354,6 @@ [(t :plugin/up-to-date ":)") :success] [error-code :error]) - msg (cond-> msg - (keyword? msg) - name) pending? (seq (:plugin/updates-pending @state/state))] (if (and only-check pending?) @@ -375,11 +366,10 @@ ;; notify human tips (notification/show! - (str - (if (= :error type) (t :ui/error) "") - "<" (:id payload) "> " - msg) - type))) + (str + (if (= :error type) "[Error]" "") + "<" (:id payload) "> " + msg) type))) (when-not fake-error? (js/console.error "Update Error:" (:error-code payload)))) @@ -397,15 +387,15 @@ (defn- normalize-plugin-metadata [metadata] (cond-> metadata - (not (string? (:author metadata))) - (assoc :author (or (get-in metadata [:author :name]) "")))) + (not (string? (:author metadata))) + (assoc :author (or (get-in metadata [:author :name]) "")))) (defn register-plugin [plugin-metadata] (when-let [pid (keyword (:id plugin-metadata))] (some->> plugin-metadata - (normalize-plugin-metadata) - (swap! state/state update-in [:plugin/installed-plugins] assoc pid)))) + (normalize-plugin-metadata) + (swap! state/state update-in [:plugin/installed-plugins] assoc pid)))) (defn host-mounted! [] @@ -416,7 +406,7 @@ (when-let [pid (keyword pid)] (when (contains? (:plugin/installed-plugins @state/state) pid) (swap! state/state update-in [:plugin/installed-slash-commands pid] - (fnil merge {}) (hash-map cmd (mapv #(conj % {:pid pid}) actions))) + (fnil merge {}) (hash-map cmd (mapv #(conj % {:pid pid}) actions))) (state/pub-event! [:rebuild-slash-commands-list]) true))) @@ -442,7 +432,7 @@ (get keybinding-mode-handler-map (keyword mode))) :action (fn [] (state/pub-event! - [:exec-plugin-cmd {:type type :key key :pid pid :cmd cmd :action action}]))}] + [:exec-plugin-cmd {:type type :key key :pid pid :cmd cmd :action action}]))}] palette-cmd)) @@ -451,8 +441,8 @@ (let [id (keyword (str "plugin." pid "/" key)) binding (:binding keybinding) binding (some->> (if (string? binding) [binding] (vec binding)) - (remove string/blank?) - (map shortcut-utils/undecorate-binding)) + (remove string/blank?) + (map shortcut-utils/undecorate-binding)) binding (if util/mac? (or (:mac keybinding) binding) binding) mode (or (:mode keybinding) :global) @@ -465,7 +455,7 @@ (when-let [pid (keyword pid)] (when (contains? (:plugin/installed-plugins @state/state) pid) (swap! state/state update-in [:plugin/simple-commands pid] - (fnil conj []) [type cmd action pid]) + (fnil conj []) [type cmd action pid]) true))) (defn unregister-plugin-simple-command @@ -479,13 +469,15 @@ (let [items (or (get-in @state/state [:plugin/installed-ui-items pid]) []) items (filter #(not= key (:key (second %))) items)] (swap! state/state assoc-in [:plugin/installed-ui-items pid] - (conj items [type opts pid]))) + (conj items [type opts pid]))) true))) (defn unregister-plugin-ui-items [pid] (swap! state/state assoc-in [:plugin/installed-ui-items (keyword pid)] [])) +(declare *route-renderer-providers schedule-route-renderer-refresh!) + (defn register-plugin-resources [pid type {:keys [key] :as opts}] (when-let [pid (keyword pid)] @@ -494,13 +486,31 @@ ;; TODO: conditions ;; (when (contains? #{:error nil} (get-in @state/state (conj path key)))) (swap! state/state update-in path - (fnil assoc {}) key (merge opts {:pid pid})) + (fnil assoc {}) key (merge opts {:pid pid})) + true)))) + +(defn unregister-plugin-resource + [pid type key] + (when-let [pid (keyword pid)] + (when-let [type (and key (keyword type))] + (let [path [:plugin/installed-resources pid type]] + (swap! state/state + (fn [state] + (let [resources (get-in state path) + resources' (some-> resources (dissoc key))] + (if (seq resources') + (assoc-in state path resources') + (medley/dissoc-in state path))))) true)))) (defn unregister-plugin-resources [pid] (when-let [pid (keyword pid)] - (swap! state/state medley/dissoc-in [:plugin/installed-resources pid]) + (let [had-routes? (contains? @*route-renderer-providers pid)] + (swap! state/state medley/dissoc-in [:plugin/installed-resources pid]) + (swap! *route-renderer-providers disj pid) + (when had-routes? + (schedule-route-renderer-refresh!))) true)) (defn register-plugin-search-service @@ -535,87 +545,294 @@ (defn- create-local-renderer-register [type *providers] (fn [pid key {subs' :subs :keys [render] :as opts}] - (when-let [key (some-> key (normalize-user-key-without-ns) (keyword))] - (register-plugin-resources pid type - (merge opts {:key key :subs subs' :render render})) - (swap! *providers conj pid) - #(swap! *providers disj pid)))) + (when-let [key (and key (keyword key))] + (let [pid (keyword pid)] + (register-plugin-resources pid type + (merge opts {:key key :subs subs' :render render})) + (swap! *providers conj pid) + #(do + (unregister-plugin-resource pid type key) + (when-not (seq (state/get-plugin-resources-with-type pid type)) + (swap! *providers disj pid))))))) (defn- create-local-renderer-getter ([type *providers] (create-local-renderer-getter type *providers false)) ([type *providers many?] (fn [key] (when (seq @*providers) - (if-let [key (some-> key (normalize-user-key-without-ns) (keyword))] + (if key (when-let [rs (->> @*providers - (map (fn [pid] (state/get-plugin-resource pid type key))) - (remove nil?) - (flatten) - (seq))] + (map (fn [pid] (state/get-plugin-resource pid type key))) + (remove nil?) + (flatten) + (seq))] (if many? rs (first rs))) (->> @*providers - (mapcat (fn [pid] - (some-> (state/get-plugin-resources-with-type pid type) - (vals)))) - (seq))))))) + (mapcat (fn [pid] + (some-> (state/get-plugin-resources-with-type pid type) + (vals)))) + (seq))))))) (defonce *fenced-code-providers (atom #{})) (def register-fenced-code-renderer ;; [pid key payload] (create-local-renderer-register - :fenced-code-renderers *fenced-code-providers)) + :fenced-code-renderers *fenced-code-providers)) (def hook-fenced-code-by-lang ;; [key] (create-local-renderer-getter - :fenced-code-renderers *fenced-code-providers)) + :fenced-code-renderers *fenced-code-providers)) (def *extensions-enhancer-providers (atom #{})) (def register-extensions-enhancer ;; a plugin can only register one enhancer for a type (create-local-renderer-register - :extensions-enhancers *extensions-enhancer-providers)) + :extensions-enhancers *extensions-enhancer-providers)) (def hook-extensions-enhancers-by-key ;; multiple plug-ins can obtain more than one enhancer (create-local-renderer-getter - :extensions-enhancers *extensions-enhancer-providers true)) + :extensions-enhancers *extensions-enhancer-providers true)) (defonce *route-renderer-providers (atom #{})) -(def register-route-renderer - ;; [pid key payload] - (create-local-renderer-register - :route-renderers *route-renderer-providers)) +;; Indirection to avoid a circular dependency on `frontend.core`. The frontend +;; entry ns installs a fn here that rebuilds the reitit router so plugin routes +;; registered after initial app start actually take effect. +(defonce *route-renderer-refresh-fn (atom nil)) +(defonce ^:private *route-renderer-refresh-scheduled? (atom false)) + +(defn set-route-renderer-refresh-fn! + "Registers a 0-arg fn invoked (debounced via microtask) whenever the set of + plugin route renderers changes. Called from `frontend.core/set-router!`." + [f] + (reset! *route-renderer-refresh-fn f)) + +(defn- schedule-route-renderer-refresh! + [] + (when-let [f @*route-renderer-refresh-fn] + (when (compare-and-set! *route-renderer-refresh-scheduled? false true) + ;; Coalesce bursts of register/unregister calls into a single rebuild. + (js/setTimeout + (fn [] + (reset! *route-renderer-refresh-scheduled? false) + (try (f) + (catch :default e + (js/console.error "[plugin] refresh route renderer failed" e)))) + 0)))) + +(let [base-register (create-local-renderer-register + :route-renderers *route-renderer-providers)] + (defn register-route-renderer + ;; [pid key payload] + [pid key opts] + (let [unregister (base-register pid key opts)] + (schedule-route-renderer-refresh!) + (when (fn? unregister) + (fn [] + (let [r (unregister)] + (schedule-route-renderer-refresh!) + r)))))) + (def get-route-renderers ;; [key] optional (create-local-renderer-getter - :route-renderers *route-renderer-providers true)) + :route-renderers *route-renderer-providers true)) (defonce *daemon-renderer-providers (atom #{})) (def register-daemon-renderer ;; [pid key payload] (create-local-renderer-register - :daemon-renderers *daemon-renderer-providers)) + :daemon-renderers *daemon-renderer-providers)) (def get-daemon-renderers ;; [key] (create-local-renderer-getter - :daemon-renderers *daemon-renderer-providers true)) + :daemon-renderers *daemon-renderer-providers true)) (defonce *hosted-renderer-providers (atom #{})) -(def register-hosted-renderer - ;; [pid key payload] + +;; Pre-created internal register functions — all share *hosted-renderer-providers +;; but use separate storage type keywords so keys don't collide across renderer types. +(def ^:private -register-hosted (create-local-renderer-register - :hosted-renderers *hosted-renderer-providers)) + :hosted-renderers *hosted-renderer-providers)) + +(def ^:private -register-block + (create-local-renderer-register + :block-renderers *hosted-renderer-providers)) + +(def ^:private -register-block-properties + (create-local-renderer-register + :block-properties-renderers *hosted-renderer-providers)) + +(defn register-hosted-renderer + "Unified renderer registration. Routes by `:type` in opts: + \"block\" → block renderer storage + \"block-properties\" → block-properties renderer storage + else → hosted renderer storage (sidebar, etc.)" + [pid key opts] + (let [register-fn (case (:type opts) + "block" -register-block + "block-properties" -register-block-properties + -register-hosted)] + (register-fn pid key opts))) + (def get-hosted-renderers ;; [key] (create-local-renderer-getter - :hosted-renderers *hosted-renderer-providers true)) + :hosted-renderers *hosted-renderer-providers true)) (defn resolve-hosted-render [pid key type] (some->> (get-hosted-renderers) - (medley/find-first #(and (some-> (:pid %) (name) (= pid)) - (or (some-> (:key %) (name) (= key)) - (some-> (:key %) (str) (string/includes? (str "." key)))) - (some->> type (name) (= (:type %))))))) + (medley/find-first #(and (some-> (:pid %) (name) (= pid)) + (or (some-> (:key %) (name) (= key)) + (some-> (:key %) (str) (string/includes? (str "." key)))) + (some->> type (name) (= (:type %))))))) + +;; Block renderers +(defn- ->block-renderer-properties-js + [properties-map] + (into {} (map (fn [[k v]] [(subs (str k) 1) v]) properties-map))) + +(defn- normalize-block-renderer-match-context + [{:keys [block-id properties-map props uuid page content format] :as match-context}] + (if (contains? match-context :properties-map) + (assoc match-context + :props + (or props + (clj->js (cond-> {:blockId block-id + :properties (->block-renderer-properties-js properties-map)} + uuid (assoc :uuid uuid) + page (assoc :page page) + content (assoc :content content) + format (assoc :format format))))) + (normalize-block-renderer-match-context {:properties-map match-context}))) + +(defn- promise-like? + [result] + (or (instance? js/Promise result) + (some-> result (aget "then") fn?))) + +(defn- match-renderer-predicate + "Run a synchronous predicate against JS props for any block renderer type. + `error-tag` is a keyword prefix used to distinguish log messages, + e.g. :block-renderer or :block-properties-renderer." + [error-tag predicate {:keys [props]} {:keys [pid key]}] + (try + (let [result (predicate props)] + (cond + (promise-like? result) + (do + (log/error (keyword (str (name error-tag) "-predicate-async")) + {:pid pid + :key key + :message (str "`when` predicate for " (name error-tag) " must return synchronously.")}) + false) + + :else + (boolean result))) + (catch :default error + (log/error (keyword (str (name error-tag) "-predicate-exception")) + {:pid pid + :key key + :error error}) + false))) + +(defn match-block-properties-condition + "Match a block-properties renderer condition against a block. + condition may be nil, a declarative condition map like {:has `ident`}, + or a synchronous predicate receiving JS props {:blockId :properties}. + properties-map is a map of keyword db-idents -> values." + [condition match-context renderer] + (let [{:keys [properties-map] :as match-context'} (normalize-block-renderer-match-context match-context)] + (cond + (nil? condition) + true + + (fn? condition) + (match-renderer-predicate :block-properties-renderer condition match-context' renderer) + + :else + (let [op (some-> condition first key) + arg (some-> condition first val)] + (case op + :has (contains? properties-map (keyword arg)) + :equals (let [[prop-key expected] arg] + (= (get properties-map (keyword prop-key)) expected)) + :in (let [[prop-key coll] arg] + (contains? (set coll) (get properties-map (keyword prop-key)))) + :not (not (match-block-properties-condition arg match-context' renderer)) + :any (some #(match-block-properties-condition % match-context' renderer) arg) + :all (every? #(match-block-properties-condition % match-context' renderer) arg) + true))))) + +(defn serialize-property-value-for-plugin + "Serialize a property value so it survives `clj->js`. + Datascript Entity implements `IEncodeJS` as returning nil, + so raw entities would become null in JS. This fn converts: + - Entity → {:uuid \"...\" :title \"...\"} (js object) + - Set/coll of entities → JS array of the above + - keyword → \":ns/name\" string + - uuid → string + - other values → as-is" + [v] + (cond + (de/entity? v) + (let [m (cond-> {} + (:block/uuid v) (assoc :uuid (str (:block/uuid v))) + (:block/title v) (assoc :title (:block/title v)))] + (if (seq m) m (str (:db/id v)))) + + (set? v) + (mapv serialize-property-value-for-plugin v) + + (and (sequential? v) (some de/entity? v)) + (mapv serialize-property-value-for-plugin v) + + (keyword? v) + (subs (str v) 1) + + (uuid? v) + (str v) + + :else v)) + +(def get-block-renderers + ;; [] - get all + (create-local-renderer-getter + :block-renderers *hosted-renderer-providers true)) + +(defn any-block-renderers? + [] + (boolean (seq (get-block-renderers nil)))) + +(defn get-matched-block-renderer + "Return the highest priority matched block renderer for a block." + [match-context] + (when-let [rs (get-block-renderers nil)] + (let [match-context' (normalize-block-renderer-match-context match-context)] + (->> rs + (filter (fn [renderer] + (let [predicate (:when renderer)] + (if predicate + (match-renderer-predicate :block-renderer predicate match-context' renderer) + true)))) + (sort-by #(- (or (:priority %) 0))) + first)))) + +(def get-block-properties-renderers + ;; [] - get all + (create-local-renderer-getter + :block-properties-renderers *hosted-renderer-providers true)) + +(defn get-matched-block-properties-renderers + "Return all registered block-properties renderers whose :when condition + matches the given properties-map. Sorted by :priority descending." + [match-context] + (when-let [rs (get-block-properties-renderers nil)] + (let [match-context' (normalize-block-renderer-match-context match-context)] + (->> rs + (filter #(match-block-properties-condition (:when %) match-context' %)) + (sort-by #(- (or (:priority %) 0))))))) (defn select-a-plugin-theme [pid] @@ -626,10 +843,10 @@ (defn update-plugin-settings-state [id settings] (state/set-state! [:plugin/installed-plugins id :settings] - ;; TODO: force settings related ui reactive - ;; Sometimes toggle to `disable` not working - ;; But related-option data updated? - (assoc settings :disabled (boolean (:disabled settings))))) + ;; TODO: force settings related ui reactive + ;; Sometimes toggle to `disable` not working + ;; But related-option data updated? + (assoc settings :disabled (boolean (:disabled settings))))) (defn open-settings-file-in-default-app! [id-or-plugin] @@ -650,18 +867,19 @@ ([] (open-report-modal! nil nil)) ([pid name] (shui/dialog-open! - [:div.p-1 - (when pid - [:h1.opacity-90.font-bold.pb-1.flex.item-center.gap-1 - [:span.text-red-rx-10.flex.items-center (shui/tabler-icon "alert-triangle-filled" {:size 20})] - [:span name " " [:code "#" (str pid)]]]) - (into [:p] - (interpolate-rich-text - (t :plugin/report-modal-desc) - [[:a.hover:underline - {:href (str "mailto://support@logseq.com?subject=Report plugin from Logseq Marketplace" - (when pid (str " (#" pid ")")))} - "support@logseq.com"]]))]))) + [:div.p-1 + (when pid + [:h1.opacity-90.font-bold.pb-1.flex.item-center.gap-1 + [:span.text-red-rx-10.flex.items-center (shui/tabler-icon "alert-triangle-filled" {:size 20})] + [:span name " " [:code "#" (str pid)]]]) + [:p + (interpolate-rich-text + (t :plugin/report-modal-desc) + {:support-email + [:a.hover:underline + {:href (str "mailto://support@logseq.com?subject=Report plugin from Logseq Marketplace" + (when pid (str " (#" pid ")")))} + "support@logseq.com"]})]]))) (defn parse-user-md-content [content {:keys [url]}] @@ -669,11 +887,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 (gp-mldoc/default-config :markdown)))) (catch :default e @@ -685,17 +903,17 @@ (let [repo (:repo item)] (if (nil? repo) ;; local - (-> (p/let [content (invoke-exported-api :load_plugin_readme 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 (js/Error. "blank readme content"))) (state/set-state! :plugin/active-readme [content item]) (shui/dialog-open! (fn [_] (display)) - {:label :plugin-readme - :content-props {:class "max-h-[86vh] overflow-auto"}})) - (p/catch #(do (js/console.warn %) - (notification/show! (t :plugin/readme-empty-warning) :warning)))) + {:label "plugin-readme" + :content-props {:class "max-h-[86vh] overflow-auto"}})) + (p/catch #(do (js/console.warn %) + (notification/show! (t :plugin/readme-empty-warning) :warning)))) ;; market - (shui/dialog-open! (fn [_] (display item nil)) {:label :plugin-readme})))) + (shui/dialog-open! (fn [_] (display item nil)) {:label "plugin-readme"})))) (defn load-unpacked-plugin [] @@ -713,12 +931,12 @@ (when config/lsp-enabled? (try (js-invoke js/LSPluginCore - (str "hook" (string/capitalize (name tag))) - (name type) - (if (coll? payload) - (bean/->js (normalize-keyword-for-json payload)) - payload) - (if (keyword? plugin-id) (name plugin-id) plugin-id)) + (str "hook" (string/capitalize (name tag))) + (name type) + (if (coll? payload) + (bean/->js (normalize-keyword-for-json payload)) + payload) + (if (keyword? plugin-id) (name plugin-id) plugin-id)) (catch :default e (log/error :invoke-hook-exception e))))) @@ -756,7 +974,7 @@ (-> (if (util/electron?) (ipc/ipc "getLogseqDotDirRoot") "LSPUserDotRoot/") - (p/then #(do (reset! *ls-dotdir-root %) %)))) + (p/then #(do (reset! *ls-dotdir-root %) %)))) (defn make-fn-to-load-dotdir-json [dirname ^js default] @@ -812,14 +1030,14 @@ (-> (if (util/electron?) (ipc/ipc "getUserDefaultPlugins") (invoke-exported-api :load_installed_web_plugins)) - (p/then #(bean/->clj %)) - (p/then (fn [plugins] - (if (util/electron?) - (map #(hash-map :url %) plugins) - (some->> (vals plugins) - (filter #(:url %)))))) - (p/catch (fn [e] - (js/console.error "[get-user-default-plugins:error]" e))))) + (p/then #(bean/->clj %)) + (p/then (fn [plugins] + (if (util/electron?) + (map #(hash-map :url %) plugins) + (some->> (vals plugins) + (filter #(:url %)))))) + (p/catch (fn [e] + (js/console.error "[get-user-default-plugins:error]" e))))) (defn set-auto-checking! [v] @@ -842,7 +1060,7 @@ (defn cancel-user-checking! [] (when (and (get-user-checking?) - (not (get-auto-checking?))) + (not (get-auto-checking?))) (state/set-state! :plugin/updates-pending {}))) (defn user-check-enabled-for-updates! @@ -852,25 +1070,25 @@ (when auto-checking? (set-auto-checking! false)) (when (or auto-checking? (not user-checking?)) - ;; TODO: too many requests may be limited by GitHub api + ;; TODO: too many requests may be limited by GitHub API (when-let [plugins (seq (take 32 (state/get-enabled?-installed-plugins theme?)))] (->> plugins - (map (fn [v] [(keyword (:id v)) v])) - (into {}) - (state/set-state! :plugin/updates-pending)) + (map (fn [v] [(keyword (:id v)) v])) + (into {}) + (state/set-state! :plugin/updates-pending)) (state/pub-event! [:plugin/consume-updates]))))) (defn auto-check-enabled-for-updates! [] (when (and (not (get-updates-downloading?)) - (not (get-auto-checking?)) - (not (get-user-checking?))) + (not (get-auto-checking?)) + (not (get-user-checking?))) ;; TODO: take some plugins used recently (when-let [plugins (seq (take 16 (shuffle (state/get-enabled?-installed-plugins nil))))] (->> plugins - (map (fn [v] [(keyword (:id v)) v])) - (into {}) - (state/set-state! :plugin/updates-pending)) + (map (fn [v] [(keyword (:id v)) v])) + (into {}) + (state/set-state! :plugin/updates-pending)) (state/pub-event! [:plugin/consume-updates]) (set-auto-checking! true)))) @@ -920,16 +1138,16 @@ (some-> (re-find #"github.com/([^/]+/[^/]+)" url) (last))) package-url (if github? (some-> github-repo - (plugin-common-handler/get-web-plugin-checker-url!)) + (plugin-common-handler/get-web-plugin-checker-url!)) (str url "/package.json")) ^js res (js/window.fetch (str package-url "?v=" (js/Date.now))) package (if (and (.-ok res) - (= (.-status res) 200)) + (= (.-status res) 200)) (-> (.json res) - (p/then bean/->clj)) + (p/then bean/->clj)) (throw (js/Error. (.text res)))) logseq (or (:logseq package) - (throw (js/Error. "Illegal logseq package")))] + (throw (js/Error. "Illegal logseq package")))] (let [id (if github? (some-> github-repo (string/replace "/" "_")) (or (:id logseq) (:name package))) @@ -937,16 +1155,16 @@ theme? (some? (or (:theme logseq) (:themes logseq)))] (plugin-common-handler/emit-lsp-updates! - {:status :completed - :only-check false - :payload {:id id - :repo repo - :dst repo - :theme theme? - :web-pkg (cond-> package + {:status :completed + :only-check false + :payload {:id id + :repo repo + :dst repo + :theme theme? + :web-pkg (cond-> package - (not github?) - (assoc :installedFromUserWebUrl url))}})) + (not github?) + (assoc :installedFromUserWebUrl url))}})) url))) ;; components @@ -966,7 +1184,7 @@ (let [el (js/document.createElement "div")] (.appendChild js/document.body el) (rum/mount - (lsp-indicator) el)) + (lsp-indicator) el)) (-> (p/let [root (init-ls-dotdir-root) _ (.setupPluginCore js/LSPlugin (bean/->js {:localUserConfigRoot root :dotConfigRoot root})) @@ -974,33 +1192,33 @@ clear-commands! (fn [pid] ;; commands (unregister-plugin-slash-command pid) - (invoke-exported-api :unregister_plugin_simple_command pid) - (invoke-exported-api :uninstall_plugin_hook pid) + (invoke-exported-api "unregister_plugin_simple_command" pid) + (invoke-exported-api "uninstall_plugin_hook" pid) (unregister-plugin-ui-items pid) (unregister-plugin-resources pid) (unregister-plugin-search-services pid)) _ (doto js/LSPluginCore (.on "registered" - (fn [^js pl] - (register-plugin - (bean/->clj (.parse js/JSON (.stringify js/JSON pl)))))) + (fn [^js pl] + (register-plugin + (bean/->clj (.parse js/JSON (.stringify js/JSON pl)))))) (.on "error" - (fn [^js e] - (when (illegal-plugin-package-error->data e) - (show-illegal-plugin-package-notification! e)))) + (fn [^js e] + (when (illegal-plugin-package-error->data e) + (show-illegal-plugin-package-notification! e)))) (.on "beforeload" - (fn [^js pl] - (let [text (when (util/electron?) - (t :plugin/load-plugin-indicator (.-id pl)))] - (some->> text (state/set-state! :plugin/indicator-text))))) + (fn [^js pl] + (let [text (when (util/electron?) + (t :plugin/load-plugin-indicator (.-id pl)))] + (some->> text (state/set-state! :plugin/indicator-text))))) (.on "reloaded" - (fn [^js pl] - (register-plugin - (bean/->clj (.parse js/JSON (.stringify js/JSON pl)))))) + (fn [^js pl] + (register-plugin + (bean/->clj (.parse js/JSON (.stringify js/JSON pl)))))) (.on "unregistered" (fn [pid] (let [pid (keyword pid)] @@ -1028,7 +1246,7 @@ (.on "themes-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)))))) + (vec (mapcat (fn [[pid vs]] (mapv #(assoc % :pid pid) (bean/->clj vs))) (bean/->clj themes)))))) (.on "theme-selected" (fn [^js theme] (let [theme (bean/->clj theme) @@ -1044,10 +1262,7 @@ (.on "reset-custom-theme" (fn [^js themes] (let [themes (bean/->clj themes) custom-theme (dissoc themes :mode) - ;; Fall back to the user's current theme so that - ;; installing a non-theme plugin does not flash - ;; the UI back to light mode (#12434). - mode (or (:mode themes) (:ui/theme @state/state))] + mode (:mode themes)] (state/set-custom-theme! {:light (if (nil? (:light custom-theme)) {:mode "light"} (:light custom-theme)) :dark (if (nil? (:dark custom-theme)) {:mode "dark"} (:dark custom-theme))}) (state/set-theme-mode! mode)))) @@ -1055,51 +1270,51 @@ (.on "settings-changed" (fn [id ^js settings] (let [id (keyword id)] (when (and settings - (contains? (:plugin/installed-plugins @state/state) id)) + (contains? (:plugin/installed-plugins @state/state) id)) (update-plugin-settings-state id (bean/->clj settings)))))) (.on "ready" (fn [^js perf-table] (when-let [plugins (and perf-table (.entries perf-table))] (->> plugins - (keep - (fn [[_k ^js v]] - (when-let [end (and (some-> v (.-o) (.-disabled) (not)) - (.-e v))] - (when (and (number? end) - ;; valid end time - (> end 0) - ;; greater than 6s - (> (- end (.-s v)) 6000)) - v)))) - ((fn [perfs] - (doseq [perf perfs] - (state/pub-event! [:plugin/loader-perf-tip (bean/->clj perf)]))))))))) + (keep + (fn [[_k ^js v]] + (when-let [end (and (some-> v (.-o) (.-disabled) (not)) + (.-e v))] + (when (and (number? end) + ;; valid end time + (> end 0) + ;; greater than 6s + (> (- end (.-s v)) 6000)) + v)))) + ((fn [perfs] + (doseq [perf perfs] + (state/pub-event! [:plugin/loader-perf-tip (bean/->clj perf)]))))))))) default-plugins (get-user-default-plugins) [plugins0, plugins-async] (if (and (seq default-plugins) - (not (util/electron?))) + (not (util/electron?))) ((juxt (fn [its] (filterv #(:theme %) its)) - (fn [its] (filterv #(not (:theme %)) its))) + (fn [its] (filterv #(not (:theme %)) its))) default-plugins) [default-plugins]) _ (.register js/LSPluginCore (bean/->js (if (seq plugins0) plugins0 [])) true)] plugins-async) - (p/then - (fn [plugins-async] - ;; true indicate for preboot finished - (state/set-state! :plugin/indicator-text true) - ;; wait for the plugin register async messages - (js/setTimeout + (p/then + (fn [plugins-async] + ;; true indicate for preboot finished + (state/set-state! :plugin/indicator-text true) + ;; wait for the plugin register async messages + (js/setTimeout (fn [] (some-> (seq plugins-async) - (p/delay 16) - (p/then #(.register js/LSPluginCore (bean/->js plugins-async) true)))) + (p/delay 16) + (p/then #(.register js/LSPluginCore (bean/->js plugins-async) true)))) (if (util/electron?) 64 0)))) - (p/catch - (fn [^js e] - (log/error :setup-plugin-system-error e) - (state/set-state! :plugin/indicator-text (t :plugin/fatal-error (str e))))))) + (p/catch + (fn [^js e] + (log/error :setup-plugin-system-error e) + (state/set-state! :plugin/indicator-text (t :plugin/fatal-error e)))))) (defn setup! "setup plugin core handler" @@ -1109,8 +1324,8 @@ (init-plugins!))) (comment - {:pending (count (:plugin/updates-pending @state/state)) - :auto-checking? (boolean (:plugin/updates-auto-checking? @state/state)) - :coming (count (:plugin/updates-coming @state/state)) - :installing (:plugin/installing @state/state) - :downloading? (boolean (:plugin/updates-downloading? @state/state))}) + {:pending (count (:plugin/updates-pending @state/state)) + :auto-checking? (boolean (:plugin/updates-auto-checking? @state/state)) + :coming (count (:plugin/updates-coming @state/state)) + :installing (:plugin/installing @state/state) + :downloading? (boolean (:plugin/updates-downloading? @state/state))}) diff --git a/src/main/logseq/sdk/experiments.cljs b/src/main/logseq/sdk/experiments.cljs index 2736b9b74b..c69328bba2 100644 --- a/src/main/logseq/sdk/experiments.cljs +++ b/src/main/logseq/sdk/experiments.cljs @@ -1,6 +1,7 @@ (ns logseq.sdk.experiments (:require [frontend.components.page :as page] [frontend.handler.plugin :as plugin-handler] + [lambdaisland.glogi :as log] [frontend.state :as state] [frontend.util :as util] [logseq.sdk.utils :as sdk-util])) @@ -11,43 +12,89 @@ page-name (some-> props1 :page)] (when-let [entity (page/get-page-entity page-name)] (page/page-blocks-cp - entity {:container-id (state/get-next-container-id)})))) + entity {:container-id (state/get-next-container-id)})))) (defn ^:export register_fenced_code_renderer [pid type ^js opts] (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] (plugin-handler/register-fenced-code-renderer - (keyword pid) type (reduce #(assoc %1 %2 (aget opts (name %2))) {} - [:edit :before :subs :render])))) + (keyword pid) type (reduce #(assoc %1 %2 (aget opts (name %2))) {} + [:edit :before :subs :render])))) (defn ^:export register_route_renderer [pid key ^js opts] (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] (let [key (util/safe-keyword key)] (plugin-handler/register-route-renderer - (keyword pid) key - (reduce (fn [r k] - (assoc r k (cond-> (aget opts (name k)) - (= :name k) - (#(if % (util/safe-keyword %) key))))) - {} [:v :name :path :subs :render]))))) + (keyword pid) key + (reduce (fn [r k] + (assoc r k (cond-> (aget opts (name k)) + (= :name k) + (#(if % (util/safe-keyword %) key))))) + {} [:v :name :path :subs :render]))))) (defn ^:export register_daemon_renderer [pid key ^js opts] (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] (plugin-handler/register-daemon-renderer - (keyword pid) key (reduce #(assoc %1 %2 (aget opts (name %2))) {} - [:before :subs :render])))) + (keyword pid) key (reduce #(assoc %1 %2 (aget opts (name %2))) {} + [:before :subs :render])))) + +(defn- extract-js-renderer-opts + "Extract keys from a JS opts object into a clj map. + `transforms` is an optional map of {keyword transform-fn} for per-key processing. + Keys whose JS value is nil/undefined are omitted." + [^js opts ks transforms] + (reduce (fn [r k] + (let [v (aget opts (name k))] + (if (some? v) + (assoc r k (if-let [xf (get transforms k)] + (xf v) + v)) + r))) + {} ks)) (defn ^:export register_hosted_renderer [pid key ^js opts] (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] (plugin-handler/register-hosted-renderer - (keyword pid) key (reduce #(assoc %1 %2 (aget opts (name %2))) {} - [:title :type :mode :subs :render])))) + (keyword pid) key + (extract-js-renderer-opts opts [:title :type :mode :subs :render] nil)))) + +(defn ^:export register_block_properties_renderer + [pid key ^js opts] + (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] + (let [clj-opts (extract-js-renderer-opts + opts + [:when :mode :priority :subs :render] + {:when (fn [v] + (if (fn? v) + v + (js->clj v :keywordize-keys true)))})] + (plugin-handler/register-hosted-renderer + (keyword pid) key (assoc clj-opts :type "block-properties"))))) + +(defn ^:export register_block_renderer + [pid key ^js opts] + (when-let [^js _pl (plugin-handler/get-plugin-inst pid)] + (let [when-predicate (aget opts "when")] + (if (and (some? when-predicate) (not (fn? when-predicate))) + (log/error :register-block-renderer-invalid-when + {:pid pid + :key key + :message "`when` for registerBlockRenderer must be a synchronous predicate function."}) + (let [include-children (aget opts "includeChildren") + clj-opts (extract-js-renderer-opts + opts + [:when :priority :subs :render] + nil)] + (plugin-handler/register-hosted-renderer + (keyword pid) key (cond-> (assoc clj-opts :type "block") + (some? include-children) + (assoc :include-children include-children)))))))) (defn ^:export register_extensions_enhancer [pid type enhancer] (when-let [^js _pl (and (fn? enhancer) (plugin-handler/get-plugin-inst pid))] (plugin-handler/register-extensions-enhancer - (keyword pid) type {:enhancer enhancer}))) + (keyword pid) type {:enhancer enhancer}))) diff --git a/src/resources/dicts/af.edn b/src/resources/dicts/af.edn index ade3dd9ec7..3ca22de019 100644 --- a/src/resources/dicts/af.edn +++ b/src/resources/dicts/af.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nieuwe plug-in geregistreerd" :plugin/no-settings-schema "Geen instellingskema nie!" :plugin/not-installed "Nie geïnstalleer nie" + :plugin/open-logs "Bekyk logboeke" :plugin/open-logseq-dir "Logseq-map maak oop" :plugin/open-package "Maak pakket oop" :plugin/open-preferences "Voorkeuren maak oop" @@ -1729,7 +1730,6 @@ :ui/deleted "Verwyder" :ui/dont-remind-me-again "Moet my nie weer herinner nie" :ui/empty "Leeg" - :ui/error "Fout" :ui/error-boundary-error "Fout gevang deur UI!\n {1}" :ui/export "Voer uit" :ui/false "Onwaar" diff --git a/src/resources/dicts/ar.edn b/src/resources/dicts/ar.edn index b3c800159e..1ca4fe2496 100644 --- a/src/resources/dicts/ar.edn +++ b/src/resources/dicts/ar.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "إضافة جديدة مسجلة" :plugin/no-settings-schema "لا يوجد مخطط إعدادات!" :plugin/not-installed "غير مثبتة" + :plugin/open-logs "عرض السجلات" :plugin/open-logseq-dir "فتح مجلد Logseq" :plugin/open-package "فتح الحزمة" :plugin/open-preferences "فتح التفضيلات" @@ -1729,7 +1730,6 @@ :ui/deleted "محذوف" :ui/dont-remind-me-again "لا تذكرني مجددًا" :ui/empty "فارغ" - :ui/error "خطأ" :ui/error-boundary-error "خطأ التقطته واجهة المستخدم!\n {1}" :ui/export "تصدير" :ui/false "خطأ" diff --git a/src/resources/dicts/ca.edn b/src/resources/dicts/ca.edn index 24a161a40e..a8e10d542e 100644 --- a/src/resources/dicts/ca.edn +++ b/src/resources/dicts/ca.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nou connector registrat" :plugin/no-settings-schema "No hi ha esquema de configuració!" :plugin/not-installed "No instal·lat" + :plugin/open-logs "Veure registres" :plugin/open-logseq-dir "Obrir carpeta Logseq" :plugin/open-package "Obrir paquet" :plugin/open-preferences "Obrir preferències" @@ -1729,7 +1730,6 @@ :ui/deleted "Suprimit" :ui/dont-remind-me-again "No m'ho tornis a recordar" :ui/empty "Buit" - :ui/error "Error" :ui/error-boundary-error "Error detectat per la interfície!\n {1}" :ui/export "Exportar" :ui/false "Fals" diff --git a/src/resources/dicts/cs.edn b/src/resources/dicts/cs.edn index 0fa5ff4ac1..7afb69c60e 100644 --- a/src/resources/dicts/cs.edn +++ b/src/resources/dicts/cs.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nový plugin zaregistrován" :plugin/no-settings-schema "Žádné schéma nastavení!" :plugin/not-installed "Nenainstalováno" + :plugin/open-logs "Zobrazit protokoly" :plugin/open-logseq-dir "Otevřít složku Logseq" :plugin/open-package "Otevřít balík" :plugin/open-preferences "Otevřít předvolby" @@ -1729,7 +1730,6 @@ :ui/deleted "Smazáno" :ui/dont-remind-me-again "Už mi nepřipomínat" :ui/empty "Prázdné" - :ui/error "Chyba" :ui/error-boundary-error "Chyba zachycená uživatelským rozhraním!\n {1}" :ui/export "Export" :ui/false "Ne" diff --git a/src/resources/dicts/de.edn b/src/resources/dicts/de.edn index 77f4cec302..9a29fd9c14 100644 --- a/src/resources/dicts/de.edn +++ b/src/resources/dicts/de.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Neues Plugin registriert" :plugin/no-settings-schema "Kein Einstellungsschema!" :plugin/not-installed "Nicht installiert" + :plugin/open-logs "Protokolle anzeigen" :plugin/open-logseq-dir "Öffnen" :plugin/open-package "Paket öffnen" :plugin/open-preferences "Einstellungen öffnen" @@ -1729,7 +1730,6 @@ :ui/deleted "Gelöscht" :ui/dont-remind-me-again "Nicht mehr erinnern" :ui/empty "Leer" - :ui/error "Fehler" :ui/error-boundary-error "Fehler von der Oberfläche abgefangen!\n {1}" :ui/export "Exportieren" :ui/false "Falsch" diff --git a/src/resources/dicts/en.edn b/src/resources/dicts/en.edn index 02664c4638..4c095c9368 100644 --- a/src/resources/dicts/en.edn +++ b/src/resources/dicts/en.edn @@ -97,10 +97,13 @@ :block/remove-tag "Remove tag" :block/remove-this-tag "Remove this tag" :block/render-error "Block Render Error:" + :block/retry-plugin-renderer "Retry plugin renderer" :block/set-query "Set query" :block/set-query-label "Set query:" :block/sort-order "Sort order" :block/status-history "Status history" + :block/switch-to-outline-view "Switch to outline view" + :block/switch-to-plugin-renderer "Switch to plugin renderer" :block/untitled-query "Untitled query" :block.macro/embed-deprecated "{{embed}} is deprecated. Use '/Node embed' command instead." @@ -1172,11 +1175,19 @@ :plugin/load-plugin-indicator "Load plugin: {1}..." :plugin/load-unpacked "Load unpacked plugin" :plugin/loading-indicator "LOADING" + :plugin/logs-clear "Clear" + :plugin/logs-copied "Logs copied to clipboard." + :plugin/logs-copy "Copy all" + :plugin/logs-empty "No logs yet." + :plugin/logs-filter-placeholder "Filter logs..." + :plugin/logs-level-all "All levels" + :plugin/logs-title "Plugin logs" :plugin/malformed-plugins-edn "Malformed plugins.edn provided. Please check the file has correct edn syntax." :plugin/marketplace "Marketplace" :plugin/new-registered "New plugin registered!" :plugin/no-settings-schema "No Settings Schema!" :plugin/not-installed "Not installed" + :plugin/open-logs "View logs" :plugin/open-logseq-dir "Open" :plugin/open-package "Open package" :plugin/open-preferences "Open Preferences" @@ -1733,7 +1744,6 @@ :ui/deleted "Deleted" :ui/dont-remind-me-again "Don't remind me again" :ui/empty "Empty" - :ui/error "Error" :ui/error-boundary-error "Error caught by UI!\n {1}" :ui/export "Export" :ui/false "False" diff --git a/src/resources/dicts/es.edn b/src/resources/dicts/es.edn index ee4eb5f936..0573eb5c92 100644 --- a/src/resources/dicts/es.edn +++ b/src/resources/dicts/es.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nueva extensión registrada" :plugin/no-settings-schema "¡Sin esquema de configuración!" :plugin/not-installed "No instalado" + :plugin/open-logs "Ver registros" :plugin/open-logseq-dir "Abrir" :plugin/open-package "Abrir paquete" :plugin/open-preferences "Abrir preferencias" @@ -1729,7 +1730,6 @@ :ui/deleted "Eliminado" :ui/dont-remind-me-again "No recordarme de nuevo" :ui/empty "Vacío" - :ui/error "Error" :ui/error-boundary-error "¡Error capturado por la interfaz!\n {1}" :ui/export "Exportar" :ui/false "Falso" diff --git a/src/resources/dicts/fa.edn b/src/resources/dicts/fa.edn index be73542b93..2fe92c1af9 100644 --- a/src/resources/dicts/fa.edn +++ b/src/resources/dicts/fa.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "افزونه جدید ثبت شد" :plugin/no-settings-schema "بدون طرح تنظیمات!" :plugin/not-installed "نصب نشده" + :plugin/open-logs "مشاهده گزارش‌ها" :plugin/open-logseq-dir "باز کردن پوشه Logseq" :plugin/open-package "باز کردن بسته" :plugin/open-preferences "باز کردن تنظیمات" @@ -1729,7 +1730,6 @@ :ui/deleted "حذف‌شده" :ui/dont-remind-me-again "دیگر یادآوری نکن" :ui/empty "خالی" - :ui/error "خطا" :ui/error-boundary-error "خطا توسط رابط کاربری گرفته شد!\n {1}" :ui/export "صدور" :ui/false "نادرست" diff --git a/src/resources/dicts/fr.edn b/src/resources/dicts/fr.edn index 9319c7621b..6be01c600d 100644 --- a/src/resources/dicts/fr.edn +++ b/src/resources/dicts/fr.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nouvelle extension enregistrée" :plugin/no-settings-schema "Pas de schéma de paramètres !" :plugin/not-installed "Non installée" + :plugin/open-logs "Voir les journaux" :plugin/open-logseq-dir "Ouvrir" :plugin/open-package "Ouvrir le paquet" :plugin/open-preferences "Ouvrir les préférences" @@ -1729,7 +1730,6 @@ :ui/deleted "Supprimé" :ui/dont-remind-me-again "Ne plus me le rappeler" :ui/empty "Vide" - :ui/error "Erreur" :ui/error-boundary-error "Erreur interceptée par l'interface !\n {1}" :ui/export "Exporter" :ui/false "Faux" diff --git a/src/resources/dicts/id.edn b/src/resources/dicts/id.edn index 123d78908d..dae26d894c 100644 --- a/src/resources/dicts/id.edn +++ b/src/resources/dicts/id.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Plugin baru terdaftar" :plugin/no-settings-schema "Tidak Ada Skema Pengaturan!" :plugin/not-installed "Belum Terpasang" + :plugin/open-logs "Lihat log" :plugin/open-logseq-dir "Buka" :plugin/open-package "Buka paket" :plugin/open-preferences "Buka Preferensi" @@ -1729,7 +1730,6 @@ :ui/deleted "Dihapus" :ui/dont-remind-me-again "Jangan ingatkan saya lagi" :ui/empty "Kosong" - :ui/error "Kesalahan" :ui/error-boundary-error "Kesalahan tertangkap oleh UI!\n {1}" :ui/export "Ekspor" :ui/false "Salah" diff --git a/src/resources/dicts/it.edn b/src/resources/dicts/it.edn index c2f40a0252..ee215b10f8 100644 --- a/src/resources/dicts/it.edn +++ b/src/resources/dicts/it.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nuova estensione registrata" :plugin/no-settings-schema "Nessuno schema impostazioni!" :plugin/not-installed "Non installati" + :plugin/open-logs "Visualizza log" :plugin/open-logseq-dir "Apri" :plugin/open-package "Apri pacchetto" :plugin/open-preferences "Apri preferenze" @@ -1729,7 +1730,6 @@ :ui/deleted "Eliminato" :ui/dont-remind-me-again "Non ricordarmelo più" :ui/empty "Vuoto" - :ui/error "Errore" :ui/error-boundary-error "Errore rilevato dall'interfaccia!\n {1}" :ui/export "Esporta" :ui/false "Falso" diff --git a/src/resources/dicts/ja.edn b/src/resources/dicts/ja.edn index dc3298a141..cd83cbf92b 100644 --- a/src/resources/dicts/ja.edn +++ b/src/resources/dicts/ja.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "新しいプラグインが登録されました" :plugin/no-settings-schema "設定スキーマがありません!" :plugin/not-installed "未インストール" + :plugin/open-logs "ログを表示" :plugin/open-logseq-dir "Logseqディレクトリを開く" :plugin/open-package "パッケージを開く" :plugin/open-preferences "環境設定を開く" @@ -1729,7 +1730,6 @@ :ui/deleted "削除済み" :ui/dont-remind-me-again "今後表示しない" :ui/empty "空" - :ui/error "エラー" :ui/error-boundary-error "UIでエラーが発生しました!\n {1}" :ui/export "エクスポート" :ui/false "偽" diff --git a/src/resources/dicts/ko.edn b/src/resources/dicts/ko.edn index 0b7fff880c..c8256ac6d3 100644 --- a/src/resources/dicts/ko.edn +++ b/src/resources/dicts/ko.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "새 플러그인이 등록되었습니다" :plugin/no-settings-schema "설정 스키마 없음!" :plugin/not-installed "설치되지 않음" + :plugin/open-logs "로그 보기" :plugin/open-logseq-dir "Logseq 디렉토리 열기" :plugin/open-package "패키지 열기" :plugin/open-preferences "환경설정 열기" @@ -1729,7 +1730,6 @@ :ui/deleted "삭제됨" :ui/dont-remind-me-again "다시 알리지 않기" :ui/empty "비어 있음" - :ui/error "오류" :ui/error-boundary-error "UI 오류 발생!\n {1}" :ui/export "내보내기" :ui/false "거짓" diff --git a/src/resources/dicts/nb-no.edn b/src/resources/dicts/nb-no.edn index a4380bb92c..a6325f1353 100644 --- a/src/resources/dicts/nb-no.edn +++ b/src/resources/dicts/nb-no.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Ny utvidelse registrert" :plugin/no-settings-schema "Ingen innstillingsskjema!" :plugin/not-installed "Ikke installert" + :plugin/open-logs "Vis logger" :plugin/open-logseq-dir "Åpne Logseq-mappe" :plugin/open-package "Åpne pakke" :plugin/open-preferences "Åpne innstillinger" @@ -1729,7 +1730,6 @@ :ui/deleted "Slettet" :ui/dont-remind-me-again "Ikke påminn meg igjen" :ui/empty "Tom" - :ui/error "Feil" :ui/error-boundary-error "Feil fanget av brukergrensesnittet!\n {1}" :ui/export "Eksporter" :ui/false "Usann" diff --git a/src/resources/dicts/nl.edn b/src/resources/dicts/nl.edn index dd1273c56b..683ce0fa29 100644 --- a/src/resources/dicts/nl.edn +++ b/src/resources/dicts/nl.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nieuwe plug-in geregistreerd" :plugin/no-settings-schema "Geen instellingsschema!" :plugin/not-installed "Niet geïnstalleerd" + :plugin/open-logs "Logboeken bekijken" :plugin/open-logseq-dir "Logseq-map openen" :plugin/open-package "Open pakket" :plugin/open-preferences "Voorkeuren openen" @@ -1729,7 +1730,6 @@ :ui/deleted "Verwijderd" :ui/dont-remind-me-again "Herinner me hier niet meer aan" :ui/empty "Leeg" - :ui/error "Fout" :ui/error-boundary-error "Fout opgevangen door UI!\n {1}" :ui/export "Exporteren" :ui/false "Onwaar" diff --git a/src/resources/dicts/pl.edn b/src/resources/dicts/pl.edn index 070202a5f7..06b433faf8 100644 --- a/src/resources/dicts/pl.edn +++ b/src/resources/dicts/pl.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nowa wtyczka zarejestrowana" :plugin/no-settings-schema "Brak schematu ustawień!" :plugin/not-installed "Nie zainstalowany" + :plugin/open-logs "Wyświetl dzienniki" :plugin/open-logseq-dir "Otwórz folder Logseq" :plugin/open-package "Otwórz paczkę" :plugin/open-preferences "Otwórz preferencje" @@ -1729,7 +1730,6 @@ :ui/deleted "Usunięto" :ui/dont-remind-me-again "Nie przypominaj mi ponownie" :ui/empty "Puste" - :ui/error "Błąd" :ui/error-boundary-error "Błąd przechwycony przez UI!\n {1}" :ui/export "Eksportuj" :ui/false "Fałsz" diff --git a/src/resources/dicts/pt-br.edn b/src/resources/dicts/pt-br.edn index b91080b6b1..50e87744a1 100644 --- a/src/resources/dicts/pt-br.edn +++ b/src/resources/dicts/pt-br.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Novo plugin registrado" :plugin/no-settings-schema "Sem esquema de configurações!" :plugin/not-installed "Não instalado" + :plugin/open-logs "Ver registros" :plugin/open-logseq-dir "Abrir" :plugin/open-package "Abrir pacote" :plugin/open-preferences "Abrir Preferências" @@ -1729,7 +1730,6 @@ :ui/deleted "Excluído" :ui/dont-remind-me-again "Não me lembrar novamente" :ui/empty "Vazio" - :ui/error "Erro" :ui/error-boundary-error "Erro capturado pela UI!\n {1}" :ui/export "Exportar" :ui/false "Falso" diff --git a/src/resources/dicts/pt-pt.edn b/src/resources/dicts/pt-pt.edn index 12dc682d7c..b0ba023ff3 100644 --- a/src/resources/dicts/pt-pt.edn +++ b/src/resources/dicts/pt-pt.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nova extensão registada" :plugin/no-settings-schema "Sem esquema de definições!" :plugin/not-installed "Não instalado" + :plugin/open-logs "Ver registos" :plugin/open-logseq-dir "Abrir pasta Logseq" :plugin/open-package "Abrir pacote" :plugin/open-preferences "Abrir preferências" @@ -1729,7 +1730,6 @@ :ui/deleted "Eliminado" :ui/dont-remind-me-again "Não me lembrar novamente" :ui/empty "Vazio" - :ui/error "Erro" :ui/error-boundary-error "Erro capturado pela interface!\n {1}" :ui/export "Exportar" :ui/false "Falso" diff --git a/src/resources/dicts/ru.edn b/src/resources/dicts/ru.edn index 348fd9afc1..4772a754d6 100644 --- a/src/resources/dicts/ru.edn +++ b/src/resources/dicts/ru.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Новый плагин зарегистрирован" :plugin/no-settings-schema "Нет схемы настроек!" :plugin/not-installed "Не установлено" + :plugin/open-logs "Просмотр журналов" :plugin/open-logseq-dir "Открыть папку Logseq" :plugin/open-package "Открыть пакет" :plugin/open-preferences "Открыть настройки" @@ -1729,7 +1730,6 @@ :ui/deleted "Удалено" :ui/dont-remind-me-again "Больше не напоминать" :ui/empty "Пусто" - :ui/error "Ошибка" :ui/error-boundary-error "Ошибка перехвачена интерфейсом!\n {1}" :ui/export "Экспорт" :ui/false "Ложь" diff --git a/src/resources/dicts/sk.edn b/src/resources/dicts/sk.edn index bad4a21e51..bdc281e056 100644 --- a/src/resources/dicts/sk.edn +++ b/src/resources/dicts/sk.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Nový plugin zaregistrován" :plugin/no-settings-schema "Žiadna schéma nastavení!" :plugin/not-installed "Nenainštalované" + :plugin/open-logs "Zobraziť záznamy" :plugin/open-logseq-dir "Otvoriť složku Logseq" :plugin/open-package "Otvoriť balík" :plugin/open-preferences "Otvoriť předvolby" @@ -1729,7 +1730,6 @@ :ui/deleted "Odstránené" :ui/dont-remind-me-again "Už mi nepripomínať" :ui/empty "Prázdne" - :ui/error "Chyba" :ui/error-boundary-error "Chyba zachytená v UI!\n {1}" :ui/export "Exportovať" :ui/false "Nepravda" diff --git a/src/resources/dicts/tr.edn b/src/resources/dicts/tr.edn index b295d53dca..5d42e71bbb 100644 --- a/src/resources/dicts/tr.edn +++ b/src/resources/dicts/tr.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Yeni eklenti kaydedildi" :plugin/no-settings-schema "Ayar Şeması Yok!" :plugin/not-installed "Yüklü olmayan" + :plugin/open-logs "Günlükleri görüntüle" :plugin/open-logseq-dir "Logseq klasörünü aç" :plugin/open-package "Eklentinin içeriğini aç" :plugin/open-preferences "Tercihleri aç" @@ -1729,7 +1730,6 @@ :ui/deleted "Silindi" :ui/dont-remind-me-again "Bir daha hatırlatma" :ui/empty "Boş" - :ui/error "Hata" :ui/error-boundary-error "Arayüz tarafından yakalanan hata!\n {1}" :ui/export "Dışa Aktar" :ui/false "Yanlış" diff --git a/src/resources/dicts/uk.edn b/src/resources/dicts/uk.edn index a3c93f6019..fbca952ff2 100644 --- a/src/resources/dicts/uk.edn +++ b/src/resources/dicts/uk.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "Новый плагін зарегистрирован" :plugin/no-settings-schema "Немає схеми налаштувань!" :plugin/not-installed "Не встановленно" + :plugin/open-logs "Переглянути журнали" :plugin/open-logseq-dir "Відкрити папку Logseq" :plugin/open-package "Відкрити пакунок" :plugin/open-preferences "Відкрити налаштування" @@ -1729,7 +1730,6 @@ :ui/deleted "Видалено" :ui/dont-remind-me-again "Більше не нагадувати" :ui/empty "Порожньо" - :ui/error "Помилка" :ui/error-boundary-error "Помилка перехоплена інтерфейсом!\n {1}" :ui/export "Експорт" :ui/false "Хибно" diff --git a/src/resources/dicts/zh-cn.edn b/src/resources/dicts/zh-cn.edn index 4697853c11..990fa749a4 100644 --- a/src/resources/dicts/zh-cn.edn +++ b/src/resources/dicts/zh-cn.edn @@ -1177,6 +1177,7 @@ :plugin/new-registered "新插件已注册!" :plugin/no-settings-schema "没有设置模式!" :plugin/not-installed "未安装" + :plugin/open-logs "查看日志" :plugin/open-logseq-dir "打开" :plugin/open-package "打开包目录" :plugin/open-preferences "打开插件偏好设置" @@ -1733,7 +1734,6 @@ :ui/deleted "已删除" :ui/dont-remind-me-again "不再提醒" :ui/empty "空" - :ui/error "错误" :ui/error-boundary-error "界面捕获到错误!\n {1}" :ui/export "导出" :ui/false "否" diff --git a/src/resources/dicts/zh-hant.edn b/src/resources/dicts/zh-hant.edn index 7bd1689a99..d132c42120 100644 --- a/src/resources/dicts/zh-hant.edn +++ b/src/resources/dicts/zh-hant.edn @@ -1173,6 +1173,7 @@ :plugin/new-registered "新外掛已註冊!" :plugin/no-settings-schema "沒有設定結構!" :plugin/not-installed "尚未下載" + :plugin/open-logs "查看日誌" :plugin/open-logseq-dir "開啟" :plugin/open-package "開啟封包" :plugin/open-preferences "開啟外掛偏好設定" @@ -1729,7 +1730,6 @@ :ui/deleted "已刪除" :ui/dont-remind-me-again "不再提醒" :ui/empty "空" - :ui/error "錯誤" :ui/error-boundary-error "UI 攔截到錯誤!\n {1}" :ui/export "匯出" :ui/false "否" From 5907b0982441c2f4be84ad12a3f3b285f5b13c46 Mon Sep 17 00:00:00 2001 From: charlie Date: Tue, 28 Apr 2026 15:32:19 +0800 Subject: [PATCH 03/19] fix(i18n): update interpolate function reference in plugin --- src/main/frontend/handler/plugin.cljs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/frontend/handler/plugin.cljs b/src/main/frontend/handler/plugin.cljs index 6d6bc6500f..3da3c91837 100644 --- a/src/main/frontend/handler/plugin.cljs +++ b/src/main/frontend/handler/plugin.cljs @@ -8,7 +8,7 @@ [electron.ipc :as ipc] [frontend.components.svg :as svg] [frontend.config :as config] - [frontend.context.i18n :refer [t interpolate-rich-text]] + [frontend.context.i18n :refer [t interpolate-rich-text-node]] [frontend.format :as format] [frontend.fs :as fs] [frontend.handler.common.plugin :as plugin-common-handler] @@ -873,13 +873,12 @@ [:span.text-red-rx-10.flex.items-center (shui/tabler-icon "alert-triangle-filled" {:size 20})] [:span name " " [:code "#" (str pid)]]]) [:p - (interpolate-rich-text + (interpolate-rich-text-node (t :plugin/report-modal-desc) - {:support-email - [:a.hover:underline + [[:a.hover:underline {:href (str "mailto://support@logseq.com?subject=Report plugin from Logseq Marketplace" (when pid (str " (#" pid ")")))} - "support@logseq.com"]})]]))) + "support@logseq.com"]])]]))) (defn parse-user-md-content [content {:keys [url]}] From 80344840c48759d8136877702c876fec7c482762 Mon Sep 17 00:00:00 2001 From: TechnoHouse <13776377+deephbz@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:44:55 +0300 Subject: [PATCH 04/19] feat: configurable publish server URL (#12520) * feat: configurable publish server URL Mirror the Custom Sync Server URL pattern (#12459) for the single-page publish service. Settings -> Advanced -> Publish server URL opens a dialog that writes the URL to localStorage; frontend.config/publish-api-base reads it on each call so URL changes take effect without a restart. Unlike sync, publish does not need a push-config-to-worker step because the handler is purely HTTP request-response with no long-lived connection. * fix(i18n): resolve en.edn merge after master sync --------- Co-authored-by: Tienson Qin --- src/main/frontend/components/settings.cljs | 58 ++++++++++++++++++++++ src/main/frontend/config.cljs | 43 ++++++++++++++-- src/main/frontend/handler/events/ui.cljs | 5 ++ src/main/frontend/handler/publish.cljs | 11 ++-- src/main/frontend/spec/storage.cljc | 4 +- src/resources/dicts/en.edn | 6 +++ 6 files changed, 118 insertions(+), 9 deletions(-) diff --git a/src/main/frontend/components/settings.cljs b/src/main/frontend/components/settings.cljs index b0dcc118b1..0cdda99ef1 100644 --- a/src/main/frontend/components/settings.cljs +++ b/src/main/frontend/components/settings.cljs @@ -659,6 +659,63 @@ {:left-label (t :settings.sync-server/url) :action (sync-server-url-button)})) +(rum/defc publish-server-url-settings-container + [] + (let [current-url (config/get-custom-publish-server-url) + [url set-url!] (rum/use-state (or current-url "")) + reset-url! (fn [] + (config/set-custom-publish-server-url! nil) + (set-url! "") + (notification/show! (t :settings-page/publish-server-url-cleared) :success))] + [:div.cp__settings-publish-server-cnt + [:h1.mb-2.text-2xl.font-bold (t :settings-page/publish-server-url)] + [:div.p-2 + [:p.text-sm.opacity-70.mb-4 (t :settings-page/publish-server-url-desc)] + [:p + [:label + [:strong "URL"] + [:input.form-input.is-small + {:value url + :placeholder config/default-publish-api-base + :style {:width "100%"} + :on-change #(set-url! (util/evalue %))}]]] + [:p.pt-2.flex.gap-2 + (shui/button + {:size :sm + :on-click (fn [] + (let [trimmed (string/trim url)] + (if (string/blank? trimmed) + (reset-url!) + (if-not (config/valid-publish-server-url? trimmed) + (notification/show! (t :settings.sync-server/url-invalid-error) :error) + (do + (config/set-custom-publish-server-url! trimmed) + (notification/show! (t :settings-page/publish-server-url-saved) :success))))))} + (t :ui/save)) + (when (seq url) + (shui/button + {:size :sm + :variant :outline + :on-click (fn [] (reset-url!))} + (t :settings-page/publish-server-url-reset)))]]])) + +(rum/defc publish-server-url-button + [] + (let [current-url (config/get-custom-publish-server-url)] + (ui/button [:span.flex.items-center + [:span.pr-1 + (if (seq current-url) + current-url + (t :settings-page/publish-server-url-default))] + (ui/icon "edit")] + :class "text-sm" + :on-click #(state/pub-event! [:go/publish-server-settings])))) + +(defn publish-server-url-row [] + (row-with-button-action + {:left-label (t :settings-page/publish-server-url) + :action (publish-server-url-button)})) + (rum/defc user-proxy-settings [{:keys [type protocol host port] :as agent-opts}] (ui/button [:span.flex.items-center @@ -774,6 +831,7 @@ (usage-diagnostics-row t instrument-disabled?) (when-not (mobile-util/native-platform?) (developer-mode-row t developer-mode?)) (sync-server-url-row) + (publish-server-url-row) (when (util/electron?) (https-user-agent-row https-agent-opts)) (when (util/electron?) (auto-chmod-row t)) ;; (clear-cache-row t) diff --git a/src/main/frontend/config.cljs b/src/main/frontend/config.cljs index 45e07c0a59..cf7f48cd38 100644 --- a/src/main/frontend/config.cljs +++ b/src/main/frontend/config.cljs @@ -33,7 +33,7 @@ (def USER-POOL-ID "us-east-1_dtagLnju8") (def IDENTITY-POOL-ID "us-east-1:d6d3b034-1631-402b-b838-b44513e93ee0") (def OAUTH-DOMAIN "logseq-prod.auth.us-east-1.amazoncognito.com") - (def PUBLISH-API-BASE "https://logseq.io")) + (def default-publish-api-base "https://logseq.io")) (do (def API-DOMAIN "api-dev.logseq.com") (def COGNITO-IDP "https://cognito-idp.us-east-2.amazonaws.com/") @@ -42,10 +42,10 @@ (def USER-POOL-ID "us-east-2_kAqZcxIeM") (def IDENTITY-POOL-ID "us-east-2:cc7d2ad3-84d0-4faf-98fe-628f6b52c0a5") (def OAUTH-DOMAIN "logseq-test2.auth.us-east-2.amazoncognito.com") - (def PUBLISH-API-BASE "https://logseq-publish-staging.logseq.workers.dev"))) + (def default-publish-api-base "https://logseq-publish-staging.logseq.workers.dev"))) ;; Enable for local development -;; (def PUBLISH-API-BASE "http://localhost:8787") +;; (def default-publish-api-base "http://localhost:8787") (goog-define ENABLE-DB-SYNC-LOCAL false) (defonce db-sync-local? ENABLE-DB-SYNC-LOCAL) @@ -114,6 +114,43 @@ (custom-url->http-base custom) default-db-sync-http-base)) +(defn get-custom-publish-server-url + "Read the user-configured custom publish server URL from localStorage. + Returns nil when not set or empty." + [] + (when-not util/node-test? + (let [v (.getItem js/localStorage "publish-server-url")] + (when (and (string? v) (not (string/blank? v))) + v)))) + +(defn set-custom-publish-server-url! + "Persist the custom publish server URL to localStorage. Pass nil or empty string to clear." + [url] + (when-not util/node-test? + (if (or (nil? url) (string/blank? url)) + (.removeItem js/localStorage "publish-server-url") + (.setItem js/localStorage "publish-server-url" (string/trim url))))) + +(defn valid-publish-server-url? + "Return true when `url` looks like a valid HTTP(S) base URL." + [url] + (and (string? url) + (re-find #"^https?://" url))) + +(defn custom-url->publish-api-base + "Normalize a custom publish base URL by stripping trailing slashes. Pure function." + [custom-url] + (string/replace custom-url #"/+$" "")) + +(defn publish-api-base + "Return the base URL for the single-page publish service. Uses the user-configured + URL from localStorage when set, otherwise the default from the ENABLE-FILE-SYNC-PRODUCTION + branch above. Read on each call so URL changes take effect without a restart." + [] + (if-let [custom (get-custom-publish-server-url)] + (custom-url->publish-api-base custom) + default-publish-api-base)) + ;; Feature flags ;; ============= diff --git a/src/main/frontend/handler/events/ui.cljs b/src/main/frontend/handler/events/ui.cljs index a8b82d492c..af5ef5ffe1 100644 --- a/src/main/frontend/handler/events/ui.cljs +++ b/src/main/frontend/handler/events/ui.cljs @@ -87,6 +87,11 @@ (settings/sync-server-url-settings-container) {:id :sync-server-panel :center? true :class "lg:max-w-2xl"})) +(defmethod events/handle :go/publish-server-settings [[_]] + (shui/dialog-open! + (settings/publish-server-url-settings-container) + {:id :publish-server-panel :center? true :class "lg:max-w-2xl"})) + (defmethod events/handle :redirect-to-home [_] (page-handler/create-today-journal!) (when (util/capacitor?) diff --git a/src/main/frontend/handler/publish.cljs b/src/main/frontend/handler/publish.cljs index b2327e5400..8f60898547 100644 --- a/src/main/frontend/handler/publish.cljs +++ b/src/main/frontend/handler/publish.cljs @@ -30,15 +30,15 @@ (defn- publish-endpoint [] - (str config/PUBLISH-API-BASE "/pages")) + (str (config/publish-api-base) "/pages")) (defn- publish-page-endpoint [graph-uuid page-uuid] - (str config/PUBLISH-API-BASE "/pages/" graph-uuid "/" page-uuid)) + (str (config/publish-api-base) "/pages/" graph-uuid "/" page-uuid)) (defn- asset-upload-endpoint [] - (str config/PUBLISH-API-BASE "/assets")) + (str (config/publish-api-base) "/assets")) (defn- asset-content-type [ext] @@ -382,10 +382,11 @@ data (bean/->clj json)] (let [short-url (:short_url data) page-id (str (:block/uuid page)) + api-base (config/publish-api-base) fallback-url (when (and graph-id page-id) - (str config/PUBLISH-API-BASE "/page/" graph-id "/" page-id)) + (str api-base "/page/" graph-id "/" page-id)) url (or (when short-url - (str config/PUBLISH-API-BASE short-url)) + (str api-base short-url)) fallback-url)] (when (and url (:db/id page)) (property-handler/set-block-property! (:db/id page) diff --git a/src/main/frontend/spec/storage.cljc b/src/main/frontend/spec/storage.cljc index 41d97d7913..89924fb58c 100644 --- a/src/main/frontend/spec/storage.cljc +++ b/src/main/frontend/spec/storage.cljc @@ -29,6 +29,7 @@ (s/def :copy/export-block-text-remove-options set?) (s/def :copy/export-block-text-other-options map?) (s/def ::sync-server-url string?) +(s/def ::publish-server-url string?) ;; Dynamic keys which aren't as easily validated: ;; :ls-pdf-last-page-* ;; :ls-js-allowed-* @@ -68,4 +69,5 @@ :copy/export-block-text-indent-style :copy/export-block-text-remove-options :copy/export-block-text-other-options - ::sync-server-url])) + ::sync-server-url + ::publish-server-url])) diff --git a/src/resources/dicts/en.edn b/src/resources/dicts/en.edn index 4c095c9368..b1fa2d4c9e 100644 --- a/src/resources/dicts/en.edn +++ b/src/resources/dicts/en.edn @@ -1663,6 +1663,12 @@ :settings.sync-server/url "Sync Server URL" :settings.sync-server/url-desc "Set a custom HTTPS sync server URL for self-hosted sync. Your Logseq authentication tokens will be sent to this server, so only use a trusted URL. Leave empty to use the official Logseq Sync." :settings.sync-server/url-invalid-error "URL must start with https:// or http://" + :settings-page/publish-server-url "Publish server URL" + :settings-page/publish-server-url-desc "Set a custom HTTPS publish server URL for self-hosted single-page publishing. Your Logseq authentication tokens will be sent to this server, so only use a trusted URL. Leave empty to use the official Logseq publish service." + :settings-page/publish-server-url-saved "Publish server URL saved." + :settings-page/publish-server-url-cleared "Publish server URL cleared. Using official Logseq publish." + :settings-page/publish-server-url-default "Logseq publish" + :settings-page/publish-server-url-reset "Reset to default" :shell/input-command-title "Input command" From 0b6ec5916c9dea84d3d868b09342a7617f7a63ca Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 28 Apr 2026 15:51:26 +0800 Subject: [PATCH 05/19] fix(publish): hide protected page content from tag listings --- .../src/logseq/publish/meta_store.cljs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/deps/publish/src/logseq/publish/meta_store.cljs b/deps/publish/src/logseq/publish/meta_store.cljs index 0243567ee6..13d224f966 100644 --- a/deps/publish/src/logseq/publish/meta_store.cljs +++ b/deps/publish/src/logseq/publish/meta_store.cljs @@ -281,10 +281,11 @@ "page_tags.source_block_content, page_tags.source_block_format, page_tags.updated_at, " "pages.short_id " "FROM page_tags " - "LEFT JOIN pages " + "INNER JOIN pages " "ON pages.graph_uuid = page_tags.graph_uuid " "AND pages.page_uuid = page_tags.source_page_uuid " "WHERE page_tags.tag_title = ? " + "AND pages.password_hash IS NULL " "ORDER BY page_tags.updated_at DESC;") tag-name)) page-rows (publish-common/get-sql-rows @@ -293,10 +294,11 @@ "pages.short_id, " "MAX(page_tags.updated_at) AS updated_at " "FROM page_tags " - "LEFT JOIN pages " + "INNER JOIN pages " "ON pages.graph_uuid = page_tags.graph_uuid " "AND pages.page_uuid = page_tags.source_page_uuid " "WHERE page_tags.tag_title = ? " + "AND pages.password_hash IS NULL " "GROUP BY page_tags.graph_uuid, page_tags.source_page_uuid, page_tags.source_page_title, pages.short_id " "ORDER BY updated_at DESC;") tag-name))] @@ -381,11 +383,16 @@ (= (nth parts 4 nil) "tagged_nodes") (let [rows (publish-common/get-sql-rows (publish-common/sql-exec sql - (str "SELECT graph_uuid, tag_page_uuid, tag_title, source_page_uuid, " - "source_page_title, source_block_uuid, source_block_content, " - "source_block_format, updated_at " - "FROM page_tags WHERE graph_uuid = ? AND tag_page_uuid = ? " - "ORDER BY updated_at DESC;") + (str "SELECT page_tags.graph_uuid, page_tags.tag_page_uuid, page_tags.tag_title, page_tags.source_page_uuid, " + "page_tags.source_page_title, page_tags.source_block_uuid, page_tags.source_block_content, " + "page_tags.source_block_format, page_tags.updated_at " + "FROM page_tags " + "INNER JOIN pages " + "ON pages.graph_uuid = page_tags.graph_uuid " + "AND pages.page_uuid = page_tags.source_page_uuid " + "WHERE page_tags.graph_uuid = ? AND page_tags.tag_page_uuid = ? " + "AND pages.password_hash IS NULL " + "ORDER BY page_tags.updated_at DESC;") graph-uuid page-uuid))] (publish-common/json-response {:tagged_nodes (map (fn [row] From 0a077d1144d315782c81056a5737ea22ffa338ff Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 28 Apr 2026 15:55:03 +0800 Subject: [PATCH 06/19] fix(publish): remove public /pages listing endpoint --- deps/publish/src/logseq/publish/routes.cljs | 17 ----------------- deps/publish/worker/README.md | 2 -- 2 files changed, 19 deletions(-) diff --git a/deps/publish/src/logseq/publish/routes.cljs b/deps/publish/src/logseq/publish/routes.cljs index 88f8138614..5a38ed3598 100644 --- a/deps/publish/src/logseq/publish/routes.cljs +++ b/deps/publish/src/logseq/publish/routes.cljs @@ -312,20 +312,6 @@ (p/let [tags (.json tags-resp)] (publish-common/json-response (js->clj tags :keywordize-keys true) 200)))))))))) -(defn handle-list-pages [env] - (let [^js do-ns (aget env "PUBLISH_META_DO") - do-id (.idFromName do-ns "index") - do-stub (.get do-ns do-id)] - (p/let [meta-resp (.fetch do-stub "https://publish/pages" #js {:method "GET"})] - (if-not (.-ok meta-resp) - (js/Response. - (publish-render/render-404-html) - #js {:headers (publish-common/merge-headers - #js {"content-type" "text/html; charset=utf-8"} - (publish-common/cors-headers))}) - (p/let [meta (.json meta-resp)] - (publish-common/json-response (js->clj meta :keywordize-keys true) 200)))))) - (defn handle-list-graph-pages-by-uuid [graph-uuid env] (if-not graph-uuid (publish-common/bad-request "missing graph uuid") @@ -669,9 +655,6 @@ (and (= path "/pages") (= method "POST")) (handle-post-pages request env) - (and (= path "/pages") (= method "GET")) - (handle-list-pages env) - (and (string/starts-with? path "/search/") (= method "GET")) (handle-graph-search request env) diff --git a/deps/publish/worker/README.md b/deps/publish/worker/README.md index 094f7fd65d..416c9d2de3 100644 --- a/deps/publish/worker/README.md +++ b/deps/publish/worker/README.md @@ -32,8 +32,6 @@ metadata in a Durable Object backed by SQLite. - Deletes a published page - `DELETE /pages/:graph-uuid` - Deletes all pages for a graph -- `GET /pages` - - Lists metadata entries (from the index DO) - `GET /tag/:tag-name` - List all pages tagged with `:tag-name` - `GET /ref/:page-name` From e44bd795263e28d0f212bd6e63b4a9ecfa28e06c Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 28 Apr 2026 15:56:32 +0800 Subject: [PATCH 07/19] fix(publish): hide protected pages from ref and user listings --- deps/publish/src/logseq/publish/meta_store.cljs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/deps/publish/src/logseq/publish/meta_store.cljs b/deps/publish/src/logseq/publish/meta_store.cljs index 13d224f966..671f0ed607 100644 --- a/deps/publish/src/logseq/publish/meta_store.cljs +++ b/deps/publish/src/logseq/publish/meta_store.cljs @@ -318,11 +318,12 @@ "pages.short_id, " "MAX(page_refs.updated_at) AS updated_at " "FROM page_refs " - "LEFT JOIN pages " + "INNER JOIN pages " "ON pages.graph_uuid = page_refs.graph_uuid " "AND pages.page_uuid = page_refs.source_page_uuid " - "WHERE (lower(page_refs.target_page_title) = lower(?)) " - "OR (page_refs.target_page_name = lower(?)) " + "WHERE ((lower(page_refs.target_page_title) = lower(?)) " + "OR (page_refs.target_page_name = lower(?))) " + "AND pages.password_hash IS NULL " "GROUP BY page_refs.graph_uuid, page_refs.source_page_uuid, page_refs.source_page_title, pages.short_id " "ORDER BY updated_at DESC;") ref-name @@ -347,7 +348,10 @@ rows (publish-common/get-sql-rows (publish-common/sql-exec sql (str "SELECT page_uuid, page_title, short_id, graph_uuid, updated_at, owner_username " - "FROM pages WHERE owner_username = ? ORDER BY updated_at DESC;") + "FROM pages " + "WHERE owner_username = ? " + "AND password_hash IS NULL " + "ORDER BY updated_at DESC;") username))] (publish-common/json-response {:user {:username username} :pages (map (fn [row] From c2c5dbee0cc747a10884069c56fb5135d65c9f4e Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 28 Apr 2026 16:24:01 +0800 Subject: [PATCH 08/19] fix(publish): match app status icon styles and alignment fixes https://github.com/logseq/db-test/issues/819 --- deps/publish/src/logseq/publish/publish.css | 44 ++++++++++++++++++--- deps/publish/src/logseq/publish/publish.js | 2 +- deps/publish/src/logseq/publish/render.cljs | 16 +++++--- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/deps/publish/src/logseq/publish/publish.css b/deps/publish/src/logseq/publish/publish.css index b2b55ff1cd..b82b97b280 100644 --- a/deps/publish/src/logseq/publish/publish.css +++ b/deps/publish/src/logseq/publish/publish.css @@ -72,6 +72,10 @@ body { letter-spacing: 0.01em; } +.inline-flex { + display: inline-flex; +} + .publish-home { display: flex; align-items: center; @@ -474,6 +478,7 @@ a:hover { display: flex; gap: 4px; align-items: flex-start; + min-height: 26px; } .positioned-properties { @@ -486,7 +491,10 @@ a:hover { .positioned-properties.block-left, .positioned-properties.block-right { display: flex; align-items: center; - margin-top: 2px; +} + +.block-content > .positioned-properties.block-left { + align-self: center; } .positioned-properties.block-right { @@ -520,16 +528,40 @@ a:hover { display: inline-flex; align-items: center; justify-content: center; - width: 1rem; - height: 1rem; + width: 18px; + height: 18px; line-height: 1; - font-size: 1rem; + font-size: 18px; color: currentColor; } .property-icon svg { - width: 1rem; - height: 1rem; + width: 18px; + height: 18px; +} + +.positioned-properties .ls-icon-Backlog { + color: var(--rx-gray-05, #a8a29e); +} + +.positioned-properties .ls-icon-Todo { + color: var(--rx-gray-10, #78716c); +} + +.positioned-properties .ls-icon-InProgress50 { + color: var(--rx-yellow-08, #ca8a04); +} + +.positioned-properties .ls-icon-InReview { + color: var(--rx-blue-09, #1d4ed8); +} + +.positioned-properties .ls-icon-Done { + color: var(--rx-green-08, #16a34a); +} + +.positioned-properties .ls-icon-Cancelled { + color: var(--rx-red-08, #dc2626); } .property-value-with-icon { diff --git a/deps/publish/src/logseq/publish/publish.js b/deps/publish/src/logseq/publish/publish.js index 1366fb2f98..0a14b141bc 100644 --- a/deps/publish/src/logseq/publish/publish.js +++ b/deps/publish/src/logseq/publish/publish.js @@ -151,7 +151,7 @@ const getTablerExtIcon = (id) => { const renderTablerExtIcon = (el, id) => { const iconFn = getTablerExtIcon(id); if (!iconFn) return false; - const node = iconFn({ size: 14, stroke: 2 }); + const node = iconFn({ size: 18 }); if (!node) return false; el.textContent = ""; const domNode = reactNodeToDom(node); diff --git a/deps/publish/src/logseq/publish/render.cljs b/deps/publish/src/logseq/publish/render.cljs index 9a628b1c37..00fbc6976e 100644 --- a/deps/publish/src/logseq/publish/render.cljs +++ b/deps/publish/src/logseq/publish/render.cljs @@ -11,7 +11,7 @@ [logseq.publish.model :as publish-model])) ;; Timestamp in milliseconds used for cache busting static assets. -(defonce version 1767194868810) +(defonce version 1777363019629) (def ref-regex (js/RegExp. "\\[\\[([0-9a-fA-F-]{36})\\]\\]|\\(\\(([0-9a-fA-F-]{36})\\)\\)" "g")) @@ -100,13 +100,19 @@ (defn- icon-span [icon] (when (and (map? icon) (string? (:id icon)) (not (string/blank? (:id icon)))) + (let [icon-type (:type icon) + icon-id (:id icon) + class-name (str "property-icon" + (when (and (= :tabler-icon icon-type) + (not (string/blank? icon-id))) + (str " ls-icon-" icon-id)))] [:span (cond-> - {:class "property-icon" + {:class class-name :data-icon-id (:id icon) :data-icon-type (name (:type icon))} (:color icon) - (assoc :style (str "color: " (:color icon) ";")))])) + (assoc :style (str "color: " (:color icon) ";")))]))) (defn- with-icon [icon nodes] @@ -445,12 +451,12 @@ [:div.positioned-property [:span.property-name (property-title k (:property-title-by-ident ctx))] [:span.property-value - (into [:span] (positioned-value-nodes v k ctx entities))]])] + (into [:span.inline-flex] (positioned-value-nodes v k ctx entities))]])] [:div {:class (str "positioned-properties " (name position))} (for [[k v] (sorted-properties props ctx)] [:span.positioned-property - (into [:span] (positioned-value-nodes v k ctx entities))])]))) + (into [:span.inline-flex] (positioned-value-nodes v k ctx entities))])]))) (def ^:private youtube-regex #"^((?:https?:)?//)?((?:www|m).)?((?:youtube.com|youtu.be|y2u.be|youtube-nocookie.com))(/(?:[\w-]+\?v=|embed/|v/)?)([\w-]+)([\S^\?]+)?$") (def ^:private vimeo-regex #"^((?:https?:)?//)?((?:www).)?((?:player.vimeo.com|vimeo.com))(/(?:video/)?)([\w-]+)(\S+)?$") From 3a63f09beff50e6e76748f33308e6a5bc41dce0f Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 28 Apr 2026 16:40:01 +0800 Subject: [PATCH 09/19] fix(publish): keep legacy short/page URL compatibility --- deps/publish/deps.edn | 2 +- deps/publish/shadow-cljs.edn | 7 +- deps/publish/src/logseq/publish/routes.cljs | 19 ++-- .../test/logseq/publish/routes_test.cljs | 91 +++++++++++++++++++ .../test/logseq/publish/test_runner.cljs | 21 +++++ 5 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 deps/publish/test/logseq/publish/routes_test.cljs create mode 100644 deps/publish/test/logseq/publish/test_runner.cljs diff --git a/deps/publish/deps.edn b/deps/publish/deps.edn index a16b052345..39dd76a6eb 100644 --- a/deps/publish/deps.edn +++ b/deps/publish/deps.edn @@ -1,4 +1,4 @@ -{:paths ["src" "../../resources"] +{:paths ["src" "test" "../../resources"] :deps {org.clojure/clojure {:mvn/version "1.12.4"} rum/rum {:git/url "https://github.com/logseq/rum" ;; fork diff --git a/deps/publish/shadow-cljs.edn b/deps/publish/shadow-cljs.edn index 396ea96b6e..c418f99d60 100644 --- a/deps/publish/shadow-cljs.edn +++ b/deps/publish/shadow-cljs.edn @@ -9,4 +9,9 @@ PublishMetaDO logseq.publish.worker/PublishMetaDO}}} :js-options {:js-provider :import} :closure-defines {shadow.cljs.devtools.client.env/enabled false} - :devtools {:enabled false}}}} + :devtools {:enabled false}} + :publish-test {:target :node-test + :output-to "worker/dist/worker-test.js" + :devtools {:enabled false} + :compiler-options {:static-fns false} + :main logseq.publish.test-runner/main}}} diff --git a/deps/publish/src/logseq/publish/routes.cljs b/deps/publish/src/logseq/publish/routes.cljs index 5a38ed3598..b24d09e01d 100644 --- a/deps/publish/src/logseq/publish/routes.cljs +++ b/deps/publish/src/logseq/publish/routes.cljs @@ -606,6 +606,13 @@ (publish-render/render-page-html transit page-uuid refs-json tagged-nodes) #js {:headers headers})))))))))))))) +(defn- rewrite-request-path + [request new-path] + (let [request-url (js/URL. (.-url request)) + new-url (js/URL. (str (.-origin request-url) new-path))] + (set! (.-search new-url) (.-search request-url)) + (js/Request. (str new-url) request))) + (defn ^:large-vars/cleanup-todo handle-fetch [request env] (let [url (js/URL. (.-url request)) path (.-pathname url) @@ -709,7 +716,9 @@ (js/Response. (.-body object) #js {:headers headers})))))))) - (and (string/starts-with? path "/p/") (= method "GET")) + (and (or (string/starts-with? path "/p/") + (string/starts-with? path "/s/")) + (= method "GET")) (let [parts (string/split path #"/") short-id (nth parts 2 nil)] (if (string/blank? short-id) @@ -727,11 +736,9 @@ (publish-common/not-found) (let [graph-uuid (aget row "graph_uuid") page-uuid (aget row "page_uuid") - location (str "/page/" graph-uuid "/" page-uuid)] - (js/Response. nil #js {:status 302 - :headers (publish-common/merge-headers - #js {"location" location} - (publish-common/cors-headers))}))))))))) + page-path (str "/page/" graph-uuid "/" page-uuid) + page-request (rewrite-request-path request page-path)] + (handle-page-html page-request env))))))))) (and (string/starts-with? path "/u/") (= method "GET")) (let [parts (string/split path #"/") diff --git a/deps/publish/test/logseq/publish/routes_test.cljs b/deps/publish/test/logseq/publish/routes_test.cljs new file mode 100644 index 0000000000..0de872c449 --- /dev/null +++ b/deps/publish/test/logseq/publish/routes_test.cljs @@ -0,0 +1,91 @@ +(ns logseq.publish.routes-test + (:require [cljs.test :refer [async deftest is testing]] + [logseq.publish.routes :as routes] + [promesa.core :as p])) + +(defn- json-response + [data] + (js/Response. + (js/JSON.stringify data) + #js {:status 200 + :headers #js {"content-type" "application/json"}})) + +(defn- short-url-env + [] + (let [do-stub #js {:fetch (fn [url _opts] + (cond + (= "https://publish/short/abc123" url) + (js/Promise.resolve + (json-response + #js {:page #js {"graph_uuid" "graph-1" + "page_uuid" "page-1"}})) + + (re-matches #"https://publish/pages/[^/]+/[^/]+/password" url) + (js/Promise.resolve (json-response #js {})) + + (re-matches #"https://publish/pages/[^/]+/[^/]+" url) + (js/Promise.resolve + (json-response + #js {"content_hash" "etag-1" + "r2_key" "publish/graph-1/page-1.transit"})) + + :else + (js/Promise.resolve + (js/Response. "not found" #js {:status 404}))))} + do-ns #js {:idFromName (fn [_name] "index") + :get (fn [_id] do-stub)} + r2 #js {:get (fn [_key] + (js/Promise.resolve + #js {:arrayBuffer (fn [] + (let [payload (.encode (js/TextEncoder.) "{}")] + (js/Promise.resolve (.-buffer payload))))}))}] + #js {"PUBLISH_META_DO" do-ns + "PUBLISH_R2" r2})) + +(deftest short-url-does-not-redirect-to-uuid-url + (testing "short URL should not redirect to /page/:graph/:page" + (async done + (let [request (js/Request. "https://publish.example/p/abc123?password=s3cr3t") + env (short-url-env)] + (-> (p/let [response (routes/handle-fetch request env) + body (.text response)] + (is (= 200 (.-status response))) + (is (nil? (.get (.-headers response) "location"))) + (is (re-find #"" body)) + (is (not (re-find #"Page not found" body))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))))) + +(deftest legacy-s-short-url-is-supported + (testing "legacy /s/:short-id should render page html" + (async done + (let [request (js/Request. "https://publish.example/s/abc123") + env (short-url-env)] + (-> (p/let [response (routes/handle-fetch request env) + body (.text response)] + (is (= 200 (.-status response))) + (is (nil? (.get (.-headers response) "location"))) + (is (re-find #"" body)) + (is (not (re-find #"Page not found" body))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))))) + +(deftest page-uuid-route-is-still-supported + (testing "legacy /page/:graph-uuid/:page-uuid should still render page html" + (async done + (let [request (js/Request. "https://publish.example/page/3bc00ad3-f421-41e7-8c65-40861c298be5/6954ee2a-506b-4dd9-bd6d-0dc24db9c055") + env (short-url-env)] + (-> (p/let [response (routes/handle-fetch request env) + body (.text response)] + (is (= 200 (.-status response))) + (is (nil? (.get (.-headers response) "location"))) + (is (re-find #"" body)) + (is (not (re-find #"Page not found" body))) + (done)) + (p/catch (fn [error] + (is nil (str error)) + (done)))))))) diff --git a/deps/publish/test/logseq/publish/test_runner.cljs b/deps/publish/test/logseq/publish/test_runner.cljs new file mode 100644 index 0000000000..d8cfe6eeae --- /dev/null +++ b/deps/publish/test/logseq/publish/test_runner.cljs @@ -0,0 +1,21 @@ +(ns logseq.publish.test-runner + (:require [cljs.test :as ct] + [logseq.publish.routes-test] + [shadow.test :as st] + [shadow.test.env :as env])) + +(derive ::node ::ct/default) + +(defmethod ct/report [::node :end-run-tests] [m] + (if (ct/successful? m) + (js/process.exit 0) + (js/process.exit 1))) + +(defn ^:dev/after-load reset-test-data! [] + (-> (env/get-test-data) + (env/reset-test-data!))) + +(defn main [& _args] + (reset-test-data!) + (let [test-env (ct/empty-env ::node)] + (st/run-all-tests test-env nil))) From b953a5c92c44e932b3f5903364779f4f70612c53 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 28 Apr 2026 16:43:34 +0800 Subject: [PATCH 10/19] fix(publish): hide hidden properties in page render --- deps/publish/src/logseq/publish/render.cljs | 9 +++++++-- .../test/logseq/publish/render_test.cljs | 17 +++++++++++++++++ .../test/logseq/publish/test_runner.cljs | 1 + 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 deps/publish/test/logseq/publish/render_test.cljs diff --git a/deps/publish/src/logseq/publish/render.cljs b/deps/publish/src/logseq/publish/render.cljs index 00fbc6976e..ecc9d2d344 100644 --- a/deps/publish/src/logseq/publish/render.cljs +++ b/deps/publish/src/logseq/publish/render.cljs @@ -11,7 +11,7 @@ [logseq.publish.model :as publish-model])) ;; Timestamp in milliseconds used for cache busting static assets. -(defonce version 1777363019629) +(defonce version 1777365821532) (def ref-regex (js/RegExp. "\\[\\[([0-9a-fA-F-]{36})\\]\\]|\\(\\(([0-9a-fA-F-]{36})\\)\\)" "g")) @@ -261,6 +261,11 @@ (or (get property-type-by-ident prop-key) (get-in db-property/built-in-properties [prop-key :schema :type]))) +(defn property-hidden? + [prop-key property-hidden-by-ident] + (or (true? (get property-hidden-by-ident prop-key)) + (true? (get-in db-property/built-in-properties [prop-key :schema :hide?])))) + (defn page-ref->uuid [name name->uuid] (or (get name->uuid name) (get name->uuid (common-util/page-name-sanity-lc name)))) @@ -357,7 +362,7 @@ props) props (->> props (remove (fn [[k _]] - (true? (get (:property-hidden-by-ident ctx) k)))) + (property-hidden? k (:property-hidden-by-ident ctx)))) (map (fn [[k v]] (if (= k :block/tags) [k (filter-tags v entities)] diff --git a/deps/publish/test/logseq/publish/render_test.cljs b/deps/publish/test/logseq/publish/render_test.cljs new file mode 100644 index 0000000000..bd16e7bd49 --- /dev/null +++ b/deps/publish/test/logseq/publish/render_test.cljs @@ -0,0 +1,17 @@ +(ns logseq.publish.render-test + (:require [cljs.test :refer [deftest is testing]] + [logseq.publish.render :as render])) + +(deftest entity-properties-filters-explicitly-hidden-properties + (testing "property is filtered when ctx marks it hidden" + (let [entity {:user.property/secret "value"} + ctx {:property-hidden-by-ident {:user.property/secret true}} + result (render/entity-properties entity ctx {})] + (is (nil? (get result :user.property/secret)))))) + +(deftest entity-properties-filters-built-in-hidden-properties + (testing "built-in properties with :hide? true are filtered without property entity metadata" + (let [entity {:logseq.property/created-from-property "some-value"} + ctx {:property-hidden-by-ident {}} + result (render/entity-properties entity ctx {})] + (is (nil? (get result :logseq.property/created-from-property)))))) diff --git a/deps/publish/test/logseq/publish/test_runner.cljs b/deps/publish/test/logseq/publish/test_runner.cljs index d8cfe6eeae..5e6e5f4074 100644 --- a/deps/publish/test/logseq/publish/test_runner.cljs +++ b/deps/publish/test/logseq/publish/test_runner.cljs @@ -1,5 +1,6 @@ (ns logseq.publish.test-runner (:require [cljs.test :as ct] + [logseq.publish.render-test] [logseq.publish.routes-test] [shadow.test :as st] [shadow.test.env :as env])) From 4b7c84e7cb16dfe4c64d8ea582529a7da21728d7 Mon Sep 17 00:00:00 2001 From: charlie Date: Tue, 28 Apr 2026 16:53:47 +0800 Subject: [PATCH 11/19] chore: build libs core --- resources/js/lsplugin.core.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/lsplugin.core.js b/resources/js/lsplugin.core.js index af232231e0..66842ed7a6 100644 --- a/resources/js/lsplugin.core.js +++ b/resources/js/lsplugin.core.js @@ -1,2 +1,2 @@ /*! For license information please see lsplugin.core.js.LICENSE.txt */ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.LSPlugin=t():e.LSPlugin=t()}(self,()=>(()=>{var e={833(e,t,n){var r=n(606);t.formatArgs=function(t){if(t[0]=(this.useColors?"%c":"")+this.namespace+(this.useColors?" %c":" ")+t[0]+(this.useColors?"%c ":" ")+"+"+e.exports.humanize(this.diff),!this.useColors)return;const n="color: "+this.color;t.splice(1,0,n,"color: inherit");let r=0,s=0;t[0].replace(/%[a-zA-Z%]/g,e=>{"%%"!==e&&(r++,"%c"===e&&(s=r))}),t.splice(s,0,n)},t.save=function(e){try{e?t.storage.setItem("debug",e):t.storage.removeItem("debug")}catch(e){}},t.load=function(){let e;try{e=t.storage.getItem("debug")||t.storage.getItem("DEBUG")}catch(e){}return!e&&void 0!==r&&"env"in r&&(e=r.env.DEBUG),e},t.useColors=function(){if("undefined"!=typeof window&&window.process&&("renderer"===window.process.type||window.process.__nwjs))return!0;if("undefined"!=typeof navigator&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/(edge|trident)\/(\d+)/))return!1;let e;return"undefined"!=typeof document&&document.documentElement&&document.documentElement.style&&document.documentElement.style.WebkitAppearance||"undefined"!=typeof window&&window.console&&(window.console.firebug||window.console.exception&&window.console.table)||"undefined"!=typeof navigator&&navigator.userAgent&&(e=navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/))&&parseInt(e[1],10)>=31||"undefined"!=typeof navigator&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/)},t.storage=function(){try{return localStorage}catch(e){}}(),t.destroy=(()=>{let e=!1;return()=>{e||(e=!0,console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."))}})(),t.colors=["#0000CC","#0000FF","#0033CC","#0033FF","#0066CC","#0066FF","#0099CC","#0099FF","#00CC00","#00CC33","#00CC66","#00CC99","#00CCCC","#00CCFF","#3300CC","#3300FF","#3333CC","#3333FF","#3366CC","#3366FF","#3399CC","#3399FF","#33CC00","#33CC33","#33CC66","#33CC99","#33CCCC","#33CCFF","#6600CC","#6600FF","#6633CC","#6633FF","#66CC00","#66CC33","#9900CC","#9900FF","#9933CC","#9933FF","#99CC00","#99CC33","#CC0000","#CC0033","#CC0066","#CC0099","#CC00CC","#CC00FF","#CC3300","#CC3333","#CC3366","#CC3399","#CC33CC","#CC33FF","#CC6600","#CC6633","#CC9900","#CC9933","#CCCC00","#CCCC33","#FF0000","#FF0033","#FF0066","#FF0099","#FF00CC","#FF00FF","#FF3300","#FF3333","#FF3366","#FF3399","#FF33CC","#FF33FF","#FF6600","#FF6633","#FF9900","#FF9933","#FFCC00","#FFCC33"],t.log=console.debug||console.log||(()=>{}),e.exports=n(736)(t);const{formatters:s}=e.exports;s.j=function(e){try{return JSON.stringify(e)}catch(e){return"[UnexpectedJSONParseError]: "+e.message}}},736(e,t,n){e.exports=function(e){function t(e){let n,s,i,o=null;function a(...e){if(!a.enabled)return;const r=a,s=Number(new Date),i=s-(n||s);r.diff=i,r.prev=n,r.curr=s,n=s,e[0]=t.coerce(e[0]),"string"!=typeof e[0]&&e.unshift("%O");let o=0;e[0]=e[0].replace(/%([a-zA-Z%])/g,(n,s)=>{if("%%"===n)return"%";o++;const i=t.formatters[s];if("function"==typeof i){const t=e[o];n=i.call(r,t),e.splice(o,1),o--}return n}),t.formatArgs.call(r,e),(r.log||t.log).apply(r,e)}return a.namespace=e,a.useColors=t.useColors(),a.color=t.selectColor(e),a.extend=r,a.destroy=t.destroy,Object.defineProperty(a,"enabled",{enumerable:!0,configurable:!1,get:()=>null!==o?o:(s!==t.namespaces&&(s=t.namespaces,i=t.enabled(e)),i),set:e=>{o=e}}),"function"==typeof t.init&&t.init(a),a}function r(e,n){const r=t(this.namespace+(void 0===n?":":n)+e);return r.log=this.log,r}function s(e,t){let n=0,r=0,s=-1,i=0;for(;n"-"+e)].join(",");return t.enable(""),e},t.enable=function(e){t.save(e),t.namespaces=e,t.names=[],t.skips=[];const n=("string"==typeof e?e:"").trim().replace(/\s+/g,",").split(",").filter(Boolean);for(const e of n)"-"===e[0]?t.skips.push(e.slice(1)):t.names.push(e)},t.enabled=function(e){for(const n of t.skips)if(s(e,n))return!1;for(const n of t.names)if(s(e,n))return!0;return!1},t.humanize=n(585),t.destroy=function(){console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`.")},Object.keys(e).forEach(n=>{t[n]=e[n]}),t.names=[],t.skips=[],t.formatters={},t.selectColor=function(e){let n=0;for(let t=0;t=1.5*n;return Math.round(e/n)+" "+r+(s?"s":"")}e.exports=function(e,a){a=a||{};var l,c,u=typeof e;if("string"===u&&e.length>0)return function(e){if(!((e=String(e)).length>100)){var o=/^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(e);if(o){var a=parseFloat(o[1]);switch((o[2]||"ms").toLowerCase()){case"years":case"year":case"yrs":case"yr":case"y":return 315576e5*a;case"weeks":case"week":case"w":return a*i;case"days":case"day":case"d":return a*s;case"hours":case"hour":case"hrs":case"hr":case"h":return a*r;case"minutes":case"minute":case"mins":case"min":case"m":return a*n;case"seconds":case"second":case"secs":case"sec":case"s":return a*t;case"milliseconds":case"millisecond":case"msecs":case"msec":case"ms":return a;default:return}}}}(e);if("number"===u&&isFinite(e))return a.long?(l=e,(c=Math.abs(l))>=s?o(l,c,s,"day"):c>=r?o(l,c,r,"hour"):c>=n?o(l,c,n,"minute"):c>=t?o(l,c,t,"second"):l+" ms"):function(e){var i=Math.abs(e);return i>=s?Math.round(e/s)+"d":i>=r?Math.round(e/r)+"h":i>=n?Math.round(e/n)+"m":i>=t?Math.round(e/t)+"s":e+"ms"}(e);throw new Error("val is not a non-empty string or a valid number. val="+JSON.stringify(e))}},627(e,t,n){"use strict";var r=n(606),s="win32"===r.platform,i=n(537);function o(e,t){for(var n=[],r=0;r=0&&!e[r];r--);return 0===n&&r===t?e:n>r?[]:e.slice(n,r+1)}var l=/^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/,c=/^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/,u={};function h(e){var t=l.exec(e),n=(t[1]||"")+(t[2]||""),r=t[3]||"",s=c.exec(r);return[n,s[1],s[2],s[3]]}function d(e){var t=l.exec(e),n=t[1]||"",r=!!n&&":"!==n[1];return{device:n,isUnc:r,isAbsolute:r||!!t[2],tail:t[3]}}function p(e){return"\\\\"+e.replace(/^[\\\/]+/,"").replace(/[\\\/]+/g,"\\")}u.resolve=function(){for(var e="",t="",n=!1,s=arguments.length-1;s>=-1;s--){var a;if(s>=0?a=arguments[s]:e?(a=r.env["="+e])&&a.substr(0,3).toLowerCase()===e.toLowerCase()+"\\"||(a=e+"\\"):a=r.cwd(),!i.isString(a))throw new TypeError("Arguments to path.resolve must be strings");if(a){var l=d(a),c=l.device,u=l.isUnc,h=l.isAbsolute,f=l.tail;if((!c||!e||c.toLowerCase()===e.toLowerCase())&&(e||(e=c),n||(t=f+"\\"+t,n=h),e&&n))break}}return u&&(e=p(e)),e+(n?"\\":"")+(t=o(t.split(/[\\\/]+/),!n).join("\\"))||"."},u.normalize=function(e){var t=d(e),n=t.device,r=t.isUnc,s=t.isAbsolute,i=t.tail,a=/[\\\/]$/.test(i);return(i=o(i.split(/[\\\/]+/),!s).join("\\"))||s||(i="."),i&&a&&(i+="\\"),r&&(n=p(n)),n+(s?"\\":"")+i},u.isAbsolute=function(e){return d(e).isAbsolute},u.join=function(){for(var e=[],t=0;t=-1&&!t;n--){var s=n>=0?arguments[n]:r.cwd();if(!i.isString(s))throw new TypeError("Arguments to path.resolve must be strings");s&&(e=s+"/"+e,t="/"===s[0])}return(t?"/":"")+(e=o(e.split("/"),!t).join("/"))||"."},g.normalize=function(e){var t=g.isAbsolute(e),n=e&&"/"===e[e.length-1];return(e=o(e.split("/"),!t).join("/"))||t||(e="."),e&&n&&(e+="/"),(t?"/":"")+e},g.isAbsolute=function(e){return"/"===e.charAt(0)},g.join=function(){for(var e="",t=0;t1)for(var n=1;n=i)return e;switch(e){case"%s":return String(r[n++]);case"%d":return Number(r[n++]);case"%j":try{return JSON.stringify(r[n++])}catch(e){return"[Circular]"}default:return e}}),l=r[n];n=3&&(r.depth=arguments[2]),arguments.length>=4&&(r.colors=arguments[3]),f(n)?r.showHidden=n:n&&t._extend(r,n),_(r.showHidden)&&(r.showHidden=!1),_(r.depth)&&(r.depth=2),_(r.colors)&&(r.colors=!1),_(r.customInspect)&&(r.customInspect=!0),r.colors&&(r.stylize=l),u(r,e,r.depth)}function l(e,t){var n=a.styles[t];return n?"["+a.colors[n][0]+"m"+e+"["+a.colors[n][1]+"m":e}function c(e,t){return e}function u(e,n,r){if(e.customInspect&&n&&S(n.inspect)&&n.inspect!==t.inspect&&(!n.constructor||n.constructor.prototype!==n)){var s=n.inspect(r,e);return y(s)||(s=u(e,s,r)),s}var i=function(e,t){if(_(t))return e.stylize("undefined","undefined");if(y(t)){var n="'"+JSON.stringify(t).replace(/^"|"$/g,"").replace(/'/g,"\\'").replace(/\\"/g,'"')+"'";return e.stylize(n,"string")}return m(t)?e.stylize(""+t,"number"):f(t)?e.stylize(""+t,"boolean"):g(t)?e.stylize("null","null"):void 0}(e,n);if(i)return i;var o=Object.keys(n),a=function(e){var t={};return e.forEach(function(e,n){t[e]=!0}),t}(o);if(e.showHidden&&(o=Object.getOwnPropertyNames(n)),C(n)&&(o.indexOf("message")>=0||o.indexOf("description")>=0))return h(n);if(0===o.length){if(S(n)){var l=n.name?": "+n.name:"";return e.stylize("[Function"+l+"]","special")}if(b(n))return e.stylize(RegExp.prototype.toString.call(n),"regexp");if(w(n))return e.stylize(Date.prototype.toString.call(n),"date");if(C(n))return h(n)}var c,v="",x=!1,E=["{","}"];return p(n)&&(x=!0,E=["[","]"]),S(n)&&(v=" [Function"+(n.name?": "+n.name:"")+"]"),b(n)&&(v=" "+RegExp.prototype.toString.call(n)),w(n)&&(v=" "+Date.prototype.toUTCString.call(n)),C(n)&&(v=" "+h(n)),0!==o.length||x&&0!=n.length?r<0?b(n)?e.stylize(RegExp.prototype.toString.call(n),"regexp"):e.stylize("[Object]","special"):(e.seen.push(n),c=x?function(e,t,n,r,s){for(var i=[],o=0,a=t.length;o60?n[0]+(""===t?"":t+"\n ")+" "+e.join(",\n ")+" "+n[1]:n[0]+t+" "+e.join(", ")+" "+n[1]}(c,v,E)):E[0]+v+E[1]}function h(e){return"["+Error.prototype.toString.call(e)+"]"}function d(e,t,n,r,s,i){var o,a,l;if((l=Object.getOwnPropertyDescriptor(t,s)||{value:t[s]}).get?a=l.set?e.stylize("[Getter/Setter]","special"):e.stylize("[Getter]","special"):l.set&&(a=e.stylize("[Setter]","special")),A(r,s)||(o="["+s+"]"),a||(e.seen.indexOf(l.value)<0?(a=g(n)?u(e,l.value,null):u(e,l.value,n-1)).indexOf("\n")>-1&&(a=i?a.split("\n").map(function(e){return" "+e}).join("\n").substr(2):"\n"+a.split("\n").map(function(e){return" "+e}).join("\n")):a=e.stylize("[Circular]","special")),_(o)){if(i&&s.match(/^\d+$/))return a;(o=JSON.stringify(""+s)).match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)?(o=o.substr(1,o.length-2),o=e.stylize(o,"name")):(o=o.replace(/'/g,"\\'").replace(/\\"/g,'"').replace(/(^"|"$)/g,"'"),o=e.stylize(o,"string"))}return o+": "+a}function p(e){return Array.isArray(e)}function f(e){return"boolean"==typeof e}function g(e){return null===e}function m(e){return"number"==typeof e}function y(e){return"string"==typeof e}function _(e){return void 0===e}function b(e){return v(e)&&"[object RegExp]"===x(e)}function v(e){return"object"==typeof e&&null!==e}function w(e){return v(e)&&"[object Date]"===x(e)}function C(e){return v(e)&&("[object Error]"===x(e)||e instanceof Error)}function S(e){return"function"==typeof e}function x(e){return Object.prototype.toString.call(e)}function E(e){return e<10?"0"+e.toString(10):e.toString(10)}t.debuglog=function(e){if(_(i)&&(i=r.env.NODE_DEBUG||""),e=e.toUpperCase(),!o[e])if(new RegExp("\\b"+e+"\\b","i").test(i)){var n=r.pid;o[e]=function(){var r=t.format.apply(t,arguments);console.error("%s %d: %s",e,n,r)}}else o[e]=function(){};return o[e]},t.inspect=a,a.colors={bold:[1,22],italic:[3,23],underline:[4,24],inverse:[7,27],white:[37,39],grey:[90,39],black:[30,39],blue:[34,39],cyan:[36,39],green:[32,39],magenta:[35,39],red:[31,39],yellow:[33,39]},a.styles={special:"cyan",number:"yellow",boolean:"yellow",undefined:"grey",null:"bold",string:"green",date:"magenta",regexp:"red"},t.isArray=p,t.isBoolean=f,t.isNull=g,t.isNullOrUndefined=function(e){return null==e},t.isNumber=m,t.isString=y,t.isSymbol=function(e){return"symbol"==typeof e},t.isUndefined=_,t.isRegExp=b,t.isObject=v,t.isDate=w,t.isError=C,t.isFunction=S,t.isPrimitive=function(e){return null===e||"boolean"==typeof e||"number"==typeof e||"string"==typeof e||"symbol"==typeof e||void 0===e},t.isBuffer=n(135);var T=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function A(e,t){return Object.prototype.hasOwnProperty.call(e,t)}t.log=function(){var e,n;console.log("%s - %s",(n=[E((e=new Date).getHours()),E(e.getMinutes()),E(e.getSeconds())].join(":"),[e.getDate(),T[e.getMonth()],n].join(" ")),t.format.apply(t,arguments))},t.inherits=n(698),t._extend=function(e,t){if(!t||!v(t))return e;for(var n=Object.keys(t),r=n.length;r--;)e[n[r]]=t[n[r]];return e}}},t={};function n(r){var s=t[r];if(void 0!==s)return s.exports;var i=t[r]={exports:{}};return e[r](i,i.exports,n),i.exports}n.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},n.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var r={};return(()=>{"use strict";n.r(r),n.d(r,{PluginLocal:()=>vs,pluginHelpers:()=>t,setupPluginCore:()=>Cs});var e={};n.r(e),n.d(e,{setSDKMetadata:()=>ye});var t={};n.r(t),n.d(t,{IS_DEV:()=>be,PROTOCOL_FILE:()=>ve,PROTOCOL_LSP:()=>we,PluginLogger:()=>ke,URL_LSP:()=>Ce,cleanInjectedScripts:()=>He,cleanInjectedUI:()=>ze,deepMerge:()=>Oe,deferred:()=>De,genID:()=>Le,getAppPathRoot:()=>Ee,getSDKPathRoot:()=>Te,injectTheme:()=>qe,invokeHostExportedApi:()=>Ne,isObject:()=>Ae,isValidUUID:()=>Pe,mergeSettingsWithSchema:()=>Be,normalizeKeyStr:()=>Ge,path:()=>_e,safeSnakeCase:()=>xe,safetyPathJoin:()=>Me,safetyPathNormalize:()=>Re,setupInjectedStyle:()=>Ue,setupInjectedUI:()=>$e,transformableEvent:()=>We,ucFirst:()=>Ie,withFileProtocol:()=>je});var s=n(228);const i=s;var o=n(627);const{entries:a,setPrototypeOf:l,isFrozen:c,getPrototypeOf:u,getOwnPropertyDescriptor:h}=Object;let{freeze:d,seal:p,create:f}=Object,{apply:g,construct:m}="undefined"!=typeof Reflect&&Reflect;d||(d=function(e){return e}),p||(p=function(e){return e}),g||(g=function(e,t){for(var n=arguments.length,r=new Array(n>2?n-2:0),s=2;s1?t-1:0),r=1;r1?n-1:0),s=1;s2&&void 0!==arguments[2]?arguments[2]:C;l&&l(e,null);let r=t.length;for(;r--;){let s=t[r];if("string"==typeof s){const e=n(s);e!==s&&(c(t)||(t[r]=e),s=e)}e[s]=!0}return e}function M(e){for(let t=0;t/gm),V=p(/\$\{[\w\W]*/gm),X=p(/^data-[\-\w.\u00B7-\uFFFF]+$/),Z=p(/^aria-[\-\w]+$/),Q=p(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),ee=p(/^(?:\w+script|data):/i),te=p(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),ne=p(/^html$/i),re=p(/^[a-z][.\w]*(-[.\w]+)+$/i);var se=Object.freeze({__proto__:null,ARIA_ATTR:Z,ATTR_WHITESPACE:te,CUSTOM_ELEMENT:re,DATA_ATTR:X,DOCTYPE_NAME:ne,ERB_EXPR:K,IS_ALLOWED_URI:Q,IS_SCRIPT_OR_DATA:ee,MUSTACHE_EXPR:Y,TMPLIT_EXPR:V});const ie=function(){return"undefined"==typeof window?null:window};var oe=function e(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:ie();const n=t=>e(t);if(n.version="3.3.3",n.removed=[],!t||!t.document||9!==t.document.nodeType||!t.Element)return n.isSupported=!1,n;let{document:r}=t;const s=r,i=s.currentScript,{DocumentFragment:o,HTMLTemplateElement:l,Node:c,Element:u,NodeFilter:h,NamedNodeMap:p=t.NamedNodeMap||t.MozNamedAttrMap,HTMLFormElement:g,DOMParser:m,trustedTypes:L}=t,I=u.prototype,M=D(I,"cloneNode"),Y=D(I,"remove"),K=D(I,"nextSibling"),V=D(I,"childNodes"),X=D(I,"parentNode");if("function"==typeof l){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let Z,ee="";const{implementation:te,createNodeIterator:re,createDocumentFragment:oe,getElementsByTagName:ae}=r,{importNode:le}=s;let ce={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};n.isSupported="function"==typeof a&&"function"==typeof X&&te&&void 0!==te.createHTMLDocument;const{MUSTACHE_EXPR:ue,ERB_EXPR:he,TMPLIT_EXPR:de,DATA_ATTR:pe,ARIA_ATTR:fe,IS_SCRIPT_OR_DATA:ge,ATTR_WHITESPACE:me,CUSTOM_ELEMENT:ye}=se;let{IS_ALLOWED_URI:_e}=se,be=null;const ve=j({},[...N,...U,...F,...z,...W]);let we=null;const Ce=j({},[...q,...B,...G,...J]);let Se=Object.seal(f(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),xe=null,Ee=null;const Te=Object.seal(f(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let Ae=!0,Oe=!0,ke=!1,Pe=!0,Le=!1,Ie=!0,je=!1,Me=!1,Re=!1,De=!1,Ne=!1,Ue=!1,Fe=!0,$e=!1,ze=!0,He=!1,We={},qe=null;const Be=j({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Ge=null;const Je=j({},["audio","video","img","source","image","track"]);let Ye=null;const Ke=j({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Ve="http://www.w3.org/1998/Math/MathML",Xe="http://www.w3.org/2000/svg",Ze="http://www.w3.org/1999/xhtml";let Qe=Ze,et=!1,tt=null;const nt=j({},[Ve,Xe,Ze],S);let rt=j({},["mi","mo","mn","ms","mtext"]),st=j({},["annotation-xml"]);const it=j({},["title","style","font","a","script"]);let ot=null;const at=["application/xhtml+xml","text/html"];let lt=null,ct=null;const ut=r.createElement("form"),ht=function(e){return e instanceof RegExp||e instanceof Function},dt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ct||ct!==e){if(e&&"object"==typeof e||(e={}),e=R(e),ot=-1===at.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,lt="application/xhtml+xml"===ot?S:C,be=O(e,"ALLOWED_TAGS")?j({},e.ALLOWED_TAGS,lt):ve,we=O(e,"ALLOWED_ATTR")?j({},e.ALLOWED_ATTR,lt):Ce,tt=O(e,"ALLOWED_NAMESPACES")?j({},e.ALLOWED_NAMESPACES,S):nt,Ye=O(e,"ADD_URI_SAFE_ATTR")?j(R(Ke),e.ADD_URI_SAFE_ATTR,lt):Ke,Ge=O(e,"ADD_DATA_URI_TAGS")?j(R(Je),e.ADD_DATA_URI_TAGS,lt):Je,qe=O(e,"FORBID_CONTENTS")?j({},e.FORBID_CONTENTS,lt):Be,xe=O(e,"FORBID_TAGS")?j({},e.FORBID_TAGS,lt):R({}),Ee=O(e,"FORBID_ATTR")?j({},e.FORBID_ATTR,lt):R({}),We=!!O(e,"USE_PROFILES")&&e.USE_PROFILES,Ae=!1!==e.ALLOW_ARIA_ATTR,Oe=!1!==e.ALLOW_DATA_ATTR,ke=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Pe=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,Le=e.SAFE_FOR_TEMPLATES||!1,Ie=!1!==e.SAFE_FOR_XML,je=e.WHOLE_DOCUMENT||!1,De=e.RETURN_DOM||!1,Ne=e.RETURN_DOM_FRAGMENT||!1,Ue=e.RETURN_TRUSTED_TYPE||!1,Re=e.FORCE_BODY||!1,Fe=!1!==e.SANITIZE_DOM,$e=e.SANITIZE_NAMED_PROPS||!1,ze=!1!==e.KEEP_CONTENT,He=e.IN_PLACE||!1,_e=e.ALLOWED_URI_REGEXP||Q,Qe=e.NAMESPACE||Ze,rt=e.MATHML_TEXT_INTEGRATION_POINTS||rt,st=e.HTML_INTEGRATION_POINTS||st,Se=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Se.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Se.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Se.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),Le&&(Oe=!1),Ne&&(De=!0),We&&(be=j({},W),we=f(null),!0===We.html&&(j(be,N),j(we,q)),!0===We.svg&&(j(be,U),j(we,B),j(we,J)),!0===We.svgFilters&&(j(be,F),j(we,B),j(we,J)),!0===We.mathMl&&(j(be,z),j(we,G),j(we,J))),O(e,"ADD_TAGS")||(Te.tagCheck=null),O(e,"ADD_ATTR")||(Te.attributeCheck=null),e.ADD_TAGS&&("function"==typeof e.ADD_TAGS?Te.tagCheck=e.ADD_TAGS:(be===ve&&(be=R(be)),j(be,e.ADD_TAGS,lt))),e.ADD_ATTR&&("function"==typeof e.ADD_ATTR?Te.attributeCheck=e.ADD_ATTR:(we===Ce&&(we=R(we)),j(we,e.ADD_ATTR,lt))),e.ADD_URI_SAFE_ATTR&&j(Ye,e.ADD_URI_SAFE_ATTR,lt),e.FORBID_CONTENTS&&(qe===Be&&(qe=R(qe)),j(qe,e.FORBID_CONTENTS,lt)),e.ADD_FORBID_CONTENTS&&(qe===Be&&(qe=R(qe)),j(qe,e.ADD_FORBID_CONTENTS,lt)),ze&&(be["#text"]=!0),je&&j(be,["html","head","body"]),be.table&&(j(be,["tbody"]),delete xe.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw P('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw P('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');Z=e.TRUSTED_TYPES_POLICY,ee=Z.createHTML("")}else void 0===Z&&(Z=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const r="data-tt-policy-suffix";t&&t.hasAttribute(r)&&(n=t.getAttribute(r));const s="dompurify"+(n?"#"+n:"");try{return e.createPolicy(s,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+s+" could not be created."),null}}(L,i)),null!==Z&&"string"==typeof ee&&(ee=Z.createHTML(""));d&&d(e),ct=e}},pt=j({},[...U,...F,...$]),ft=j({},[...z,...H]),gt=function(e){v(n.removed,{element:e});try{X(e).removeChild(e)}catch(t){Y(e)}},mt=function(e,t){try{v(n.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){v(n.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(De||Ne)try{gt(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},yt=function(e){let t=null,n=null;if(Re)e=""+e;else{const t=x(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===ot&&Qe===Ze&&(e=''+e+"");const s=Z?Z.createHTML(e):e;if(Qe===Ze)try{t=(new m).parseFromString(s,ot)}catch(e){}if(!t||!t.documentElement){t=te.createDocument(Qe,"template",null);try{t.documentElement.innerHTML=et?ee:s}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),Qe===Ze?ae.call(t,je?"html":"body")[0]:je?t.documentElement:i},_t=function(e){return re.call(e.ownerDocument||e,e,h.SHOW_ELEMENT|h.SHOW_COMMENT|h.SHOW_TEXT|h.SHOW_PROCESSING_INSTRUCTION|h.SHOW_CDATA_SECTION,null)},bt=function(e){return e instanceof g&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof p)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},vt=function(e){return"function"==typeof c&&e instanceof c};function wt(e,t,r){y(e,e=>{e.call(n,t,r,ct)})}const Ct=function(e){let t=null;if(wt(ce.beforeSanitizeElements,e,null),bt(e))return gt(e),!0;const r=lt(e.nodeName);if(wt(ce.uponSanitizeElement,e,{tagName:r,allowedTags:be}),Ie&&e.hasChildNodes()&&!vt(e.firstElementChild)&&k(/<[/\w!]/g,e.innerHTML)&&k(/<[/\w!]/g,e.textContent))return gt(e),!0;if(7===e.nodeType)return gt(e),!0;if(Ie&&8===e.nodeType&&k(/<[/\w]/g,e.data))return gt(e),!0;if(!(Te.tagCheck instanceof Function&&Te.tagCheck(r))&&(!be[r]||xe[r])){if(!xe[r]&&xt(r)){if(Se.tagNameCheck instanceof RegExp&&k(Se.tagNameCheck,r))return!1;if(Se.tagNameCheck instanceof Function&&Se.tagNameCheck(r))return!1}if(ze&&!qe[r]){const t=X(e)||e.parentNode,n=V(e)||e.childNodes;if(n&&t)for(let r=n.length-1;r>=0;--r){const s=M(n[r],!0);s.__removalCount=(e.__removalCount||0)+1,t.insertBefore(s,K(e))}}return gt(e),!0}return e instanceof u&&!function(e){let t=X(e);t&&t.tagName||(t={namespaceURI:Qe,tagName:"template"});const n=C(e.tagName),r=C(t.tagName);return!!tt[e.namespaceURI]&&(e.namespaceURI===Xe?t.namespaceURI===Ze?"svg"===n:t.namespaceURI===Ve?"svg"===n&&("annotation-xml"===r||rt[r]):Boolean(pt[n]):e.namespaceURI===Ve?t.namespaceURI===Ze?"math"===n:t.namespaceURI===Xe?"math"===n&&st[r]:Boolean(ft[n]):e.namespaceURI===Ze?!(t.namespaceURI===Xe&&!st[r])&&!(t.namespaceURI===Ve&&!rt[r])&&!ft[n]&&(it[n]||!pt[n]):!("application/xhtml+xml"!==ot||!tt[e.namespaceURI]))}(e)?(gt(e),!0):"noscript"!==r&&"noembed"!==r&&"noframes"!==r||!k(/<\/no(script|embed|frames)/i,e.innerHTML)?(Le&&3===e.nodeType&&(t=e.textContent,y([ue,he,de],e=>{t=E(t,e," ")}),e.textContent!==t&&(v(n.removed,{element:e.cloneNode()}),e.textContent=t)),wt(ce.afterSanitizeElements,e,null),!1):(gt(e),!0)},St=function(e,t,n){if(Ee[t])return!1;if(Fe&&("id"===t||"name"===t)&&(n in r||n in ut))return!1;if(Oe&&!Ee[t]&&k(pe,t));else if(Ae&&k(fe,t));else if(Te.attributeCheck instanceof Function&&Te.attributeCheck(t,e));else if(!we[t]||Ee[t]){if(!(xt(e)&&(Se.tagNameCheck instanceof RegExp&&k(Se.tagNameCheck,e)||Se.tagNameCheck instanceof Function&&Se.tagNameCheck(e))&&(Se.attributeNameCheck instanceof RegExp&&k(Se.attributeNameCheck,t)||Se.attributeNameCheck instanceof Function&&Se.attributeNameCheck(t,e))||"is"===t&&Se.allowCustomizedBuiltInElements&&(Se.tagNameCheck instanceof RegExp&&k(Se.tagNameCheck,n)||Se.tagNameCheck instanceof Function&&Se.tagNameCheck(n))))return!1}else if(Ye[t]);else if(k(_e,E(n,me,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==T(n,"data:")||!Ge[e])if(ke&&!k(ge,E(n,me,"")));else if(n)return!1;return!0},xt=function(e){return"annotation-xml"!==e&&x(e,ye)},Et=function(e){wt(ce.beforeSanitizeAttributes,e,null);const{attributes:t}=e;if(!t||bt(e))return;const r={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:we,forceKeepAttr:void 0};let s=t.length;for(;s--;){const i=t[s],{name:o,namespaceURI:a,value:l}=i,c=lt(o),u=l;let h="value"===o?u:A(u);if(r.attrName=c,r.attrValue=h,r.keepAttr=!0,r.forceKeepAttr=void 0,wt(ce.uponSanitizeAttribute,e,r),h=r.attrValue,!$e||"id"!==c&&"name"!==c||(mt(o,e),h="user-content-"+h),Ie&&k(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,h)){mt(o,e);continue}if("attributename"===c&&x(h,"href")){mt(o,e);continue}if(r.forceKeepAttr)continue;if(!r.keepAttr){mt(o,e);continue}if(!Pe&&k(/\/>/i,h)){mt(o,e);continue}Le&&y([ue,he,de],e=>{h=E(h,e," ")});const d=lt(e.nodeName);if(St(d,c,h)){if(Z&&"object"==typeof L&&"function"==typeof L.getAttributeType)if(a);else switch(L.getAttributeType(d,c)){case"TrustedHTML":h=Z.createHTML(h);break;case"TrustedScriptURL":h=Z.createScriptURL(h)}if(h!==u)try{a?e.setAttributeNS(a,o,h):e.setAttribute(o,h),bt(e)?gt(e):b(n.removed)}catch(t){mt(o,e)}}else mt(o,e)}wt(ce.afterSanitizeAttributes,e,null)},Tt=function e(t){let n=null;const r=_t(t);for(wt(ce.beforeSanitizeShadowDOM,t,null);n=r.nextNode();)wt(ce.uponSanitizeShadowNode,n,null),Ct(n),Et(n),n.content instanceof o&&e(n.content);wt(ce.afterSanitizeShadowDOM,t,null)};return n.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r=null,i=null,a=null,l=null;if(et=!e,et&&(e="\x3c!--\x3e"),"string"!=typeof e&&!vt(e)){if("function"!=typeof e.toString)throw P("toString is not a function");if("string"!=typeof(e=e.toString()))throw P("dirty is not a string, aborting")}if(!n.isSupported)return e;if(Me||dt(t),n.removed=[],"string"==typeof e&&(He=!1),He){if(e.nodeName){const t=lt(e.nodeName);if(!be[t]||xe[t])throw P("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof c)r=yt("\x3c!----\x3e"),i=r.ownerDocument.importNode(e,!0),1===i.nodeType&&"BODY"===i.nodeName||"HTML"===i.nodeName?r=i:r.appendChild(i);else{if(!De&&!Le&&!je&&-1===e.indexOf("<"))return Z&&Ue?Z.createHTML(e):e;if(r=yt(e),!r)return De?null:Ue?ee:""}r&&Re&>(r.firstChild);const u=_t(He?e:r);for(;a=u.nextNode();)Ct(a),Et(a),a.content instanceof o&&Tt(a.content);if(He)return e;if(De){if(Ne)for(l=oe.call(r.ownerDocument);r.firstChild;)l.appendChild(r.firstChild);else l=r;return(we.shadowroot||we.shadowrootmode)&&(l=le.call(s,l,!0)),l}let h=je?r.outerHTML:r.innerHTML;return je&&be["!doctype"]&&r.ownerDocument&&r.ownerDocument.doctype&&r.ownerDocument.doctype.name&&k(ne,r.ownerDocument.doctype.name)&&(h="\n"+h),Le&&y([ue,he,de],e=>{h=E(h,e," ")}),Z&&Ue?Z.createHTML(h):h},n.setConfig=function(){dt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Me=!0},n.clearConfig=function(){ct=null,Me=!1},n.isValidAttribute=function(e,t,n){ct||dt({});const r=lt(e),s=lt(t);return St(r,s,n)},n.addHook=function(e,t){"function"==typeof t&&v(ce[e],t)},n.removeHook=function(e,t){if(void 0!==t){const n=_(ce[e],t);return-1===n?void 0:w(ce[e],n,1)[0]}return b(ce[e])},n.removeHooks=function(e){ce[e]=[]},n.removeAllHooks=function(){ce={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},n}(),ae=n(744),le=n.n(ae);const ce=/([\p{Ll}\d])(\p{Lu})/gu,ue=/(\p{Lu})([\p{Lu}][\p{Ll}])/gu,he=/(\d)\p{Ll}|(\p{L})\d/u,de=/[^\p{L}\d]+/giu,pe="$1\0$2";function fe(e){let t=e.trim();t=t.replace(ce,pe).replace(ue,pe),t=t.replace(de,"\0");let n=0,r=t.length;for(;"\0"===t.charAt(n);)n++;if(n===r)return[];for(;"\0"===t.charAt(r-1);)r--;return t.slice(n,r).split(/\0/g)}function ge(e){const t=fe(e);for(let e=0;ei;){const t=o-1,n=e.charAt(t);if(!s.includes(n))break;o=t}return[e.slice(0,i),n(e.slice(i,o)),e.slice(o)]}(e,t);return n+r.map((i=t?.locale,!1===i?e=>e.toLowerCase():e=>e.toLocaleLowerCase(i))).join(t?.delimiter??" ")+s;var i}function ye(e){this?.sdk&&e&&(this.sdk=Object.assign({},this.sdk,e))}const _e="win32"===navigator.platform.toLowerCase()?o.win32:o.posix,be=!1,ve="file://",we="lsp://",Ce=we+"logseq.io/";let Se;const xe=function(e,t){return me(e,{delimiter:"_",...t})};async function Ee(){return Se||(Se=await Ne("_callApplication","getAppPath"))}async function Te(){return be?localStorage.getItem("LSP_DEV_SDK_ROOT")||"http://localhost:8080":Me(await Ee(),"js")}function Ae(e){return e===Object(e)&&!Array.isArray(e)}function Oe(e,t){return le()(e,t,{arrayMerge:(e,t)=>t})}class ke extends i{_tag;_opts;_logs=[];constructor(e,t){super(),this._tag=e,this._opts=t}write(e,t,n){t?.length&&!0===t[t.length-1]&&(n=!0,t.pop());const r=t.reduce((e,t)=>(t&&t instanceof Error?e+=`${t.message} ${t.stack}`:e+=t.toString(),e),`[${this._tag}][${(new Date).toLocaleTimeString()}] `);this._logs.push([e,r]),(n||this._opts?.console)&&console?.["ERROR"===e?"error":"debug"](`${e}: ${r}`),this.emit("change")}clear(){this._logs=[],this.emit("change")}info(...e){this.write("INFO",e)}error(...e){this.write("ERROR",e)}warn(...e){this.write("WARN",e)}setTag(e){this._tag=e}toJSON(){return this._logs}}function Pe(e){return"string"==typeof e&&36===e.length&&/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi.test(e)}function Le(){return"_"+Math.random().toString(36).substr(2,9)}function Ie(e){return e.charAt(0).toUpperCase()+e.slice(1)}function je(e){return e?(/^(http|file|lsp)/.test(e)||(e=ve+e),e):""}function Me(e,...t){try{const n=new URL(e);if(!n.origin)throw new Error(null);const r=_e.join(e.substr(n.origin.length),...t);return n.origin+r}catch(n){return _e.join(e,...t)}}function Re(e){return e?.match(/^(http?|lsp|assets):/)||(e=_e.normalize(e)),e}function De(e,t){let n,r,s=!1;const i=t=>n=>{e&&clearTimeout(e),t(n),s=!0},o=new Promise((s,o)=>{n=i(s),r=i(o),e&&(e=setTimeout(()=>r(new Error(`[deferred timeout] ${t}`)),e))});return{created:Date.now(),setTag:e=>t=e,resolve:n,reject:r,promise:o,get settled(){return s}}}function Ne(t,...n){t=t?.startsWith("_call")?t:t?.replace(/^[_$]+/,"");let r=xe(t);const s=window.logseq?.sdk,i=s&&Object.keys(s);let o={};const a=r?.split("_")?.[0];a&&i.includes(a)&&(r=r.replace(new RegExp(`^${a}_`),""),o=s?.[a]);const l=Object.assign({},window.logseq?.api,o,e),c=l[r]||window.apis[r]||l[t]||window.apis[t];if(!c)throw new Error(`Not existed method #${t}`);return"function"!=typeof c?c:c.apply(this,n)}function Ue(e,t){const n=t["data-injected-style"];let r=n&&document.querySelector(`[data-injected-style=${n}]`);if(!r)return r=document.createElement("style"),r.textContent=e,t&&Object.entries(t).forEach(([e,t])=>{r.setAttribute(e,t)}),document.head.append(r),()=>{document.head.removeChild(r)};r.textContent=e}const Fe=new Map;function $e(e,t,n){let r,s,i="";const o=this;"slot"in e?(i=e.slot,r=`#${i}`):"path"in e?r=e.path:s=!0;const a=`${o.id}--${e.key||Le()}`,l=a,c=s?document.body:r&&document.querySelector(r);if(!c)return console.error(`${this.debugTag} can not resolve selector target ${r}`),!1;if(!e.template)return void Fe.get(a)?.call(null);e.template=oe.sanitize(e.template,{ADD_TAGS:["iframe"],ALLOW_UNKNOWN_PROTOCOLS:!0,ADD_ATTR:["allow","src","allowfullscreen","frameborder","scrolling","target"]});let u,h,d=document.querySelector(`#${a}`),p=s?d?.querySelector(".ls-ui-float-content"):d;if(p){p.innerHTML=e.template,t&&Object.entries(t).forEach(([e,t])=>{d.setAttribute(e,t)});let n=null!=d.dataset.dx;return void(e.style&&Object.entries(e.style).forEach(([e,t])=>{n&&["left","top","bottom","right","width","height"].includes(e)||(d.style[e]=t)}))}if(d=document.createElement("div"),d.id=a,d.dataset.injectedUi=l||"",s?(p=document.createElement("div"),p.classList.add("ls-ui-float-content"),d.appendChild(p)):p=d,p.innerHTML=e.template,t&&Object.entries(t).forEach(([e,t])=>{d.setAttribute(e,t)}),e.style&&Object.entries(e.style).forEach(([e,t])=>{d.style[e]=t}),s&&(d.setAttribute("draggable","true"),d.setAttribute("resizable","true"),e.close&&(d.dataset.close=e.close),d.classList.add("lsp-ui-float-container","visible"),o._setupResizableContainer(d,l),h=o._setupDraggableContainer(d,{key:l,close:()=>u(),title:t?.title})),i&&e.reset){const e=Array.from(c.querySelectorAll("[data-injected-ui]")).map(e=>e.id);e?.forEach(e=>{Fe.get(e)?.call(null)})}return c.appendChild(d),["click","focus","focusin","focusout","blur","dblclick","keyup","keypress","keydown","change","input","contextmenu"].forEach(e=>{d.addEventListener(e,t=>{const n=t.target.closest(`[data-on-${e}]`);if(!n)return;const{preventDefault:r}=n.dataset,s=n.dataset[`on${Ie(e)}`];s&&o.caller?.callUserModel(s,We(n,t)),"true"===r?.toLowerCase()&&t.preventDefault()},!1)}),n?.({el:d,float:s}),u=()=>{h?.(),Fe.delete(a),c.removeChild(d)},Fe.set(a,u),u}function ze(e){if(!Fe.has(e))return;const t=Fe.get(e);try{t()}catch(t){console.warn("[CLEAN Injected UI] ",e,t)}}function He(){const e=document.head.querySelectorAll(`script[data-ref=${this.id}]`);e?.forEach(e=>e.remove())}function We(e,t){const n={};if(e){n.type=t.type;const r=e.dataset,s="rect";["value","id","className","dataset",s].forEach(t=>{let i;if(t===s){if(!r.hasOwnProperty(s))return;i=e.getBoundingClientRect().toJSON()}else i=e[t];"object"==typeof i&&(i={...i}),n[t]=i})}return n}function qe(e){const t=document.createElement("link");return t.rel="stylesheet",t.href=e,document.head.appendChild(t),()=>{try{document.head.removeChild(t)}catch(e){console.error(e)}}}function Be(e,t){const n=(t||[]).reduce((e,t)=>("default"in t&&(e[t.key]=t.default),e),{});return Object.assign(n,e)}function Ge(e){if("string"==typeof e)return e.trim().replace(/\s/g,"_").toLowerCase()}window.__injectedUIEffects=Fe;var Je=n(833),Ye=n.n(Je);const Ke="application/x-postmate-v1+json";let Ve=0;const Xe={handshake:1,"handshake-reply":1,call:1,emit:1,reply:1,request:1},Ze=(e,t)=>!("string"==typeof t&&"*"!==t&&e.origin!==t||!e.data||"object"==typeof e.data&&!("postmate"in e.data)||e.data.type!==Ke||!Xe[e.data.postmate]);class Qe{parent;frame;child;events={};childOrigin;listener;messagePort;addTransportListener(e){this.messagePort?(console.debug("[DEBUG] Using MessagePort for communication:",this.frame.src),this.messagePort.addEventListener("message",e),this.messagePort.start?.()):(console.debug("[DEBUG] Using postMessage for communication:",this.frame.src),this.parent.addEventListener("message",e,!1))}removeTransportListener(e){this.messagePort?this.messagePort.removeEventListener("message",e):this.parent.removeEventListener("message",e,!1)}postToChild(e){this.messagePort?this.messagePort.postMessage(e):this.child.postMessage(e,this.childOrigin)}constructor(e){this.parent=e.parent,this.frame=e.frame,this.child=e.child,this.childOrigin=e.childOrigin,this.messagePort=e.messagePort,this.listener=e=>{if(this.messagePort){if(!e?.data)return!1;if("object"==typeof e.data&&!("postmate"in e.data))return!1;if(e.data.type!==Ke)return!1;if(!Xe[e.data.postmate])return!1}else if(!Ze(e,this.childOrigin))return!1;const{data:t,name:n}=((e||{}).data||{}).value||{};"emit"===e.data.postmate&&n in this.events&&this.events[n].forEach(e=>{e.call(this,t)})},this.addTransportListener(this.listener)}get(e,...t){return new Promise((n,r)=>{const s=++Ve,i="number"==typeof tt.requestTimeout?tt.requestTimeout:1e4;let o;const a=e=>{e?.data?.uid===s&&"reply"===e.data.postmate&&(this.removeTransportListener(a),o&&clearTimeout(o),e.data.error?r(e.data.error):n(e.data.value))};this.addTransportListener(a),i>0&&(o=setTimeout(()=>{this.removeTransportListener(a),r(new Error(`Postmate: request timeout (${i}ms)`))},i)),this.postToChild({postmate:"request",type:Ke,property:e,args:t,uid:s})})}call(e,t){this.postToChild({postmate:"call",type:Ke,property:e,data:t})}on(e,t){this.events[e]||(this.events[e]=[]),this.events[e].push(t)}destroy(){this.removeTransportListener(this.listener);try{this.messagePort?.close()}catch(e){}this.frame.parentNode.removeChild(this.frame)}}class et{model;parent;parentOrigin;child;messagePort;listener;addTransportListener(e){this.messagePort?(this.messagePort.addEventListener("message",e),this.messagePort.start?.()):this.child.addEventListener("message",e,!1)}postToParent(e,t){if(this.messagePort)this.messagePort.postMessage(e);else if(t?.source){const n="null"===t.origin?"*":t.origin;t.source.postMessage(e,n)}else this.parent.postMessage(e,this.parentOrigin)}constructor(e){if(this.model=e.model,this.parent=e.parent,this.parentOrigin=e.parentOrigin,this.child=e.child,this.messagePort=e.messagePort,this.listener=e=>{if(this.messagePort){if(!e?.data)return;if("object"==typeof e.data&&!("postmate"in e.data))return;if(e.data.type!==Ke)return;if(!Xe[e.data.postmate])return}else if(!Ze(e,this.parentOrigin))return;const{property:t,uid:n,data:r,args:s}=e.data;"call"!==e.data.postmate?((e,t,n)=>{const r="function"==typeof e[t]?e[t].apply(null,n):e[t];return Promise.resolve(r)})(this.model,t,s).then(r=>{this.postToParent({property:t,postmate:"reply",type:Ke,uid:n,value:r},e)}).catch(r=>{this.postToParent({property:t,postmate:"reply",type:Ke,uid:n,error:r},e)}):t in this.model&&"function"==typeof this.model[t]&&this.model[t](r)},this.addTransportListener(this.listener),!this.messagePort){const e=t=>{const n=t.detail?.port;n&&(this.child.removeEventListener("message",this.listener,!1),this.messagePort=n,this.messagePort.addEventListener("message",this.listener),this.messagePort.start?.(),this.child.removeEventListener("postmate:channel-ready",e))};this.child.addEventListener("postmate:channel-ready",e)}}emit(e,t){this.postToParent({postmate:"emit",type:Ke,value:{name:e,data:t}})}}class tt{static debug=!1;static requestTimeout=1e4;container;parent;frame;child;childOrigin;url;model;static Model;messagePort;enableMessageChannel;constructor(e){this.container=e.container,this.url=e.url,this.parent=window,this.frame=document.createElement("iframe"),e.id&&(this.frame.id=e.id),e.name&&(this.frame.name=e.name),e.allow&&(this.frame.allow=e.allow),this.frame.classList.add.apply(this.frame.classList,e.classListArray||[]),this.container.appendChild(this.frame),this.child=this.frame.contentWindow,this.model=e.model||{},this.enableMessageChannel=!!e.enableMessageChannel}sendHandshake(e){const t=(e=>{const t=document.createElement("a");if(t.href=e,"file:"===t.protocol)return"*";const n=t.protocol.length>4?t.protocol:window.location.protocol,r=t.host.length?"80"===t.port||"443"===t.port?t.hostname:t.host:window.location.host;return t.origin||`${n}//${r}`})(e=e||this.url);let n,r=0;return new Promise((s,i)=>{const o="undefined"!=typeof MessageChannel&&"function"==typeof MessageChannel,a=this.enableMessageChannel&&o,l=e=>{if(!Ze(e,t))return!1;if("handshake-reply"===e.data.postmate){if(clearInterval(n),this.parent.removeEventListener("message",l,!1),this.childOrigin=e.origin,a)if(e?.ports?.length){const t=e.ports[0];t&&(this.messagePort=t,this.messagePort.start?.())}else if(e.data.acceptsMessageChannel){const e=new MessageChannel;this.messagePort=e.port1,this.messagePort.start?.(),this.child.postMessage({postmate:"setup-channel",type:Ke},t,[e.port2])}return s(new Qe(this))}return i("Failed handshake")};this.parent.addEventListener("message",l,!1);const c=()=>{r++;const e={postmate:"handshake",type:Ke,model:this.model,enableMessageChannel:a?1:0};this.child.postMessage(e,t),5===r&&clearInterval(n)};this.frame.addEventListener("load",()=>{c(),n=setInterval(c,500)}),this.frame.src=e})}destroy(){try{this.messagePort?.close()}catch(e){}this.frame.parentNode.removeChild(this.frame)}}class nt{child;model;parent;parentOrigin;messagePort;enableMessageChannel;constructor(e){this.child=window,this.model=e,this.parent=this.child.parent,this.enableMessageChannel=!1}sendHandshakeReply(){return new Promise((e,t)=>{const n=r=>{if(r.data.postmate){if("handshake"===r.data.postmate){this.child.removeEventListener("message",n,!1),this.enableMessageChannel=!!r.data?.enableMessageChannel,this.parentOrigin=r.origin;const t="undefined"!=typeof MessageChannel&&"function"==typeof MessageChannel;r.source.postMessage({postmate:"handshake-reply",type:Ke,acceptsMessageChannel:this.enableMessageChannel&&t?1:0},"null"===r.origin?"*":r.origin);const s=r.data.model;if(s&&Object.keys(s).forEach(e=>{this.model[e]=s[e]}),this.enableMessageChannel&&t){const e=t=>{if("setup-channel"===t.data?.postmate&&t.data?.type===Ke&&t.origin===this.parentOrigin){const n=t?.ports?.[0];n&&(this.messagePort=n,this.messagePort.start?.(),this.child.dispatchEvent(new CustomEvent("postmate:channel-ready",{detail:{port:this.messagePort}}))),this.child.removeEventListener("message",e,!1)}};this.child.addEventListener("message",e,!1)}return e(new et(this))}return t("Handshake Reply Failed")}};this.child.addEventListener("message",n,!1)})}}class rt{ctx;opts;constructor(e,t){this.ctx=e,this.opts=t}get ctxId(){return this.ctx.baseInfo.id}setItem(e,t){return this.ctx.caller.callAsync("api:call",{method:"write-plugin-storage-file",args:[this.ctxId,e,t,this.opts?.assets]})}getItem(e){return this.ctx.caller.callAsync("api:call",{method:"read-plugin-storage-file",args:[this.ctxId,e,this.opts?.assets]})}removeItem(e){return this.ctx.caller.call("api:call",{method:"unlink-plugin-storage-file",args:[this.ctxId,e,this.opts?.assets]})}allKeys(){return this.ctx.caller.callAsync("api:call",{method:"list-plugin-storage-files",args:[this.ctxId,this.opts?.assets]})}clear(){return this.ctx.caller.call("api:call",{method:"clear-plugin-storage-files",args:[this.ctxId,this.opts?.assets]})}hasItem(e){return this.ctx.caller.callAsync("api:call",{method:"exist-plugin-storage-file",args:[this.ctxId,e,this.opts?.assets]})}}class st{ctx;constructor(e){this.ctx=e}get React(){return this.ensureHostScope().React}get ReactDOM(){return this.ensureHostScope().ReactDOM}get Components(){return{Editor:this.ensureHostScope().logseq.sdk.experiments.cp_page_editor}}get Utils(){const e=this.ensureHostScope().logseq.sdk.utils,t=t=>e[xe(t)];return{toClj:t("toClj"),jsxToClj:t("jsxToClj"),toJs:t("toJs"),toKeyword:t("toKeyword"),toSymbol:t("toSymbol")}}get pluginLocal(){return this.ensureHostScope().LSPluginCore.ensurePlugin(this.ctx.baseInfo.id)}invokeExperMethod(e,...t){const n=this.ensureHostScope();e=xe(e)?.toLowerCase();const r=n.logseq.api["exper_"+e]||n.logseq.sdk.experiments[e];return r?.apply(n,t)}async loadScripts(...e){(e=e.map(e=>e?.startsWith("http")?e:this.ctx.resolveResourceFullUrl(e))).unshift(this.ctx.baseInfo.id),await this.invokeExperMethod("loadScripts",...e)}registerFencedCodeRenderer(e,t){return this.invokeExperMethod("registerFencedCodeRenderer",this.ctx.baseInfo.id,e,t)}registerDaemonRenderer(e,t){return this.invokeExperMethod("registerDaemonRenderer",this.ctx.baseInfo.id,e,t)}registerHostedRenderer(e,t){return this.invokeExperMethod("registerHostedRenderer",this.ctx.baseInfo.id,e,t)}registerSidebarRenderer(e,t){return e=`_sidebar.${e}`,t.type="sidebar",this.registerHostedRenderer(e,t)}registerRouteRenderer(e,t){return this.invokeExperMethod("registerRouteRenderer",this.ctx.baseInfo.id,e,t)}registerExtensionsEnhancer(e,t){const n=this.ensureHostScope();return"katex"===e&&n.katex&&t(n.katex).catch(console.error),this.invokeExperMethod("registerExtensionsEnhancer",this.ctx.baseInfo.id,e,t)}ensureHostScope(){try{window.top}catch(e){console.error("Can not access host scope!")}return window.top}}const it=e=>`task_callback_${e}`;class ot{_client;_requestId;_requestOptions;_promise;_aborted=!1;constructor(e,t,n={}){this._client=e,this._requestId=t,this._requestOptions=n,this._promise=new Promise((e,t)=>{if(!this._requestId)return t(null);this._client.once(it(this._requestId),n=>{n&&n instanceof Error?t(n):e(n)})});const{success:r,fail:s,final:i}=this._requestOptions;this._promise.then(e=>{r?.(e)}).catch(e=>{s?.(e)}).finally(()=>{i?.()})}abort(){this._requestOptions.abortable&&!this._aborted&&(this._client.ctx._execCallableAPI("http_request_abort",this._requestId),this._aborted=!0)}get promise(){return this._promise}get client(){return this._client}get requestId(){return this._requestId}}class at extends s{_ctx;constructor(e){super(),this._ctx=e,this.ctx.caller.on("#lsp#request#callback",e=>{const t=e?.requestId;t&&this.emit(it(t),e?.payload)})}static createRequestTask(e,t,n){return new ot(e,t,n)}async _request(e){const t=this.ctx.baseInfo.id,{success:n,fail:r,final:s,...i}=e,o=this.ctx.Experiments.invokeExperMethod("request",t,i),a=at.createRequestTask(this.ctx.Request,o,e);return i.abortable?a:a.promise}get ctx(){return this._ctx}}const lt=Array.isArray,ct="object"==typeof n.g&&n.g&&n.g.Object===Object&&n.g;var ut="object"==typeof self&&self&&self.Object===Object&&self;const ht=ct||ut||Function("return this")(),dt=ht.Symbol;var pt=Object.prototype,ft=pt.hasOwnProperty,gt=pt.toString,mt=dt?dt.toStringTag:void 0;var yt=Object.prototype.toString;var _t=dt?dt.toStringTag:void 0;const bt=function(e){return null==e?void 0===e?"[object Undefined]":"[object Null]":_t&&_t in Object(e)?function(e){var t=ft.call(e,mt),n=e[mt];try{e[mt]=void 0;var r=!0}catch(e){}var s=gt.call(e);return r&&(t?e[mt]=n:delete e[mt]),s}(e):function(e){return yt.call(e)}(e)},vt=function(e){var t=typeof e;return null!=e&&("object"==t||"function"==t)},wt=function(e){if(!vt(e))return!1;var t=bt(e);return"[object Function]"==t||"[object GeneratorFunction]"==t||"[object AsyncFunction]"==t||"[object Proxy]"==t},Ct=ht["__core-js_shared__"];var St,xt=(St=/[^.]+$/.exec(Ct&&Ct.keys&&Ct.keys.IE_PROTO||""))?"Symbol(src)_1."+St:"";var Et=Function.prototype.toString;const Tt=function(e){if(null!=e){try{return Et.call(e)}catch(e){}try{return e+""}catch(e){}}return""};var At=/^\[object .+?Constructor\]$/,Ot=Function.prototype,kt=Object.prototype,Pt=Ot.toString,Lt=kt.hasOwnProperty,It=RegExp("^"+Pt.call(Lt).replace(/[\\^$.*+?()[\]{}|]/g,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");const jt=function(e){return!(!vt(e)||(t=e,xt&&xt in t))&&(wt(e)?It:At).test(Tt(e));var t},Mt=function(e,t){var n=function(e,t){return null==e?void 0:e[t]}(e,t);return jt(n)?n:void 0},Rt=function(){try{var e=Mt(Object,"defineProperty");return e({},"",{}),e}catch(e){}}(),Dt=function(e,t,n){for(var r=-1,s=Object(e),i=n(e),o=i.length;o--;){var a=i[++r];if(!1===t(s[a],a,s))break}return e},Nt=function(e){return null!=e&&"object"==typeof e},Ut=function(e){return Nt(e)&&"[object Arguments]"==bt(e)};var Ft=Object.prototype,$t=Ft.hasOwnProperty,zt=Ft.propertyIsEnumerable;const Ht=Ut(function(){return arguments}())?Ut:function(e){return Nt(e)&&$t.call(e,"callee")&&!zt.call(e,"callee")};var Wt="object"==typeof exports&&exports&&!exports.nodeType&&exports,qt=Wt&&"object"==typeof module&&module&&!module.nodeType&&module,Bt=qt&&qt.exports===Wt?ht.Buffer:void 0;const Gt=(Bt?Bt.isBuffer:void 0)||function(){return!1};var Jt=/^(?:0|[1-9]\d*)$/;const Yt=function(e,t){var n=typeof e;return!!(t=null==t?9007199254740991:t)&&("number"==n||"symbol"!=n&&Jt.test(e))&&e>-1&&e%1==0&&e-1&&e%1==0&&e<=9007199254740991};var Vt={};Vt["[object Float32Array]"]=Vt["[object Float64Array]"]=Vt["[object Int8Array]"]=Vt["[object Int16Array]"]=Vt["[object Int32Array]"]=Vt["[object Uint8Array]"]=Vt["[object Uint8ClampedArray]"]=Vt["[object Uint16Array]"]=Vt["[object Uint32Array]"]=!0,Vt["[object Arguments]"]=Vt["[object Array]"]=Vt["[object ArrayBuffer]"]=Vt["[object Boolean]"]=Vt["[object DataView]"]=Vt["[object Date]"]=Vt["[object Error]"]=Vt["[object Function]"]=Vt["[object Map]"]=Vt["[object Number]"]=Vt["[object Object]"]=Vt["[object RegExp]"]=Vt["[object Set]"]=Vt["[object String]"]=Vt["[object WeakMap]"]=!1;var Xt="object"==typeof exports&&exports&&!exports.nodeType&&exports,Zt=Xt&&"object"==typeof module&&module&&!module.nodeType&&module,Qt=Zt&&Zt.exports===Xt&&ct.process,en=function(){try{return Zt&&Zt.require&&Zt.require("util").types||Qt&&Qt.binding&&Qt.binding("util")}catch(e){}}(),tn=en&&en.isTypedArray;const nn=tn?(rn=tn,function(e){return rn(e)}):function(e){return Nt(e)&&Kt(e.length)&&!!Vt[bt(e)]};var rn,sn=Object.prototype.hasOwnProperty;const on=function(e,t){var n=lt(e),r=!n&&Ht(e),s=!n&&!r&&Gt(e),i=!n&&!r&&!s&&nn(e),o=n||r||s||i,a=o?function(e,t){for(var n=-1,r=Array(e);++n-1},gn.prototype.set=function(e,t){var n=this.__data__,r=pn(n,e);return r<0?(++this.size,n.push([e,t])):n[r][1]=t,this};const mn=gn,yn=Mt(ht,"Map"),_n=Mt(Object,"create");var bn=Object.prototype.hasOwnProperty;var vn=Object.prototype.hasOwnProperty;function wn(e){var t=-1,n=null==e?0:e.length;for(this.clear();++ta))return!1;var c=i.get(e),u=i.get(t);if(c&&u)return c==t&&u==e;var h=-1,d=!0,p=2&n?new kn:void 0;for(i.set(e,t),i.set(t,e);++h(lt(e?.blocks)&&(e.blocks=e.blocks.map(e=>e&&Mr(e,(e,t)=>`block/${t}`))),e)},rebuildBlocksIndice:{f:"onIndiceInit",args:["graph","blocks"]},transactBlocks:{f:"onBlocksChanged",args:["graph","data"]},truncateBlocks:{f:"onIndiceReset",args:["graph"]},removeDb:{f:"onGraph",args:["graph"]}}).forEach(([n,r])=>{const s=(e=>`service:search:${e}:${t.name}`)(n);e.caller.on(s,async n=>{if(wt(t?.[r.f])){let i=null;try{i=await t[r.f].apply(t,(r.args||[]).map(e=>{if(n){if(!0===e)return n;if(n.hasOwnProperty(e)){const t=n[e];return delete n[e],t}}})),r.transformOutput&&(i=r.transformOutput(i))}catch(e){console.error("[SearchService] ",e),i=e}finally{r.reply&&e.caller.call(`${s}:reply`,i)}}})})}}const Dr=Symbol.for("proxy-continue"),Nr=Ye()("LSPlugin:user"),Ur=new ke("",{console:!0});function Fr(e,t,n){const{key:r,label:s,desc:i,palette:o,keybinding:a,extras:l}=t;if("function"!=typeof n)return this.logger.error(`${r||s}: command action should be function.`),!1;const c=Ge(r);if(!c)return this.logger.error(`${s}: command key is required.`),!1;const u=`SimpleCommandHook${c}${++qr}`;this.Editor["on"+u](n),this.caller?.call("api:call",{method:"register-plugin-simple-command",args:[this.baseInfo.id,[{key:c,label:s,type:e,desc:i,keybinding:a,extras:l},["editor/hook",u]],o]})}function $r(e){return!!Pe(e)||(Ur.error(`#${e} is not a valid UUID string.`),!1)}let zr=null,Hr=new Map;const Wr={async getInfo(e){return zr||(zr=await this._execCallableAPIAsync("get-app-info")),"string"==typeof e?zr[e]:zr},registerCommand:Fr,registerSearchService(e){if(Hr.has(e.name))throw new Error(`SearchService: #${e.name} has registered!`);Hr.set(e.name,new Rr(this,e))},registerCommandPalette(e,t){const{key:n,label:r,keybinding:s}=e;return Fr.call(this,"$palette$",{key:n,label:r,palette:!0,keybinding:s},t)},registerCommandShortcut(e,t,n={}){"string"==typeof e&&(e={mode:"global",binding:e});const{binding:r}=e,s="$shortcut$",i=n.key||s+xe(r?.toString());return Fr.call(this,s,{...n,key:i,palette:!1,keybinding:e},t)},registerUIItem(e,t){const n=this.baseInfo.id;this.caller?.call("api:call",{method:"register-plugin-ui-item",args:[n,e,t]})},registerPageMenuItem(e,t){if("function"!=typeof t)return!1;const n=e+"_"+this.baseInfo.id,r=e;Fr.call(this,"page-menu-item",{key:n,label:r},t)},onBlockRendererSlotted(e,t){if(!$r(e))return;const n=this.baseInfo.id,r=`hook:editor:${xe(`slot:${e}`)}`;return this.caller.on(r,t),this.App._installPluginHook(n,r),()=>{this.caller.off(r,t),this.App._uninstallPluginHook(n,r)}},invokeExternalPlugin(e,...t){if(e=e?.trim(),!e)return;let[n,r]=e.split(".");if(!["models","commands"].includes(r?.toLowerCase()))throw new Error("Type only support '.models' or '.commands' currently.");const s=e.replace(`${n}.${r}.`,"");if(!n||!r||!s)throw new Error(`Illegal type of #${e} to invoke external plugin.`);return this._execCallableAPIAsync("invoke_external_plugin_cmd",n,r.toLowerCase(),s,t)},setFullScreen(e){const t=(...e)=>this._callWin("setFullScreen",...e);"toggle"===e?this._callWin("isFullScreen").then(e=>{e?t():t(!0)}):e?t(!0):t()}};let qr=0;const Br={newBlockUUID(){return this._execCallableAPIAsync("new_block_uuid")},isPageBlock:e=>e.uuid&&e.hasOwnProperty("name"),registerSlashCommand(e,t){Nr("Register slash command #",this.baseInfo.id,e,t),"function"==typeof t&&(t=[["editor/clear-current-slash",!1],["editor/restore-saved-cursor"],["editor/hook",t]]),t=t.map(e=>{const[t,...n]=e;if("editor/hook"===t){let r=n[0],s=()=>{this.caller?.callUserModel(r)};"function"==typeof r&&(s=r);const i=`SlashCommandHook${t}${++qr}`;e[1]=i,this.Editor["on"+i](s)}return e}),this.caller?.call("api:call",{method:"register-plugin-slash-command",args:[this.baseInfo.id,[e,t]]})},registerBlockContextMenuItem(e,t){if("function"!=typeof t)return!1;const n=e+"_"+this.baseInfo.id;Fr.call(this,"block-context-menu-item",{key:n,label:e},t)},registerHighlightContextMenuItem(e,t,n){if("function"!=typeof t)return!1;const r=e+"_"+this.baseInfo.id;Fr.call(this,"highlight-context-menu-item",{key:r,label:e,extras:n},t)},scrollToBlockInPage(e,t,n){const r="block-content-"+t;n?.replaceState?this.App.replaceState("page",{name:e},{anchor:r}):this.App.pushState("page",{name:e},{anchor:r})}},Gr={onBlockChanged(e,t){if(!$r(e))return;const n=this.baseInfo.id,r=`hook:db:${xe(`block:${e}`)}`,s=({block:n,txData:r,txMeta:s})=>{n.uuid===e&&t(n,r,s)};return this.caller.on(r,s),this.App._installPluginHook(n,r),()=>{this.caller.off(r,s),this.App._uninstallPluginHook(n,r)}},datascriptQuery(e,...t){return t.pop(),t?.some(e=>"function"==typeof e)?this.Experiments.ensureHostScope().logseq.api.datascript_query(e,...t):this._execCallableAPIAsync("datascript_query",e,...t)}},Jr={},Yr={},Kr={},Vr={makeSandboxStorage(){return new rt(this,{assets:!0})}};class Xr extends i{_baseInfo;_caller;_version=LIB_VERSION;_debugTag="";_settingsSchema;_connected=!1;_ui=new Map;_mFileStorage;_mRequest;_mExperiments;_beforeunloadCallback;constructor(e,t){super(),this._baseInfo=e,this._caller=t,t.on("sys:ui:visible",e=>{e?.toggle&&this.toggleMainUI()}),t.on("settings:changed",e=>{const t={...this.settings||{}},n={...e||{}};this._baseInfo={...this._baseInfo,settings:n},this.emit("settings:changed",n,t)}),t.on("beforeunload",async e=>{const{actor:t,...n}=e,r=this._beforeunloadCallback;try{r&&await r(n),t?.resolve(null)}catch(e){this.logger.error("[beforeunload] ",e),t?.reject(e)}})}async ready(e,t){if(!this._connected)try{"function"==typeof e&&(t=e,e={});let n=await this._caller.connectToParent(e);const r=n?.settings;this._connected=!0,n=Oe(this._baseInfo,n),void 0!==r&&(n.settings=r),this._baseInfo=n,n?.id&&(this._debugTag=this._caller.debugTag=`#${n.id} [${n.name}]`,this.logger.setTag(this._debugTag)),this._settingsSchema&&(n.settings=Be(n.settings,this._settingsSchema),await this.useSettingsSchema(this._settingsSchema));try{await this._execCallableAPIAsync("setSDKMetadata",{version:this._version,runtime:"js"})}catch(e){console.warn(e)}t&&t.call(this,n)}catch(e){console.error(`${this._debugTag} [Ready Error]`,e)}}ensureConnected(){if(!this._connected)throw new Error("not connected")}beforeunload(e){"function"==typeof e&&(this._beforeunloadCallback=e)}provideModel(e){return this.caller._extendUserModel(e),this}provideTheme(e){return this.caller.call("provider:theme",e),this}provideStyle(e){return this.caller.call("provider:style",e),this}provideUI(e){return this.caller.call("provider:ui",e),this}useSettingsSchema(e){return this.connected&&this.caller.call("settings:schema",{schema:e,isSync:!0}),this._settingsSchema=e,this}updateSettings(e){this.caller.call("settings:update",e)}onSettingsChanged(e){const t="settings:changed";return this.on(t,e),()=>this.off(t,e)}showSettingsUI(){this.caller.call("settings:visible:changed",{visible:!0})}hideSettingsUI(){this.caller.call("settings:visible:changed",{visible:!1})}setMainUIAttrs(e){this.caller.call("main-ui:attrs",e)}setMainUIInlineStyle(e){this.caller.call("main-ui:style",e)}hideMainUI(e){const t={key:0,visible:!1,cursor:e?.restoreEditingCursor};this.caller.call("main-ui:visible",t),this.emit("ui:visible:changed",t),this._ui.set(t.key,t)}showMainUI(e){const t={key:0,visible:!0,autoFocus:e?.autoFocus};this.caller.call("main-ui:visible",t),this.emit("ui:visible:changed",t),this._ui.set(t.key,t)}toggleMainUI(){const e=this._ui.get(0);e&&e.visible?this.hideMainUI():this.showMainUI()}get version(){return this._version}get isMainUIVisible(){const e=this._ui.get(0);return Boolean(e&&e.visible)}get connected(){return this._connected}get baseInfo(){return this._baseInfo}get effect(){return this&&(this.baseInfo?.effect||!this.baseInfo?.iir)}get logger(){return Ur}get settings(){return this.baseInfo?.settings}get caller(){return this._caller}resolveResourceFullUrl(e){if(this.ensureConnected(),e)return e=e.replace(/^[.\\/]+/,""),Me(this._baseInfo.lsr,e)}_makeUserProxy(e,t){const n=this,r=this.caller;return new Proxy(e,{get(e,s,i){const o=e[s];return function(...e){if(o){0!==e?.length&&e.push(t);const r=o.apply(n,e);if(r!==Dr)return r}if(t){const i=s.toString().match(/^(once|off|on)/i);if(null!=i){const s=i[0].toLowerCase(),o=i.input,a="off"===s,l=n.baseInfo.id;let c=o.slice(s.length),u=e[0],h=e[1];"string"==typeof u&&"function"==typeof h&&(u=u.replace(/^logseq./,":"),c=`${c}${u}`,u=h,h=e[2]),c=`hook:${t}:${xe(c)}`,r[s](c,u);const d=()=>{r.off(c,u),r.listenerCount(c)||n.App._uninstallPluginHook(l,c)};return a?void d():(n.App._installPluginHook(l,c,h),d)}}let i=s;return["git","ui","assets","utils"].includes(t)&&(i=t+"_"+i),r.callAsync("api:call",{tag:t,method:i,args:e})}}})}_execCallableAPIAsync(e,...t){return this._caller.callAsync("api:call",{method:e,args:t})}_execCallableAPI(e,...t){this._caller.call("api:call",{method:e,args:t})}_callWin(...e){return this._execCallableAPIAsync("_callMainWin",...e)}#e;#t;#n;#r;#s;get App(){return this.#e?this.#e:this.#e=this._makeUserProxy(Wr,"app")}get Editor(){return this.#t?this.#t:this.#t=this._makeUserProxy(Br,"editor")}get DB(){return this.#n?this.#n:this.#n=this._makeUserProxy(Gr,"db")}get UI(){return this.#r?this.#r:this.#r=this._makeUserProxy(Yr,"ui")}get Utils(){return this.#s?this.#s:this.#s=this._makeUserProxy(Kr,"utils")}get Git(){return this._makeUserProxy(Jr,"git")}get Assets(){return this._makeUserProxy(Vr,"assets")}get FileStorage(){let e=this._mFileStorage;return e||(e=this._mFileStorage=new rt(this)),e}get Request(){let e=this._mRequest;return e||(e=this._mRequest=new at(this)),e}get Experiments(){let e=this._mExperiments;return e||(e=this._mExperiments=new st(this)),e}}if(null==window.__LSP__HOST__){const e=new ds(null);window.logseq=new Xr({},e)}const{importHTML:Zr,createSandboxContainer:Qr}=window.QSandbox||{};function es(e,t){return e.startsWith("http")?fetch(e,t):(e=e.replace("file://",""),new Promise(async(t,n)=>{try{const n=await window.apis.doAction(["readFile",e]);t({text:()=>n})}catch(e){console.error(e),n(e)}}))}class ts extends i{_pluginLocal;_frame;_root;_loaded=!1;_unmountFns=[];constructor(e){super(),this._pluginLocal=e,e._dispose(()=>{this._unmount()})}async load(){const{name:e,entry:t}=this._pluginLocal.options;if(this.loaded||!t)return;const{template:n,execScripts:r}=await Zr(t,{fetch:es});this._mount(n,document.body);const s=Qr(e,{elementGetter:()=>this._root?.firstChild}).instance.proxy;s.__shadow_mode__=!0,s.LSPluginLocal=this._pluginLocal,s.LSPluginShadow=this,s.LSPluginUser=s.logseq=new Xr(this._pluginLocal.toJSON(),this._pluginLocal.caller);const i=await r(s,!0);this._unmountFns.push(i.unmount),this._loaded=!0}_mount(e,t){const n=this._frame=document.createElement("div");n.classList.add("lsp-shadow-sandbox"),n.id=this._pluginLocal.id,this._root=n.attachShadow({mode:"open"}),this._root.innerHTML=`
${e}
`,t.appendChild(n),this.emit("mounted")}_unmount(){for(const e of this._unmountFns)e&&e.call(null)}destroy(){this.frame?.parentNode?.removeChild(this.frame)}get loaded(){return this._loaded}get document(){return this._root?.firstChild}get frame(){return this._frame}}const ns=Ye()("LSPlugin:caller"),rs="#await#response#",ss="#lspmsg#",is="#lspmsg#error#",os="#lspmsg#settings#",as="#lspmsg#beforeunload#",ls="#lspmsg#reply#",cs="#lspmsg#ready#",us=e=>`${ss}${e}`,hs=e=>`${rs}${e}`;class ds extends i{_pluginLocal;_connected=!1;_parent;_child;_shadow;_status;_userModel={};_syncGCTimer=null;_call;_callUserModel;_debugTag="";constructor(e){super(),this._pluginLocal=e,e&&(this._debugTag=e.debugTag)}async connectToChild(){if(this._connected)return;const{shadow:e}=this._pluginLocal;e?await this._setupShadowSandbox():await this._setupIframeSandbox()}async connectToParent(e={}){if(this._connected)return;const t=this,n=null!=this._pluginLocal;let r=0;const s=new Map,i=De(6e4),o=this._extendUserModel({[cs]:async e=>{o[us(e?.pid)]=({type:e,payload:n})=>{ns(`[host (_call) -> *user] ${this._debugTag}`,e,n),t.emit(e,n)},await i.resolve()},[as]:async e=>{const n=De(1e4);t.emit("beforeunload",Object.assign({actor:n},e)),await n.promise},[os]:async({payload:e})=>{t.emit("settings:changed",e)},[ss]:async({ns:e,type:n,payload:r})=>{ns(`[host (async) -> *user] ${this._debugTag} ns=${e} type=${n}`,r),e&&e.startsWith("hook")?t.emit(`${e}:${n}`,r):t.emit(n,r)},[ls]:({_sync:e,result:t})=>{if(ns(`[sync host -> *user] #${e}`,t),s.has(e)){const n=s.get(e);n&&(t?.hasOwnProperty(is)?n.reject(t[is]):n.resolve(t),s.delete(e))}},...e});if(n)return await i.promise,JSON.parse(JSON.stringify(this._pluginLocal?.toJSON()));const a=new nt(o).sendHandshakeReply();return this._status="pending",await a.then(e=>{this._child=e,this._connected=!0,this._call=async(t,n={},i)=>{if(i){const e=++r;s.set(e,i),n._sync=e,i.setTag(`async call #${e}`),ns(`async call #${e}`)}return e.emit(us(o.baseInfo.id),{type:t,payload:n}),i?.promise},this._callUserModel=async(e,t)=>{try{o[e](t)}catch(t){ns(`call user model(${e}) not exist. #${this._debugTag}`)}},this._syncGCTimer=setInterval(()=>{if(s.size>100)for(const[e,t]of s)t.settled&&s.delete(e)},18e5)}).finally(()=>{this._status=void 0}),await i.promise,o.baseInfo}async call(e,t={}){return this._call?.call(this,e,t)}async callAsync(e,t={}){const n=De(1e4);return this._call?.call(this,e,t,n)}async callUserModel(e,...t){return this._callUserModel?.apply(this,[e,...t])}async callUserModelAsync(e,...t){return e=hs(e),this._callUserModel?.apply(this,[e,...t])}_calcLayoutPosition(e,t,n=0){const r=Math.max(e,n);return"number"==typeof t?`${Math.min(100*r/t,99)}%`:`${r}px`}async _applyContainerLayout(e){const t=(await(this._pluginLocal?._loadLayoutsData()))?.$$0;if(!t)return;const{width:n,height:r,left:s,top:i,vw:o,vh:a}=t;e.dataset.inited_layout="true",Object.assign(e.style,{width:`${n}px`,height:`${r}px`,left:this._calcLayoutPosition(s,o),top:this._calcLayoutPosition(i,a,45)})}async _setupIframeSandbox(){const e=this._pluginLocal,t=e.id,n=`${t}_lsp_main`,r=new URL(e.options.entry);r.searchParams.set("__v__",be?Date.now().toString():e.options.version),document.querySelector(`#${n}`)?.remove();const s=document.createElement("div");s.classList.add("lsp-iframe-sandbox-container"),s.id=n,s.dataset.pid=t;try{await this._applyContainerLayout(s)}catch(e){console.error("[Restore Layout Error]",e)}document.body.appendChild(s);const i=new tt({id:t+"_iframe",container:s,url:r.href,classListArray:["lsp-iframe-sandbox"],model:{baseInfo:JSON.parse(JSON.stringify(e.toJSON()))},allow:e.options.allow,enableMessageChannel:!0});let o;this._status="pending";const a=new Promise((e,t)=>{o=setTimeout(()=>{i.destroy(),t(new Error("handshake Timeout"))},8e3)});try{const t=await Promise.race([i.sendHandshake(),a]);this._parent=t,this._connected=!0,this.emit("connected"),t.on(us(e.id),({type:e,payload:t})=>{ns("[user -> *host] ",e,t),this._pluginLocal?.emit(e,t||{}),this._pluginLocal?.caller.emit(e,t||{})}),this._call=async(...n)=>{t.call(us(e.id),{type:n[0],payload:Object.assign(n[1]||{},{$$pid:e.id})})},this._callUserModel=async(e,...n)=>{if(e.startsWith(rs))return await t.get(e.replace(rs,""),...n);t.call(e,n?.[0])}}catch(e){throw ns("[iframe sandbox] error",e),e}finally{clearTimeout(o),this._status=void 0}}async _setupShadowSandbox(){const e=this._pluginLocal,t=this._shadow=new ts(e);try{this._status="pending",await t.load(),this._connected=!0,this.emit("connected"),this._call=async(t,n={},r)=>(r&&(n.actor=r),this._pluginLocal?.emit(t,Object.assign(n,{$$pid:e.id})),r?.promise),this._callUserModel=async(...e)=>{let t=e[0];t?.startsWith(rs)&&(t=t.replace(rs,""));const n=e[1]||{},r=this._userModel[t];"function"==typeof r&&await r.call(null,n)}}catch(e){throw ns("[shadow sandbox] error",e),e}finally{this._status=void 0}}_extendUserModel(e){return Object.assign(this._userModel,e)}_getSandboxIframeContainer(){return this._parent?.frame.parentNode}_getSandboxShadowContainer(){return this._shadow?.frame}_getSandboxIframeRoot(){return this._parent?.frame}_getSandboxShadowRoot(){return this._shadow?.frame}set debugTag(e){this._debugTag=e}async destroy(){let e=null;this._parent&&(e=this._getSandboxIframeContainer(),this._parent.destroy()),this._shadow&&(e=this._getSandboxShadowContainer(),this._shadow.destroy()),e?.parentNode?.removeChild(e),this._syncGCTimer&&(clearInterval(this._syncGCTimer),this._syncGCTimer=null),this._connected=!1,this._parent=void 0,this._child=void 0,this._shadow=void 0,this._call=void 0,this._callUserModel=void 0,this._status=void 0}}const ps=Ye()("LSPlugin:core"),fs="plugins";class gs extends i{_userPluginSettings;_schema;_settings={disabled:!1};constructor(e,t){super(),this._userPluginSettings=e,this._schema=t,Object.assign(this._settings,e)}get(e){return this._settings[e]}set(e,t){const n=Oe({},this._settings);this._settings[e]!==t&&(this._settings={...this._settings,[e]:t},this.emit("change",{...this._settings},n))}patch(e){if(!Ae(e))return;const t=Oe({},this._settings);this._settings=Oe(this._settings,e),this.emit("change",{...this._settings},t)}replace(e){const t=Oe({},this._settings);this._settings={disabled:!1,...e||{}},this.emit("change",{...this._settings},t)}set settings(e){this.replace(e)}get settings(){return this._settings}setSchema(e,t){this._schema=e,t&&this.replace(Be(this._settings,e))}reset(){const e=this.settings,t={};this._schema,this.replace(t),this.emit("reset",t,e)}toJSON(){return this._settings}}var ms;function ys(e,t){return t&&e.startsWith(ve+t)&&(e=Me(Ce,e.substr(ve.length+t.length))),e}!function(e){e.LOADING="loading",e.UNLOADING="unloading",e.LOADED="loaded",e.UNLOADED="unload",e.ERROR="error"}(ms||(ms={}));class _s extends Error{url;packageJsonPath;constructor(e,t={}){super(e),this.name="IllegalPluginPackageError",Object.assign(this,t)}}class bs extends Error{constructor(e){super(e),this.name="ExistedImportedPluginPackageError"}}class vs extends i{_options;_themeMgr;_ctx;_sdk={};_runtimeDisposes=[];_registrationDisposes=[];_id;_status=ms.UNLOADED;_loadErr;_localRoot;_dotSettingsFile;_caller;_logger=new ke("PluginLocal");_disposeSettingsObserver;constructor(e,t,n){var r;super(),this._options=e,this._themeMgr=t,this._ctx=n,this._id=e.key||Le(),this._disposeRegistration(async()=>{this._disposeSettingsObserver?.(),this._disposeSettingsObserver=void 0}),function(e){const t=e=>`settings:${e}`;e.on(t("schema"),({schema:t,isSync:n})=>{e.settingsSchema=t,e.settings?.setSchema(t,n)}),e.on(t("update"),t=>{t&&e.settings?.patch(t)}),e.on(t("visible:changed"),t=>{const n=t?.visible;Ne("set_focused_settings",n?e.id:null)})}(this),function(e){const t=e=>`main-ui:${e}`;e.on(t("visible"),({visible:t,toggle:n,cursor:r,autoFocus:s})=>{const i=e.getMainUIContainer();i?.classList[n?"toggle":t?"add":"remove"]("visible"),t?!e.shadow&&i&&!1!==s&&i.querySelector("iframe")?.contentWindow?.focus():i.ownerDocument.activeElement.blur(),r&&Ne("restore_editing_cursor")}),e.on(t("attrs"),t=>{const n=e.getMainUIContainer();Object.entries(t).forEach(([t,r])=>{n?.setAttribute(t,String(r)),"draggable"===t&&r&&e._dispose(e._setupDraggableContainer(n,{title:e.options.name,close:()=>{e.caller.call("sys:ui:visible",{toggle:!0})}})),"resizable"===t&&r&&e._dispose(e._setupResizableContainer(n))})}),e.on(t("style"),t=>{const n=e.getMainUIContainer(),r=!!n.dataset.inited_layout;Object.entries(t).forEach(([e,t])=>{r&&["left","top","bottom","right","width","height"].includes(e)||(n.style[e]=t)})})}(this),function(e){const t=e=>`provider:${e}`;let n=!1;e.on(t("theme"),t=>{e.themeMgr.registerTheme(e.id,t),n||(e._dispose(()=>{e.themeMgr.unregisterTheme(e.id)}),n=!0)}),e.on(t("style"),t=>{let n;"string"!=typeof t&&(n=t.key,t=t.style),t&&t.trim()&&e._dispose(Ue(t,{"data-injected-style":n?`${n}-${e.id}`:"","data-ref":e.id}))}),e.on(t("ui"),t=>{e._onHostMounted(()=>{const n=$e.call(e,t,Object.assign({"data-ref":e.id},t.attrs||{}),({el:t,float:n})=>{if(!n)return;const r=t.dataset.identity;e.layoutCore.move_container_to_top(r)});"function"==typeof n&&e._dispose(n)})})}(this),(r=this).on("api:call",async e=>{let t;try{window.$$callerPluginID=r.id,t=await Ne.apply(r,[e.method,...e.args])}catch(e){t={[is]:e}}finally{window.$$callerPluginID=void 0}if(r.shadow)return void(e.actor&&(t?.hasOwnProperty(is)?e.actor.reject(t[is]):e.actor.resolve(t)));const{_sync:n}=e;if(null!=n){const e=e=>{r.caller?.callUserModel(ls,{result:e,_sync:n})};Promise.resolve(t).then(e,e)}})}async _setupUserSettings(e){const{_options:t}=this,n=this._logger=new ke(`Loader:${this.debugTag}`);if(!t.settings||e||!this._disposeSettingsObserver)try{const n=()=>Ne("load_plugin_user_settings",this.id),[r,s]=await n();this._dotSettingsFile=r;let i=t.settings;i||(i=t.settings=new gs(s)),this._disposeSettingsObserver?.(),this._disposeSettingsObserver=void 0,e&&i.replace(s);const o=async e=>{ps("Settings changed",this.debugTag,e),e&&Ne("save_plugin_user_settings",this.id,e)};i.on("change",o);const a=()=>{i.off("change",o),this._disposeSettingsObserver===a&&(this._disposeSettingsObserver=void 0)};this._disposeSettingsObserver=a}catch(e){ps("[load plugin user settings Error]",e),n?.error(e)}}getMainUIContainer(){return this.shadow?this.caller?._getSandboxShadowContainer():this.caller?._getSandboxIframeContainer()}_resolveResourceFullUrl(e,t){if(!e?.trim())return;if(t=t||this._localRoot,this.isWebPlugin)return this.installedFromUserWebUrl?`${this.installedFromUserWebUrl}/${e}`:`https://pub-80f42b85b62c40219354a834fcf2bbfa.r2.dev/${_e.join(t,e)}`;const n=/^(http|file)/;if(!n.test(e)){const r=_e.join(t,e);e=n.test(r)?r:ve+r}return!this.options.effect&&this.isInstalledInLocalDotRoot?ys(e,this.dotPluginsRoot):e}async _preparePackageConfigs(){const{url:e,webPkg:t}=this._options;let n=t;if(!n){let t;if(e){ps("prepare package root",e);try{n=await Ne("load_plugin_config",e),n?(n=JSON.parse(n),n||(t=`Parse package config error #${e}/package.json`)):t=`Parse package config error #${e}/package.json`}catch(e){t=e?.message||String(e)}}else t="Can not resolve package config location";if(t)throw new _s(t,{url:e,packageJsonPath:e?_e.join(e,"package.json"):void 0})}["name","author","repository","version","description","repo","title","effect","sponsors"].concat(this.isInstalledInLocalDotRoot?[]:["devEntry"]).forEach(e=>{this._options[e]=n[e]});const{repo:r,version:s}=this._options,i=this._localRoot=this.isWebPlugin?`${r||e}/${s}`:Re(e),o=n.logseq||{},a=o.entry||o.main||n.main;o.devEntry?(this._options.devEntry=o.devEntry,this._options.entry=o.devEntry):this._options.entry=this._resolveResourceFullUrl(a,i),o.mode&&(this._options.mode=o.mode);const l=o.title||n.title,c=o.icon||n.icon;if(this._options.title=l,this._options.icon=c&&this._resolveResourceFullUrl(c),this._options.theme=Boolean(o.theme||!!o.themes),this.isInstalledInLocalDotRoot)this._id=_e.basename(i);else if(!this.isWebPlugin)if(o.id)this._id=o.id;else{o.id=this.id;try{await Ne("save_plugin_package_json",e,{...n,logseq:o})}catch(e){ps("[save plugin ID Error] ",e)}}const{registeredPlugins:u,isRegistering:h}=this._ctx;if(h&&u.has(this.id))throw new bs(this.id);return async()=>{try{const e=o.themes;e&&await this._loadConfigThemes(Array.isArray(e)?e:[e])}catch(e){ps("[prepare package effect Error]",e)}}}async _tryToNormalizeEntry(){let{entry:e,settings:t,devEntry:n}=this.options;if(n=n||t?.get("_devEntry"),n)return void(this._options.entry=n);if(!e.endsWith(".js"))return;let r=null,s="write_user_tmp_file";this.isInstalledInLocalDotRoot&&(s="write_dotdir_file",r=this._localRoot.replace(this.dotPluginsRoot,""),r=_e.join(fs,r));const i=(new Date).getDay(),o=await Te(),a=await Ne(s,`${this._id}_index.html`,`\n\n \n \n logseq plugin entry\n ${this.isWebPlugin?`