feat(export): add visual word-template settings dialog with graceful default-template fallback

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
This commit is contained in:
2026-08-23 19:31:33 +05:30
parent 8a28c21512
commit c8883e77fe
6 changed files with 443 additions and 71 deletions
+50
View File
@@ -2354,6 +2354,56 @@
</div>
</div>
<!-- Word Template Settings Dialog -->
<div
id="word-template-dialog"
class="modal hidden"
role="dialog"
aria-modal="true"
aria-labelledby="word-template-title"
>
<div class="modal-backdrop" data-close></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="word-template-title">Word Template Settings</h3>
<button class="modal-close" id="word-template-close" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div class="wt-status-section">
<div id="word-template-status" class="wt-status wt-status-none">
<div class="wt-status-icon" aria-hidden="true">&#128196;</div>
<div class="wt-status-text">
<div id="word-template-status-title" class="wt-status-title">
No template selected
</div>
<div id="word-template-status-detail" class="wt-status-detail">
Using default formatting
</div>
</div>
</div>
<div class="wt-status-actions">
<button id="word-template-browse" class="browse-btn">Browse...</button>
<button id="word-template-clear" class="clear-btn">Clear</button>
</div>
</div>
<div class="wt-startpage-section">
<label for="word-template-start-page">Content starts from page</label>
<input type="number" id="word-template-start-page" min="1" max="100" value="3" />
<p class="wt-help">
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.
</p>
</div>
</div>
<div class="modal-footer">
<button id="word-template-cancel" class="btn btn-secondary" data-close>Cancel</button>
<button id="word-template-save" class="btn btn-primary">Save Settings</button>
</div>
</div>
</div>
<div class="main-content" id="main-content">
<!-- Sidebar -->
<div class="sidebar collapsed" id="sidebar">
+64 -71
View File
@@ -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
+94
View File
@@ -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)
+111
View File
@@ -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;
Binary file not shown.
+124
View File
@@ -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(
'<w:p><w:r><w:t>COVER</w:t></w:r></w:p>'
);
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);
}
});
});