feat(plugins): add export-format registration hook to plugin API

Add context.formats.registerExportFormat(id, opts) to PluginContext,
backed by a new FormatRegistry (mirrors PluginRegistry's Map-based
shape). Plugins register namespaced (${pluginId}:${id}) export
formats with a label/extension/handler; the writing-studio built-in
plugin registers a trivial "sprint-summary" .txt export as a
worked example.

The plugin system lives entirely in the renderer process while the
Export menu is built in main.js, so wiring formats into the menu
required a small IPC round-trip: renderer sends format metadata to
main after plugin load (main rebuilds the menu via the already-
idempotent createMenu()), and a menu click sends the resolved save
path back to the renderer, which is the only process holding the
plugin's handler function.

Amit Haridas
This commit is contained in:
2026-08-23 19:31:33 +05:30
parent 44624cd4bf
commit d6baa2daf7
10 changed files with 304 additions and 3 deletions
+60
View File
@@ -419,6 +419,7 @@ let pandocVersionCache = null; // Cached parsed { major, minor } from `pandoc --
let wordTemplatePath = null; // Path to selected Word template
let templateStartPage = 3; // Which page to start inserting content (default: page 3)
let rendererReady = false; // Track if renderer is ready to receive file data
let pluginExportFormats = []; // Export formats registered by plugins: [{ id, label, extension }]
// Header & Footer Settings
let headerFooterSettings = {
@@ -997,6 +998,7 @@ function createMenu() {
label: 'Jupyter Notebook (.ipynb)',
click: () => exportFile('ipynb'),
},
...buildPluginExportMenuItems(),
],
},
{
@@ -1895,6 +1897,42 @@ function exportFile(format) {
function showExportOptionsDialog(format) {
mainWindow.webContents.send('show-export-dialog', format);
}
// Build the dynamic tail of the Export submenu from plugin-registered
// formats (see plugin-export-formats-registered IPC handler below). Returns
// [] when no plugin has registered a format, so the Export menu is
// unchanged for a plain install.
function buildPluginExportMenuItems() {
if (!pluginExportFormats.length) return [];
const items = pluginExportFormats.map((fmt) => ({
label: fmt.label || fmt.id,
click: () => runPluginExportFormat(fmt),
}));
return [{ type: 'separator' }, ...items];
}
// Resolve an output path for a plugin-registered export format (main
// process owns save dialogs, same as every other export path in this
// file), then hand off to the renderer — the plugin's handler function
// only exists there, since that's the process that loaded the plugin.
function runPluginExportFormat(fmt) {
if (!currentFile) {
dialog.showErrorBox('Error', 'Please save the file first');
return;
}
const ext = fmt.extension || 'txt';
const outputFile = dialog.showSaveDialogSync(mainWindow, {
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${ext}`),
filters: [
{
name: fmt.label || fmt.id,
extensions: [ext],
},
],
});
if (!outputFile) return; // User cancelled
mainWindow.webContents.send('run-plugin-export-format', { id: fmt.id, outputPath: outputFile });
}
function showBatchConversionDialog() {
mainWindow.webContents.send('show-batch-dialog');
}
@@ -4637,6 +4675,28 @@ ipcMain.on('clear-recent-files', (event) => {
}
});
// Plugins (loaded in the renderer) report the export formats they've
// registered; rebuild the Export menu so they show up as entries.
// createMenu() is idempotent and already re-invoked elsewhere (e.g. after
// clearRecentFilesOnDisk() above) so calling it again here is safe.
ipcMain.on('plugin-export-formats-registered', (event, formats) => {
pluginExportFormats = Array.isArray(formats)
? formats.filter((f) => f && typeof f.id === 'string')
: [];
createMenu();
});
// Result of a plugin export handler running in the renderer (see
// runPluginExportFormat() / the Export menu wiring above).
ipcMain.on('plugin-export-format-result', (event, result) => {
const { outputPath, success, error } = result || {};
if (success) {
showExportSuccess(outputPath);
} else {
dialog.showErrorBox('Export Error', sanitizeErrorMessage(error || 'Unknown error'));
}
});
// Handle file opening on macOS
app.on('open-file', (event, filePath) => {
event.preventDefault();
@@ -29,6 +29,7 @@ class WritingStudioPlugin extends PluginAPI {
this._registerCommands(context);
this._registerStatusBar(context);
this._registerExportFormats(context);
}
_registerCommands(context) {
@@ -120,6 +121,42 @@ class WritingStudioPlugin extends PluginAPI {
});
}
// Example usage of context.formats.registerExportFormat (Task 17): adds
// a "Writing Studio Summary" entry to the Export menu that writes a
// plain-text snapshot of today's sprint/goal progress instead of going
// through Pandoc. Doubles as documentation for how a plugin can offer
// its own export target.
_registerExportFormats(context) {
const { sprintEngine, goalTracker } = this;
context.formats.registerExportFormat('sprint-summary', {
label: 'Writing Studio Summary (.txt)',
extension: 'txt',
handler: async (markdownContent, outputPath) => {
const fs = require('fs');
const goal = context.settings.get('dailyGoal') || 1000;
const progress = goalTracker.getDailyProgress(goal);
const streak = goalTracker.getStreak(goal);
const wordCount = (markdownContent || '').split(/\s+/).filter(Boolean).length;
const lines = [
'Writing Studio Summary',
'=======================',
`Generated: ${new Date().toISOString()}`,
'',
`Document word count: ${wordCount}`,
`Daily goal: ${goal}`,
`Words written today: ${progress.written} (${progress.pct}%)`,
`Current streak: ${streak} day(s)`,
`Sprint active: ${sprintEngine.isActive() ? 'yes' : 'no'}`,
'',
];
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8');
},
});
}
deactivate() {
if (this._sprintInterval) clearInterval(this._sprintInterval);
}
@@ -32,7 +32,14 @@
"shortcut": "Ctrl+Alt+G"
}
],
"statusBar": { "indicators": ["sprint-timer", "word-goal"] }
"statusBar": { "indicators": ["sprint-timer", "word-goal"] },
"exportFormats": [
{
"id": "sprint-summary",
"label": "Writing Studio Summary (.txt)",
"extension": "txt"
}
]
},
"settings": [
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
+40
View File
@@ -0,0 +1,40 @@
/**
* FormatRegistry — tracks export formats registered by plugins via
* `context.formats.registerExportFormat(id, opts)`.
*
* Mirrors the simple Map-backed storage pattern used by PluginRegistry
* (see plugin-registry.js), scoped to a single concern: export format
* metadata + handler functions rather than whole plugin instances.
*/
class FormatRegistry {
constructor() {
this.formats = new Map();
}
/**
* Register (or overwrite) an export format entry.
* @param {string} id - Fully-namespaced format id, e.g. "writing-studio:sprint-summary"
* @param {object} opts - { label, extension, handler: async (markdownContent, outputPath, options) => void }
*/
register(id, opts) {
this.formats.set(id, opts);
}
/**
* Look up a single registered format entry by its namespaced id.
* @param {string} id
* @returns {object|undefined}
*/
get(id) {
return this.formats.get(id);
}
/**
* Return all registered formats as an array of { id, ...opts }.
*/
getAll() {
return Array.from(this.formats.entries()).map(([id, opts]) => ({ id, ...opts }));
}
}
module.exports = { FormatRegistry };
+26 -2
View File
@@ -10,10 +10,21 @@ class PluginContext {
* @param {object} deps.editor - { getContent, getSelection, insertAtCursor, onContentChanged }
* @param {object} deps.ipc - { invoke, on }
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
* @param {object} deps.formatRegistry - FormatRegistry instance ({ register, get, getAll })
*/
constructor(deps) {
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } =
deps;
const {
pluginId,
sidebar,
commands,
statusBar,
eventBus,
settings,
editor,
ipc,
exportHooks,
formatRegistry,
} = deps;
this.sidebar = {
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts),
@@ -69,6 +80,19 @@ class PluginContext {
if (exportHooks) exportHooks.postHooks.push(handler);
},
};
this.formats = {
/**
* Register a plugin-provided export format. It is namespaced as
* `${pluginId}:${id}` so plugins can't collide with each other or
* with the built-in Pandoc-backed formats.
* @param {string} id - Format id, unique within this plugin.
* @param {object} opts - { label, extension, handler: async (markdownContent, outputPath, options) => void }
*/
registerExportFormat: (id, opts) => {
if (formatRegistry) formatRegistry.register(`${pluginId}:${id}`, opts);
},
};
}
}
+1
View File
@@ -33,6 +33,7 @@ class PluginRegistry {
editor: this.deps.editor,
ipc: this.deps.ipc,
exportHooks: this.exportHooks,
formatRegistry: this.deps.formatRegistry,
});
try {
+41
View File
@@ -1817,8 +1817,10 @@ document.addEventListener('DOMContentLoaded', async () => {
const { PluginRegistry } = require('./plugins/plugin-registry');
const { EventBus } = require('./plugins/event-bus');
const { SettingsStore } = require('./plugins/settings-store');
const { FormatRegistry } = require('./plugins/format-registry');
const pluginPath = require('path');
const pluginEventBus = new EventBus();
const pluginFormatRegistry = new FormatRegistry();
const pluginSettings = new SettingsStore({
get: (key) => window.electronAPI.invoke('plugin-settings:get', key),
set: (key, value) =>
@@ -1863,12 +1865,51 @@ document.addEventListener('DOMContentLoaded', async () => {
invoke: (ch, data) => window.electronAPI.invoke(ch, data),
on: (ch, cb) => window.electronAPI.on(ch, cb),
},
formatRegistry: pluginFormatRegistry,
});
const builtInDir = pluginPath.join(__dirname, 'plugins', 'built-in');
const loader = new PluginLoader([builtInDir]);
const discovered = loader.discoverPlugins();
discovered.forEach((p) => pluginRegistry.register(p));
// Tell the main process which export formats plugins registered, so the
// Export menu (built in main.js, a separate process from this one) can
// grow entries for them. Only serializable metadata crosses the IPC
// boundary — the handler functions stay here in the renderer, since
// that's the only process that actually holds them.
ipcRenderer.send(
'plugin-export-formats-registered',
pluginFormatRegistry.getAll().map(({ id, label, extension }) => ({ id, label, extension }))
);
// Main process resolved a save path (via the Export menu) for a
// plugin-registered format; run the plugin's handler with the live
// editor content and report back so main can show success/error UI.
ipcRenderer.on('run-plugin-export-format', async (event, { id, outputPath } = {}) => {
const entry = pluginFormatRegistry.get(id);
if (!entry || typeof entry.handler !== 'function') {
ipcRenderer.send('plugin-export-format-result', {
id,
outputPath,
success: false,
error: `Export format "${id}" is not registered.`,
});
return;
}
try {
const content = tabManager.getCurrentContent();
await entry.handler(content, outputPath, {});
ipcRenderer.send('plugin-export-format-result', { id, outputPath, success: true });
} catch (err) {
ipcRenderer.send('plugin-export-format-result', {
id,
outputPath,
success: false,
error: err && err.message ? err.message : String(err),
});
}
});
// Initialize Zen Mode
const ZenModeClass = getZenMode();
const zenMode = new ZenModeClass(tabManager);