+
+
+
+
+
+
+
Save and reuse your favorite export configurations
diff --git a/src/main.js b/src/main.js
index a663dfe..9504059 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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);
diff --git a/src/main/ExportPresets.js b/src/main/ExportPresets.js
new file mode 100644
index 0000000..5c4a15d
--- /dev/null
+++ b/src/main/ExportPresets.js
@@ -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 };
diff --git a/src/preload.js b/src/preload.js
index 4a2e029..5060faa 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -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',
diff --git a/src/renderer.js b/src/renderer.js
index 221a6af..6466bc8 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -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', () => {
diff --git a/src/renderer/export-presets.js b/src/renderer/export-presets.js
new file mode 100644
index 0000000..c40e33d
--- /dev/null
+++ b/src/renderer/export-presets.js
@@ -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