From 7ec8f0709a30be1c98d67d1f2b77da569fcd6944 Mon Sep 17 00:00:00 2001 From: Alexey Fedyunin Date: Tue, 19 May 2026 23:24:01 +0500 Subject: [PATCH] feat: schema-driven UI and worker dispatch by converter id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI and worker now talk to the registry instead of the legacy convert.js engine directly. Adding a converter is one folder under src/core/converters/ — the desktop app auto-renders its options panel from the option schema; no UI changes required to surface it. Renderer - New "Converter" card with a dropdown of registered converters and a per-converter description line. - "Options" card rendered dynamically from the selected converter's options[] schema (boolean -> checkbox, enum -> select, number -> number input, string -> text). Saved per-converter so switching direction keeps each one's last values. - Drop-zone and file picker now filter by the current converter's inputs.extensions (e.g. .mif/.mid for mif-to-xlsx, .kml/.kmz for kml-to-mif). - Removed all converter-specific HTML/JS for the Excel exporter; the same form generator handles both shipped converters. Worker / IPC - worker.js dispatches converters.get(id).run({inputs, output, options}, ctx) and runs converters.validateOptions() before invoking run. - main.js: new IPC channel converters:list returns a serializable lean view of the registry (id, name, description, io extensions, options). selectFiles accepts the current converter's allowed extensions. - preload.js exposes listConverters() and the extension-aware selectFiles(options). - convert:start payload moved from the legacy {inputMode, inputFolder, selectedFiles, recursive, paintRows, ...} bag to the new {converterId, inputs, output, options} contract. Settings - New format v2: shared top-level (language, inputMode, inputFolder, outputFolder, selectedFiles), plus converterId and converterOptions keyed by converter id. - migrate() lifts legacy flat option keys (paintRows, skipBlack, combineIntoOneWorkbook, etc.) into converterOptions['mif-to-xlsx'] so existing users see identical behavior on first launch. - saveSettings stamps the version on every write so future migrations can branch on it. i18n - New shared keys: section.converter, field.converter, hint.optionsEmpty. Subtitle and drop hint reworded to be format-agnostic. English / Russian / Kazakh updated. - Per-option labels currently come from the schema (English). Future PR can add optional i18nKey on options without touching the renderer. Tests: 81 passing (75 + 6 settings migration). Renderer is browser-only JS so it is not unit-tested here; verified via node --check. Manual smoke (run by maintainer): `npm run dev`, pick "MapInfo -> Excel/CSV", confirm the same form as 1.0.4 renders and conversion behavior is unchanged; switch to "KML/KMZ -> MapInfo MIF/MID", confirm the new options (flat, charset, recursive) render and a real KMZ converts. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/settings.js | 77 +++++++++-- src/main/main.js | 43 +++++- src/main/preload.js | 3 +- src/main/worker.js | 34 ++++- src/renderer/i18n.js | 21 ++- src/renderer/index.html | 55 ++------ src/renderer/renderer.js | 263 +++++++++++++++++++++++++++---------- src/renderer/styles.css | 18 +++ test/core/settings.test.js | 126 ++++++++++++++++++ 9 files changed, 503 insertions(+), 137 deletions(-) create mode 100644 test/core/settings.test.js diff --git a/src/core/settings.js b/src/core/settings.js index 24d3819..b6a0c2a 100644 --- a/src/core/settings.js +++ b/src/core/settings.js @@ -1,23 +1,32 @@ const fs = require('fs') const path = require('path') +const SETTINGS_VERSION = 2 + +// Keys that were stored at the root in v1 and belong to mif-to-xlsx options now. +const LEGACY_MIF_TO_XLSX_KEYS = [ + 'recursive', + 'paintRows', + 'skipBlack', + 'combineIntoOneWorkbook', + 'combinedName', + 'includeCsv', + 'includeColorColumn', + 'colorColumnName', + 'freezeHeader', + 'autofilter', +] + function getDefaultSettings() { return { + version: SETTINGS_VERSION, language: 'en', inputMode: 'folder', inputFolder: '', outputFolder: '', selectedFiles: [], - recursive: true, - skipBlack: true, - paintRows: true, - combineIntoOneWorkbook: false, - combinedName: '', - includeCsv: false, - includeColorColumn: true, - colorColumnName: '', - freezeHeader: true, - autofilter: true, + converterId: 'mif-to-xlsx', + converterOptions: {}, } } @@ -29,19 +38,63 @@ function loadSettings(settingsPath) { const raw = fs.readFileSync(settingsPath, 'utf8') const parsed = JSON.parse(raw) - return { ...getDefaultSettings(), ...parsed } + return migrate(parsed) } catch (error) { return getDefaultSettings() } } +function migrate(stored) { + const base = getDefaultSettings() + if (!stored || typeof stored !== 'object') { + return base + } + + // Already on the latest format. + if (stored.version === SETTINGS_VERSION) { + return { + ...base, + ...stored, + converterOptions: { ...stored.converterOptions }, + } + } + + // v1: flat option keys at the root, no converterId, no converterOptions. + const carryOver = { + language: stored.language, + inputMode: stored.inputMode, + inputFolder: stored.inputFolder, + outputFolder: stored.outputFolder, + selectedFiles: stored.selectedFiles, + } + + const mifToXlsxOptions = {} + for (const key of LEGACY_MIF_TO_XLSX_KEYS) { + if (Object.prototype.hasOwnProperty.call(stored, key)) { + mifToXlsxOptions[key] = stored[key] + } + } + + return { + ...base, + ...Object.fromEntries(Object.entries(carryOver).filter(([, v]) => v !== undefined)), + converterId: 'mif-to-xlsx', + converterOptions: Object.keys(mifToXlsxOptions).length + ? { 'mif-to-xlsx': mifToXlsxOptions } + : {}, + } +} + function saveSettings(settingsPath, settings) { fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8') + const payload = { ...settings, version: SETTINGS_VERSION } + fs.writeFileSync(settingsPath, JSON.stringify(payload, null, 2), 'utf8') } module.exports = { getDefaultSettings, loadSettings, saveSettings, + migrate, + SETTINGS_VERSION, } diff --git a/src/main/main.js b/src/main/main.js index 92f0497..a396ccb 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -2,6 +2,7 @@ const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron') const path = require('path') const { Worker } = require('worker_threads') const { loadSettings, saveSettings } = require('../core/settings') +const converters = require('../core/converters') let mainWindow @@ -62,13 +63,11 @@ ipcMain.handle('dialog:selectOutputFolder', async () => { return result.filePaths[0] }) -ipcMain.handle('dialog:selectFiles', async () => { +ipcMain.handle('dialog:selectFiles', async (event, options) => { + const filters = buildFileFilters(options && options.extensions) const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile', 'multiSelections'], - filters: [ - { name: 'MapInfo files', extensions: ['mif', 'mid'] }, - { name: 'All files', extensions: ['*'] }, - ], + filters, }) if (result.canceled || !result.filePaths.length) { @@ -78,6 +77,40 @@ ipcMain.handle('dialog:selectFiles', async () => { return result.filePaths }) +function buildFileFilters(extensions) { + const clean = Array.isArray(extensions) + ? extensions.map((e) => String(e).replace(/^\./, '')).filter(Boolean) + : ['mif', 'mid'] + return [ + { name: clean.join('/').toUpperCase() || 'Files', extensions: clean }, + { name: 'All files', extensions: ['*'] }, + ] +} + +ipcMain.handle('converters:list', async () => { + return converters.list().map(serializeConverter) +}) + +function serializeConverter(c) { + return { + id: c.id, + name: c.name, + description: c.description || '', + inputs: { extensions: [...c.inputs.extensions], type: c.inputs.type }, + outputs: { extensions: [...c.outputs.extensions], type: c.outputs.type }, + options: c.options.map((o) => ({ + key: o.key, + type: o.type, + label: o.label || o.key, + description: o.description || '', + default: o.default, + values: o.values ? [...o.values] : undefined, + min: o.min, + max: o.max, + })), + } +} + ipcMain.handle('settings:load', async () => { return loadSettings(getSettingsPath()) }) diff --git a/src/main/preload.js b/src/main/preload.js index a5bc4e5..6f92e2a 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -3,7 +3,8 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron') contextBridge.exposeInMainWorld('api', { selectInputFolder: () => ipcRenderer.invoke('dialog:selectInputFolder'), selectOutputFolder: () => ipcRenderer.invoke('dialog:selectOutputFolder'), - selectFiles: () => ipcRenderer.invoke('dialog:selectFiles'), + selectFiles: (options) => ipcRenderer.invoke('dialog:selectFiles', options), + listConverters: () => ipcRenderer.invoke('converters:list'), loadSettings: () => ipcRenderer.invoke('settings:load'), saveSettings: (settings) => ipcRenderer.invoke('settings:save', settings), startConversion: (config) => ipcRenderer.invoke('convert:start', config), diff --git a/src/main/worker.js b/src/main/worker.js index 096b899..6d8e699 100644 --- a/src/main/worker.js +++ b/src/main/worker.js @@ -1,14 +1,42 @@ const { parentPort, workerData } = require('worker_threads') -const { runConversion } = require('../core/convert') +const converters = require('../core/converters') const listeners = { log: (message) => parentPort.postMessage({ type: 'log', message }), progress: (payload) => parentPort.postMessage({ type: 'progress', payload }), } -runConversion(workerData.config, listeners) +async function run() { + const { config } = workerData + const { converterId, inputs, output, options } = config + const converter = converters.get(converterId) + + if (!converter) { + throw new Error(`Unknown converter: ${converterId || ''}`) + } + + const validated = converters.validateOptions(converter, options || {}) + if (validated.errors.length) { + throw new Error(`Invalid options:\n ${validated.errors.join('\n ')}`) + } + + return converter.run( + { inputs: inputs || [], output: output || '', options: validated.merged }, + listeners, + ) +} + +run() .then((result) => { - parentPort.postMessage({ type: 'done', result }) + parentPort.postMessage({ + type: 'done', + result: { + outputs: result.outputs || [], + processed: result.stats?.processed || 0, + skipped: result.stats?.skipped || 0, + errors: result.stats?.errors || [], + }, + }) }) .catch((error) => { parentPort.postMessage({ diff --git a/src/renderer/i18n.js b/src/renderer/i18n.js index 9e6e8e7..9da78ad 100644 --- a/src/renderer/i18n.js +++ b/src/renderer/i18n.js @@ -1,7 +1,10 @@ ;(function () { const DICT = { en: { - 'app.subtitle': 'Convert MIF/MID into XLSX with optional row fill from region color.', + 'app.subtitle': 'MapInfo data toolkit. Pick a converter and run it.', + 'section.converter': 'Converter', + 'field.converter': 'Conversion direction', + 'hint.optionsEmpty': 'This converter has no options.', 'section.source': 'Source', 'mode.folder': 'Folder', 'mode.files': 'Files', @@ -34,7 +37,7 @@ 'status.done': 'Done. Processed: {n}', 'status.doneErrors': 'Done with errors. Processed: {n}', 'section.log': 'Log', - 'drop.hint': 'Drop MIF/MID files or a folder here', + 'drop.hint': 'Drop files or a folder here', 'field.language': 'Language', 'field.combinedName': 'Combined workbook file name', 'placeholder.combinedName': 'mapinfo-converted.xlsx', @@ -43,7 +46,10 @@ 'progress.of': '{done} of {total}', }, ru: { - 'app.subtitle': 'Конвертация MIF/MID в XLSX с опциональной заливкой строк по цвету региона.', + 'app.subtitle': 'Набор инструментов для данных MapInfo. Выберите конвертер и запустите.', + 'section.converter': 'Конвертер', + 'field.converter': 'Направление конвертации', + 'hint.optionsEmpty': 'У этого конвертера нет параметров.', 'section.source': 'Источник', 'mode.folder': 'Папка', 'mode.files': 'Файлы', @@ -76,7 +82,7 @@ 'status.done': 'Готово. Обработано: {n}', 'status.doneErrors': 'Завершено с ошибками. Обработано: {n}', 'section.log': 'Журнал', - 'drop.hint': 'Перетащите сюда файлы MIF/MID или папку', + 'drop.hint': 'Перетащите сюда файлы или папку', 'field.language': 'Язык', 'field.combinedName': 'Имя объединённой книги', 'placeholder.combinedName': 'mapinfo-converted.xlsx', @@ -85,7 +91,10 @@ 'progress.of': '{done} из {total}', }, kk: { - 'app.subtitle': 'MIF/MID файлдарын XLSX пішіміне түрлендіру, жол фонын аймақ түсімен бояу мүмкіндігі.', + 'app.subtitle': 'MapInfo деректерімен жұмыс істеуге арналған құралдар жинағы. Конвертерді таңдап, іске қосыңыз.', + 'section.converter': 'Конвертер', + 'field.converter': 'Түрлендіру бағыты', + 'hint.optionsEmpty': 'Бұл конвертерде параметрлер жоқ.', 'section.source': 'Дереккөз', 'mode.folder': 'Қалта', 'mode.files': 'Файлдар', @@ -118,7 +127,7 @@ 'status.done': 'Дайын. Өңделді: {n}', 'status.doneErrors': 'Қателермен аяқталды. Өңделді: {n}', 'section.log': 'Журнал', - 'drop.hint': 'MIF/MID файлдарын немесе қалтаны осы жерге тастаңыз', + 'drop.hint': 'Файлдарды немесе қалтаны осы жерге тастаңыз', 'field.language': 'Тіл', 'field.combinedName': 'Біріктірілген кітап атауы', 'placeholder.combinedName': 'mapinfo-converted.xlsx', diff --git a/src/renderer/index.html b/src/renderer/index.html index f46bcd8..fb1c544 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -12,7 +12,7 @@

MifKit

-

Convert MIF/MID into XLSX with optional row fill from region color.

+

MapInfo data toolkit. Pick a converter and run it.

+

+ +

Source

@@ -49,10 +56,6 @@

Source

- -
Drop MIF/MID files or a folder here
+
Drop files or a folder here
@@ -79,44 +82,8 @@

Output

Options

-
- - - - - - - -
- -
- - -
+
+
diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 14d15a1..3fe5bfa 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -6,28 +6,23 @@ const state = { lastOutputFile: '', saveTimer: null, settingsReady: false, + converters: [], + converterId: '', + converterOptions: {}, } const SAVE_DEBOUNCE_MS = 400 const elements = { languageSelect: document.getElementById('languageSelect'), + converterSelect: document.getElementById('converterSelect'), + converterDescription: document.getElementById('converterDescription'), + optionsContainer: document.getElementById('optionsContainer'), + optionsEmpty: document.getElementById('optionsEmpty'), inputFolder: document.getElementById('inputFolder'), outputFolder: document.getElementById('outputFolder'), selectedFilesSummary: document.getElementById('selectedFilesSummary'), selectedFilesList: document.getElementById('selectedFilesList'), - recursive: document.getElementById('recursive'), - skipBlack: document.getElementById('skipBlack'), - paintRows: document.getElementById('paintRows'), - combineIntoOneWorkbook: document.getElementById('combineIntoOneWorkbook'), - combinedName: document.getElementById('combinedName'), - combinedNameBlock: document.getElementById('combinedNameBlock'), - includeCsv: document.getElementById('includeCsv'), - includeColorColumn: document.getElementById('includeColorColumn'), - colorColumnName: document.getElementById('colorColumnName'), - colorColumnNameBlock: document.getElementById('colorColumnNameBlock'), - freezeHeader: document.getElementById('freezeHeader'), - autofilter: document.getElementById('autofilter'), folderModeBlock: document.getElementById('folderModeBlock'), filesModeBlock: document.getElementById('filesModeBlock'), selectInputFolderButton: document.getElementById('selectInputFolderButton'), @@ -39,6 +34,7 @@ const elements = { logOutput: document.getElementById('logOutput'), status: document.getElementById('status'), sourceCard: document.getElementById('sourceCard'), + dropHint: document.getElementById('dropHint'), progressRow: document.getElementById('progressRow'), progressFill: document.getElementById('progressFill'), progressLabel: document.getElementById('progressLabel'), @@ -48,9 +44,11 @@ window.addEventListener('DOMContentLoaded', async () => { bindEvents() setStatus('status.loading', 'idle') + state.converters = await window.api.listConverters() + populateConverterSelect() + const savedSettings = await window.api.loadSettings() applySettings(savedSettings) - bindAutoSave() state.settingsReady = true attachListeners() setStatus('status.ready', 'idle') @@ -68,12 +66,21 @@ function bindEvents() { const code = elements.languageSelect.value window.i18n.setLanguage(code) refreshStatusFromKey() + renderConverterMeta() + renderOptionsForm() if (!state.selectedFiles.length) { renderSelectedFiles() } scheduleSave() }) + elements.converterSelect.addEventListener('change', () => { + state.converterId = elements.converterSelect.value + renderConverterMeta() + renderOptionsForm() + scheduleSave() + }) + elements.selectInputFolderButton.addEventListener('click', onSelectInputFolder) elements.selectOutputFolderButton.addEventListener('click', onSelectOutputFolder) elements.selectFilesButton.addEventListener('click', onSelectFiles) @@ -86,38 +93,139 @@ function bindEvents() { bindDragAndDrop() } -function bindAutoSave() { - const checkboxes = [ - elements.recursive, - elements.skipBlack, - elements.paintRows, - elements.combineIntoOneWorkbook, - elements.includeCsv, - elements.includeColorColumn, - elements.freezeHeader, - elements.autofilter, - ] +function getCurrentConverter() { + return state.converters.find((c) => c.id === state.converterId) || state.converters[0] +} - for (const cb of checkboxes) { - cb.addEventListener('change', scheduleSave) +function populateConverterSelect() { + const select = elements.converterSelect + select.replaceChildren() + for (const c of state.converters) { + const opt = document.createElement('option') + opt.value = c.id + opt.textContent = c.name + select.appendChild(opt) } +} - elements.combineIntoOneWorkbook.addEventListener('change', updateCombinedNameVisibility) - elements.combinedName.addEventListener('input', scheduleSave) - elements.includeColorColumn.addEventListener('change', updateColorColumnNameVisibility) - elements.colorColumnName.addEventListener('input', scheduleSave) +function renderConverterMeta() { + const converter = getCurrentConverter() + if (!converter) { + elements.converterDescription.textContent = '' + return + } + elements.converterDescription.textContent = converter.description || '' } -function updateCombinedNameVisibility() { - elements.combinedNameBlock.classList.toggle('hidden', !elements.combineIntoOneWorkbook.checked) +function renderOptionsForm() { + const converter = getCurrentConverter() + const container = elements.optionsContainer + container.replaceChildren() + + if (!converter || !converter.options.length) { + elements.optionsEmpty.classList.remove('hidden') + return + } + elements.optionsEmpty.classList.add('hidden') + + const saved = state.converterOptions[converter.id] || {} + for (const option of converter.options) { + container.appendChild(renderOptionField(option, saved[option.key])) + } } -function updateColorColumnNameVisibility() { - elements.colorColumnNameBlock.classList.toggle('hidden', !elements.includeColorColumn.checked) +function renderOptionField(option, savedValue) { + const value = savedValue !== undefined ? savedValue : option.default + + if (option.type === 'boolean') { + const wrapper = document.createElement('label') + wrapper.className = 'checkbox' + const cb = document.createElement('input') + cb.type = 'checkbox' + cb.checked = Boolean(value) + cb.dataset.optionKey = option.key + cb.dataset.optionType = 'boolean' + cb.addEventListener('change', scheduleSave) + wrapper.appendChild(cb) + + const labelText = document.createElement('span') + labelText.textContent = option.label + wrapper.appendChild(labelText) + return wrapper + } + + const wrapper = document.createElement('div') + wrapper.className = 'option-field stack' + + const labelEl = document.createElement('label') + labelEl.className = 'field-label' + labelEl.textContent = option.label + wrapper.appendChild(labelEl) + + let input + if (option.type === 'enum') { + input = document.createElement('select') + for (const v of option.values || []) { + const opt = document.createElement('option') + opt.value = v + opt.textContent = v + input.appendChild(opt) + } + input.value = value != null ? String(value) : '' + } else if (option.type === 'number') { + input = document.createElement('input') + input.type = 'number' + if (option.min !== undefined) input.min = String(option.min) + if (option.max !== undefined) input.max = String(option.max) + input.value = value != null ? String(value) : '' + } else { + input = document.createElement('input') + input.type = 'text' + input.value = value != null ? String(value) : '' + } + input.dataset.optionKey = option.key + input.dataset.optionType = option.type + input.addEventListener('input', scheduleSave) + input.addEventListener('change', scheduleSave) + wrapper.appendChild(input) + + if (option.description) { + const hint = document.createElement('p') + hint.className = 'hint' + hint.textContent = option.description + wrapper.appendChild(hint) + } + return wrapper +} + +function collectOptionsFromForm() { + const converter = getCurrentConverter() + if (!converter) return {} + const result = {} + const inputs = elements.optionsContainer.querySelectorAll('[data-option-key]') + for (const input of inputs) { + const key = input.dataset.optionKey + const type = input.dataset.optionType + if (type === 'boolean') { + result[key] = input.checked + } else if (type === 'number') { + const raw = input.value + const option = converter.options.find((o) => o.key === key) + result[key] = raw === '' ? (option && option.default) : Number(raw) + } else { + result[key] = input.value + } + } + return result } function scheduleSave() { if (!state.settingsReady) return + // capture current form state into per-converter options before saving + const converter = getCurrentConverter() + if (converter) { + state.converterOptions[converter.id] = collectOptionsFromForm() + } if (state.saveTimer) clearTimeout(state.saveTimer) state.saveTimer = setTimeout(saveSettingsNow, SAVE_DEBOUNCE_MS) } @@ -192,6 +300,9 @@ function handleDroppedItems(fileList) { if (!paths.length) return + const converter = getCurrentConverter() + const accepted = converter ? converter.inputs.extensions.map(stripDot) : [] + if (paths.length === 1) { const single = paths[0] if (looksLikeDirectory(single)) { @@ -200,7 +311,7 @@ function handleDroppedItems(fileList) { } const ext = single.split('.').pop().toLowerCase() - if (ext === 'mif' || ext === 'mid') { + if (accepted.includes(ext)) { setFilesMode([single]) return } @@ -209,13 +320,14 @@ function handleDroppedItems(fileList) { return } - const relevant = paths.filter((p) => { - const ext = p.split('.').pop().toLowerCase() - return ext === 'mif' || ext === 'mid' - }) + const relevant = paths.filter((p) => accepted.includes(p.split('.').pop().toLowerCase())) setFilesMode(relevant.length ? relevant : paths) } +function stripDot(ext) { + return String(ext || '').replace(/^\./, '').toLowerCase() +} + function looksLikeDirectory(filePath) { return !/\.[a-z0-9]{1,6}$/i.test(filePath) } @@ -260,23 +372,46 @@ async function onSelectOutputFolder() { } async function onSelectFiles() { - const files = await window.api.selectFiles() + const converter = getCurrentConverter() + const extensions = converter ? converter.inputs.extensions.map(stripDot) : [] + const files = await window.api.selectFiles({ extensions }) state.selectedFiles = files || [] renderSelectedFiles() scheduleSave() } async function onStart() { - const config = collectSettings() + const converter = getCurrentConverter() + if (!converter) { + appendLog('No converter selected', 'error') + return + } + + // ensure latest form state is captured + state.converterOptions[converter.id] = collectOptionsFromForm() + + const inputs = buildInputs() + if (!inputs.length) { + appendLog('No input selected', 'error') + return + } + + const config = { + converterId: converter.id, + inputs, + output: elements.outputFolder.value.trim(), + options: state.converterOptions[converter.id], + } + appendLog('------------------------------', 'info') - appendLog(`Start: ${new Date().toLocaleString()}`, 'info') + appendLog(`Start: ${new Date().toLocaleString()} — ${converter.id}`, 'info') setStatus('status.converting', 'running') setRunning(true) showProgress(true) updateProgress({ total: 0, done: 0 }) elements.openOutputButton.disabled = true state.lastOutputFile = '' - state.lastOutputFolder = '' + state.lastOutputFolder = config.output const response = await window.api.startConversion(config) @@ -301,7 +436,6 @@ async function onStart() { appendLog(` ${output}`, 'info') } state.lastOutputFile = summary.outputs[0] - state.lastOutputFolder = summary.outputFolder || '' elements.openOutputButton.disabled = false } @@ -317,6 +451,14 @@ async function onStart() { setStatus('status.done', 'success', { n: summary.processed }) } +function buildInputs() { + if (getInputMode() === 'folder') { + const folder = elements.inputFolder.value.trim() + return folder ? [folder] : [] + } + return [...state.selectedFiles] +} + async function onOpenOutput() { const target = state.lastOutputFolder || dirname(state.lastOutputFile) if (!target) return @@ -332,20 +474,12 @@ function dirname(filePath) { function collectSettings() { return { language: elements.languageSelect.value, + converterId: state.converterId, + converterOptions: { ...state.converterOptions }, inputMode: getInputMode(), inputFolder: elements.inputFolder.value.trim(), outputFolder: elements.outputFolder.value.trim(), selectedFiles: [...state.selectedFiles], - recursive: elements.recursive.checked, - skipBlack: elements.skipBlack.checked, - paintRows: elements.paintRows.checked, - combineIntoOneWorkbook: elements.combineIntoOneWorkbook.checked, - combinedName: elements.combinedName.value.trim(), - includeCsv: elements.includeCsv.checked, - includeColorColumn: elements.includeColorColumn.checked, - colorColumnName: elements.colorColumnName.value.trim(), - freezeHeader: elements.freezeHeader.checked, - autofilter: elements.autofilter.checked, } } @@ -354,27 +488,23 @@ function applySettings(settings) { elements.languageSelect.value = lang window.i18n.setLanguage(lang) + state.converterOptions = settings.converterOptions ? { ...settings.converterOptions } : {} + const wantedId = settings.converterId + const fallback = state.converters[0] ? state.converters[0].id : '' + state.converterId = state.converters.some((c) => c.id === wantedId) ? wantedId : fallback + elements.converterSelect.value = state.converterId + const inputMode = settings.inputMode || 'folder' const modeRadio = document.querySelector(`input[name="inputMode"][value="${inputMode}"]`) if (modeRadio) modeRadio.checked = true elements.inputFolder.value = settings.inputFolder || '' elements.outputFolder.value = settings.outputFolder || '' - elements.recursive.checked = settings.recursive !== false - elements.skipBlack.checked = settings.skipBlack !== false - elements.paintRows.checked = settings.paintRows !== false - elements.combineIntoOneWorkbook.checked = Boolean(settings.combineIntoOneWorkbook) - elements.combinedName.value = settings.combinedName || '' - updateCombinedNameVisibility() - elements.includeCsv.checked = Boolean(settings.includeCsv) - elements.includeColorColumn.checked = settings.includeColorColumn !== false - elements.colorColumnName.value = settings.colorColumnName || '' - updateColorColumnNameVisibility() - elements.freezeHeader.checked = settings.freezeHeader !== false - elements.autofilter.checked = settings.autofilter !== false state.selectedFiles = Array.isArray(settings.selectedFiles) ? settings.selectedFiles : [] renderSelectedFiles() + renderConverterMeta() + renderOptionsForm() onInputModeChange() } @@ -407,6 +537,7 @@ function setRunning(isRunning) { elements.selectFilesButton.disabled = isRunning elements.selectInputFolderButton.disabled = isRunning elements.selectOutputFolderButton.disabled = isRunning + elements.converterSelect.disabled = isRunning } function showProgress(visible) { diff --git a/src/renderer/styles.css b/src/renderer/styles.css index de88c9c..c556423 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -183,6 +183,24 @@ input[type='text'] { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 18px; + align-items: start; +} + +.option-field { + grid-column: 1 / -1; + display: grid; + gap: 6px; +} + +.option-field input[type="text"], +.option-field input[type="number"], +.option-field select { + width: 100%; +} + +.converter-select { + width: 100%; + margin-top: 6px; } .combined-name-block { diff --git a/test/core/settings.test.js b/test/core/settings.test.js new file mode 100644 index 0000000..46c8958 --- /dev/null +++ b/test/core/settings.test.js @@ -0,0 +1,126 @@ +const test = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const { getDefaultSettings, loadSettings, saveSettings, migrate, SETTINGS_VERSION } = require('../../src/core/settings') + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'mifkit-settings-')) +} + +test('defaults are on the current version with the new shape', () => { + const defaults = getDefaultSettings() + assert.strictEqual(defaults.version, SETTINGS_VERSION) + assert.strictEqual(defaults.converterId, 'mif-to-xlsx') + assert.deepStrictEqual(defaults.converterOptions, {}) +}) + +test('migrate moves legacy flat option keys into converterOptions.mif-to-xlsx', () => { + const legacy = { + language: 'ru', + inputMode: 'files', + inputFolder: '/x', + outputFolder: '/y', + selectedFiles: ['/a.mif'], + recursive: false, + skipBlack: true, + paintRows: false, + combineIntoOneWorkbook: true, + combinedName: 'merged', + includeCsv: true, + includeColorColumn: false, + colorColumnName: 'hex', + freezeHeader: false, + autofilter: false, + } + + const migrated = migrate(legacy) + assert.strictEqual(migrated.version, SETTINGS_VERSION) + assert.strictEqual(migrated.converterId, 'mif-to-xlsx') + assert.strictEqual(migrated.language, 'ru') + assert.strictEqual(migrated.inputMode, 'files') + assert.deepStrictEqual(migrated.selectedFiles, ['/a.mif']) + assert.deepStrictEqual(migrated.converterOptions['mif-to-xlsx'], { + recursive: false, + skipBlack: true, + paintRows: false, + combineIntoOneWorkbook: true, + combinedName: 'merged', + includeCsv: true, + includeColorColumn: false, + colorColumnName: 'hex', + freezeHeader: false, + autofilter: false, + }) +}) + +test('migrate passes through already-current settings without losing data', () => { + const current = { + version: SETTINGS_VERSION, + language: 'kk', + converterId: 'kml-to-mif', + converterOptions: { + 'kml-to-mif': { flat: true, charset: 'Neutral' }, + 'mif-to-xlsx': { paintRows: true }, + }, + inputMode: 'folder', + inputFolder: '/in', + outputFolder: '/out', + selectedFiles: [], + } + + const migrated = migrate(current) + assert.strictEqual(migrated.converterId, 'kml-to-mif') + assert.deepStrictEqual(migrated.converterOptions, current.converterOptions) + // confirm it's a copy, not the same reference, so callers can mutate safely + assert.notStrictEqual(migrated.converterOptions, current.converterOptions) +}) + +test('migrate returns defaults on garbage input', () => { + assert.deepStrictEqual(migrate(null), getDefaultSettings()) + assert.deepStrictEqual(migrate('not an object'), getDefaultSettings()) +}) + +test('loadSettings + saveSettings round-trip writes the version field', () => { + const dir = mkTmp() + const file = path.join(dir, 'settings.json') + saveSettings(file, { + language: 'en', + converterId: 'kml-to-mif', + converterOptions: { 'kml-to-mif': { flat: true } }, + inputMode: 'folder', + inputFolder: '', + outputFolder: '', + selectedFiles: [], + }) + + const raw = JSON.parse(fs.readFileSync(file, 'utf8')) + assert.strictEqual(raw.version, SETTINGS_VERSION) + + const loaded = loadSettings(file) + assert.strictEqual(loaded.converterId, 'kml-to-mif') + assert.deepStrictEqual(loaded.converterOptions['kml-to-mif'], { flat: true }) +}) + +test('loadSettings of a v1 file on disk auto-migrates', () => { + const dir = mkTmp() + const file = path.join(dir, 'settings.json') + fs.writeFileSync(file, JSON.stringify({ + language: 'ru', + inputMode: 'folder', + paintRows: false, + skipBlack: true, + colorColumnName: 'col', + })) + + const loaded = loadSettings(file) + assert.strictEqual(loaded.version, SETTINGS_VERSION) + assert.strictEqual(loaded.converterId, 'mif-to-xlsx') + assert.deepStrictEqual(loaded.converterOptions['mif-to-xlsx'], { + paintRows: false, + skipBlack: true, + colorColumnName: 'col', + }) +})