mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
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:
+60
@@ -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" },
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class PluginRegistry {
|
||||
editor: this.deps.editor,
|
||||
ipc: this.deps.ipc,
|
||||
exportHooks: this.exportHooks,
|
||||
formatRegistry: this.deps.formatRegistry,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const { FormatRegistry } = require('../src/plugins/format-registry');
|
||||
|
||||
describe('FormatRegistry', () => {
|
||||
let registry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new FormatRegistry();
|
||||
});
|
||||
|
||||
test('register — stores an entry retrievable by id', () => {
|
||||
const handler = jest.fn();
|
||||
registry.register('plugin-a:fmt', { label: 'Format A', extension: 'txt', handler });
|
||||
const entry = registry.get('plugin-a:fmt');
|
||||
expect(entry).toEqual({ label: 'Format A', extension: 'txt', handler });
|
||||
});
|
||||
|
||||
test('get — returns undefined for unknown id', () => {
|
||||
expect(registry.get('nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('register — overwrites an existing id', () => {
|
||||
registry.register('plugin-a:fmt', { label: 'First' });
|
||||
registry.register('plugin-a:fmt', { label: 'Second' });
|
||||
expect(registry.get('plugin-a:fmt').label).toBe('Second');
|
||||
});
|
||||
|
||||
test('getAll — returns all entries with id merged in', () => {
|
||||
registry.register('plugin-a:fmt', { label: 'Format A', extension: 'txt' });
|
||||
registry.register('plugin-b:fmt', { label: 'Format B', extension: 'csv' });
|
||||
const all = registry.getAll();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all).toContainEqual({ id: 'plugin-a:fmt', label: 'Format A', extension: 'txt' });
|
||||
expect(all).toContainEqual({ id: 'plugin-b:fmt', label: 'Format B', extension: 'csv' });
|
||||
});
|
||||
|
||||
test('getAll — returns empty array when nothing registered', () => {
|
||||
expect(registry.getAll()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ describe('PluginContext', () => {
|
||||
},
|
||||
ipc: { invoke: jest.fn(), on: jest.fn() },
|
||||
exportHooks: { preHooks: [], postHooks: [] },
|
||||
formatRegistry: { register: jest.fn(), get: jest.fn(), getAll: jest.fn() },
|
||||
};
|
||||
context = new PluginContext(mockDeps);
|
||||
});
|
||||
@@ -87,4 +88,18 @@ describe('PluginContext', () => {
|
||||
context.exports.registerPostHook(handler);
|
||||
expect(mockDeps.exportHooks.postHooks).toContain(handler);
|
||||
});
|
||||
|
||||
test('exposes formats.registerExportFormat with namespaced id', () => {
|
||||
const handler = jest.fn();
|
||||
const opts = { label: 'My Format', extension: 'txt', handler };
|
||||
context.formats.registerExportFormat('my-format', opts);
|
||||
expect(mockDeps.formatRegistry.register).toHaveBeenCalledWith('test-plugin:my-format', opts);
|
||||
});
|
||||
|
||||
test('formats.registerExportFormat is a no-op when no formatRegistry is injected', () => {
|
||||
const noRegistryContext = new PluginContext({ ...mockDeps, formatRegistry: undefined });
|
||||
expect(() =>
|
||||
noRegistryContext.formats.registerExportFormat('x', { handler: jest.fn() })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { PluginRegistry } = require('../src/plugins/plugin-registry');
|
||||
const { PluginAPI } = require('../src/plugins/plugin-api');
|
||||
const { EventBus } = require('../src/plugins/event-bus');
|
||||
const { FormatRegistry } = require('../src/plugins/format-registry');
|
||||
|
||||
class TestPlugin extends PluginAPI {
|
||||
init(context) {
|
||||
@@ -153,4 +154,40 @@ describe('PluginRegistry', () => {
|
||||
registry.getPlugin('test').instance.ctx.exports.registerPreHook(handler);
|
||||
expect(registry.exportHooks.preHooks).toContain(handler);
|
||||
});
|
||||
|
||||
test('a plugin calling context.formats.registerExportFormat populates the injected FormatRegistry with a namespaced entry', () => {
|
||||
const formatRegistry = new FormatRegistry();
|
||||
const registryWithFormats = new PluginRegistry({ ...mockDeps, formatRegistry });
|
||||
|
||||
class ExportingPlugin extends PluginAPI {
|
||||
init(context) {
|
||||
context.formats.registerExportFormat('sprint-summary', {
|
||||
label: 'Writing Studio Summary (.txt)',
|
||||
extension: 'txt',
|
||||
handler: async () => {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registryWithFormats.register({
|
||||
id: 'writing-studio',
|
||||
name: 'Writing Studio',
|
||||
version: '1.0.0',
|
||||
description: 'desc',
|
||||
manifest: {},
|
||||
PluginClass: ExportingPlugin,
|
||||
dir: '/tmp/test',
|
||||
});
|
||||
|
||||
const entry = formatRegistry.get('writing-studio:sprint-summary');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.label).toBe('Writing Studio Summary (.txt)');
|
||||
expect(entry.extension).toBe('txt');
|
||||
expect(typeof entry.handler).toBe('function');
|
||||
|
||||
const all = formatRegistry.getAll();
|
||||
expect(all).toContainEqual(
|
||||
expect.objectContaining({ id: 'writing-studio:sprint-summary', extension: 'txt' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user