diff --git a/src/main.js b/src/main.js index 4930191..508dce4 100644 --- a/src/main.js +++ b/src/main.js @@ -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(); diff --git a/src/plugins/built-in/writing-studio/index.js b/src/plugins/built-in/writing-studio/index.js index 1fefa50..1fd5746 100644 --- a/src/plugins/built-in/writing-studio/index.js +++ b/src/plugins/built-in/writing-studio/index.js @@ -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); } diff --git a/src/plugins/built-in/writing-studio/manifest.json b/src/plugins/built-in/writing-studio/manifest.json index 129ff97..30e724d 100644 --- a/src/plugins/built-in/writing-studio/manifest.json +++ b/src/plugins/built-in/writing-studio/manifest.json @@ -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" }, diff --git a/src/plugins/format-registry.js b/src/plugins/format-registry.js new file mode 100644 index 0000000..380d04a --- /dev/null +++ b/src/plugins/format-registry.js @@ -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 }; diff --git a/src/plugins/plugin-context.js b/src/plugins/plugin-context.js index 2121339..a39310b 100644 --- a/src/plugins/plugin-context.js +++ b/src/plugins/plugin-context.js @@ -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); + }, + }; } } diff --git a/src/plugins/plugin-registry.js b/src/plugins/plugin-registry.js index be6e2fb..efa37d9 100644 --- a/src/plugins/plugin-registry.js +++ b/src/plugins/plugin-registry.js @@ -33,6 +33,7 @@ class PluginRegistry { editor: this.deps.editor, ipc: this.deps.ipc, exportHooks: this.exportHooks, + formatRegistry: this.deps.formatRegistry, }); try { diff --git a/src/renderer.js b/src/renderer.js index e408e3d..ea05bfb 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -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); diff --git a/tests/format-registry.test.js b/tests/format-registry.test.js new file mode 100644 index 0000000..896a5fd --- /dev/null +++ b/tests/format-registry.test.js @@ -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([]); + }); +}); diff --git a/tests/plugin-context.test.js b/tests/plugin-context.test.js index 3a1f206..9e70bf9 100644 --- a/tests/plugin-context.test.js +++ b/tests/plugin-context.test.js @@ -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(); + }); }); diff --git a/tests/plugin-registry.test.js b/tests/plugin-registry.test.js index 29cf224..33ea75a 100644 --- a/tests/plugin-registry.test.js +++ b/tests/plugin-registry.test.js @@ -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' }) + ); + }); });