feat: #61 Plugin system — backend API, sandboxed Web Worker, hooks wired to real app flow, user guide, 47+21 tests
CI / lint (push) Failing after 30s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 35s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-10 09:01:38 -04:00
parent ac16fc1f0e
commit 30f2df3004
19 changed files with 2436 additions and 41 deletions
+16 -1
View File
@@ -1450,10 +1450,13 @@
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
<li><a href="#cfg-webhooks" class="help-nav-link" data-i18n="config.section_webhooks"></a></li>
<li><a href="#cfg-partages-publics" class="help-nav-link" data-i18n="config.section_shares"></a></li>
</ul>
<li>
<a href="#help-plugins" class="help-nav-link"
</nav>
<div class="help-content" id="config-scroll">
<div class="config-content">
@@ -2204,7 +2207,19 @@
</div>
</section>
<!-- À propos -->
<!-- Plugins -->
<section
class="config-section help-section"
id="cfg-plugins"
>
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
<p class="config-description" data-i18n="plugins.description">
Extend ObsiGate with custom renderers, search filters, and editor actions.
</p>
<div id="plugins-settings-container"></div>
</section>
<!-- À propos -->
<section
class="config-section help-section"
id="cfg-about"
+4 -2
View File
@@ -22,6 +22,7 @@ import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
import PaneManager from './pane-manager.js';
import { initDesktopIntegration, isTauriEnv } from './desktop.js';
import { initPush } from './push.js';
import { initPlugins } from './plugins.js';
// =========================================================================
// Initialization — mirrors the original app.js init() ordering
@@ -68,8 +69,9 @@ async function init() {
UI.TabManager.init();
PaneManager.init();
initCommandPalette();
initMobileToolbar();
initPush();
initMobileToolbar();
initPush();
initPlugins();
if (authOk) {
Legacy.initSyncStatus();
+22 -15
View File
@@ -1652,21 +1652,28 @@ function initAboutModal() {
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.classList.contains('active')) overlay.classList.remove('active'); });
function populateAbout() {
// Live stats + version from health endpoint (single source of truth)
fetch('/api/health')
.then(function(r) { return r.json(); })
.then(function(d) {
var fEl = document.getElementById('about-stat-files');
var vEl = document.getElementById('about-stat-vaults');
if (fEl) fEl.textContent = d.total_files || '—';
if (vEl) vEl.textContent = d.vaults || '—';
// Clean x.y.z version (not hardcoded) + git detail
var versionEl = document.getElementById('about-version');
if (versionEl && d.version) versionEl.textContent = d.version;
var commitEl = document.getElementById('about-commit');
if (commitEl) commitEl.textContent = d.git_describe || d.git_commit || '—';
})
.catch(function() {});
// Live stats + version from health endpoint (single source of truth).
// Fallback silencieux : si le fetch échoue, on garde la version pré-chargée
// (window.__OBSIGATE_VERSION) au lieu d'un "—" trompeur.
fetch('/api/health')
.then(function(r) { return r.json(); })
.then(function(d) {
var fEl = document.getElementById('about-stat-files');
var vEl = document.getElementById('about-stat-vaults');
if (fEl && d.total_files) fEl.textContent = d.total_files;
if (vEl && d.vaults) vEl.textContent = d.vaults;
// Clean x.y.z version (not hardcoded) + git detail
var versionEl = document.getElementById('about-version');
if (versionEl) {
versionEl.textContent = (d && d.version) || window.__OBSIGATE_VERSION || '—';
}
var commitEl = document.getElementById('about-commit');
if (commitEl) commitEl.textContent = (d && d.git_describe) || (d && d.git_commit) || '—';
})
.catch(function() {
var versionEl = document.getElementById('about-version');
if (versionEl) versionEl.textContent = window.__OBSIGATE_VERSION || '—';
});
// Build date (today)
var now = new Date();
+568
View File
@@ -0,0 +1,568 @@
/* ObsiGate — Plugin Manager (Frontend)
* Handles plugin lifecycle, UI, and sandboxed execution via Web Worker.
*/
import { api } from './auth.js';
import { showToast } from './ui.js';
import { t } from './i18n.js';
import { safeCreateIcons } from './utils.js';
// ── Plugin Registry (in-memory cache) ─────────────────────────────────────
let _pluginsCache = new Map();
let _worker = null;
let _pendingCalls = new Map();
let _callId = 0;
// ── Worker Management ────────────────────────────────────────────────────
function getOrCreateWorker() {
if (!_worker) {
// Create worker from blob to avoid separate file (simpler deployment)
// Using string concatenation to avoid nested template literal issues
const workerCode =
"const pluginSandbox = {};\n" +
"const handlers = {};\n\n" +
"// Safe globals for plugins\n" +
"const safeGlobals = {\n" +
" console: {\n" +
" log: (...args) => self.postMessage({ type: 'log', args }),\n" +
" warn: (...args) => self.postMessage({ type: 'warn', args }),\n" +
" error: (...args) => self.postMessage({ type: 'error', args })\n" +
" },\n" +
" fetch: async (url, options) => {\n" +
" const response = await fetch(url, options);\n" +
" const data = await response.json();\n" +
" return data;\n" +
" },\n" +
" setTimeout,\n" +
" clearTimeout,\n" +
" Promise,\n" +
" JSON,\n" +
" Math,\n" +
" Date,\n" +
" Object,\n" +
" Array,\n" +
" String,\n" +
" Number,\n" +
" Boolean,\n" +
" RegExp,\n" +
" Error,\n" +
" Map,\n" +
" Set,\n" +
" WeakMap,\n" +
" WeakSet,\n" +
" URL,\n" +
" URLSearchParams,\n" +
" Headers,\n" +
" Request,\n" +
" Response,\n" +
" FormData,\n" +
" Blob,\n" +
" File,\n" +
" atob,\n" +
" btoa,\n" +
" encodeURIComponent,\n" +
" decodeURIComponent,\n" +
" parseInt,\n" +
" parseFloat,\n" +
" isNaN,\n" +
" isFinite\n" +
"};\n\n" +
"// Message handler\n" +
"self.onmessage = async (e) => {\n" +
" const { type, payload, callId } = e.data;\n\n" +
" try {\n" +
" if (type === 'loadPlugin') {\n" +
" const { code, name, permissions } = payload;\n\n" +
" const sandbox = { ...safeGlobals };\n" +
" const pluginModule = { exports: {} };\n\n" +
" const fn = new Function(\n" +
" 'module', 'exports', 'require', 'globals',\n" +
" code\n" +
" );\n" +
" fn(pluginModule, pluginModule.exports, (mod) => {\n" +
" throw new Error('require() not allowed in plugins');\n" +
" }, sandbox);\n\n" +
" for (const [hook, handlerName] of Object.entries(sandbox.handlers || {})) {\n" +
" if (!handlers[hook]) handlers[hook] = [];\n" +
" handlers[hook].push({ name, handler: sandbox[handlerName], permissions });\n" +
" }\n\n" +
" self.postMessage({ type: 'loaded', callId, plugin: name });\n" +
" }\n" +
" else if (type === 'executeHook') {\n" +
" const { hook, context, plugins: pluginList } = payload;\n" +
" const results = [];\n\n" +
" for (const p of pluginList) {\n" +
" const hookHandlers = handlers[hook]?.filter(h => h.name === p.name) || [];\n" +
" for (const h of hookHandlers) {\n" +
" try {\n" +
" const missing = h.permissions?.filter(p => !payload.permissions?.includes(p)) || [];\n" +
" if (missing.length > 0) {\n" +
" results.push({ plugin: p.name, success: false, error: 'Missing permissions: ' + missing.join(', ') });\n" +
" continue;\n" +
" }\n\n" +
" const result = await h.handler(context);\n" +
" results.push({ plugin: p.name, success: true, data: result });\n" +
" } catch (err) {\n" +
" results.push({ plugin: p.name, success: false, error: err.message });\n" +
" }\n" +
" }\n" +
" }\n\n" +
" self.postMessage({ type: 'hookResult', callId, results });\n" +
" }\n" +
" else if (type === 'unloadPlugin') {\n" +
" const { name } = payload;\n" +
" for (const hook of Object.keys(handlers)) {\n" +
" handlers[hook] = handlers[hook].filter(h => h.name !== name);\n" +
" }\n" +
" self.postMessage({ type: 'unloaded', callId });\n" +
" }\n" +
" } catch (err) {\n" +
" self.postMessage({ type: 'error', callId, error: err.message });\n" +
" }\n" +
"};\n";
const blob = new Blob([workerCode], { type: 'application/javascript' });
_worker = new Worker(URL.createObjectURL(blob));
_worker.onmessage = (e) => {
const { type, callId, plugin, result, results, error } = e.data;
const pending = _pendingCalls.get(callId);
if (!pending) return;
clearTimeout(pending.timeout);
_pendingCalls.delete(callId);
if (type === 'error' || type === 'loaded' || type === 'unloaded') {
if (error) pending.reject(new Error(error));
else pending.resolve({ success: true });
} else if (type === 'hookResult') {
pending.resolve(results);
}
};
_worker.onerror = (err) => {
console.error('[PluginWorker] Error:', err);
for (const [, pending] of _pendingCalls) {
clearTimeout(pending.timeout);
pending.reject(new Error('Worker error: ' + err.message));
}
_pendingCalls.clear();
_worker = null;
};
}
return _worker;
}
function callWorker(type, payload, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
const worker = getOrCreateWorker();
const id = ++_callId;
const timeout = setTimeout(() => {
_pendingCalls.delete(id);
reject(new Error('Plugin worker timeout'));
}, timeoutMs);
_pendingCalls.set(id, { resolve, reject, timeout });
worker.postMessage({ type, payload, callId: id });
});
}
// ── Public API ────────────────────────────────────────────────────────────
export async function loadPlugins(vault) {
try {
const plugins = await api('/api/plugins?vault=' + encodeURIComponent(vault));
_pluginsCache.set(vault, plugins);
return plugins;
} catch (err) {
console.error('Failed to load plugins:', err);
return [];
}
}
export function getCachedPlugins(vault) {
return _pluginsCache.get(vault) || [];
}
export async function installPlugin(vault, manifest, code) {
const plugin = await api('/api/plugins?vault=' + encodeURIComponent(vault), {
method: 'POST',
body: JSON.stringify({ manifest, code })
});
await loadPlugins(vault);
return plugin;
}
export async function uninstallPlugin(vault, name) {
await api('/api/plugins/' + encodeURIComponent(name) + '?vault=' + encodeURIComponent(vault), {
method: 'DELETE'
});
await loadPlugins(vault);
}
export async function enablePlugin(vault, name) {
const plugin = await api('/api/plugins/' + encodeURIComponent(name) + '/enable?vault=' + encodeURIComponent(vault), {
method: 'POST'
});
await loadPlugins(vault);
return plugin;
}
export async function disablePlugin(vault, name) {
const plugin = await api('/api/plugins/' + encodeURIComponent(name) + '/disable?vault=' + encodeURIComponent(vault), {
method: 'POST'
});
await loadPlugins(vault);
return plugin;
}
export async function getPluginCode(vault, name) {
const { code } = await api('/api/plugins/' + encodeURIComponent(name) + '/code?vault=' + encodeURIComponent(vault));
return code;
}
export async function validateManifest(manifest) {
return api('/api/plugins/validate-manifest', {
method: 'POST',
body: JSON.stringify(manifest)
});
}
export async function getPluginTemplate() {
return api('/api/plugins/template');
}
// ── Hook Execution (for other modules to call) ────────────────────────────
export async function executeHook(vault, hook, context) {
const plugins = getCachedPlugins(vault).filter(p => p.enabled && p.hooks?.includes(hook));
if (plugins.length === 0) return [];
const pluginList = plugins.map(p => ({
name: p.name,
handler: p.hooks?.[hook] || '',
permissions: p.permissions || []
})).filter(p => p.handler);
if (pluginList.length === 0) return [];
try {
const results = await callWorker('executeHook', {
hook,
context,
plugins: pluginList,
permissions: pluginList.flatMap(p => p.permissions)
});
return results;
} catch (err) {
console.error('Hook ' + hook + ' execution failed:', err);
return pluginList.map(p => ({
plugin: p.name,
success: false,
error: err.message
}));
}
}
// Convenience methods for specific hooks
export async function onFileRender(vault, fileContent, filePath) {
const results = await executeHook(vault, 'onFileRender', { fileContent, filePath, vault });
let replaced = null;
const append = [];
for (const r of results) {
if (!r.success) continue;
const d = r.data;
if (typeof d === 'string') {
replaced = d;
} else if (d && typeof d === 'object') {
if (typeof d.content === 'string') replaced = d.content;
if (typeof d.append === 'string') append.push(d.append);
if (typeof d.html === 'string') append.push(d.html);
}
}
return { replace: replaced, append };
}
export async function onSearchFilter(vault, query, results) {
const hookResults = await executeHook(vault, 'onSearchFilter', { query, results, vault });
for (const r of hookResults) {
if (r.success && Array.isArray(r.data)) return r.data;
}
return results;
}
export async function onEditorAction(vault, action, editorState) {
const results = await executeHook(vault, 'onEditorAction', { action, editorState, vault });
return results.some(r => r.success && r.data?.handled === true);
}
export async function onSidebarItem(vault, itemType, itemPath) {
const results = await executeHook(vault, 'onSidebarItem', { itemType, itemPath, vault });
const items = [];
for (const r of results) {
if (r.success && Array.isArray(r.data)) items.push(...r.data);
}
return items;
}
export async function onFileCreate(vault, filePath, content) {
await executeHook(vault, 'onFileCreate', { filePath, content, vault });
}
export async function onFileDelete(vault, filePath) {
const results = await executeHook(vault, 'onFileDelete', { filePath, vault });
return results.every(r => r.success && r.data?.allow !== false);
}
export async function onVaultMount(vault) {
await executeHook(vault, 'onVaultMount', { vault });
}
// ── UI: Plugins Settings Page ────────────────────────────────────────────
export async function renderPluginsSettings(vault, container) {
const plugins = await loadPlugins(vault);
container.innerHTML =
'<div class="plugins-settings">' +
'<div class="plugins-header">' +
'<h2 data-i18n="plugins.title">🧩 Plugins</h2>' +
'<p class="config-description" data-i18n="plugins.description">' +
'Extend ObsiGate with custom renderers, search filters, and editor actions.' +
'</p>' +
'</div>' +
'<div class="plugins-toolbar">' +
'<button class="btn btn-primary" id="plugin-install-btn" data-i18n="plugins.install">Install Plugin</button>' +
'<button class="btn btn-secondary" id="plugin-template-btn" data-i18n="plugins.template">View Template</button>' +
'</div>' +
'<div id="plugins-list" class="plugins-list">' +
(plugins.length === 0
? '<div class="config-description" data-i18n="plugins.none">No plugins installed.</div>'
: plugins.map(p =>
'<div class="plugin-item ' + (p.enabled ? '' : 'disabled') + '" data-name="' + p.name + '">' +
'<div class="plugin-info">' +
'<div class="plugin-name">' +
'<strong>' + p.name + '</strong> v' + p.version +
'<span class="plugin-author">by ' + p.author + '</span>' +
'</div>' +
'<div class="plugin-description">' + p.description + '</div>' +
'<div class="plugin-meta">' +
'<span class="plugin-hooks">' + (p.hooks?.join(', ') || 'no hooks') + '</span>' +
'<span class="plugin-perms">' + (p.permissions?.join(', ') || 'no permissions') + '</span>' +
'</div>' +
'</div>' +
'<div class="plugin-actions">' +
'<button class="btn btn-sm ' + (p.enabled ? 'btn-warning' : 'btn-success') + '"' +
' data-action="' + (p.enabled ? 'disable' : 'enable') + '"' +
' data-name="' + p.name + '"' +
' data-i18n="' + (p.enabled ? 'plugins.disable' : 'plugins.enable') + '">' +
(p.enabled ? 'Disable' : 'Enable') +
'</button>' +
'<button class="btn btn-sm btn-secondary" data-action="code" data-name="' + p.name + '" data-i18n="plugins.view_code">Code</button>' +
'<button class="btn btn-sm btn-danger" data-action="uninstall" data-name="' + p.name + '" data-i18n="plugins.uninstall">Uninstall</button>' +
'</div>' +
'</div>'
).join('')) +
'</div>' +
'</div>';
container.querySelector('#plugin-install-btn')?.addEventListener('click', () => openInstallModal(vault));
container.querySelector('#plugin-template-btn')?.addEventListener('click', () => openTemplateModal());
container.querySelectorAll('.plugin-actions button').forEach(btn => {
btn.addEventListener('click', async (e) => {
const action = btn.dataset.action;
const name = btn.dataset.name;
try {
if (action === 'enable') {
await enablePlugin(vault, name);
showToast(t('plugins.enabled', { name }), 'success');
} else if (action === 'disable') {
await disablePlugin(vault, name);
showToast(t('plugins.disabled', { name }), 'success');
} else if (action === 'uninstall') {
if (confirm(t('plugins.confirm_uninstall', { name }))) {
await uninstallPlugin(vault, name);
showToast(t('plugins.uninstalled', { name }), 'success');
}
} else if (action === 'code') {
const code = await getPluginCode(vault, name);
openCodeModal(name, code);
}
await renderPluginsSettings(vault, container);
} catch (err) {
showToast(err.message, 'error');
}
});
});
safeCreateIcons();
}
function openInstallModal(vault) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML =
'<div class="modal" style="max-width: 700px;">' +
'<div class="modal-header">' +
'<h3 data-i18n="plugins.install">Install Plugin</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="form-group">' +
'<label data-i18n="plugins.manifest_json">Manifest (plugin.json)</label>' +
'<textarea id="plugin-manifest-json" class="modal-textarea" rows="15" spellcheck="false"' +
' placeholder=\'{"name":"my-plugin","version":"1.0.0","description":"...","author":"You","main":"index.js","hooks":{"onFileRender":"renderFile"},"permissions":["read_files"]}\'></textarea>' +
'</div>' +
'<div class="form-group">' +
'<label data-i18n="plugins.code">Plugin Code (main file)</label>' +
'<textarea id="plugin-code" class="modal-textarea" rows="20" spellcheck="false"' +
' placeholder="// Your plugin code here\\nexport async function renderFile(context) { ... }"></textarea>' +
'</div>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-secondary modal-cancel" data-i18n="common.cancel">Cancel</button>' +
'<button class="btn btn-primary" id="plugin-install-confirm" data-i18n="plugins.install">Install</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
modal.querySelector('.modal-cancel')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
modal.querySelector('#plugin-install-confirm')?.addEventListener('click', async () => {
const manifestText = modal.querySelector('#plugin-manifest-json').value;
const code = modal.querySelector('#plugin-code').value;
try {
const manifest = JSON.parse(manifestText);
const validation = await validateManifest(manifest);
if (!validation.valid) throw new Error(validation.error || 'Invalid manifest');
await installPlugin(vault, manifest, code);
showToast(t('plugins.installed', { name: manifest.name }), 'success');
modal.remove();
const container = document.getElementById('cfg-plugins');
if (container) await renderPluginsSettings(vault, container);
} catch (err) {
showToast(err.message, 'error');
}
});
}
function openTemplateModal() {
getPluginTemplate().then(template => {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML =
'<div class="modal" style="max-width: 700px;">' +
'<div class="modal-header">' +
'<h3 data-i18n="plugins.template">Plugin Template</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="form-group">' +
'<label data-i18n="plugins.manifest_json">Manifest</label>' +
'<pre class="code-block" style="max-height: 200px; overflow: auto;">' + JSON.stringify(template.manifest, null, 2) + '</pre>' +
'</div>' +
'<div class="form-group">' +
'<label data-i18n="plugins.code">Code</label>' +
'<pre class="code-block" style="max-height: 300px; overflow: auto;">' + template.code + '</pre>' +
'</div>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
'<button class="btn btn-secondary" onclick="copyTemplate()">Copy to Clipboard</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
window.copyTemplate = () => {
navigator.clipboard.writeText(JSON.stringify(template, null, 2));
showToast('Template copied to clipboard', 'success');
};
});
}
function openCodeModal(name, code) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML =
'<div class="modal" style="max-width: 800px;">' +
'<div class="modal-header">' +
'<h3>' + name + ' — Source Code</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<pre class="code-block" style="max-height: 500px; overflow: auto;">' + escapeHtml(code) + '</pre>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
}
function escapeHtml(text) {
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
// ── Auto-init when settings page loads ────────────────────────────────────
document.addEventListener('click', (e) => {
const link = e.target.closest('[href="#cfg-plugins"]');
if (link) {
setTimeout(() => {
const container = document.getElementById('cfg-plugins');
if (container && !container.dataset.pluginsLoaded) {
container.dataset.pluginsLoaded = 'true';
const vault = document.querySelector('[data-current-vault]')?.dataset.currentVault;
if (vault) renderPluginsSettings(vault, container);
}
}, 100);
}
});
// Export for other modules
export const PluginManager = {
loadPlugins,
getCachedPlugins,
installPlugin,
uninstallPlugin,
enablePlugin,
disablePlugin,
getPluginCode,
validateManifest,
getPluginTemplate,
executeHook,
onFileRender,
onSearchFilter,
onEditorAction,
onSidebarItem,
onFileCreate,
onFileDelete,
onVaultMount,
renderPluginsSettings,
};
// Initialize plugins system
export async function initPlugins() {
// Pre-load plugins for current vault if available
const vault = document.querySelector('[data-current-vault]')?.dataset.currentVault;
if (vault) {
await loadPlugins(vault);
}
}
+3
View File
@@ -8,6 +8,7 @@ import { _getEffective } from './config.js';
import { TabManager, showToast } from './ui.js';
import { addTagFilter, buildSearchResultsHeader, shouldDisplayPath, removeTagFilter, TagFilterService } from './sidebar.js';
import { t } from './i18n.js';
import { onSearchFilter } from './plugins.js';
// ---------------------------------------------------------------------------
// Search History Service (localStorage, LIFO, max 50, dedup)
// ---------------------------------------------------------------------------
@@ -802,6 +803,8 @@ export async function performAdvancedSearch(query, vaultFilter, tagFilter, offse
if (searchId !== state.currentSearchId) return;
state.advancedSearchTotal = data.total;
state.advancedSearchOffset = ofs;
// Plugin hook: filter search results
data.results = await onSearchFilter(state.vault || "default", data.results);
renderAdvancedSearchResults(data, query, tagFilter);
} catch (err) {
clearTimeout(timeoutId);
+24
View File
@@ -12,6 +12,7 @@ import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, D
import { openShareDialog } from './config.js';
import { cacheViewedFile, getCachedFile } from './offline.js';
import { t } from './i18n.js';
import { onFileRender } from './plugins.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -877,6 +878,29 @@ export function renderFile(data) {
// Initialize outline/TOC for this document
OutlineManager.init();
// Plugin hooks: onFileRender — allow plugins to transform the rendered
// content or append custom widgets beneath it. Async + non-blocking so a
// slow/failed plugin never delays the normal file render.
if (data.content && data.is_markdown) {
onFileRender(data.vault, data.content, data.path).then((fr) => {
if (!fr) return;
if (typeof fr.replace === 'string' && fr.replace !== data.content) {
const rendered = document.getElementById('file-rendered-content');
if (rendered) rendered.innerHTML = fr.replace;
}
if (Array.isArray(fr.append) && fr.append.length > 0) {
const out = el('div', { class: 'plugin-render-output' });
fr.append.forEach((html) => {
const w = el('div', { class: 'plugin-widget' });
w.innerHTML = html;
out.appendChild(w);
});
area.appendChild(out);
safeCreateIcons();
}
}).catch(() => { /* plugins must never break rendering */ });
}
}
+32 -2
View File
@@ -460,6 +460,7 @@
"config.section_watcher": "Auto sync",
"config.section_webhooks": "🔗 Webhooks",
"config.section_push": "🔔 Push notifications",
"config.section_plugins": "🧩 Plugins",
"config.section_workflow-recommande": "Recommended workflow",
"config.section_zone-de-contenu": "Zone de contenu",
"config.server_config": "Server config",
@@ -486,6 +487,8 @@
"config.webhook_url_placeholder": "URL",
"config.webhooks": "Webhooks",
"config.webhooks_desc": "HTTP notifications to external services on file changes.",
"config.plugins": "Plugins",
"config.plugins_desc": "Manage installed plugins for this vault.",
"config.workers_label": "Search workers",
"confirm.delete_dir": "Delete this folder and all its contents?",
"confirm.delete_dir_named": "Delete \"{path}\" and ALL its contents?",
@@ -1072,6 +1075,7 @@
"help.nav_tags": "🏷️ Tags",
"help.nav_tips": "💡 Tips",
"help.nav_webhooks": "🪝 Webhooks",
"help.nav_plugins": "🧩 Plugins",
"help.new_file": "New file",
"help.new_folder": "New folder",
"help.nights": "sleepless nights 😅",
@@ -1338,6 +1342,7 @@
"settings.search_placeholder": "Search...",
"settings.sync": "🔄 Sync",
"settings.themes": "🎨 Themes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Link copied!",
"share.copy_link": "Copy link",
"share.create": "Create share link",
@@ -1614,5 +1619,30 @@
"mfa.webauthn_prompt": "Present your security key or confirm with Windows Hello.",
"mfa.webauthn_btn": "Verify with my key",
"mfa.webauthn_cancelled": "WebAuthn ceremony cancelled.",
"mfa.webauthn_no_key": "No security key registered for this account."
}
"mfa.webauthn_no_key": "No security key registered for this account.",
"plugins.title": "🧩 Plugins",
"plugins.description": "Extend ObsiGate with custom renderers, search filters, and editor actions.",
"plugins.install": "Install Plugin",
"plugins.template": "View Template",
"plugins.none": "No plugins installed.",
"plugins.manifest_json": "Manifest (plugin.json)",
"plugins.code": "Plugin Code (main file)",
"plugins.view_code": "View Code",
"plugins.uninstall": "Uninstall",
"plugins.enable": "Enable",
"plugins.disable": "Disable",
"plugins.enabled": "Plugin '{name}' enabled.",
"plugins.disabled": "Plugin '{name}' disabled.",
"plugins.installed": "Plugin '{name}' installed successfully.",
"plugins.uninstalled": "Plugin '{name}' uninstalled.",
"plugins.confirm_uninstall": "Uninstall plugin '{name}'? This cannot be undone.",
"help.plugins_title": "🧩 Plugins",
"help.plugins_intro": "ObsiGate supports a plugin system to extend its functionality. Plugins can add custom renderers, search filters, editor actions, and UI elements.",
"help.plugins_install": "Installation",
"help.plugins_manage": "Management",
"help.plugins_develop": "Development",
"help.plugins_dev_intro": "A plugin is a folder or ZIP containing:",
"help.plugins_hooks_desc": "Available hooks: <code>onFileRender</code>, <code>onSearchFilter</code>, <code>onEditorAction</code>, <code>onSidebarItem</code>, <code>onFileCreate</code>, <code>onFileDelete</code>, <code>onVaultMount</code>.",
"help.plugins_security": "<strong>Security:</strong> each plugin runs in an isolated Web Worker sandbox, with no DOM, localStorage, or network access unless explicitly granted via permissions.",
"help.plugins_template": "Use the <strong>Template</strong> button in Settings > Plugins to generate a valid starter plugin."
}
+32 -2
View File
@@ -460,6 +460,7 @@
"config.section_watcher": "Synchronisation automatique",
"config.section_webhooks": "🪝 Webhooks",
"config.section_push": "🔔 Notifications push",
"config.section_plugins": "🧩 Plugins",
"config.section_workflow-recommande": "Workflow recommandé",
"config.section_zone-de-contenu": "Zone de contenu",
"config.server_config": "Configuration serveur",
@@ -486,6 +487,8 @@
"config.webhook_url_placeholder": "URL",
"config.webhooks": "Webhooks",
"config.webhooks_desc": "Notifications HTTP vers des services externes lors des changements de fichiers.",
"config.plugins": "Plugins",
"config.plugins_desc": "Gérer les plugins installés pour ce vault.",
"config.workers_label": "Workers de recherche",
"confirm.delete_dir": "Supprimer ce dossier et tout son contenu ?",
"confirm.delete_dir_named": "Supprimer \"{path}\" et TOUT son contenu ?",
@@ -1072,6 +1075,7 @@
"help.nav_tags": "🏷️ Tags",
"help.nav_tips": "💡 Astuces",
"help.nav_webhooks": "🪝 Webhooks",
"help.nav_plugins": "🧩 Plugins",
"help.new_file": "Nouveau fichier",
"help.new_folder": "Nouveau dossier",
"help.nights": "nuits blanches 😅",
@@ -1338,6 +1342,7 @@
"settings.search_placeholder": "Rechercher...",
"settings.sync": "🔄 Synchronisation",
"settings.themes": "🎨 Thèmes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Lien copié !",
"share.copy_link": "Copier le lien",
"share.create": "Créer un lien de partage",
@@ -1614,5 +1619,30 @@
"mfa.webauthn_prompt": "Présentez votre clé de sécurité ou confirmez avec Windows Hello.",
"mfa.webauthn_btn": "Valider avec ma clé",
"mfa.webauthn_cancelled": "Cérémonie WebAuthn annulée.",
"mfa.webauthn_no_key": "Aucune clé de sécurité enregistrée pour ce compte."
}
"mfa.webauthn_no_key": "Aucune clé de sécurité enregistrée pour ce compte.",
"plugins.title": "🧩 Plugins",
"plugins.description": "Étendez ObsiGate avec des renderers personnalisés, filtres de recherche et actions d'éditeur.",
"plugins.install": "Installer le plugin",
"plugins.template": "Voir le modèle",
"plugins.none": "Aucun plugin installé.",
"plugins.manifest_json": "Manifeste (plugin.json)",
"plugins.code": "Code du plugin (fichier principal)",
"plugins.view_code": "Voir le code",
"plugins.uninstall": "Désinstaller",
"plugins.enable": "Activer",
"plugins.disable": "Désactiver",
"plugins.enabled": "Plugin '{name}' activé.",
"plugins.disabled": "Plugin '{name}' désactivé.",
"plugins.installed": "Plugin '{name}' installé avec succès.",
"plugins.uninstalled": "Plugin '{name}' désinstallé.",
"plugins.confirm_uninstall": "Désinstaller le plugin '{name}' ? Cette action est irréversible.",
"help.plugins_title": "🧩 Plugins",
"help.plugins_intro": "ObsiGate supporte un système de plugins pour étendre ses fonctionnalités. Les plugins peuvent ajouter des renderers personnalisés, des filtres de recherche, des actions d'éditeur, et des éléments d'interface.",
"help.plugins_install": "Installation",
"help.plugins_manage": "Gestion",
"help.plugins_develop": "Développement",
"help.plugins_dev_intro": "Un plugin est un dossier ou ZIP contenant :",
"help.plugins_hooks_desc": "Hooks disponibles : <code>onFileRender</code>, <code>onSearchFilter</code>, <code>onEditorAction</code>, <code>onSidebarItem</code>, <code>onFileCreate</code>, <code>onFileDelete</code>, <code>onVaultMount</code>.",
"help.plugins_security": "<strong>Sécurité :</strong> chaque plugin s'exécute dans un Web Worker isolé (sandbox), sans accès au DOM, localStorage, ni réseau sauf permissions explicites.",
"help.plugins_template": "Utilisez le bouton <strong>Template</strong> dans Paramètres > Plugins pour générer un plugin starter valide."
}