feat(export): add save/select/delete export presets

This commit is contained in:
2026-08-23 19:31:33 +05:30
parent 2e3af826f7
commit 02ce06d364
10 changed files with 1399 additions and 152 deletions
+22 -16
View File
@@ -352,22 +352,28 @@
</div>
<div class="modal-body">
<!-- Simple/Advanced Export Toggle -->
<!-- Export Profiles -->
<div class="export-section export-profiles">
<label>Export Profile:</label>
<div class="profile-controls">
<select id="export-profile-select">
<option value="">Custom Settings</option>
</select>
<button
id="save-profile-btn"
type="button"
title="Save current settings as profile"
>
💾 Save
</button>
<button id="delete-profile-btn" type="button" title="Delete selected profile">
🗑️ Delete
<!-- Export Presets (main-process persisted; see src/renderer/export-presets.js) -->
<div class="export-section export-presets">
<label>Export Preset:</label>
<div class="preset-controls">
<div class="preset-dropdown">
<button
type="button"
id="preset-dropdown-toggle"
aria-haspopup="listbox"
aria-expanded="false"
>
Custom Settings
</button>
<div
id="preset-dropdown-list"
class="preset-dropdown-list hidden"
role="listbox"
aria-label="Export presets"
></div>
</div>
<button id="save-preset-btn" type="button" title="Save current settings as preset">
💾 Save as preset
</button>
</div>
<small class="export-help">Save and reuse your favorite export configurations</small>
+12
View File
@@ -13,6 +13,7 @@ const GitOperations = require('./main/GitOperations');
const PdfFontHeader = require('./main/PdfFontHeader');
const MonospaceFontConfig = require('./main/MonospaceFontConfig');
const ExportCss = require('./main/ExportCss');
const ExportPresets = require('./main/ExportPresets');
const EpubFontEmbedder = require('./main/EpubFontEmbedder');
const DocxFontEmbedder = require('./main/DocxFontEmbedder');
@@ -2021,6 +2022,17 @@ ipcMain.on('save-header-footer-settings', (event, settings) => {
});
});
// Export Presets IPC Handlers (invoke-style). Presets live in settings.json
// under the `exportPresets` key — the same store used for header/footer and
// page settings; list logic is in src/main/ExportPresets.js.
ipcMain.handle('get-export-presets', async () => ExportPresets.loadPresets(store));
ipcMain.handle('save-export-preset', async (event, preset) =>
ExportPresets.savePreset(store, preset)
);
ipcMain.handle('delete-export-preset', async (event, presetId) =>
ExportPresets.deletePreset(store, presetId)
);
// Get current page settings
ipcMain.on('get-page-settings', (event) => {
event.reply('page-settings-data', pageSettings);
+92
View File
@@ -0,0 +1,92 @@
'use strict';
/**
* Export preset persistence (Task 21 — export presets/profiles).
*
* Pure list operations over the `exportPresets` array kept in the app's
* settings.json store (the `store.get`/`store.set` helpers defined in
* src/main.js — the same store that holds headerFooterSettings and
* pageSettings). The store is injected so the logic is unit-testable with a
* fake store (see tests/main/ExportPresets.test.js); src/main.js wires these
* functions to the get-export-presets / save-export-preset /
* delete-export-preset invoke channels.
*
* Preset shape: { id: string, name: string, format: string|null, options: object }
* — `options` is the export-options snapshot captured from the renderer's
* export dialog, so selecting a preset can pre-fill that dialog exactly.
*/
const PRESET_KEY = 'exportPresets';
const MAX_PRESETS = 50;
const MAX_NAME_LENGTH = 100;
/**
* Read the stored presets, defensively skipping corrupt data.
* @param {{get: Function, set: Function}} store settings store
* @returns {Array<{id: string, name: string, format: string|null, options: Object}>}
*/
function loadPresets(store) {
const stored = store.get(PRESET_KEY, []);
if (!Array.isArray(stored)) return [];
return stored.filter(
(preset) =>
preset &&
typeof preset === 'object' &&
typeof preset.id === 'string' &&
typeof preset.name === 'string'
);
}
/**
* Validate, normalize, and upsert a preset by id. A missing id gets a newly
* generated one (insert); an id that already exists is replaced (update).
* @param {{get: Function, set: Function}} store settings store
* @param {{id?: string, name?: string, format?: string, options?: Object}} preset
* @returns {Array} the updated preset list (also persisted)
* @throws when the preset is not an object, the name is empty, or the cap is hit
*/
function savePreset(store, preset) {
if (!preset || typeof preset !== 'object') {
throw new Error('Preset must be an object');
}
const name = typeof preset.name === 'string' ? preset.name.trim().slice(0, MAX_NAME_LENGTH) : '';
if (!name) {
throw new Error('Preset name is required');
}
const options = preset.options && typeof preset.options === 'object' ? preset.options : {};
const format = typeof preset.format === 'string' ? preset.format : null;
const presets = loadPresets(store);
const id = typeof preset.id === 'string' && preset.id ? preset.id : createPresetId();
const entry = { id, name, format, options };
const index = presets.findIndex((existing) => existing.id === id);
if (index >= 0) {
presets[index] = entry;
} else {
if (presets.length >= MAX_PRESETS) {
throw new Error(`Cannot store more than ${MAX_PRESETS} export presets`);
}
presets.push(entry);
}
store.set(PRESET_KEY, presets);
return presets;
}
/**
* Remove the preset with the given id (idempotent).
* @param {{get: Function, set: Function}} store settings store
* @param {string} presetId
* @returns {Array} the updated preset list (also persisted)
*/
function deletePreset(store, presetId) {
const presets = loadPresets(store).filter((preset) => preset.id !== presetId);
store.set(PRESET_KEY, presets);
return presets;
}
function createPresetId() {
return `preset-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
module.exports = { loadPresets, savePreset, deletePreset, PRESET_KEY, MAX_PRESETS };
+5
View File
@@ -74,6 +74,11 @@ const ALLOWED_SEND_CHANNELS = [
'browse-word-template',
'clear-word-template',
// Export presets (invoke channels — gated by this same array)
'get-export-presets',
'save-export-preset',
'delete-export-preset',
// Page settings
'get-page-settings',
'update-page-settings',
+11 -126
View File
@@ -13,6 +13,7 @@ const { createEditor } = require('./editor/codemirror-setup');
const { undo, redo } = require('@codemirror/commands');
const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog');
const { showDocumentCompareDialog } = require('./renderer/document-compare-dialog');
const { initExportPresets, refreshExportPresets } = require('./renderer/export-presets');
const { csvToMarkdownTable } = require('./utils/csv-to-markdown-table');
/**
@@ -2425,6 +2426,9 @@ function showExportDialog(format) {
// Initialize form values
initializeExportForm(format);
// Populate the preset dropdown from the main-process store
refreshExportPresets();
}
function hideExportDialog() {
window.modals.exportModal.close();
@@ -2599,129 +2603,14 @@ function collectExportOptions() {
return options;
}
// Export Profiles Management
let exportProfiles = {};
function loadExportProfiles() {
const saved = localStorage.getItem('exportProfiles');
if (saved) {
try {
exportProfiles = JSON.parse(saved);
populateProfileDropdown();
} catch (e) {
console.error('Failed to load export profiles:', e);
exportProfiles = {};
}
}
}
function saveExportProfiles() {
localStorage.setItem('exportProfiles', JSON.stringify(exportProfiles));
}
function populateProfileDropdown() {
const select = document.getElementById('export-profile-select');
if (!select) return;
// Clear existing options except the first one
while (select.options.length > 1) {
select.remove(1);
}
// Add saved profiles
Object.keys(exportProfiles).forEach((name) => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
});
}
function saveCurrentProfile() {
const name = prompt('Enter a name for this export profile:', 'My Profile');
if (!name || name.trim() === '') return;
const profileName = name.trim();
// Collect current settings
const profile = {
format: currentExportFormat,
advancedMode: document.getElementById('advanced-export-toggle').checked,
pageSize: document.getElementById('page-size').value,
pageOrientation: document.getElementById('page-orientation').value,
basicToc: document.getElementById('basic-toc').checked,
basicNumberSections: document.getElementById('basic-number-sections').checked,
};
// Add advanced options if enabled
if (profile.advancedMode) {
profile.template = document.getElementById('export-template').value;
profile.toc = document.getElementById('export-toc').checked;
profile.tocDepth = document.getElementById('export-toc-depth').value;
profile.numberSections = document.getElementById('export-number-sections').checked;
profile.citeproc = document.getElementById('export-citeproc').checked;
if (currentExportFormat === 'pdf') {
profile.pdfEngine = document.getElementById('pdf-engine').value;
profile.pdfGeometry = document.getElementById('pdf-geometry').value;
}
}
exportProfiles[profileName] = profile;
saveExportProfiles();
populateProfileDropdown();
// Select the newly created profile
document.getElementById('export-profile-select').value = profileName;
notifyUser(`Profile "${profileName}" saved successfully.`, 'success');
}
function loadProfile(profileName) {
if (!profileName || !exportProfiles[profileName]) return;
const profile = exportProfiles[profileName];
// Apply settings
if (profile.advancedMode !== undefined) {
document.getElementById('advanced-export-toggle').checked = profile.advancedMode;
const advancedOptions = document.getElementById('advanced-export-options');
if (profile.advancedMode) {
advancedOptions.classList.remove('hidden');
} else {
advancedOptions.classList.add('hidden');
}
}
if (profile.pageSize) document.getElementById('page-size').value = profile.pageSize;
if (profile.pageOrientation)
document.getElementById('page-orientation').value = profile.pageOrientation;
if (profile.basicToc !== undefined)
document.getElementById('basic-toc').checked = profile.basicToc;
if (profile.basicNumberSections !== undefined)
document.getElementById('basic-number-sections').checked = profile.basicNumberSections;
// Advanced options
if (profile.advancedMode && profile.template)
document.getElementById('export-template').value = profile.template;
if (profile.toc !== undefined) document.getElementById('export-toc').checked = profile.toc;
if (profile.tocDepth) document.getElementById('export-toc-depth').value = profile.tocDepth;
if (profile.numberSections !== undefined)
document.getElementById('export-number-sections').checked = profile.numberSections;
if (profile.citeproc !== undefined)
document.getElementById('export-citeproc').checked = profile.citeproc;
if (profile.pdfEngine) document.getElementById('pdf-engine').value = profile.pdfEngine;
if (profile.pdfGeometry) document.getElementById('pdf-geometry').value = profile.pdfGeometry;
}
function deleteSelectedProfile() {
const select = document.getElementById('export-profile-select');
const profileName = select.value;
if (!profileName) {
notifyUser('Select a profile to delete.', 'warning');
return;
}
if (confirm(`Are you sure you want to delete the profile "${profileName}"?`)) {
delete exportProfiles[profileName];
saveExportProfiles();
populateProfileDropdown();
select.value = '';
notifyUser(`Profile "${profileName}" deleted successfully.`, 'success');
}
}
// Export Presets — the preset dropdown / "Save as preset" logic lives in
// src/renderer/export-presets.js; presets are persisted by the main process
// in settings.json (get-export-presets / save-export-preset /
// delete-export-preset invoke channels), replacing the earlier
// localStorage-only export profiles.
// Event listeners for export dialog
document.addEventListener('DOMContentLoaded', () => {
// Load export profiles on startup
loadExportProfiles();
// Template selection
document.getElementById('export-template').addEventListener('change', (e) => {
const customPath = document.getElementById('custom-template-path');
@@ -2812,12 +2701,8 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Export Profile buttons
document.getElementById('save-profile-btn').addEventListener('click', saveCurrentProfile);
document.getElementById('delete-profile-btn').addEventListener('click', deleteSelectedProfile);
document.getElementById('export-profile-select').addEventListener('change', (e) => {
loadProfile(e.target.value);
});
// Export Preset controls (dropdown, per-row delete, save-as-preset)
initExportPresets({ notify: notifyUser });
// Add metadata field
document.getElementById('add-metadata-field').addEventListener('click', () => {
+430
View File
@@ -0,0 +1,430 @@
/**
* Export Presets UI
*
* Preset dropdown + "Save as preset" for the export-options dialog
* (#export-dialog in src/index.html). Replaces the earlier localStorage-only
* "export profiles": presets are now owned by the main process and persisted
* in settings.json (`exportPresets` key) through three invoke channels —
* get-export-presets / save-export-preset / delete-export-preset — so they
* behave like every other app setting instead of living and dying with the
* renderer's localStorage.
*
* Construction mirrors src/renderer/document-compare-dialog.js: a dialog
* module in src/renderer/ using the raw `ipcRenderer` invoke surface
* (nodeIntegration is enabled for this renderer). DOM is hand-rolled — a
* button + row list instead of a native <select> so each preset row can carry
* its own delete icon.
*
* The dialog markup lives in src/index.html; renderer.js calls
* initExportPresets() once at startup and refreshExportPresets() whenever the
* export dialog opens.
*/
const { ipcRenderer } = require('electron');
let currentPresets = [];
let selectedPresetId = null;
let notify = (message, type) => console.warn(`Export presets (${type}): ${message}`);
// ============================================
// DOM helpers (all element access is guarded —
// this module may outlive a dialog re-render)
// ============================================
function elementById(id) {
return document.getElementById(id);
}
function valueOf(id) {
const el = elementById(id);
return el ? el.value : '';
}
function setValue(id, value) {
const el = elementById(id);
if (el) el.value = value;
}
function isChecked(id) {
const el = elementById(id);
return !!(el && el.checked);
}
function setChecked(id, checked) {
const el = elementById(id);
if (el) el.checked = checked;
}
function setVisible(id, visible) {
const el = elementById(id);
if (el) el.style.display = visible ? 'block' : 'none';
}
function getDialogFormat() {
const dialogEl = elementById('export-dialog');
return dialogEl ? dialogEl.getAttribute('data-format') : null;
}
// ============================================
// Capture / restore of the dialog's option values
// ============================================
/**
* Snapshot every option field of the export dialog into a plain object.
* Side-effect free (unlike collectExportOptions in renderer.js, which also
* pushes page settings to the main process) — the snapshot is stored as the
* preset's `options` and replayed by applyPresetToDialog().
* @returns {Object} options snapshot
*/
function captureDialogOptions() {
const format = getDialogFormat();
const advancedMode = isChecked('advanced-export-toggle');
const options = {
advancedMode,
pageSize: valueOf('page-size'),
pageOrientation: valueOf('page-orientation'),
customWidth: valueOf('custom-width').trim() || null,
customHeight: valueOf('custom-height').trim() || null,
};
if (!advancedMode) {
options.toc = isChecked('basic-toc');
options.numberSections = isChecked('basic-number-sections');
return options;
}
const template = valueOf('export-template');
options.template = template === 'custom' ? valueOf('custom-template-path').trim() : template;
options.metadata = {};
document.querySelectorAll('.metadata-field').forEach((field) => {
const key = field.querySelector('.metadata-key').value.trim();
const value = field.querySelector('.metadata-value').value.trim();
if (key && value) options.metadata[key] = value;
});
options.toc = isChecked('export-toc');
options.tocDepth = valueOf('export-toc-depth') || '3';
options.numberSections = isChecked('export-number-sections');
options.citeproc = isChecked('export-citeproc');
if (format === 'pdf') {
options.pdfEngine = valueOf('pdf-engine');
const geometrySelect = valueOf('pdf-geometry');
options.geometry =
geometrySelect === 'custom'
? valueOf('custom-geometry').trim() || 'margin=1in'
: geometrySelect;
}
if (format === 'revealjs') {
options.revealTheme = valueOf('reveal-theme');
options.revealTransition = valueOf('reveal-transition');
options.revealTransitionSpeed = valueOf('reveal-speed');
options.revealSlideNumber = isChecked('reveal-slide-number');
options.revealControls = isChecked('reveal-controls');
options.revealProgress = isChecked('reveal-progress');
options.revealHistory = isChecked('reveal-history');
options.revealCenter = isChecked('reveal-center');
}
const bibliography = valueOf('bibliography-file').trim();
const csl = valueOf('csl-file').trim();
if (bibliography) options.bibliography = bibliography;
if (csl) options.csl = csl;
return options;
}
/**
* Restore a preset's option snapshot onto the dialog. Every field is written
* explicitly (preset value or the dialog default), so switching from a rich
* preset to a plain one clears whatever the rich one had set.
* @param {{options?: Object}} preset preset to apply
*/
function applyPresetToDialog(preset) {
const options = (preset && preset.options) || {};
const advancedMode = options.advancedMode === true;
setChecked('advanced-export-toggle', advancedMode);
const advancedSection = elementById('advanced-export-options');
if (advancedSection) advancedSection.classList.toggle('hidden', !advancedMode);
// Page setup (applies to both modes)
setValue('page-size', options.pageSize || 'a4');
setValue('page-orientation', options.pageOrientation || 'portrait');
setValue('custom-width', options.customWidth || '');
setValue('custom-height', options.customHeight || '');
setVisible('custom-page-size', valueOf('page-size') === 'custom');
setChecked('basic-toc', !advancedMode && options.toc === true);
setChecked('basic-number-sections', !advancedMode && options.numberSections === true);
// In basic mode the advanced-only fields are reset to their defaults so no
// stale values from a previously applied rich preset survive the switch.
const advancedOptions = advancedMode ? options : {};
// Template: anything other than the literal default is a custom path
const template =
advancedOptions.template && advancedOptions.template !== 'default'
? advancedOptions.template
: null;
if (template) {
setValue('export-template', 'custom');
setValue('custom-template-path', template);
setVisible('custom-template-path', true);
setVisible('template-file-input', true);
} else {
setValue('export-template', 'default');
setValue('custom-template-path', '');
setVisible('custom-template-path', false);
setVisible('template-file-input', false);
}
rebuildMetadataRows(advancedOptions.metadata || {});
setChecked('export-toc', advancedOptions.toc === true);
setValue('export-toc-depth', advancedOptions.tocDepth || '3');
setChecked('export-number-sections', advancedOptions.numberSections === true);
setChecked('export-citeproc', advancedOptions.citeproc === true);
// PDF options
setValue('pdf-engine', advancedOptions.pdfEngine || 'xelatex');
const geometry = advancedOptions.geometry || 'margin=1in';
const geometrySelect = elementById('pdf-geometry');
const hasGeometryOption =
geometrySelect && Array.from(geometrySelect.options).some((opt) => opt.value === geometry);
if (hasGeometryOption) {
setValue('pdf-geometry', geometry);
setValue('custom-geometry', '');
setVisible('custom-geometry', false);
} else {
setValue('pdf-geometry', 'custom');
setValue('custom-geometry', geometry);
setVisible('custom-geometry', true);
}
// Reveal.js options
setValue('reveal-theme', advancedOptions.revealTheme || 'black');
setValue('reveal-transition', advancedOptions.revealTransition || 'slide');
setValue('reveal-speed', advancedOptions.revealTransitionSpeed || 'default');
setChecked('reveal-slide-number', advancedOptions.revealSlideNumber === true);
setChecked('reveal-controls', advancedOptions.revealControls !== false);
setChecked('reveal-progress', advancedOptions.revealProgress !== false);
setChecked('reveal-history', advancedOptions.revealHistory !== false);
setChecked('reveal-center', advancedOptions.revealCenter !== false);
setValue('bibliography-file', advancedOptions.bibliography || '');
setValue('csl-file', advancedOptions.csl || '');
}
function rebuildMetadataRows(metadata) {
const container = document.querySelector('.metadata-container');
if (!container) return;
const entries = Object.keys(metadata).map((key) => [key, metadata[key]]);
if (entries.length === 0) {
['title', 'author', 'date', 'subject'].forEach((key) => entries.push([key, '']));
}
container.innerHTML = '';
entries.forEach(([key, value]) => {
const field = document.createElement('div');
field.className = 'metadata-field';
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.className = 'metadata-key';
keyInput.value = key;
const valueInput = document.createElement('input');
valueInput.type = 'text';
valueInput.className = 'metadata-value';
valueInput.value = value;
field.append(keyInput, valueInput);
container.appendChild(field);
});
}
// ============================================
// Preset dropdown rendering + interaction
// ============================================
function createPresetRow(preset) {
const selected = preset.id === selectedPresetId;
const row = document.createElement('div');
row.className = selected ? 'preset-row selected' : 'preset-row';
row.dataset.id = preset.id;
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', selected ? 'true' : 'false');
const selectButton = document.createElement('button');
selectButton.type = 'button';
selectButton.className = 'preset-row-select';
selectButton.textContent = preset.name;
row.appendChild(selectButton);
if (preset.format) {
const badge = document.createElement('span');
badge.className = 'preset-format';
badge.textContent = preset.format;
row.appendChild(badge);
}
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'preset-delete';
deleteButton.textContent = '×';
deleteButton.title = `Delete preset ${preset.name}`;
deleteButton.setAttribute('aria-label', `Delete preset ${preset.name}`);
row.appendChild(deleteButton);
return row;
}
function renderPresets() {
const list = elementById('preset-dropdown-list');
const toggle = elementById('preset-dropdown-toggle');
if (!list || !toggle) return;
list.innerHTML = '';
if (currentPresets.length === 0) {
const empty = document.createElement('div');
empty.className = 'preset-empty';
empty.textContent = 'No saved presets';
list.appendChild(empty);
} else {
currentPresets.forEach((preset) => list.appendChild(createPresetRow(preset)));
}
const selected = currentPresets.find((preset) => preset.id === selectedPresetId);
toggle.textContent = selected ? selected.name : 'Custom Settings';
closeDropdown();
}
function toggleDropdown() {
const list = elementById('preset-dropdown-list');
if (!list) return;
const opened = !list.classList.contains('hidden');
list.classList.toggle('hidden');
const toggle = elementById('preset-dropdown-toggle');
if (toggle) toggle.setAttribute('aria-expanded', String(!opened));
}
function closeDropdown() {
const list = elementById('preset-dropdown-list');
if (list) list.classList.add('hidden');
const toggle = elementById('preset-dropdown-toggle');
if (toggle) toggle.setAttribute('aria-expanded', 'false');
}
function handleListClick(event) {
const deleteButton = event.target.closest('.preset-delete');
const row = event.target.closest('.preset-row');
if (deleteButton && row) {
deleteExportPreset(row.dataset.id);
return;
}
if (row) selectPreset(row.dataset.id);
}
function selectPreset(presetId) {
const preset = currentPresets.find((candidate) => candidate.id === presetId);
if (!preset) return;
selectedPresetId = presetId;
applyPresetToDialog(preset);
renderPresets();
}
// ============================================
// IPC-backed preset operations
// ============================================
/**
* Fetch presets from the main process and re-render the dropdown.
* Called whenever the export dialog opens.
*/
async function refreshExportPresets() {
try {
const presets = await ipcRenderer.invoke('get-export-presets');
currentPresets = Array.isArray(presets) ? presets : [];
} catch (error) {
console.error('Failed to load export presets:', error);
currentPresets = [];
}
if (!currentPresets.some((preset) => preset.id === selectedPresetId)) {
selectedPresetId = null;
}
renderPresets();
}
async function saveCurrentAsPreset() {
const selected = currentPresets.find((preset) => preset.id === selectedPresetId);
const defaultName = selected ? selected.name : 'My Preset';
const answer = window.prompt('Enter a name for this export preset:', defaultName);
if (answer === null) return; // user cancelled the prompt
const name = answer.trim();
if (!name) {
notify('Preset name cannot be empty.', 'warning');
return;
}
// A selected preset is overwritten (same id); otherwise a new id is minted.
// The main process re-validates and remains the source of truth.
const preset = {
id: selected ? selected.id : createPresetId(),
name,
format: getDialogFormat(),
options: captureDialogOptions(),
};
try {
const presets = await ipcRenderer.invoke('save-export-preset', preset);
currentPresets = Array.isArray(presets) ? presets : currentPresets;
// Normally the saved preset keeps the id we sent; fall back to the last
// entry with the same name should the main process have normalized it.
selectedPresetId = preset.id;
if (!currentPresets.some((candidate) => candidate.id === selectedPresetId)) {
const byName = currentPresets.filter((candidate) => candidate.name === name).pop();
selectedPresetId = byName ? byName.id : null;
}
renderPresets();
notify(`Preset "${name}" saved.`, 'success');
} catch (error) {
console.error('Failed to save export preset:', error);
notify('Failed to save preset. Please try again.', 'warning');
}
}
async function deleteExportPreset(presetId) {
const preset = currentPresets.find((candidate) => candidate.id === presetId);
const label = preset ? preset.name : 'this preset';
if (!window.confirm(`Are you sure you want to delete the preset "${label}"?`)) return;
try {
const presets = await ipcRenderer.invoke('delete-export-preset', presetId);
currentPresets = Array.isArray(presets) ? presets : currentPresets;
if (selectedPresetId === presetId) selectedPresetId = null;
renderPresets();
notify(`Preset "${label}" deleted.`, 'success');
} catch (error) {
console.error('Failed to delete export preset:', error);
notify('Failed to delete preset. Please try again.', 'warning');
}
}
function createPresetId() {
return `preset-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Wire the preset section of the export dialog. Call once after DOM ready.
* @param {{notify?: Function}} options hooks from renderer.js
*/
function initExportPresets(options = {}) {
if (typeof options.notify === 'function') notify = options.notify;
selectedPresetId = null;
const saveButton = elementById('save-preset-btn');
if (saveButton) saveButton.addEventListener('click', saveCurrentAsPreset);
const toggle = elementById('preset-dropdown-toggle');
if (toggle) toggle.addEventListener('click', toggleDropdown);
const list = elementById('preset-dropdown-list');
if (list) list.addEventListener('click', handleListClick);
}
module.exports = {
initExportPresets,
refreshExportPresets,
captureDialogOptions,
applyPresetToDialog,
};
+98 -10
View File
@@ -1491,29 +1491,26 @@ body.theme-github .line-numbers {
color: #586069;
}
/* Export Profiles Styles */
.export-profiles {
/* Export Presets Styles */
.export-presets {
border-bottom: 1px solid #ddd;
padding-bottom: 15px;
margin-bottom: 15px;
}
.profile-controls {
.preset-controls {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.profile-controls select {
.preset-dropdown {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
position: relative;
}
.profile-controls button {
.preset-dropdown button {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
@@ -1523,7 +1520,98 @@ body.theme-github .line-numbers {
transition: all 0.2s;
}
.profile-controls button:hover {
.preset-dropdown button:hover {
background: #e8e8e8;
}
#preset-dropdown-toggle {
width: 100%;
text-align: left;
}
.preset-dropdown-list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 220px;
overflow-y: auto;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 10;
}
.preset-dropdown-list.hidden {
display: none;
}
.preset-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
}
.preset-row.selected {
background: #eef4fb;
}
.preset-row:hover {
background: #f0f0f0;
}
.preset-row-select {
flex: 1;
border: none;
background: none;
text-align: left;
padding: 6px 4px;
cursor: pointer;
font-size: 13px;
}
.preset-format {
font-size: 11px;
text-transform: uppercase;
color: #586069;
border: 1px solid #ddd;
border-radius: 3px;
padding: 1px 4px;
}
.preset-delete {
border: none;
background: none;
color: #586069;
cursor: pointer;
font-size: 15px;
line-height: 1;
padding: 4px 6px;
}
.preset-delete:hover {
color: #cb2431;
}
.preset-empty {
padding: 8px;
font-size: 13px;
color: #586069;
}
#save-preset-btn {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: #f5f5f5;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
#save-preset-btn:hover {
background: #e8e8e8;
}
+530
View File
@@ -0,0 +1,530 @@
/**
* Tests for the Export Presets dialog UI (Task 21).
* Exercises the preset dropdown / save / delete flows of the export-options
* dialog against a jsdom replica of the dialog's markup, with the electron
* IPC surface mocked — following the jest.mock('electron') pattern in
* document-compare-dialog.test.js.
*/
jest.mock('electron', () => ({
ipcRenderer: {
invoke: jest.fn(),
send: jest.fn(),
on: jest.fn(),
once: jest.fn(),
removeAllListeners: jest.fn(),
},
}));
const { ipcRenderer } = require('electron');
const {
initExportPresets,
refreshExportPresets,
captureDialogOptions,
applyPresetToDialog,
} = require('../src/renderer/export-presets');
const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
function metadataRow(key = '', value = '') {
const row = document.createElement('div');
row.className = 'metadata-field';
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.className = 'metadata-key';
keyInput.value = key;
const valueInput = document.createElement('input');
valueInput.type = 'text';
valueInput.className = 'metadata-value';
valueInput.value = value;
row.append(keyInput, valueInput);
return row;
}
/**
* Minimal replica of the #export-dialog markup in src/index.html — only the
* elements the presets module reads or writes.
*/
function buildDialogFixture(format = 'pdf') {
document.body.innerHTML = `
<div id="export-dialog" data-format="${format}">
<div class="export-section export-presets">
<div class="preset-controls">
<div class="preset-dropdown">
<button type="button" id="preset-dropdown-toggle">Custom Settings</button>
<div id="preset-dropdown-list" class="preset-dropdown-list hidden"></div>
</div>
<button id="save-preset-btn" type="button">Save as preset</button>
</div>
</div>
<input type="checkbox" id="advanced-export-toggle" />
<div id="advanced-export-options" class="advanced-options hidden"></div>
<div class="export-section pdf-only">
<select id="pdf-engine">
<option value="xelatex">XeLaTeX</option>
<option value="pdflatex">PDFLaTeX</option>
<option value="lualatex">LuaLaTeX</option>
</select>
<select id="pdf-geometry">
<option value="margin=1in">1in</option>
<option value="margin=2in">2in</option>
<option value="custom">Custom</option>
</select>
<input type="text" id="custom-geometry" style="display: none" />
</div>
<div class="export-section revealjs-only">
<select id="reveal-theme"><option value="black">black</option><option value="white">white</option></select>
<select id="reveal-transition"><option value="slide">slide</option><option value="fade">fade</option></select>
<select id="reveal-speed"><option value="default">default</option><option value="fast">fast</option></select>
<input type="checkbox" id="reveal-slide-number" />
<input type="checkbox" id="reveal-controls" />
<input type="checkbox" id="reveal-progress" />
<input type="checkbox" id="reveal-history" />
<input type="checkbox" id="reveal-center" />
</div>
<div class="export-section">
<select id="export-template">
<option value="default">Default</option>
<option value="custom">Custom</option>
</select>
<input type="file" id="template-file-input" style="display: none" />
<input type="text" id="custom-template-path" style="display: none" />
<div class="metadata-container"></div>
<input type="checkbox" id="export-toc" />
<input type="number" id="export-toc-depth" value="3" min="1" max="6" />
<input type="checkbox" id="export-number-sections" />
<input type="checkbox" id="export-citeproc" />
<input type="text" id="bibliography-file" />
<input type="text" id="csl-file" />
</div>
<div class="export-section">
<input type="checkbox" id="basic-toc" />
<input type="checkbox" id="basic-number-sections" />
<select id="page-size">
<option value="a4">A4</option>
<option value="letter">Letter</option>
<option value="custom">Custom</option>
</select>
<select id="page-orientation">
<option value="portrait">Portrait</option>
<option value="landscape">Landscape</option>
</select>
<div id="custom-page-size" style="display: none">
<input type="text" id="custom-width" />
<input type="text" id="custom-height" />
</div>
</div>
</div>`;
// Seed the four default metadata rows the dialog ships with.
const container = document.querySelector('.metadata-container');
['title', 'author', 'date', 'subject'].forEach((key) => {
container.appendChild(metadataRow(key, ''));
});
}
const advancedPdfPreset = {
id: 'preset-pdf1',
name: 'Book PDF',
format: 'pdf',
options: {
advancedMode: true,
template: '/home/user/templates/book.tex',
metadata: { title: 'My Book', author: 'Jane' },
toc: true,
tocDepth: 4,
numberSections: true,
citeproc: false,
pdfEngine: 'lualatex',
geometry: 'margin=2.5cm',
bibliography: '/refs.bib',
pageSize: 'custom',
pageOrientation: 'landscape',
customWidth: '210mm',
customHeight: '297mm',
},
};
const basicPreset = {
id: 'preset-basic1',
name: 'Quick HTML',
format: 'html',
options: {
advancedMode: false,
toc: true,
numberSections: false,
pageSize: 'letter',
pageOrientation: 'portrait',
},
};
function mockInvoke(presets) {
ipcRenderer.invoke.mockImplementation((channel) => {
if (channel === 'get-export-presets') return Promise.resolve(presets);
return Promise.resolve(presets);
});
}
function rows() {
return Array.from(document.querySelectorAll('#preset-dropdown-list .preset-row'));
}
describe('Export presets dialog', () => {
let notify;
beforeEach(() => {
buildDialogFixture('pdf');
ipcRenderer.invoke.mockReset();
notify = jest.fn();
initExportPresets({ notify });
});
describe('refreshExportPresets', () => {
it('loads presets via get-export-presets and renders one row per preset with a delete icon', async () => {
mockInvoke([advancedPdfPreset, basicPreset]);
await refreshExportPresets();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledWith('get-export-presets');
expect(rows()).toHaveLength(2);
expect(rows()[0].textContent).toContain('Book PDF');
expect(rows()[0].querySelector('.preset-delete')).not.toBeNull();
expect(rows()[1].textContent).toContain('Quick HTML');
});
it('shows the format badge next to each preset name', async () => {
mockInvoke([advancedPdfPreset]);
await refreshExportPresets();
await flush();
expect(rows()[0].querySelector('.preset-format').textContent).toBe('pdf');
});
it('renders an empty notice when there are no presets', async () => {
mockInvoke([]);
await refreshExportPresets();
await flush();
expect(rows()).toHaveLength(0);
expect(document.getElementById('preset-dropdown-list').textContent).toContain(
'No saved presets'
);
});
it('survives a rejected get-export-presets call without crashing', async () => {
ipcRenderer.invoke.mockRejectedValue(new Error('boom'));
await refreshExportPresets();
await flush();
expect(rows()).toHaveLength(0);
});
});
describe('selecting a preset', () => {
it('pre-fills every dialog field from an advanced PDF preset', async () => {
mockInvoke([advancedPdfPreset]);
await refreshExportPresets();
await flush();
rows()[0].querySelector('.preset-row-select').click();
// Advanced mode
expect(document.getElementById('advanced-export-toggle').checked).toBe(true);
expect(document.getElementById('advanced-export-options').classList.contains('hidden')).toBe(
false
);
// Template: custom path
expect(document.getElementById('export-template').value).toBe('custom');
expect(document.getElementById('custom-template-path').value).toBe(
'/home/user/templates/book.tex'
);
expect(document.getElementById('custom-template-path').style.display).toBe('block');
// Metadata rows rebuilt from the preset
const keys = Array.from(document.querySelectorAll('.metadata-key')).map((i) => i.value);
const values = Array.from(document.querySelectorAll('.metadata-value')).map((i) => i.value);
expect(keys).toEqual(['title', 'author']);
expect(values).toEqual(['My Book', 'Jane']);
// Document options
expect(document.getElementById('export-toc').checked).toBe(true);
expect(document.getElementById('export-toc-depth').value).toBe('4');
expect(document.getElementById('export-number-sections').checked).toBe(true);
expect(document.getElementById('export-citeproc').checked).toBe(false);
// PDF options with a non-preset geometry -> custom
expect(document.getElementById('pdf-engine').value).toBe('lualatex');
expect(document.getElementById('pdf-geometry').value).toBe('custom');
expect(document.getElementById('custom-geometry').value).toBe('margin=2.5cm');
expect(document.getElementById('custom-geometry').style.display).toBe('block');
// Bibliography
expect(document.getElementById('bibliography-file').value).toBe('/refs.bib');
// Page setup
expect(document.getElementById('page-size').value).toBe('custom');
expect(document.getElementById('custom-page-size').style.display).toBe('block');
expect(document.getElementById('custom-width').value).toBe('210mm');
expect(document.getElementById('custom-height').value).toBe('297mm');
expect(document.getElementById('page-orientation').value).toBe('landscape');
});
it('pre-fills basic-mode checkboxes and leaves advanced options hidden', async () => {
mockInvoke([basicPreset]);
await refreshExportPresets();
await flush();
rows()[0].querySelector('.preset-row-select').click();
expect(document.getElementById('advanced-export-toggle').checked).toBe(false);
expect(document.getElementById('advanced-export-options').classList.contains('hidden')).toBe(
true
);
expect(document.getElementById('basic-toc').checked).toBe(true);
expect(document.getElementById('basic-number-sections').checked).toBe(false);
expect(document.getElementById('page-size').value).toBe('letter');
});
it('marks the selected row, updates the toggle label and closes the dropdown', async () => {
mockInvoke([advancedPdfPreset, basicPreset]);
await refreshExportPresets();
await flush();
document.getElementById('preset-dropdown-toggle').click();
expect(document.getElementById('preset-dropdown-list').classList.contains('hidden')).toBe(
false
);
rows()[1].querySelector('.preset-row-select').click();
expect(rows()[1].classList.contains('selected')).toBe(true);
expect(rows()[0].classList.contains('selected')).toBe(false);
expect(document.getElementById('preset-dropdown-toggle').textContent).toBe('Quick HTML');
expect(document.getElementById('preset-dropdown-list').classList.contains('hidden')).toBe(
true
);
});
it('resets stale field values when switching from a rich preset to a plain one', async () => {
mockInvoke([advancedPdfPreset, basicPreset]);
await refreshExportPresets();
await flush();
rows()[0].querySelector('.preset-row-select').click();
rows()[1].querySelector('.preset-row-select').click();
// The basic preset has no bibliography — the field must be cleared, not left over.
expect(document.getElementById('bibliography-file').value).toBe('');
expect(document.getElementById('advanced-export-toggle').checked).toBe(false);
});
});
describe('saving the current dialog state as a preset', () => {
it('prompts for a name and invokes save-export-preset with the captured options', async () => {
mockInvoke([]);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'prompt').mockReturnValue('My Preset');
// Configure the dialog the way a user would before saving.
document.getElementById('advanced-export-toggle').checked = true;
document.getElementById('export-toc').checked = true;
document.getElementById('pdf-engine').value = 'pdflatex';
document.getElementById('page-size').value = 'letter';
document.getElementById('save-preset-btn').click();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledWith(
'save-export-preset',
expect.objectContaining({
name: 'My Preset',
format: 'pdf',
})
);
const saved = ipcRenderer.invoke.mock.calls.find((c) => c[0] === 'save-export-preset')[1];
expect(saved.id).toMatch(/^preset-/);
expect(saved.options.advancedMode).toBe(true);
expect(saved.options.toc).toBe(true);
expect(saved.options.pdfEngine).toBe('pdflatex');
expect(saved.options.pageSize).toBe('letter');
expect(notify).toHaveBeenCalledWith('Preset "My Preset" saved.', 'success');
});
it('re-renders the dropdown from the list returned by save-export-preset', async () => {
// Mirror the main process: the saved preset echoes back with the id that was sent.
ipcRenderer.invoke.mockImplementation((channel, payload) => {
if (channel === 'get-export-presets') return Promise.resolve([]);
return Promise.resolve([
{ id: payload.id, name: payload.name, format: payload.format, options: payload.options },
]);
});
await refreshExportPresets();
await flush();
jest.spyOn(window, 'prompt').mockReturnValue('My Preset');
document.getElementById('save-preset-btn').click();
await flush();
expect(rows()).toHaveLength(1);
expect(document.getElementById('preset-dropdown-toggle').textContent).toBe('My Preset');
});
it('reuses the selected preset id so saving overwrites instead of duplicating', async () => {
mockInvoke([advancedPdfPreset]);
await refreshExportPresets();
await flush();
rows()[0].querySelector('.preset-row-select').click();
jest.spyOn(window, 'prompt').mockReturnValue('Book PDF v2');
document.getElementById('save-preset-btn').click();
await flush();
const saved = ipcRenderer.invoke.mock.calls.find((c) => c[0] === 'save-export-preset')[1];
expect(saved.id).toBe('preset-pdf1');
expect(saved.name).toBe('Book PDF v2');
});
it('does nothing when the user cancels the name prompt', async () => {
mockInvoke([]);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'prompt').mockReturnValue(null);
document.getElementById('save-preset-btn').click();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1); // only the initial get
expect(notify).not.toHaveBeenCalled();
});
it('warns and skips the save when the name is empty', async () => {
mockInvoke([]);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'prompt').mockReturnValue(' ');
document.getElementById('save-preset-btn').click();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith('Preset name cannot be empty.', 'warning');
});
it('warns without throwing when the save IPC call rejects', async () => {
ipcRenderer.invoke.mockImplementation((channel) =>
channel === 'get-export-presets' ? Promise.resolve([]) : Promise.reject(new Error('disk'))
);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'prompt').mockReturnValue('My Preset');
document.getElementById('save-preset-btn').click();
await flush();
expect(notify).toHaveBeenCalledWith('Failed to save preset. Please try again.', 'warning');
});
});
describe('deleting a preset', () => {
it('asks for confirmation and invokes delete-export-preset with the row id', async () => {
mockInvoke([advancedPdfPreset, basicPreset]);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'confirm').mockReturnValue(true);
ipcRenderer.invoke.mockImplementation((channel) =>
channel === 'delete-export-preset'
? Promise.resolve([basicPreset])
: Promise.resolve([advancedPdfPreset, basicPreset])
);
rows()[0].querySelector('.preset-delete').click();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledWith('delete-export-preset', 'preset-pdf1');
expect(rows()).toHaveLength(1);
expect(rows()[0].textContent).toContain('Quick HTML');
});
it('clears the selection when the deleted preset was selected', async () => {
mockInvoke([advancedPdfPreset]);
await refreshExportPresets();
await flush();
rows()[0].querySelector('.preset-row-select').click();
jest.spyOn(window, 'confirm').mockReturnValue(true);
ipcRenderer.invoke.mockImplementation((channel) =>
channel === 'delete-export-preset'
? Promise.resolve([])
: Promise.resolve([advancedPdfPreset])
);
rows()[0].querySelector('.preset-delete').click();
await flush();
expect(document.getElementById('preset-dropdown-toggle').textContent).toBe('Custom Settings');
});
it('does not invoke delete when the user cancels the confirmation', async () => {
mockInvoke([advancedPdfPreset]);
await refreshExportPresets();
await flush();
jest.spyOn(window, 'confirm').mockReturnValue(false);
rows()[0].querySelector('.preset-delete').click();
await flush();
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1); // only the initial get
expect(rows()).toHaveLength(1);
});
});
describe('captureDialogOptions / applyPresetToDialog round-trip', () => {
it('restores exactly what was captured, including reveal.js fields', () => {
buildDialogFixture('revealjs');
initExportPresets({ notify });
document.getElementById('advanced-export-toggle').checked = true;
document.getElementById('reveal-theme').value = 'white';
document.getElementById('reveal-transition').value = 'fade';
document.getElementById('reveal-speed').value = 'fast';
document.getElementById('reveal-slide-number').checked = true;
document.getElementById('reveal-controls').checked = false;
document.getElementById('reveal-progress').checked = false;
document.getElementById('reveal-history').checked = false;
document.getElementById('reveal-center').checked = false;
document.getElementById('csl-file').value = '/styles.csl';
const captured = captureDialogOptions();
buildDialogFixture('revealjs');
initExportPresets({ notify });
applyPresetToDialog({ options: captured });
expect(document.getElementById('reveal-theme').value).toBe('white');
expect(document.getElementById('reveal-transition').value).toBe('fade');
expect(document.getElementById('reveal-speed').value).toBe('fast');
expect(document.getElementById('reveal-slide-number').checked).toBe(true);
expect(document.getElementById('reveal-controls').checked).toBe(false);
expect(document.getElementById('reveal-progress').checked).toBe(false);
expect(document.getElementById('reveal-history').checked).toBe(false);
expect(document.getElementById('reveal-center').checked).toBe(false);
expect(document.getElementById('csl-file').value).toBe('/styles.csl');
});
it('captures a preset geometry as-is and restores it back onto the select', () => {
document.getElementById('advanced-export-toggle').checked = true;
document.getElementById('pdf-geometry').value = 'margin=2in';
const captured = captureDialogOptions();
expect(captured.geometry).toBe('margin=2in');
buildDialogFixture('pdf');
initExportPresets({ notify });
applyPresetToDialog({ options: captured });
expect(document.getElementById('pdf-geometry').value).toBe('margin=2in');
expect(document.getElementById('custom-geometry').style.display).toBe('none');
});
it('captures basic mode without any advanced keys leaking in', () => {
const captured = captureDialogOptions();
expect(captured.advancedMode).toBe(false);
expect(captured.toc).toBe(false);
expect(captured.template).toBeUndefined();
expect(captured.pdfEngine).toBeUndefined();
});
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* Tests for the export-preset persistence module (Task 21).
* The module is pure list logic over the settings store's `exportPresets`
* array; the store is injected, mirroring the SettingsStore test pattern.
*/
const ExportPresets = require('../../src/main/ExportPresets');
function createStore(initial = {}) {
const data = { ...initial };
return {
get: (key, defaultValue) => (data[key] === undefined ? defaultValue : data[key]),
set: (key, value) => {
data[key] = value;
},
data,
};
}
describe('ExportPresets', () => {
describe('loadPresets', () => {
test('returns an empty array when nothing is stored', () => {
expect(ExportPresets.loadPresets(createStore())).toEqual([]);
});
test('returns the stored presets', () => {
const store = createStore({
exportPresets: [{ id: 'p1', name: 'Book PDF', format: 'pdf', options: { toc: true } }],
});
const presets = ExportPresets.loadPresets(store);
expect(presets).toHaveLength(1);
expect(presets[0].name).toBe('Book PDF');
});
test('returns an empty array when the stored value is corrupt (not an array)', () => {
expect(ExportPresets.loadPresets(createStore({ exportPresets: 'garbage' }))).toEqual([]);
expect(ExportPresets.loadPresets(createStore({ exportPresets: { p1: 1 } }))).toEqual([]);
});
test('drops malformed entries (missing id or name) from the stored array', () => {
const store = createStore({
exportPresets: [
{ id: 'p1', name: 'Good', format: 'pdf', options: {} },
{ name: 'No id', options: {} },
{ id: 'p3', options: {} },
null,
],
});
const presets = ExportPresets.loadPresets(store);
expect(presets).toHaveLength(1);
expect(presets[0].id).toBe('p1');
});
});
describe('savePreset', () => {
test('adds a new preset with a generated id and persists it', () => {
const store = createStore();
const presets = ExportPresets.savePreset(store, {
name: 'Book PDF',
format: 'pdf',
options: { toc: true, tocDepth: 3 },
});
expect(presets).toHaveLength(1);
expect(presets[0].id).toMatch(/^preset-/);
expect(presets[0].name).toBe('Book PDF');
expect(presets[0].format).toBe('pdf');
expect(presets[0].options).toEqual({ toc: true, tocDepth: 3 });
expect(store.data.exportPresets).toEqual(presets);
});
test('upserts by id — saving with an existing id replaces the entry', () => {
const store = createStore({
exportPresets: [{ id: 'p1', name: 'Old name', format: 'pdf', options: { toc: false } }],
});
const presets = ExportPresets.savePreset(store, {
id: 'p1',
name: 'New name',
format: 'docx',
options: { toc: true },
});
expect(presets).toHaveLength(1);
expect(presets[0]).toEqual({
id: 'p1',
name: 'New name',
format: 'docx',
options: { toc: true },
});
expect(store.data.exportPresets).toEqual(presets);
});
test('appends when the id is new, preserving existing presets', () => {
const store = createStore({
exportPresets: [{ id: 'p1', name: 'First', format: 'pdf', options: {} }],
});
const presets = ExportPresets.savePreset(store, {
name: 'Second',
format: 'html',
options: {},
});
expect(presets).toHaveLength(2);
expect(presets.map((p) => p.name)).toEqual(['First', 'Second']);
});
test('throws when the preset is not an object', () => {
const store = createStore();
expect(() => ExportPresets.savePreset(store, null)).toThrow('Preset must be an object');
expect(() => ExportPresets.savePreset(store, 'nope')).toThrow('Preset must be an object');
});
test('throws when the name is missing or empty after trimming', () => {
const store = createStore();
expect(() => ExportPresets.savePreset(store, { name: ' ', options: {} })).toThrow(
'Preset name is required'
);
expect(() => ExportPresets.savePreset(store, { options: {} })).toThrow(
'Preset name is required'
);
});
test('trims the name and caps it at 100 characters', () => {
const store = createStore();
const presets = ExportPresets.savePreset(store, { name: ' Spaced ', options: {} });
expect(presets[0].name).toBe('Spaced');
const long = ExportPresets.savePreset(store, { name: 'x'.repeat(150), options: {} });
expect(long[1].name).toHaveLength(100);
});
test('defaults a missing options object to {} and a missing format to null', () => {
const store = createStore();
const presets = ExportPresets.savePreset(store, { name: 'Bare' });
expect(presets[0].options).toEqual({});
expect(presets[0].format).toBeNull();
});
test('refuses to add beyond the preset cap', () => {
const full = Array.from({ length: ExportPresets.MAX_PRESETS }, (_, i) => ({
id: `p${i}`,
name: `Preset ${i}`,
format: 'pdf',
options: {},
}));
const store = createStore({ exportPresets: full });
expect(() => ExportPresets.savePreset(store, { name: 'One too many' })).toThrow(
/more than \d+ export presets/
);
});
test('still allows updating an existing preset when the list is full', () => {
const full = Array.from({ length: ExportPresets.MAX_PRESETS }, (_, i) => ({
id: `p${i}`,
name: `Preset ${i}`,
format: 'pdf',
options: {},
}));
const store = createStore({ exportPresets: full });
const presets = ExportPresets.savePreset(store, { id: 'p7', name: 'Updated' });
expect(presets).toHaveLength(ExportPresets.MAX_PRESETS);
expect(presets.find((p) => p.id === 'p7').name).toBe('Updated');
});
test('generates distinct ids for successive new presets', () => {
const store = createStore();
const a = ExportPresets.savePreset(store, { name: 'A' });
const b = ExportPresets.savePreset(store, { name: 'B' });
expect(a[0].id).not.toBe(b[1].id);
});
});
describe('deletePreset', () => {
test('removes the preset with the given id and returns the updated list', () => {
const store = createStore({
exportPresets: [
{ id: 'p1', name: 'Keep', format: 'pdf', options: {} },
{ id: 'p2', name: 'Drop', format: 'html', options: {} },
],
});
const presets = ExportPresets.deletePreset(store, 'p2');
expect(presets).toHaveLength(1);
expect(presets[0].id).toBe('p1');
expect(store.data.exportPresets).toEqual(presets);
});
test('is idempotent when the id does not exist', () => {
const store = createStore({
exportPresets: [{ id: 'p1', name: 'Keep', format: 'pdf', options: {} }],
});
const presets = ExportPresets.deletePreset(store, 'missing');
expect(presets).toHaveLength(1);
expect(store.data.exportPresets).toHaveLength(1);
});
});
});
+3
View File
@@ -42,6 +42,9 @@ describe('Preload Security', () => {
'browse-header-footer-logo',
'save-header-footer-logo',
'clear-header-footer-logo',
'get-export-presets',
'save-export-preset',
'delete-export-preset',
'get-page-settings',
'update-page-settings',
'process-pdf-operation',