From c8883e77fe26f47ef1078242cf7ff42bd1b7bbf5 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sun, 23 Aug 2026 11:20:57 +0530 Subject: [PATCH] feat(export): add visual word-template settings dialog with graceful default-template fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two native OS dialogs used to configure the DOCX "Enhanced" export template (an open-file picker + a message-box question) with a single in-app modal that shows the currently active template state, per Task 18's original audit finding that this state was invisible until a user thought to reopen the menu. Consolidates the "Select Word Template..."/"Template Settings..." menu items into one "Word Template Settings..." entry wired to the new dialog; Browse still uses the native file picker since there is genuinely no bundled folder of templates to enumerate (confirmed by investigation — see task-18-report.md). Also fixes a related dangling-reference bug: WordTemplateExporter's hardcoded default template path (word_template.docx) was deleted from the repo in an earlier commit, but the code still tried to read it and threw ENOENT whenever no custom template was selected. convert() now degrades gracefully by generating a minimal, valid DOCX shell (styles + numbering matching what markdownToWordXml() already references) instead of crashing, and the new dialog surfaces this state honestly ("using default formatting, no default template is bundled") rather than implying a working default exists. Out of scope, per explicit instruction: bundling fabricated starter .docx templates to populate a literal multi-item gallery (rejected as disproportionate/fake-content scope), and an EPUB template gallery (no EPUB template mechanism exists anywhere in this codebase to build one for). Amit Haridas --- src/index.html | 50 ++++++++++ src/main.js | 135 +++++++++++++-------------- src/renderer.js | 94 +++++++++++++++++++ src/styles.css | 111 ++++++++++++++++++++++ src/wordTemplateExporter.js | Bin 27065 -> 36603 bytes tests/word-template-exporter.test.js | 124 ++++++++++++++++++++++++ 6 files changed, 443 insertions(+), 71 deletions(-) diff --git a/src/index.html b/src/index.html index 2ff738b..36be950 100644 --- a/src/index.html +++ b/src/index.html @@ -2354,6 +2354,56 @@ + +
+
+
+
+

Word Template Settings

+ +
+
+
+
+
📄
+
+
+ No template selected +
+
+ Using default formatting +
+
+
+
+ + +
+
+ +
+ + +

+ Templates usually reserve the first pages for a cover sheet and table of contents; + your Markdown content is inserted starting from this page. Ignored when no template + is selected. +

+
+
+
+ + +
+
+
+
diff --git a/src/main.js b/src/main.js index 508dce4..1675e4a 100644 --- a/src/main.js +++ b/src/main.js @@ -1005,12 +1005,12 @@ function createMenu() { type: 'separator', }, { - label: 'Select Word Template...', - click: selectWordTemplate, - }, - { - label: 'Template Settings...', - click: showTemplateSettings, + label: 'Word Template Settings...', + click: () => { + if (mainWindow) { + mainWindow.webContents.send('open-word-template-dialog'); + } + }, }, { label: 'Header & Footer Settings...', @@ -1937,76 +1937,69 @@ function showBatchConversionDialog() { mainWindow.webContents.send('show-batch-dialog'); } -// Select Word Template -async function selectWordTemplate() { - const result = await dialog.showOpenDialog(mainWindow, { - title: 'Select Word Template', - filters: [ - { - name: 'Word Document', - extensions: ['docx'], - }, - ], - properties: ['openFile'], +// Word Template Settings IPC Handlers +// +// Template selection/settings used to be two separate native OS dialogs +// (an open-file picker and a message-box question) with no visible +// in-app UI showing the currently active template — the original audit +// finding this replaces. There is still genuinely no folder of bundled +// templates to enumerate (see WordTemplateExporter.getDefaultTemplatePath() +// docs), so "Browse..." still opens a native file picker, but the result +// and current state are now shown in a real renderer dialog instead of +// being invisible until a user thinks to reopen the menu item. + +// Send current template state to the renderer dialog +ipcMain.on('get-word-template-settings', (event) => { + event.reply('word-template-settings-data', { + templatePath: wordTemplatePath, + templateFileName: wordTemplatePath ? path.basename(wordTemplatePath) : null, + startPage: templateStartPage, + defaultTemplateAvailable: fs.existsSync(WordTemplateExporter.getDefaultTemplatePath()), }); - if (!result.canceled && result.filePaths.length > 0) { - wordTemplatePath = result.filePaths[0]; - store.set('wordTemplatePath', wordTemplatePath); - dialog.showMessageBox(mainWindow, { - type: 'info', - title: 'Template Selected', - message: 'Word template has been updated', - detail: `Template: ${path.basename(wordTemplatePath)}`, - }); - } -} +}); -// Template Settings Dialog -async function showTemplateSettings() { - const result = await dialog.showMessageBox(mainWindow, { - type: 'question', - title: 'Template Settings', - message: 'Configure Word Template Export', - detail: `Current template: ${wordTemplatePath ? path.basename(wordTemplatePath) : 'Default template'}\nContent starts from page: ${templateStartPage}\n\nWhich page should content start from?\n(Templates usually have cover pages, TOC, etc.)`, - buttons: ['Page 1', 'Page 2', 'Page 3', 'Page 4', 'Page 5', 'Custom...', 'Cancel'], - defaultId: templateStartPage - 1, - cancelId: 6, - }); - if (result.response === 6) return; // Cancel - - let newStartPage; - if (result.response === 5) { - // Custom - // Show input dialog for custom page number - mainWindow.webContents.send('show-custom-start-page-dialog', templateStartPage); - } else { - newStartPage = result.response + 1; // Convert button index to page number - templateStartPage = newStartPage; - store.set('templateStartPage', templateStartPage); - dialog.showMessageBox(mainWindow, { - type: 'info', - title: 'Settings Updated', - message: 'Template settings have been updated', - detail: `Content will now start from page ${templateStartPage}`, +// Browse for a template file via the native picker; does not persist +// until the dialog's Save button sends 'save-word-template-settings'. +ipcMain.on('browse-word-template', async (event) => { + try { + const result = await dialog.showOpenDialog(mainWindow, { + title: 'Select Word Template', + filters: [ + { + name: 'Word Document', + extensions: ['docx'], + }, + ], + properties: ['openFile'], }); + if (!result.canceled && result.filePaths.length > 0) { + const filePath = result.filePaths[0]; + event.reply('word-template-browsed', { + templatePath: filePath, + templateFileName: path.basename(filePath), + }); + } + } catch (error) { + console.error('Word template browse error:', error); + dialog.showErrorBox( + 'Template Error', + sanitizeErrorMessage(`Failed to select template: ${error.message}`) + ); } -} +}); -// Handle custom start page input from renderer -ipcMain.on('set-custom-start-page', (event, pageNumber) => { - const page = parseInt(pageNumber); - if (page >= 1 && page <= 100) { - templateStartPage = page; - store.set('templateStartPage', templateStartPage); - dialog.showMessageBox(mainWindow, { - type: 'info', - title: 'Settings Updated', - message: 'Template settings have been updated', - detail: `Content will now start from page ${templateStartPage}`, - }); - } else { - dialog.showErrorBox('Invalid Page Number', 'Please enter a page number between 1 and 100'); - } +// Clear the currently selected template (revert to default formatting) +ipcMain.on('clear-word-template', (event) => { + event.reply('word-template-browsed', { templatePath: null, templateFileName: null }); +}); + +// Persist template path + start page from the dialog's Save button +ipcMain.on('save-word-template-settings', (event, settings) => { + wordTemplatePath = (settings && settings.templatePath) || null; + const page = parseInt(settings && settings.startPage, 10); + templateStartPage = page >= 1 && page <= 100 ? page : 3; + store.set('wordTemplatePath', wordTemplatePath); + store.set('templateStartPage', templateStartPage); }); // Header & Footer Settings IPC Handlers diff --git a/src/renderer.js b/src/renderer.js index ea05bfb..214fc3d 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -1705,6 +1705,7 @@ document.addEventListener('DOMContentLoaded', async () => { const pdfEditorModal = new ModalManager('#pdf-editor-dialog'); const headerFooterModal = new ModalManager('#header-footer-dialog'); const fieldPickerModal = new ModalManager('#field-picker-dialog'); + const wordTemplateModal = new ModalManager('#word-template-dialog'); // Make modals globally accessible for functions outside this scope window.modals = { @@ -1718,6 +1719,7 @@ document.addEventListener('DOMContentLoaded', async () => { pdfEditorModal, headerFooterModal, fieldPickerModal, + wordTemplateModal, }; // Initialize sidebar @@ -4975,6 +4977,98 @@ window.openHeaderFooterDialog = openHeaderFooterDialog; ipcRenderer.on('open-header-footer-dialog', () => { openHeaderFooterDialog(); }); + +// ================================ +// Word Template Settings Dialog +// ================================ +// +// Replaces the two former native OS dialogs ("Select Word Template...", +// "Template Settings...") with a single in-app modal that shows the +// currently active template state, instead of that state being invisible +// until a user thought to reopen a menu item. "Browse..." still triggers +// a native file picker in the main process — there is no bundled folder +// of templates to enumerate, so a gallery of multiple templates would +// require inventing content that doesn't exist; see Task 18 report. + +// Tracks the template path chosen via Browse in this dialog session, +// before it is persisted by Save. +let pendingWordTemplatePath = null; +let wordTemplateDefaultAvailable = false; + +function openWordTemplateDialog() { + window.modals.wordTemplateModal.open(); + ipcRenderer.send('get-word-template-settings'); +} + +function closeWordTemplateDialog() { + window.modals.wordTemplateModal.close(); +} + +function renderWordTemplateStatus(templateFileName) { + const statusEl = document.getElementById('word-template-status'); + const titleEl = document.getElementById('word-template-status-title'); + const detailEl = document.getElementById('word-template-status-detail'); + + statusEl.classList.remove('wt-status-none', 'wt-status-selected', 'wt-status-missing'); + + if (templateFileName) { + statusEl.classList.add('wt-status-selected'); + titleEl.textContent = templateFileName; + detailEl.textContent = 'Custom template selected'; + } else if (wordTemplateDefaultAvailable) { + statusEl.classList.add('wt-status-none'); + titleEl.textContent = 'No template selected'; + detailEl.textContent = 'Using the bundled default template'; + } else { + statusEl.classList.add('wt-status-missing'); + titleEl.textContent = 'No template selected'; + detailEl.textContent = 'Using default formatting (no default template is bundled)'; + } +} + +// Populate dialog with current settings from main process +ipcRenderer.on('word-template-settings-data', (event, data) => { + pendingWordTemplatePath = data.templatePath || null; + wordTemplateDefaultAvailable = !!data.defaultTemplateAvailable; + renderWordTemplateStatus(data.templateFileName); + document.getElementById('word-template-start-page').value = data.startPage || 3; +}); + +// Result of a Browse... click (native picker) or Clear +ipcRenderer.on('word-template-browsed', (event, data) => { + pendingWordTemplatePath = data.templatePath || null; + renderWordTemplateStatus(data.templateFileName); +}); + +function saveWordTemplateSettings() { + const startPageInput = document.getElementById('word-template-start-page'); + let startPage = parseInt(startPageInput.value, 10); + if (!Number.isFinite(startPage) || startPage < 1) startPage = 1; + if (startPage > 100) startPage = 100; + + ipcRenderer.send('save-word-template-settings', { + templatePath: pendingWordTemplatePath, + startPage, + }); + closeWordTemplateDialog(); +} + +document.getElementById('word-template-close').addEventListener('click', closeWordTemplateDialog); +document.getElementById('word-template-cancel').addEventListener('click', closeWordTemplateDialog); +document.getElementById('word-template-save').addEventListener('click', saveWordTemplateSettings); +document.getElementById('word-template-browse').addEventListener('click', () => { + ipcRenderer.send('browse-word-template'); +}); +document.getElementById('word-template-clear').addEventListener('click', () => { + ipcRenderer.send('clear-word-template'); +}); + +window.openWordTemplateDialog = openWordTemplateDialog; + +ipcRenderer.on('open-word-template-dialog', () => { + openWordTemplateDialog(); +}); + // Command Palette - initialized via CommandPalette class // (see DOMContentLoaded handler for registration of commands) diff --git a/src/styles.css b/src/styles.css index f8d45d2..7a830e7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3705,6 +3705,117 @@ body[data-theme='dark'] .field-option:hover { background: #0d6efd; color: white; } + +/* ================================ + Word Template Dialog Styles + ================================ */ + +.wt-status-section { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + margin-bottom: 20px; + padding: 15px; + background: var(--bg-secondary, #f5f5f5); + border-radius: 8px; + border: 1px solid var(--border-color, #e0e0e0); +} + +.wt-status { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.wt-status-icon { + font-size: 28px; + line-height: 1; + flex-shrink: 0; +} + +.wt-status-text { + min-width: 0; +} + +.wt-status-title { + font-weight: 600; + color: var(--text-primary, #333); + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wt-status-detail { + font-size: 12px; + color: var(--text-secondary, #666); + margin-top: 2px; +} + +.wt-status-missing .wt-status-detail { + color: var(--danger-color, #dc3545); +} + +.wt-status-selected .wt-status-detail { + color: var(--accent-color, #007bff); +} + +.wt-status-actions { + display: flex; + flex-shrink: 0; +} + +.wt-startpage-section { + padding: 15px; + background: var(--bg-tertiary, #fafafa); + border-radius: 8px; + border: 1px solid var(--border-color, #e0e0e0); +} + +.wt-startpage-section label { + display: block; + font-weight: 600; + margin-bottom: 8px; + color: var(--text-secondary, #666); + font-size: 13px; +} + +.wt-startpage-section input[type='number'] { + width: 100px; + padding: 8px 10px; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + font-size: 14px; + background: var(--input-bg, white); + color: var(--text-primary, #333); +} + +.wt-startpage-section input[type='number']:focus { + outline: none; + border-color: var(--accent-color, #007bff); + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1); +} + +.wt-help { + margin: 10px 0 0 0; + font-size: 12px; + color: var(--text-secondary, #666); +} + +body[data-theme='dark'] .wt-status-section, +body[data-theme='dark'] .wt-startpage-section { + background: #252525; + border-color: #404040; +} + +body[data-theme='dark'] .wt-startpage-section input[type='number'] { + background: #2d2d2d; + color: #e0e0e0; + border-color: #404040; +} + /* Mermaid Diagram Styles */ .mermaid { background: #f9f9f9; diff --git a/src/wordTemplateExporter.js b/src/wordTemplateExporter.js index 5ea857b79a021abfd21e06049bf02d968fb18ec9..d37ada995762ca5c0b0d3ab50ef3fd2a754ba1a0 100644 GIT binary patch delta 8030 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 Q>c@<1h`Rrar^ny_AIsV(?f?J) delta 113 zcmV-%0FM9rodUVj0kA0nlS%_Y5VRU5*lf4f| z1!He+b99ph5Gs=~5FV3J5J8ia5G<3z5FwN05Hpi66bzG$4kVMr4zQCY5j&G&5oD9d T5h|1P5iGMY6fOa??=%crJ=G|K diff --git a/tests/word-template-exporter.test.js b/tests/word-template-exporter.test.js index 55f9d4b..e5f9138 100644 --- a/tests/word-template-exporter.test.js +++ b/tests/word-template-exporter.test.js @@ -2,6 +2,10 @@ * Tests for WordTemplateExporter */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const PizZip = require('pizzip'); const WordTemplateExporter = require('../src/wordTemplateExporter'); describe('WordTemplateExporter.preprocessMarkdownForWordExport', () => { @@ -115,3 +119,123 @@ Centered expect(WordTemplateExporter.preprocessMarkdownForWordExport(123)).toBe(123); }); }); + +describe('WordTemplateExporter.hasTemplateFile', () => { + test('is false when no path is given and the bundled default template is absent', () => { + // word_template.docx was removed from the repo (see git history); this + // asserts the current, real state of the repo rather than assuming a + // file that may or may not exist. + const exporter = new WordTemplateExporter(null); + const defaultExists = fs.existsSync(WordTemplateExporter.getDefaultTemplatePath()); + expect(exporter.hasTemplateFile()).toBe(defaultExists); + }); + + test('is false for a path that does not exist on disk', () => { + const exporter = new WordTemplateExporter('/definitely/not/a/real/path/template.docx'); + expect(exporter.hasTemplateFile()).toBe(false); + }); + + test('is true for a path that does exist on disk', () => { + const tmpFile = path.join(os.tmpdir(), `wt-exists-${Date.now()}.docx`); + fs.writeFileSync(tmpFile, 'not a real docx, existence is all that matters here'); + try { + const exporter = new WordTemplateExporter(tmpFile); + expect(exporter.hasTemplateFile()).toBe(true); + } finally { + fs.unlinkSync(tmpFile); + } + }); +}); + +describe('WordTemplateExporter.convert — graceful fallback with no template file', () => { + let outputPath; + + afterEach(() => { + if (outputPath && fs.existsSync(outputPath)) { + fs.unlinkSync(outputPath); + } + outputPath = null; + }); + + test('does not throw ENOENT and produces a readable DOCX when the template path is missing', async () => { + outputPath = path.join(os.tmpdir(), `wt-fallback-${Date.now()}.docx`); + const exporter = new WordTemplateExporter('/definitely/not/a/real/path/template.docx', 3, null); + + await expect(exporter.convert('# Title\n\nSome paragraph text.', outputPath)).resolves.toBe( + outputPath + ); + + expect(fs.existsSync(outputPath)).toBe(true); + + // The generated file must be a well-formed DOCX (zip) with the parts + // Word requires, containing the markdown content. + const zip = new PizZip(fs.readFileSync(outputPath)); + expect(zip.file('word/document.xml')).not.toBeNull(); + expect(zip.file('word/styles.xml')).not.toBeNull(); + expect(zip.file('word/numbering.xml')).not.toBeNull(); + + const documentXml = zip.file('word/document.xml').asText(); + expect(documentXml).toContain('Title'); + expect(documentXml).toContain('Some paragraph text.'); + expect(documentXml).toContain('Heading1'); + }); + + test('also degrades gracefully when templatePath is null and the default template is absent', async () => { + outputPath = path.join(os.tmpdir(), `wt-fallback-null-${Date.now()}.docx`); + const exporter = new WordTemplateExporter(null, 3, null); + + if (exporter.hasTemplateFile()) { + // Environment happens to have a real default template on disk — + // this test only asserts the fallback path, so skip in that case. + return; + } + + await expect(exporter.convert('Plain content.', outputPath)).resolves.toBe(outputPath); + expect(fs.existsSync(outputPath)).toBe(true); + }); + + test('honors pageSettings (landscape) in the generated default document', async () => { + outputPath = path.join(os.tmpdir(), `wt-fallback-landscape-${Date.now()}.docx`); + const exporter = new WordTemplateExporter('/definitely/not/a/real/path/template.docx', 3, { + size: 'a4', + orientation: 'landscape', + }); + + await exporter.convert('Landscape content.', outputPath); + + const zip = new PizZip(fs.readFileSync(outputPath)); + const documentXml = zip.file('word/document.xml').asText(); + expect(documentXml).toContain('w:orient="landscape"'); + // A4 landscape swaps width/height relative to portrait (11906x16838). + expect(documentXml).toContain('w:w="16838"'); + expect(documentXml).toContain('w:h="11906"'); + }); + + test('still uses the real template file when one exists on disk (regression check)', async () => { + // Build a tiny but valid docx fixture (using the same generator used + // for the no-template fallback) to stand in for a "real" template, so + // this test does not depend on any bundled fixture file existing. + const templatePath = path.join(os.tmpdir(), `wt-fixture-template-${Date.now()}.docx`); + const fixtureExporter = new WordTemplateExporter('/no/such/file.docx'); + const fixtureZip = fixtureExporter.buildDefaultDocumentZip( + 'COVER' + ); + fs.writeFileSync(templatePath, fixtureZip.generate({ type: 'nodebuffer' })); + + outputPath = path.join(os.tmpdir(), `wt-with-template-${Date.now()}.docx`); + try { + const exporter = new WordTemplateExporter(templatePath, 3, null); + expect(exporter.hasTemplateFile()).toBe(true); + + await exporter.convert('Body content.', outputPath); + + const zip = new PizZip(fs.readFileSync(outputPath)); + const documentXml = zip.file('word/document.xml').asText(); + // Content from the "template" (cover) and the new export both present. + expect(documentXml).toContain('COVER'); + expect(documentXml).toContain('Body content.'); + } finally { + fs.unlinkSync(templatePath); + } + }); +});