From bc0ce1ac7b719888a0afd1295ff6e310b4cf60e7 Mon Sep 17 00:00:00 2001 From: Alexey Fedyunin Date: Tue, 19 May 2026 22:42:06 +0500 Subject: [PATCH] feat: rebrand to MifKit and add Converter registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the project from MifMapXL to MifKit so additional converters (KML/KMZ, GeoJSON, Shapefile, ...) can live alongside the existing Excel exporter without the name implying a single-purpose tool. Adds a Converter contract and registry under src/core/converters/: - types.js JSDoc contract (id, inputs, outputs, options, run) - registry.js register / get / list / applyDefaults / validateOptions - index.js auto-registers all built-in converters - mif-to-xlsx/ first converter, wraps the existing convert.js engine so behavior is byte-identical to 1.0.4 GUI and CLI will dispatch through the registry; new converters become one folder + one fixture + one registry entry. UI and worker are not touched in this PR — wiring them through the registry comes next, once the second converter (kml-to-mif) is in place to validate the contract. Rename touches: package.json (name, version 1.1.0, appId, productName, artifactName), main window title, renderer header, README (with converter roadmap table and a note that old GitHub URLs auto-redirect), PROJECT_CONTEXT.md preamble, and tmpdir prefixes in two tests. Tests: 55 existing + 7 new (registry validation, default merging, converter dispatch through registry, file-list input mode). All green. Co-Authored-By: Claude Opus 4.7 (1M context) --- PROJECT_CONTEXT.md | 4 +- README.md | 83 ++++++++----- package.json | 10 +- src/core/converters/index.js | 19 +++ src/core/converters/mif-to-xlsx/index.js | 69 +++++++++++ src/core/converters/registry.js | 131 ++++++++++++++++++++ src/core/converters/types.js | 47 +++++++ src/main/main.js | 2 +- src/renderer/index.html | 4 +- test/core/csv.test.js | 2 +- test/core/registry.test.js | 91 ++++++++++++++ test/integration/convert.test.js | 2 +- test/integration/converter-dispatch.test.js | 76 ++++++++++++ 13 files changed, 499 insertions(+), 41 deletions(-) create mode 100644 src/core/converters/index.js create mode 100644 src/core/converters/mif-to-xlsx/index.js create mode 100644 src/core/converters/registry.js create mode 100644 src/core/converters/types.js create mode 100644 test/core/registry.test.js create mode 100644 test/integration/converter-dispatch.test.js diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 85a11dd..785692e 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -2,7 +2,9 @@ ## Project name -MifMapXL — MapInfo MIF/MID → Excel/CSV desktop converter +MifKit — MapInfo data toolkit (desktop GUI + CLI). Renamed from MifMapXL in 1.1.0. + +The notes below describe the original `mif-to-xlsx` feature, which remains the first converter. Newer converters (KML/KMZ ↔ MapInfo, GeoJSON, Shapefile, etc.) are added as additional entries in `src/core/converters/` under the same Converter contract — see `src/core/converters/types.js` and `registry.js`. ## Purpose diff --git a/README.md b/README.md index 7aa5c02..e038a05 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,25 @@ -# MifMapXL +# MifKit -[![Latest release](https://img.shields.io/github/v/release/fedyunin/MifMapXL)](https://github.com/fedyunin/MifMapXL/releases/latest) -[![Tests](https://github.com/fedyunin/MifMapXL/actions/workflows/test.yml/badge.svg)](https://github.com/fedyunin/MifMapXL/actions/workflows/test.yml) +[![Latest release](https://img.shields.io/github/v/release/fedyunin/mifkit)](https://github.com/fedyunin/mifkit/releases/latest) +[![Tests](https://github.com/fedyunin/mifkit/actions/workflows/test.yml/badge.svg)](https://github.com/fedyunin/mifkit/actions/workflows/test.yml) -Desktop app on Electron for converting MapInfo `.mif/.mid` pairs into `.xlsx` with a new `region_color_hex` column and optional row fill. +MapInfo data toolkit. A growing set of converters for MapInfo `.mif/.mid` and adjacent geo formats (KML/KMZ, GeoJSON, Shapefile, Excel) — packaged as a desktop GUI (Electron) and a CLI built on the same engine. + +> Renamed from **MifMapXL** in 1.1.0. Old GitHub URLs auto-redirect. The Excel export feature is unchanged — it is now one converter (`mif-to-xlsx`) inside a pluggable registry. + +## Converters + +| id | direction | status | +|-----------------|--------------------------------------------|------------| +| `mif-to-xlsx` | MapInfo MIF/MID → Excel (`.xlsx`, `.csv`) | shipped | +| `kml-to-mif` | KML/KMZ → MapInfo MIF/MID | planned | +| `mif-to-kml` | MapInfo MIF/MID → KML/KMZ | planned | +| `mif-to-geojson`| MapInfo MIF/MID → GeoJSON | planned | +| `geojson-to-mif`| GeoJSON → MapInfo MIF/MID | planned | +| `shp-to-mif` | Shapefile → MapInfo MIF/MID | planned | +| `mif-to-shp` | MapInfo MIF/MID → Shapefile | planned | + +Adding a converter is one folder under `src/core/converters/`, one test fixture, and one entry in the registry — it then appears automatically in the GUI and CLI. ## Download @@ -11,27 +27,27 @@ The links below always point to the latest published release. | Platform | File | | --- | --- | -| **Windows** — installer | [MifMapXL-win-x64-setup.exe](https://github.com/fedyunin/MifMapXL/releases/latest/download/MifMapXL-win-x64-setup.exe) | -| **Windows** — portable | [MifMapXL-win-x64-portable.exe](https://github.com/fedyunin/MifMapXL/releases/latest/download/MifMapXL-win-x64-portable.exe) | -| **macOS** — Apple Silicon | [MifMapXL-mac-arm64.dmg](https://github.com/fedyunin/MifMapXL/releases/latest/download/MifMapXL-mac-arm64.dmg) | -| **Linux** — Debian/Ubuntu | [MifMapXL-linux-amd64.deb](https://github.com/fedyunin/MifMapXL/releases/latest/download/MifMapXL-linux-amd64.deb) | -| **Linux** — AppImage | [MifMapXL-linux-x86_64.AppImage](https://github.com/fedyunin/MifMapXL/releases/latest/download/MifMapXL-linux-x86_64.AppImage) | +| **Windows** — installer | [MifKit-win-x64-setup.exe](https://github.com/fedyunin/mifkit/releases/latest/download/MifKit-win-x64-setup.exe) | +| **Windows** — portable | [MifKit-win-x64-portable.exe](https://github.com/fedyunin/mifkit/releases/latest/download/MifKit-win-x64-portable.exe) | +| **macOS** — Apple Silicon | [MifKit-mac-arm64.dmg](https://github.com/fedyunin/mifkit/releases/latest/download/MifKit-mac-arm64.dmg) | +| **Linux** — Debian/Ubuntu | [MifKit-linux-amd64.deb](https://github.com/fedyunin/mifkit/releases/latest/download/MifKit-linux-amd64.deb) | +| **Linux** — AppImage | [MifKit-linux-x86_64.AppImage](https://github.com/fedyunin/mifkit/releases/latest/download/MifKit-linux-x86_64.AppImage) | -All builds from the [releases page](https://github.com/fedyunin/MifMapXL/releases). +All builds from the [releases page](https://github.com/fedyunin/mifkit/releases). ### macOS first run -Builds are unsigned. macOS will refuse to open the app with “MifMapXL.app is damaged”. Remove the quarantine attribute once: +Builds are unsigned. macOS will refuse to open the app with “MifKit.app is damaged”. Remove the quarantine attribute once: ```bash -xattr -dr com.apple.quarantine /Applications/MifMapXL.app +xattr -dr com.apple.quarantine /Applications/MifKit.app ``` ### Windows SmartScreen Unsigned Windows builds may show “Windows protected your PC”. Click **More info → Run anyway**. -## Features +## `mif-to-xlsx` features - choose either a folder or specific files - recursive folder scan @@ -56,23 +72,30 @@ npm run dist # build for the host platform Artifacts are written to `dist/`. -## Notes +## Project structure -- the app reads column names from the `Columns` section in `.mif` -- the app reads row data from `.mid` -- the app extracts fill color from `Brush(pattern,color,bg)` lines in `.mif` -- if a row has no matching brush color, the `region_color_hex` cell is left empty -- if `Skip black color` is enabled, `#000000` rows are not painted +``` +src/ + main/ Electron main process, IPC, worker orchestration + main.js · preload.js · worker.js + renderer/ Desktop UI (HTML + vanilla JS + CSS, i18n) + index.html · renderer.js · styles.css · i18n.js + core/ + convert.js legacy orchestration for mif-to-xlsx (will fold into the converter) + mif.js · mid.js MapInfo MIF/MID parsers + excel.js · csv.js output writers + files.js folder scan, MIF/MID pairing + encoding.js charset detection via iconv-lite + settings.js settings persistence + converters/ + registry.js register / get / list / validateOptions + types.js JSDoc Converter contract + index.js auto-registers all converters + mif-to-xlsx/ first converter (wraps convert.js for now) +test/ + core/ · integration/ · fixtures/ node:test suite, runs on every PR +``` -## Project structure +## Architecture notes -- `src/main/main.js` — Electron main process, dialogs, settings IPC, worker orchestration -- `src/main/preload.js` — context-isolated renderer bridge -- `src/main/worker.js` — `worker_threads` entry; runs the conversion off the main process -- `src/renderer/index.html` · `renderer.js` · `styles.css` — UI -- `src/core/convert.js` — orchestration of scan → parse → export -- `src/core/mif.js` · `mid.js` — MapInfo MIF/MID parsers -- `src/core/excel.js` · `csv.js` — output writers -- `src/core/files.js` — folder scan, MIF/MID pairing, output path resolution -- `src/core/encoding.js` — charset detection and decoding via `iconv-lite` -- `src/core/settings.js` — defaults and settings persistence helpers +Every converter exports a plain object: `id`, `name`, `inputs/outputs` shape, declarative `options[]` schema, and an async `run({inputs, output, options}, ctx)`. The GUI builds its options panel from the schema; the CLI parses flags from the same schema. Both call the same `run` so behavior is identical regardless of the entry point. diff --git a/package.json b/package.json index 5ee544c..6064ed7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "mifmapxl", - "version": "1.0.4", - "description": "Desktop app to convert MapInfo MIF/MID files to Excel with region row colors.", + "name": "mifkit", + "version": "1.1.0", + "description": "MapInfo data toolkit — convert MIF/MID to Excel, KML/KMZ to MapInfo, and other geo formats. Desktop GUI and CLI.", "main": "src/main/main.js", "author": { "name": "Alexey Fedyunin", @@ -25,8 +25,8 @@ "electron-builder": "^26.0.12" }, "build": { - "appId": "com.fedyunin.mifmapxl", - "productName": "MifMapXL", + "appId": "com.fedyunin.mifkit", + "productName": "MifKit", "artifactName": "${productName}-${os}-${arch}.${ext}", "directories": { "output": "dist" diff --git a/src/core/converters/index.js b/src/core/converters/index.js new file mode 100644 index 0000000..f9c7826 --- /dev/null +++ b/src/core/converters/index.js @@ -0,0 +1,19 @@ +const registry = require('./registry') +const mifToXlsx = require('./mif-to-xlsx') + +let initialized = false + +function ensureInitialized() { + if (initialized) { + return + } + registry.register(mifToXlsx) + initialized = true +} + +ensureInitialized() + +module.exports = { + ...registry, + ensureInitialized, +} diff --git a/src/core/converters/mif-to-xlsx/index.js b/src/core/converters/mif-to-xlsx/index.js new file mode 100644 index 0000000..2bd2f56 --- /dev/null +++ b/src/core/converters/mif-to-xlsx/index.js @@ -0,0 +1,69 @@ +const fs = require('fs') +const path = require('path') + +const { runConversion } = require('../../convert') + +/** + * mif-to-xlsx — MapInfo MIF/MID -> Excel/CSV. + * + * Wraps the existing convert.js engine without changing behavior. The new + * Converter contract lets the GUI/CLI dispatch through the registry; under + * the hood we still call runConversion(config, listeners). + * + * @type {import('../types').Converter} + */ +const converter = { + id: 'mif-to-xlsx', + name: 'MapInfo → Excel/CSV', + description: 'Convert MapInfo MIF/MID pairs to .xlsx (and optional .csv) with extracted region colors and optional row painting.', + inputs: { extensions: ['.mif'], type: 'file-or-folder' }, + outputs: { extensions: ['.xlsx', '.csv'], type: 'folder' }, + options: [ + { key: 'recursive', type: 'boolean', default: true, label: 'Scan subfolders' }, + { key: 'paintRows', type: 'boolean', default: true, label: 'Paint rows with region color' }, + { key: 'skipBlack', type: 'boolean', default: true, label: 'Skip black fill (#000000)' }, + { key: 'includeColorColumn', type: 'boolean', default: true, label: 'Include color column' }, + { key: 'colorColumnName', type: 'string', default: 'region_color_hex', label: 'Color column name' }, + { key: 'combineIntoOneWorkbook', type: 'boolean', default: false, label: 'Merge into one workbook' }, + { key: 'combinedName', type: 'string', default: 'mapinfo-converted', label: 'Combined workbook name' }, + { key: 'includeCsv', type: 'boolean', default: false, label: 'Also export CSV' }, + { key: 'freezeHeader', type: 'boolean', default: false, label: 'Freeze header row' }, + { key: 'autofilter', type: 'boolean', default: false, label: 'Enable autofilter' }, + ], + async run({ inputs, output, options }, ctx) { + const config = buildLegacyConfig(inputs, output, options) + const listeners = { log: ctx.log, progress: ctx.progress } + const summary = await runConversion(config, listeners) + return { + outputs: summary.outputs || [], + stats: { + processed: summary.processed || 0, + skipped: summary.skipped || 0, + errors: summary.errors || [], + }, + } + }, +} + +function buildLegacyConfig(inputs, output, options) { + const paths = (inputs || []).map((p) => path.resolve(p)) + const folderInput = paths.length === 1 && safeIsDirectory(paths[0]) + + return { + inputMode: folderInput ? 'folder' : 'files', + inputFolder: folderInput ? paths[0] : '', + selectedFiles: folderInput ? [] : paths, + outputFolder: output || '', + ...options, + } +} + +function safeIsDirectory(p) { + try { + return fs.statSync(p).isDirectory() + } catch (error) { + return false + } +} + +module.exports = converter diff --git a/src/core/converters/registry.js b/src/core/converters/registry.js new file mode 100644 index 0000000..833cd99 --- /dev/null +++ b/src/core/converters/registry.js @@ -0,0 +1,131 @@ +const converters = new Map() + +function register(converter) { + assertConverterShape(converter) + + if (converters.has(converter.id)) { + throw new Error(`Converter already registered: ${converter.id}`) + } + + converters.set(converter.id, Object.freeze({ ...converter, options: Object.freeze(converter.options.slice()) })) + return converter +} + +function unregister(id) { + return converters.delete(id) +} + +function get(id) { + return converters.get(id) || null +} + +function list() { + return Array.from(converters.values()) +} + +function clear() { + converters.clear() +} + +function applyDefaults(converter, options) { + const result = {} + + for (const option of converter.options) { + if (options && Object.prototype.hasOwnProperty.call(options, option.key)) { + result[option.key] = options[option.key] + } else if (option.default !== undefined) { + result[option.key] = option.default + } + } + + if (options) { + for (const key of Object.keys(options)) { + if (!Object.prototype.hasOwnProperty.call(result, key)) { + result[key] = options[key] + } + } + } + + return result +} + +function validateOptions(converter, options) { + const errors = [] + const merged = applyDefaults(converter, options) + + for (const option of converter.options) { + const value = merged[option.key] + + if (value === undefined) { + continue + } + + if (option.type === 'boolean' && typeof value !== 'boolean') { + errors.push(`Option "${option.key}" must be boolean`) + continue + } + + if (option.type === 'string' && typeof value !== 'string') { + errors.push(`Option "${option.key}" must be string`) + continue + } + + if (option.type === 'number') { + if (typeof value !== 'number' || !Number.isFinite(value)) { + errors.push(`Option "${option.key}" must be a finite number`) + continue + } + if (option.min !== undefined && value < option.min) { + errors.push(`Option "${option.key}" must be >= ${option.min}`) + } + if (option.max !== undefined && value > option.max) { + errors.push(`Option "${option.key}" must be <= ${option.max}`) + } + } + + if (option.type === 'enum') { + if (!Array.isArray(option.values) || !option.values.includes(value)) { + errors.push(`Option "${option.key}" must be one of: ${(option.values || []).join(', ')}`) + } + } + } + + return { merged, errors } +} + +function assertConverterShape(converter) { + if (!converter || typeof converter !== 'object') { + throw new Error('Converter must be an object') + } + if (!converter.id || typeof converter.id !== 'string') { + throw new Error('Converter.id must be a non-empty string') + } + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(converter.id)) { + throw new Error(`Converter.id must be kebab-case: ${converter.id}`) + } + if (typeof converter.name !== 'string' || !converter.name) { + throw new Error(`Converter ${converter.id} must have a name`) + } + if (!converter.inputs || !Array.isArray(converter.inputs.extensions)) { + throw new Error(`Converter ${converter.id} must declare inputs.extensions`) + } + if (!converter.outputs || !Array.isArray(converter.outputs.extensions)) { + throw new Error(`Converter ${converter.id} must declare outputs.extensions`) + } + if (!Array.isArray(converter.options)) { + throw new Error(`Converter ${converter.id} must declare options array`) + } + if (typeof converter.run !== 'function') { + throw new Error(`Converter ${converter.id} must implement run()`) + } +} + +module.exports = { + register, + unregister, + get, + list, + clear, + applyDefaults, + validateOptions, +} diff --git a/src/core/converters/types.js b/src/core/converters/types.js new file mode 100644 index 0000000..de998a6 --- /dev/null +++ b/src/core/converters/types.js @@ -0,0 +1,47 @@ +/** + * Converter contract used by the registry, GUI, and CLI. + * + * Every converter is a plain object that describes its identity, input/output + * shape, declarative option schema, and an async `run(input, context)` method. + * GUI builds the options panel from the schema; CLI parses flags from it; both + * paths converge on the same `run` call. + * + * @typedef {Object} ConverterOption + * @property {string} key Stable identifier used in code, CLI flag, and config object. + * @property {'boolean'|'string'|'enum'|'number'} type + * @property {string} [label] Human-readable label for GUI. + * @property {string} [description] Longer help text. + * @property {*} [default] Default value when the user does not set it. + * @property {string[]} [values] Allowed values for `enum` type. + * @property {number} [min] Minimum for `number` type. + * @property {number} [max] Maximum for `number` type. + * + * @typedef {Object} ConverterIO + * @property {string[]} extensions Lowercase file extensions including the dot. + * @property {'file'|'folder'|'file-or-folder'} type + * + * @typedef {Object} ConverterContext + * @property {(message: string) => void} log + * @property {(payload: { done: number, total: number, currentFile?: string }) => void} progress + * @property {AbortSignal} [signal] + * + * @typedef {Object} ConverterRunInput + * @property {string[]} inputs Absolute paths the user selected (files and/or one folder). + * @property {string} output Absolute path of the output folder. + * @property {Object} options Already merged with defaults from the schema. + * + * @typedef {Object} ConverterRunResult + * @property {string[]} outputs Paths of files the converter created. + * @property {{ processed: number, skipped: number, errors: Array<{file: string, error: string}> }} stats + * + * @typedef {Object} Converter + * @property {string} id Stable kebab-case id (e.g. "kml-to-mif"). + * @property {string} name + * @property {string} description + * @property {ConverterIO} inputs + * @property {ConverterIO} outputs + * @property {ConverterOption[]} options + * @property {(input: ConverterRunInput, ctx: ConverterContext) => Promise} run + */ + +module.exports = {} diff --git a/src/main/main.js b/src/main/main.js index 0c44bab..92f0497 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -16,7 +16,7 @@ function createWindow() { contextIsolation: true, nodeIntegration: false, }, - title: 'MifMapXL', + title: 'MifKit', }) mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')) diff --git a/src/renderer/index.html b/src/renderer/index.html index e7d7dbf..f46bcd8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,14 +4,14 @@ - MifMapXL + MifKit
-

MifMapXL

+

MifKit

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

diff --git a/test/core/csv.test.js b/test/core/csv.test.js index f000e2d..500f4c9 100644 --- a/test/core/csv.test.js +++ b/test/core/csv.test.js @@ -7,7 +7,7 @@ const os = require('node:os') const { writeCsvFile } = require('../../src/core/csv') function tmpFile(name) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mifmapxl-csv-')) + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mifkit-csv-')) return path.join(dir, name) } diff --git a/test/core/registry.test.js b/test/core/registry.test.js new file mode 100644 index 0000000..55ca62c --- /dev/null +++ b/test/core/registry.test.js @@ -0,0 +1,91 @@ +const test = require('node:test') +const assert = require('node:assert') + +const registry = require('../../src/core/converters/registry') + +test.beforeEach(() => { + registry.clear() +}) + +test('register rejects converters missing required fields', () => { + assert.throws(() => registry.register(null), /must be an object/) + assert.throws(() => registry.register({}), /id must be a non-empty string/) + assert.throws(() => registry.register({ id: 'CamelCase' }), /kebab-case/) + assert.throws(() => registry.register({ id: 'no-name' }), /must have a name/) + assert.throws( + () => registry.register({ id: 'partial', name: 'x' }), + /inputs.extensions/, + ) +}) + +test('register stores a valid converter and forbids duplicate ids', () => { + const converter = { + id: 'noop', + name: 'Noop', + description: '', + inputs: { extensions: ['.x'], type: 'file' }, + outputs: { extensions: ['.y'], type: 'folder' }, + options: [], + run: async () => ({ outputs: [], stats: { processed: 0, skipped: 0, errors: [] } }), + } + + registry.register(converter) + assert.strictEqual(registry.get('noop').id, 'noop') + assert.strictEqual(registry.list().length, 1) + assert.throws(() => registry.register(converter), /already registered/) +}) + +test('applyDefaults fills only unspecified keys and preserves explicit values', () => { + const converter = { + id: 'opts', + name: 'Options test', + inputs: { extensions: ['.x'], type: 'file' }, + outputs: { extensions: ['.y'], type: 'folder' }, + options: [ + { key: 'a', type: 'boolean', default: true }, + { key: 'b', type: 'string', default: 'fallback' }, + { key: 'c', type: 'number', default: 5 }, + ], + run: async () => ({}), + } + + const merged = registry.applyDefaults(converter, { b: 'explicit', extra: 'kept' }) + assert.deepStrictEqual(merged, { a: true, b: 'explicit', c: 5, extra: 'kept' }) +}) + +test('validateOptions reports type and enum violations', () => { + const converter = { + id: 'val', + name: 'Validation', + inputs: { extensions: ['.x'], type: 'file' }, + outputs: { extensions: ['.y'], type: 'folder' }, + options: [ + { key: 'flag', type: 'boolean', default: true }, + { key: 'mode', type: 'enum', values: ['a', 'b'], default: 'a' }, + { key: 'count', type: 'number', default: 1, min: 0, max: 10 }, + ], + run: async () => ({}), + } + + const { errors } = registry.validateOptions(converter, { flag: 'yes', mode: 'z', count: 99 }) + assert.deepStrictEqual(errors.sort(), [ + 'Option "count" must be <= 10', + 'Option "flag" must be boolean', + 'Option "mode" must be one of: a, b', + ].sort()) + + const ok = registry.validateOptions(converter, { flag: false, mode: 'b', count: 3 }) + assert.deepStrictEqual(ok.errors, []) + assert.deepStrictEqual(ok.merged, { flag: false, mode: 'b', count: 3 }) +}) + +test('mif-to-xlsx converter is registered by default via the index entry point', () => { + registry.clear() + delete require.cache[require.resolve('../../src/core/converters')] + const converters = require('../../src/core/converters') + const found = converters.get('mif-to-xlsx') + assert.ok(found, 'mif-to-xlsx must be in the default registry') + assert.strictEqual(found.inputs.extensions[0], '.mif') + assert.ok(found.outputs.extensions.includes('.xlsx')) + assert.ok(typeof found.run === 'function') +}) diff --git a/test/integration/convert.test.js b/test/integration/convert.test.js index fabc3a6..47fb6b3 100644 --- a/test/integration/convert.test.js +++ b/test/integration/convert.test.js @@ -11,7 +11,7 @@ const { runConversion } = require('../../src/core/convert') const FIXTURES = path.join(__dirname, '..', 'fixtures') function mkTmp() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'mifmapxl-it-')) + return fs.mkdtempSync(path.join(os.tmpdir(), 'mifkit-it-')) } function copyFixture(srcName, dstDir, dstName) { diff --git a/test/integration/converter-dispatch.test.js b/test/integration/converter-dispatch.test.js new file mode 100644 index 0000000..a2d5994 --- /dev/null +++ b/test/integration/converter-dispatch.test.js @@ -0,0 +1,76 @@ +const test = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const path = require('node:path') +const os = require('node:os') +const ExcelJS = require('exceljs') + +const converters = require('../../src/core/converters') + +const FIXTURES = path.join(__dirname, '..', 'fixtures') + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'mifkit-dispatch-')) +} + +function copyFixture(name, dstDir) { + fs.copyFileSync(path.join(FIXTURES, name), path.join(dstDir, name)) +} + +test('mif-to-xlsx converter dispatched through the registry produces an xlsx', async () => { + const inputDir = mkTmp() + const outputDir = mkTmp() + copyFixture('simple-colors.mif', inputDir) + copyFixture('simple-colors.mid', inputDir) + + const converter = converters.get('mif-to-xlsx') + assert.ok(converter) + + const { merged } = converters.validateOptions(converter, { + paintRows: true, + skipBlack: true, + includeColorColumn: true, + }) + + const logs = [] + const result = await converter.run( + { inputs: [inputDir], output: outputDir, options: merged }, + { log: (m) => logs.push(m), progress: () => {} }, + ) + + assert.strictEqual(result.stats.processed, 1) + assert.strictEqual(result.stats.skipped, 0) + assert.deepStrictEqual(result.stats.errors, []) + + const xlsxPath = path.join(outputDir, 'simple-colors.xlsx') + assert.ok(fs.existsSync(xlsxPath)) + assert.ok(result.outputs.includes(xlsxPath)) + + const wb = new ExcelJS.Workbook() + await wb.xlsx.readFile(xlsxPath) + const sheet = wb.worksheets[0] + assert.deepStrictEqual( + sheet.getRow(1).values.slice(1), + ['name', 'code', 'note', 'region_color_hex'], + ) +}) + +test('mif-to-xlsx converter accepts explicit file list as inputs', async () => { + const inputDir = mkTmp() + const outputDir = mkTmp() + copyFixture('simple-colors.mif', inputDir) + copyFixture('simple-colors.mid', inputDir) + + const converter = converters.get('mif-to-xlsx') + const result = await converter.run( + { + inputs: [path.join(inputDir, 'simple-colors.mif')], + output: outputDir, + options: converters.applyDefaults(converter, { paintRows: false }), + }, + { log: () => {}, progress: () => {} }, + ) + + assert.strictEqual(result.stats.processed, 1) + assert.ok(fs.existsSync(path.join(outputDir, 'simple-colors.xlsx'))) +})