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);
+ }
+ });
+});