From bc47316746c12f6a552b2525b9cc995409680716 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sun, 23 Aug 2026 15:04:35 +0530 Subject: [PATCH] fix(export): one-time import of legacy localStorage export profiles into presets --- src/renderer/export-presets.js | 94 ++++++++++++++++++ tests/export-presets-dialog.test.js | 143 ++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) diff --git a/src/renderer/export-presets.js b/src/renderer/export-presets.js index c40e33d..b73de0c 100644 --- a/src/renderer/export-presets.js +++ b/src/renderer/export-presets.js @@ -22,6 +22,10 @@ const { ipcRenderer } = require('electron'); +// localStorage key of the pre-4.x renderer-only "export profiles" that the +// preset system replaced; consumed once by importLegacyProfiles(). +const LEGACY_PROFILES_KEY = 'exportProfiles'; + let currentPresets = []; let selectedPresetId = null; let notify = (message, type) => console.warn(`Export presets (${type}): ${message}`); @@ -407,6 +411,90 @@ function createPresetId() { return `preset-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } +// ============================================ +// One-time import of legacy localStorage profiles +// ============================================ + +/** + * Map one legacy export profile onto the preset options shape captured by + * captureDialogOptions(). Legacy shape (saveCurrentProfile at git 52ef5b4): + * { format, advancedMode, pageSize, pageOrientation, basicToc, + * basicNumberSections } plus, in advanced mode, { template, toc, tocDepth, + * numberSections, citeproc, pdfEngine, pdfGeometry } — all raw select/input + * values. Two fidelity limits of the legacy format itself: the custom + * template PATH and the custom geometry TEXT were never persisted (only the + * literal select value 'custom'), so those map back to the dialog defaults. + * @param {Object} profile legacy profile value + * @returns {Object} preset options snapshot + */ +function mapLegacyProfileOptions(profile) { + const advancedMode = profile.advancedMode === true; + const options = { + advancedMode, + pageSize: typeof profile.pageSize === 'string' ? profile.pageSize : 'a4', + pageOrientation: + typeof profile.pageOrientation === 'string' ? profile.pageOrientation : 'portrait', + }; + + if (!advancedMode) { + // Basic mode maps like collectExportOptions: basicToc -> toc, + // basicNumberSections -> numberSections. + options.toc = profile.basicToc === true; + options.numberSections = profile.basicNumberSections === true; + return options; + } + + options.template = 'default'; + options.metadata = {}; + options.toc = profile.toc === true; + options.tocDepth = + typeof profile.tocDepth === 'string' && profile.tocDepth ? profile.tocDepth : '3'; + options.numberSections = profile.numberSections === true; + options.citeproc = profile.citeproc === true; + options.pdfEngine = typeof profile.pdfEngine === 'string' ? profile.pdfEngine : 'xelatex'; + options.geometry = + typeof profile.pdfGeometry === 'string' && profile.pdfGeometry !== 'custom' + ? profile.pdfGeometry + : 'margin=1in'; + return options; +} + +/** + * Import the legacy localStorage export profiles into the main-process preset + * store via save-export-preset, then remove the legacy key so the import runs + * only once. Ids are deterministic (`preset-legacy-`), so an import + * interrupted midway retries as an upsert on the next launch instead of + * duplicating presets. Malformed or unexpected data degrades to "skip import" + * — it must never break dialog init. + * @returns {Promise} true when at least one preset was imported + */ +async function importLegacyProfiles() { + try { + const raw = localStorage.getItem(LEGACY_PROFILES_KEY); + if (!raw) return false; + const legacy = JSON.parse(raw); + if (!legacy || typeof legacy !== 'object' || Array.isArray(legacy)) return false; + + let imported = false; + for (const name of Object.keys(legacy)) { + const profile = legacy[name]; + if (!name.trim() || !profile || typeof profile !== 'object') continue; + await ipcRenderer.invoke('save-export-preset', { + id: `preset-legacy-${name}`, + name, + format: typeof profile.format === 'string' ? profile.format : null, + options: mapLegacyProfileOptions(profile), + }); + imported = true; + } + localStorage.removeItem(LEGACY_PROFILES_KEY); + return imported; + } catch (error) { + console.error('Skipping legacy export profile import:', error); + return false; + } +} + /** * Wire the preset section of the export dialog. Call once after DOM ready. * @param {{notify?: Function}} options hooks from renderer.js @@ -420,6 +508,12 @@ function initExportPresets(options = {}) { if (toggle) toggle.addEventListener('click', toggleDropdown); const list = elementById('preset-dropdown-list'); if (list) list.addEventListener('click', handleListClick); + + // One-time legacy import; refresh afterwards so imported presets are + // visible even if the dialog is already open. + importLegacyProfiles().then((imported) => { + if (imported) refreshExportPresets(); + }); } module.exports = { diff --git a/tests/export-presets-dialog.test.js b/tests/export-presets-dialog.test.js index a13c33c..5ebaa22 100644 --- a/tests/export-presets-dialog.test.js +++ b/tests/export-presets-dialog.test.js @@ -527,4 +527,147 @@ describe('Export presets dialog', () => { expect(captured.pdfEngine).toBeUndefined(); }); }); + + describe('one-time import of legacy localStorage export profiles', () => { + const legacyBlob = JSON.stringify({ + 'Quick HTML': { + format: 'html', + advancedMode: false, + pageSize: 'letter', + pageOrientation: 'portrait', + basicToc: true, + basicNumberSections: false, + }, + 'Book PDF': { + format: 'pdf', + advancedMode: true, + pageSize: 'a4', + pageOrientation: 'landscape', + basicToc: false, + basicNumberSections: false, + template: 'custom', + toc: true, + tocDepth: '4', + numberSections: true, + citeproc: false, + pdfEngine: 'lualatex', + pdfGeometry: 'custom', + }, + }); + + function saveCalls() { + return ipcRenderer.invoke.mock.calls.filter((call) => call[0] === 'save-export-preset'); + } + + function mockStoredValue(value) { + jest.spyOn(window.Storage.prototype, 'getItem').mockReturnValue(value); + return jest.spyOn(window.Storage.prototype, 'removeItem'); + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('imports every legacy profile through save-export-preset with the mapped shape', async () => { + const removeItem = mockStoredValue(legacyBlob); + ipcRenderer.invoke.mockResolvedValue([]); + + initExportPresets({ notify }); + await flush(); + await flush(); + + const calls = saveCalls(); + expect(calls).toHaveLength(2); + + const quickHtml = calls.find(([, preset]) => preset.name === 'Quick HTML')[1]; + expect(quickHtml.id).toBe('preset-legacy-Quick HTML'); + expect(quickHtml.format).toBe('html'); + expect(quickHtml.options).toEqual({ + advancedMode: false, + pageSize: 'letter', + pageOrientation: 'portrait', + toc: true, // basicToc -> toc (basic branch of collectExportOptions) + numberSections: false, // basicNumberSections -> numberSections + }); + + const bookPdf = calls.find(([, preset]) => preset.name === 'Book PDF')[1]; + expect(bookPdf.id).toBe('preset-legacy-Book PDF'); + expect(bookPdf.format).toBe('pdf'); + expect(bookPdf.options).toEqual({ + advancedMode: true, + pageSize: 'a4', + pageOrientation: 'landscape', + template: 'default', // legacy stored the raw select value; path was never persisted + metadata: {}, + toc: true, + tocDepth: '4', + numberSections: true, + citeproc: false, + pdfEngine: 'lualatex', + geometry: 'margin=1in', // legacy 'custom' literal had no restorable text -> default + }); + + // Import must never run twice. + expect(removeItem).toHaveBeenCalledWith('exportProfiles'); + // Presets are refreshed after a successful import so the dropdown is current. + expect(ipcRenderer.invoke).toHaveBeenCalledWith('get-export-presets'); + }); + + it('keeps the legacy key when a save fails, so a retry upserts instead of duplicating', async () => { + const removeItem = mockStoredValue(legacyBlob); + ipcRenderer.invoke.mockRejectedValue(new Error('disk full')); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + initExportPresets({ notify }); + await flush(); + await flush(); + + expect(saveCalls()).toHaveLength(1); + expect(removeItem).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('skips gracefully on a malformed legacy blob without breaking dialog init', async () => { + mockStoredValue('{"Quick HTML": not valid json'); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(window, 'prompt').mockReturnValue(null); + + initExportPresets({ notify }); + await flush(); + await flush(); + + expect(saveCalls()).toHaveLength(0); + expect(jest.mocked(window.Storage.prototype.removeItem)).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + + // Dialog init unaffected: the save button still works. + document.getElementById('save-preset-btn').click(); + await flush(); + expect(ipcRenderer.invoke).toHaveBeenCalledTimes(0); + expect(notify).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('skips a legacy blob that is valid JSON but not a profile map', async () => { + mockStoredValue(JSON.stringify(['not', 'a', 'map'])); + initExportPresets({ notify }); + await flush(); + await flush(); + + expect(saveCalls()).toHaveLength(0); + expect(jest.mocked(window.Storage.prototype.removeItem)).not.toHaveBeenCalled(); + expect(ipcRenderer.invoke).toHaveBeenCalledTimes(0); + }); + + it('is a no-op when the legacy key is absent', async () => { + mockStoredValue(null); + initExportPresets({ notify }); + await flush(); + await flush(); + + expect(ipcRenderer.invoke).toHaveBeenCalledTimes(0); + expect(jest.mocked(window.Storage.prototype.removeItem)).not.toHaveBeenCalled(); + }); + }); });