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 5ea857b..d37ada9 100644 Binary files a/src/wordTemplateExporter.js and b/src/wordTemplateExporter.js differ 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); + } + }); +});