From 16446e4857b8bc806b64266f1c24fd4f67bf0a53 Mon Sep 17 00:00:00 2001 From: Alexey Fedyunin Date: Tue, 19 May 2026 23:02:14 +0500 Subject: [PATCH 1/4] feat: kml-to-mif converter (KML/KMZ -> MapInfo MIF/MID) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the second converter through the registry contract introduced in PR1. Produces byte-identical output to the reference Python script (kml_to_mif.py) on a real 1854-feature dataset (Karaganda 108 KMZ). What it does - Resolves per-feature colors by following styleUrl -> StyleMap(normal) -> Style chains. KML AABBGGRR -> MapInfo decimal RGB int. - Preserves (label), , and the resolved StyleId as MID attribute columns. Adds a Folder column with the full slash-joined KML folder path so the hierarchy is queryable in MapInfo SQL. - Lays files out either as nested directories matching the KML Folder tree (default) or one flat directory with `parent__child` prefixes (`flat: true`) so they can be merged without TAB name collisions. - Charset option: WindowsCyrillic (cp1251, default) for Russian/Kazakh data, or Neutral (UTF-8) for MapInfo Pro 15.2+. - Supports KML and KMZ. KMZ is unzipped in-process via a minimal pure-JS ZIP reader (stored + deflate) — no native deps. - Skips placemarks without geometry; reports per-file errors in stats. New modules - src/core/common/color.js KML AABBGGRR <-> MapInfo int <-> #RRGGBB - src/core/common/zip.js minimal ZIP reader (zlib only) - src/core/converters/kml-to-mif/ parseKml.js KML parsing (fast-xml-parser) + Style/StyleMap collection + resolution writeMif.js MIF/MID writer with CRLF normalization and Unicode-safe filename sanitization index.js Converter definition + run() - test/fixtures/sample.kml polygons + line + point + StyleMap New dependency: fast-xml-parser (zero runtime deps, MIT, popular) Tests: 75 passing (62 from PR1 + 13 new) - color: AABBGGRR / BBGGRR / fallback / hex round-trip - zip: deflate + stored extraction, rejects non-ZIP buffers - kml-to-mif: schema, full folder dispatch, flat layout, KMZ archive, Neutral charset, resolved Pen/Brush/Symbol values per feature Verified end-to-end: `diff -r` of the original Python output against the JS output on the 1854-feature Karaganda 108 KMZ shows no differences across all 18 produced files. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 101 ++++++- package.json | 1 + src/core/common/color.js | 65 +++++ src/core/common/zip.js | 126 +++++++++ src/core/converters/index.js | 2 + src/core/converters/kml-to-mif/index.js | 162 +++++++++++ src/core/converters/kml-to-mif/parseKml.js | 305 +++++++++++++++++++++ src/core/converters/kml-to-mif/writeMif.js | 188 +++++++++++++ test/core/color.test.js | 36 +++ test/core/zip.test.js | 102 +++++++ test/fixtures/sample.kml | 87 ++++++ test/integration/kml-to-mif.test.js | 213 ++++++++++++++ 12 files changed, 1384 insertions(+), 4 deletions(-) create mode 100644 src/core/common/color.js create mode 100644 src/core/common/zip.js create mode 100644 src/core/converters/kml-to-mif/index.js create mode 100644 src/core/converters/kml-to-mif/parseKml.js create mode 100644 src/core/converters/kml-to-mif/writeMif.js create mode 100644 test/core/color.test.js create mode 100644 test/core/zip.test.js create mode 100644 test/fixtures/sample.kml create mode 100644 test/integration/kml-to-mif.test.js diff --git a/package-lock.json b/package-lock.json index b93d133..c4a0ef0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { - "name": "mifmapxl", - "version": "1.0.4", + "name": "mifkit", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mifmapxl", - "version": "1.0.4", + "name": "mifkit", + "version": "1.1.0", "license": "MIT", "dependencies": { "exceljs": "^4.4.0", + "fast-xml-parser": "^5.8.0", "iconv-lite": "^0.6.3" }, "devDependencies": { @@ -596,6 +597,18 @@ "node": ">= 10.0.0" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -2394,6 +2407,44 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -3641,6 +3692,21 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4245,6 +4311,18 @@ "node": ">=8" } }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -4630,6 +4708,21 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 6064ed7..61997e1 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "exceljs": "^4.4.0", + "fast-xml-parser": "^5.8.0", "iconv-lite": "^0.6.3" }, "devDependencies": { diff --git a/src/core/common/color.js b/src/core/common/color.js new file mode 100644 index 0000000..4a15da2 --- /dev/null +++ b/src/core/common/color.js @@ -0,0 +1,65 @@ +/** + * Color helpers shared by all converters. + * + * KML stores colors as AABBGGRR hex (8 chars). MapInfo MIF stores them as a + * decimal integer formed as 0xRRGGBB. Excel uses #RRGGBB. Keep all conversions + * in one place so future converters reuse the same wiring. + */ + +const HEX_RE = /^[0-9a-fA-F]+$/ + +/** + * Parse a KML `` value (AABBGGRR or BBGGRR) to MapInfo decimal int. + * Returns `fallback` if the input is missing or malformed. + */ +function kmlColorToMapInfo(kmlColor, fallback) { + if (!kmlColor || typeof kmlColor !== 'string') { + return fallback + } + + const value = kmlColor.trim().toLowerCase() + + if (!HEX_RE.test(value)) { + return fallback + } + + let bb + let gg + let rr + + if (value.length === 8) { + bb = value.slice(2, 4) + gg = value.slice(4, 6) + rr = value.slice(6, 8) + } else if (value.length === 6) { + bb = value.slice(0, 2) + gg = value.slice(2, 4) + rr = value.slice(4, 6) + } else { + return fallback + } + + return parseInt(rr + gg + bb, 16) +} + +/** + * Format a MapInfo color int as `#RRGGBB`. + */ +function mapInfoColorToHex(value) { + const safe = Number.isFinite(value) ? value & 0xffffff : 0 + return `#${safe.toString(16).toUpperCase().padStart(6, '0')}` +} + +/** + * Parse a KML `` value to `#RRGGBB`. Returns null on malformed input. + */ +function kmlColorToHex(kmlColor) { + const value = kmlColorToMapInfo(kmlColor, null) + return value === null ? null : mapInfoColorToHex(value) +} + +module.exports = { + kmlColorToMapInfo, + kmlColorToHex, + mapInfoColorToHex, +} diff --git a/src/core/common/zip.js b/src/core/common/zip.js new file mode 100644 index 0000000..a2a2ccb --- /dev/null +++ b/src/core/common/zip.js @@ -0,0 +1,126 @@ +const zlib = require('zlib') + +const SIG_LOCAL = 0x04034b50 +const SIG_CDIR = 0x02014b50 +const SIG_EOCD = 0x06054b50 +const SIG_EOCD64 = 0x06064b50 + +const MAX_EOCD_COMMENT = 0xffff +const EOCD_FIXED_SIZE = 22 + +/** + * Read a ZIP archive into an in-memory directory of entries. + * + * Supports stored (method 0) and deflate (method 8) compression — enough for + * KMZ archives and ZIP-bundled Shapefiles. ZIP64 is detected and treated as + * unsupported (most KMZ/SHP bundles are well under 4 GB). + * + * @param {Buffer} buffer + * @returns {{ entries: Array<{ name: string, size: number, extract: () => Buffer }> }} + */ +function readZip(buffer) { + if (!Buffer.isBuffer(buffer)) { + throw new Error('readZip expects a Buffer') + } + + const eocd = findEOCD(buffer) + + if (eocd.method === 'zip64') { + throw new Error('ZIP64 archives are not supported') + } + + const entries = readCentralDirectory(buffer, eocd.cdOffset, eocd.totalEntries) + return { + entries: entries.map((entry) => ({ + name: entry.name, + size: entry.uncompressedSize, + extract: () => extractEntry(buffer, entry), + })), + } +} + +function findEOCD(buffer) { + const minOffset = Math.max(0, buffer.length - EOCD_FIXED_SIZE - MAX_EOCD_COMMENT) + + for (let i = buffer.length - EOCD_FIXED_SIZE; i >= minOffset; i -= 1) { + if (buffer.readUInt32LE(i) === SIG_EOCD) { + const totalEntries = buffer.readUInt16LE(i + 10) + const cdOffset = buffer.readUInt32LE(i + 16) + + if (totalEntries === 0xffff || cdOffset === 0xffffffff) { + return { method: 'zip64' } + } + + return { method: 'standard', totalEntries, cdOffset } + } + + if (buffer.readUInt32LE(i) === SIG_EOCD64) { + return { method: 'zip64' } + } + } + + throw new Error('End of central directory not found — not a ZIP archive') +} + +function readCentralDirectory(buffer, cdOffset, totalEntries) { + const entries = [] + let cursor = cdOffset + + for (let i = 0; i < totalEntries; i += 1) { + if (buffer.readUInt32LE(cursor) !== SIG_CDIR) { + throw new Error(`Central directory record corrupted at offset ${cursor}`) + } + + const compressionMethod = buffer.readUInt16LE(cursor + 10) + const compressedSize = buffer.readUInt32LE(cursor + 20) + const uncompressedSize = buffer.readUInt32LE(cursor + 24) + const nameLength = buffer.readUInt16LE(cursor + 28) + const extraLength = buffer.readUInt16LE(cursor + 30) + const commentLength = buffer.readUInt16LE(cursor + 32) + const localHeaderOffset = buffer.readUInt32LE(cursor + 42) + const name = buffer.slice(cursor + 46, cursor + 46 + nameLength).toString('utf8') + + if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localHeaderOffset === 0xffffffff) { + throw new Error(`ZIP64 entry "${name}" is not supported`) + } + + entries.push({ + name, + compressionMethod, + compressedSize, + uncompressedSize, + localHeaderOffset, + }) + + cursor += 46 + nameLength + extraLength + commentLength + } + + return entries +} + +function extractEntry(buffer, entry) { + const localOffset = entry.localHeaderOffset + + if (buffer.readUInt32LE(localOffset) !== SIG_LOCAL) { + throw new Error(`Local file header missing for ${entry.name}`) + } + + const nameLength = buffer.readUInt16LE(localOffset + 26) + const extraLength = buffer.readUInt16LE(localOffset + 28) + const dataOffset = localOffset + 30 + nameLength + extraLength + const compressed = buffer.slice(dataOffset, dataOffset + entry.compressedSize) + + if (entry.compressionMethod === 0) { + return Buffer.from(compressed) + } + + if (entry.compressionMethod === 8) { + return zlib.inflateRawSync(compressed) + } + + throw new Error(`Unsupported compression method ${entry.compressionMethod} for ${entry.name}`) +} + +module.exports = { + readZip, +} diff --git a/src/core/converters/index.js b/src/core/converters/index.js index f9c7826..ed08f2b 100644 --- a/src/core/converters/index.js +++ b/src/core/converters/index.js @@ -1,5 +1,6 @@ const registry = require('./registry') const mifToXlsx = require('./mif-to-xlsx') +const kmlToMif = require('./kml-to-mif') let initialized = false @@ -8,6 +9,7 @@ function ensureInitialized() { return } registry.register(mifToXlsx) + registry.register(kmlToMif) initialized = true } diff --git a/src/core/converters/kml-to-mif/index.js b/src/core/converters/kml-to-mif/index.js new file mode 100644 index 0000000..4a25207 --- /dev/null +++ b/src/core/converters/kml-to-mif/index.js @@ -0,0 +1,162 @@ +const fs = require('fs') +const path = require('path') + +const { readZip } = require('../../common/zip') +const { parseKml } = require('./parseKml') +const { writeMifFromKml } = require('./writeMif') + +/** + * kml-to-mif — KML/KMZ to MapInfo MIF/MID. + * + * Preserves per-feature colors (Pen/Brush/Symbol) by resolving each + * Placemark's styleUrl through StyleMap and Style chains. Saves the KML + * Folder hierarchy either as nested directories (default) or as one + * directory with prefixed filenames (`flat`). + * + * @type {import('../types').Converter} + */ +const converter = { + id: 'kml-to-mif', + name: 'KML/KMZ → MapInfo MIF/MID', + description: 'Convert Google Earth KML/KMZ to MapInfo Interchange (MIF/MID). Preserves per-feature colors from + + + + normal + #redOutline + + + highlight + #blueFilled + + + + Layer A + + Red Region + outlined polygon + #redMap + + + + + 10,20,0 11,20,0 11,21,0 10,21,0 10,20,0 + + + + + + + Blue Region + #blueFilled + + + + + 30,40,0 31,40,0 31,41,0 30,41,0 30,40,0 + + + + + + + Sublayer + + Track + #redMap + + + 50,60,0 51,60,0 52,61,0 + + + + + Marker + #blueFilled + + 70,80,0 + + + + + + diff --git a/test/integration/kml-to-mif.test.js b/test/integration/kml-to-mif.test.js new file mode 100644 index 0000000..4793e48 --- /dev/null +++ b/test/integration/kml-to-mif.test.js @@ -0,0 +1,213 @@ +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 zlib = require('node:zlib') +const iconv = require('iconv-lite') + +const converters = require('../../src/core/converters') + +const FIXTURES = path.join(__dirname, '..', 'fixtures') +const SAMPLE_KML = path.join(FIXTURES, 'sample.kml') + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'mifkit-kml-')) +} + +function readCp1251(filePath) { + return iconv.decode(fs.readFileSync(filePath), 'windows-1251') +} + +test('kml-to-mif converter is registered with the expected option schema', () => { + const c = converters.get('kml-to-mif') + assert.ok(c) + assert.deepStrictEqual(c.inputs.extensions, ['.kml', '.kmz']) + assert.deepStrictEqual(c.outputs.extensions, ['.mif', '.mid']) + const optionKeys = c.options.map((o) => o.key).sort() + assert.deepStrictEqual(optionKeys, ['charset', 'flat', 'recursive']) +}) + +test('kml-to-mif produces MIF/MID with resolved styles and folder hierarchy', async () => { + const outDir = mkTmp() + const c = converters.get('kml-to-mif') + const result = await c.run( + { + inputs: [SAMPLE_KML], + output: outDir, + options: converters.applyDefaults(c, {}), + }, + { log: () => {}, progress: () => {} }, + ) + + assert.strictEqual(result.stats.processed, 1) + assert.strictEqual(result.stats.skipped, 0) + assert.deepStrictEqual(result.stats.errors, []) + + const layerAMif = path.join(outDir, 'Layer_A.mif') + const layerAMid = path.join(outDir, 'Layer_A.mid') + const subMif = path.join(outDir, 'Layer_A', 'Sublayer.mif') + const subMid = path.join(outDir, 'Layer_A', 'Sublayer.mid') + + assert.ok(fs.existsSync(layerAMif), 'Layer_A.mif should exist') + assert.ok(fs.existsSync(layerAMid)) + assert.ok(fs.existsSync(subMif), 'Layer_A/Sublayer.mif should exist') + assert.ok(fs.existsSync(subMid)) + + const layerAMifText = readCp1251(layerAMif) + // Red region (outlined, no fill): Pen color from ff0000aa -> 0xAA0000 (11141120), Brush pattern 1 + assert.match(layerAMifText, /Pen \(1,2,11141120\)/) + assert.match(layerAMifText, /Brush \(1,11141120,16777215\)/) + // Blue region (filled): Brush pattern 2 with poly color from ffff0000 -> 0x0000FF (255) + assert.match(layerAMifText, /Pen \(1,2,0\)/) + assert.match(layerAMifText, /Brush \(2,255,16777215\)/) + // CoordSys + Columns header present + assert.match(layerAMifText, /CoordSys Earth Projection 1, 104/) + assert.match(layerAMifText, /Columns 4/) + + const layerAMidText = readCp1251(layerAMid) + assert.match(layerAMidText, /"Red Region","outlined polygon","redOutline","Layer A"/) + assert.match(layerAMidText, /"Blue Region","","blueFilled","Layer A"/) + + const subMifText = readCp1251(subMif) + // Line with width 2 from redOutline style + assert.match(subMifText, /Pline 3/) + assert.match(subMifText, /Pen \(2,2,11141120\)/) + // Point with icon color FFFF00 -> 16776960 + assert.match(subMifText, /Point 70 80/) + assert.match(subMifText, /Symbol \(35,16776960,12\)/) + + const subMidText = readCp1251(subMid) + assert.match(subMidText, /"Track","","redOutline","Layer A \/ Sublayer"/) + assert.match(subMidText, /"Marker","","blueFilled","Layer A \/ Sublayer"/) +}) + +test('kml-to-mif with flat=true produces a single directory with prefixed names', async () => { + const outDir = mkTmp() + const c = converters.get('kml-to-mif') + await c.run( + { + inputs: [SAMPLE_KML], + output: outDir, + options: converters.applyDefaults(c, { flat: true }), + }, + { log: () => {}, progress: () => {} }, + ) + + const files = fs.readdirSync(outDir).sort() + assert.deepStrictEqual(files, [ + 'Layer_A.mid', + 'Layer_A.mif', + 'Layer_A__Sublayer.mid', + 'Layer_A__Sublayer.mif', + ]) +}) + +test('kml-to-mif handles KMZ archives', async () => { + // Build a tiny KMZ from the sample KML + const kmlBuffer = fs.readFileSync(SAMPLE_KML) + const kmzBuffer = buildKmz('doc.kml', kmlBuffer) + const inputDir = mkTmp() + const kmzPath = path.join(inputDir, 'sample.kmz') + fs.writeFileSync(kmzPath, kmzBuffer) + + const outDir = mkTmp() + const c = converters.get('kml-to-mif') + const result = await c.run( + { + inputs: [kmzPath], + output: outDir, + options: converters.applyDefaults(c, {}), + }, + { log: () => {}, progress: () => {} }, + ) + + assert.strictEqual(result.stats.processed, 1) + assert.ok(fs.existsSync(path.join(outDir, 'Layer_A.mif'))) +}) + +test('kml-to-mif charset=Neutral writes UTF-8', async () => { + const outDir = mkTmp() + const c = converters.get('kml-to-mif') + await c.run( + { + inputs: [SAMPLE_KML], + output: outDir, + options: converters.applyDefaults(c, { charset: 'Neutral' }), + }, + { log: () => {}, progress: () => {} }, + ) + + const mifText = fs.readFileSync(path.join(outDir, 'Layer_A.mif'), 'utf8') + assert.match(mifText, /Charset "Neutral"/) +}) + +// --- KMZ builder for tests ------------------------------------------------- + +const CRC_TABLE = (() => { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n += 1) { + let c = n + for (let k = 0; k < 8; k += 1) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + table[n] = c + } + return table +})() + +function crc32(buf) { + let crc = 0xffffffff + for (let i = 0; i < buf.length; i += 1) { + crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xff] + } + return (crc ^ 0xffffffff) >>> 0 +} + +function buildKmz(name, content) { + const nameBuf = Buffer.from(name, 'utf8') + const data = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8') + const compressed = zlib.deflateRawSync(data) + const crc = crc32(data) + + const local = Buffer.alloc(30) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(20, 4) + local.writeUInt16LE(0, 6) + local.writeUInt16LE(8, 8) + local.writeUInt16LE(0, 10) + local.writeUInt16LE(0, 12) + local.writeUInt32LE(crc, 14) + local.writeUInt32LE(compressed.length, 18) + local.writeUInt32LE(data.length, 22) + local.writeUInt16LE(nameBuf.length, 26) + local.writeUInt16LE(0, 28) + + const cd = Buffer.alloc(46) + cd.writeUInt32LE(0x02014b50, 0) + cd.writeUInt16LE(20, 4) + cd.writeUInt16LE(20, 6) + cd.writeUInt16LE(0, 8) + cd.writeUInt16LE(8, 10) + cd.writeUInt16LE(0, 12) + cd.writeUInt16LE(0, 14) + cd.writeUInt32LE(crc, 16) + cd.writeUInt32LE(compressed.length, 20) + cd.writeUInt32LE(data.length, 24) + cd.writeUInt16LE(nameBuf.length, 28) + + const cdOffset = local.length + nameBuf.length + compressed.length + const cdSize = cd.length + nameBuf.length + + const eocd = Buffer.alloc(22) + eocd.writeUInt32LE(0x06054b50, 0) + eocd.writeUInt16LE(0, 4) + eocd.writeUInt16LE(0, 6) + eocd.writeUInt16LE(1, 8) + eocd.writeUInt16LE(1, 10) + eocd.writeUInt32LE(cdSize, 12) + eocd.writeUInt32LE(cdOffset, 16) + eocd.writeUInt16LE(0, 20) + + return Buffer.concat([local, nameBuf, compressed, cd, nameBuf, eocd]) +} From c1b043a7ef5d7d1559c1e49bec3b263214a13d48 Mon Sep 17 00:00:00 2001 From: Alexey Fedyunin Date: Tue, 19 May 2026 23:24:01 +0500 Subject: [PATCH 2/4] 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', + }) +}) From b55ba6c38b8fcaf753c9757aba8b91e10aba3e62 Mon Sep 17 00:00:00 2001 From: Alexey Fedyunin Date: Tue, 19 May 2026 23:36:16 +0500 Subject: [PATCH 3/4] docs: refresh README and PROJECT_CONTEXT for multi-converter shape Both docs now reflect the post-rebrand reality: a registry of converters, a schema-driven UI, and kml-to-mif shipped. README - Converter table: mark mif-to-xlsx and kml-to-mif as shipped; keep the planned ones for direction. - Replace the single-feature blurb with two feature sections (one per shipped converter) covering what each actually does. - Add an "Adding a new converter" section with a runnable skeleton so contributors can land a new format without reading the whole core. - Trim and reorder project structure to match the current src/core/ layout, including src/core/common/ and src/core/converters/. PROJECT_CONTEXT - Full rewrite. Old doc only described the original MIF -> XLSX desktop app; new doc describes the toolkit, the three-layer architecture (core / shell / planned CLI), the Converter contract, and how the GUI talks to the engine. - Tables: shipped converters (with implementation notes), planned converters (with rationale). - Input format assumptions split between read (MIF, MID, KML/KMZ) and write (MIF/MID from kml-to-mif), with the byte-level details that matter for compatibility (CRLF, charset, Pen/Brush/Symbol). - Settings v2 shape documented + migration policy. - Quality bar and non-goals updated for the toolkit scope. - New "Definition of done for a new converter PR" and "Guidance for future AI assistants" sections. No code touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- PROJECT_CONTEXT.md | 556 ++++++++++++++------------------------------- README.md | 104 ++++++--- 2 files changed, 246 insertions(+), 414 deletions(-) diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 785692e..403f988 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -4,437 +4,221 @@ 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 -This is a desktop app for converting MapInfo `.mif` + `.mid` pairs into: - -- `.xlsx` files with all original attribute fields -- optional `.csv` export -- an extra column `region_color_hex` -- optional row background fill in Excel using the region fill color -- optional skip for black fill (`#000000`) - -The app is intended for non-technical Windows users who should be able to run a normal GUI app instead of using Node.js scripts in a terminal. - ---- - -## Product goal - -The user wants a simple Windows executable with UI where they can: +A growing collection of converters for MapInfo `.mif/.mid` and adjacent geo formats (KML/KMZ, GeoJSON, Shapefile, Excel). Packaged as a desktop GUI (Electron) and — once the CLI ships — a terminal binary built on the same engine. -- choose a folder or specific files for processing -- recursively scan subfolders if needed -- generate Excel files from MapInfo data -- add a color column extracted from polygon style -- optionally fill Excel rows with that color -- skip black fill if configured -- optionally merge all outputs into one workbook -- optionally also export CSV +The target user is non-technical (Windows + MapInfo Pro), so the GUI is the primary front-end. The CLI is for power users, automation, and CI pipelines. -The user is pragmatic and wants a working tool, not a theoretical one. +The product mood is **ffmpeg for MapInfo data**: one engine, every conversion direction surfaces in both the GUI and CLI through the same contract, behavior is deterministic, output is correct enough to import into MapInfo Pro without manual fixing. ---- +## Architecture -## Current stack +### Three layers -- Electron -- Node.js -- exceljs -- electron-builder +1. **Core engine** — `src/core/`. Pure Node.js, no Electron dependency, testable in isolation. Each converter is one folder under `src/core/converters/` exporting a Converter object. +2. **Desktop shell** — `src/main/` (Electron main process, IPC, worker orchestration) + `src/renderer/` (HTML/JS UI). The renderer is thin and schema-driven — it does not know any converter-specific details, it just renders forms from each converter's declarative `options[]` schema and dispatches the user's selection back through IPC. +3. **CLI (planned)** — `bin/mifkit` will be a thin wrapper over the same registry. The same option schema validates CLI flags. GUI and CLI are interchangeable entry points to the same core. ---- - -## High-level architecture +``` +src/ + main/ Electron shell + main.js · preload.js · worker.js + renderer/ Desktop UI + index.html · renderer.js · styles.css · i18n.js + core/ + common/ Shared utilities + color.js KML AABBGGRR <-> MapInfo int <-> #RRGGBB + zip.js Minimal ZIP reader (zlib only) + converters/ + registry.js register / get / list / validateOptions + types.js JSDoc Converter contract + index.js Auto-registers all built-in converters + mif-to-xlsx/ MapInfo MIF/MID -> Excel/CSV + kml-to-mif/ KML/KMZ -> MapInfo MIF/MID + convert.js Legacy orchestration for mif-to-xlsx + (still used inside that converter; will + fold into the converter folder later) + 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 with v1 -> v2 migration +test/ + core/ · integration/ · fixtures/ node:test suite, runs on every PR +``` -The project has 2 main parts: +### The Converter contract + +Every converter exports a plain object of this shape (full JSDoc lives in `src/core/converters/types.js`): + +```js +{ + id: 'kml-to-mif', // stable kebab-case + name: 'KML/KMZ → MapInfo MIF/MID', + description: '...', + inputs: { extensions: ['.kml', '.kmz'], type: 'file-or-folder' }, + outputs: { extensions: ['.mif', '.mid'], type: 'folder' }, + options: [ + { key: 'flat', type: 'boolean', default: false, label: '...', description: '...' }, + { key: 'charset', type: 'enum', values: ['WindowsCyrillic', 'Neutral'], default: 'WindowsCyrillic', label: '...' }, + // ... + ], + async run({ inputs, output, options }, ctx) { + // ctx.log(message) + // ctx.progress({ total, done, currentFile }) + // returns { outputs: string[], stats: { processed, skipped, errors: [{file, error}] } } + }, +} +``` -### 1. Desktop UI -Electron app with: -- main process -- preload bridge -- renderer UI +The registry validates this shape on `register()`. `validateOptions()` checks each option against its declared type/enum/range before the converter runs. -Responsibilities: -- choose input folder or files -- choose output folder -- edit settings -- start conversion -- show logs and results +### How the GUI talks to the engine -### 2. Conversion engine -Pure Node.js logic that: -- scans files -- matches `.mif` with corresponding `.mid` -- parses MIF metadata and geometry style -- parses MID records -- builds CSV/XLSX output -- applies Excel row fill -- skips black when requested +``` +renderer (renderer.js) + ├─ window.api.listConverters() → IPC converters:list → registry.list() + ├─ renders --output=${exampleFlags(converter)}`, + ) + + return lines.join('\n') +} + +function formatOption(option) { + switch (option.type) { + case 'boolean': + return ` --${option.key} / --no-${option.key} (boolean, default: ${option.default ?? false})` + case 'enum': + return ` --${option.key}= (enum: ${(option.values || []).join(' | ')}, default: ${option.default})` + case 'number': { + const range = (option.min !== undefined || option.max !== undefined) + ? `, range: ${option.min ?? '-∞'}..${option.max ?? '+∞'}` + : '' + return ` --${option.key}= (number, default: ${option.default}${range})` + } + default: + return ` --${option.key}= (string, default: ${option.default ? `"${option.default}"` : '(empty)'})` + } +} + +function exampleFlags(converter) { + const example = converter.options + .find((o) => o.type === 'boolean' && o.default === false) + return example ? ` --${example.key}` : '' +} + +function indent(text, prefix = ' ') { + return String(text || '') + .split('\n') + .map((line) => prefix + line) + .join('\n') +} + +module.exports = { + topHelp, + listConverters, + converterHelp, + RESERVED_FLAGS, +} diff --git a/src/cli/index.js b/src/cli/index.js new file mode 100644 index 0000000..fbc59c6 --- /dev/null +++ b/src/cli/index.js @@ -0,0 +1,151 @@ +const pkg = require('../../package.json') +const converters = require('../core/converters') +const { parseArgs } = require('./parseArgs') +const { coerceOptions } = require('./coerceOptions') +const { topHelp, listConverters, converterHelp } = require('./format') + +const RESERVED = new Set(['output', 'o', 'help', 'version']) + +/** + * Run the CLI with the given argv. Pure with respect to side effects: all + * output goes through the injected `out` / `err` writers, so tests can call + * this directly without spawning a subprocess. + * + * @param {string[]} argv process.argv.slice(2) + * @param {Object} [io] + * @param {(s: string) => void} [io.out] stdout writer + * @param {(s: string) => void} [io.err] stderr writer + * @returns {Promise} exit code + */ +async function runCli(argv, io = {}) { + const out = io.out || ((s) => process.stdout.write(`${s}\n`)) + const err = io.err || ((s) => process.stderr.write(`${s}\n`)) + + const { command, positional, flags } = parseArgs(argv) + + if (flags.version) { + out(pkg.version) + return 0 + } + + if (!command || flags.help) { + if (command === 'help') { + return runHelp(positional, out, err) + } + out(topHelp()) + return command ? 0 : 0 + } + + switch (command) { + case 'list': + return runList(out) + case 'help': + return runHelp(positional, out, err) + case 'convert': + return runConvert(positional, flags, out, err) + default: + err(`Unknown command: ${command}`) + err('') + err(topHelp()) + return 2 + } +} + +function runList(out) { + out(listConverters(converters.list())) + return 0 +} + +function runHelp(positional, out, err) { + const [id] = positional + if (!id) { + out(topHelp()) + return 0 + } + const converter = converters.get(id) + if (!converter) { + err(`Unknown converter: ${id}`) + err('') + err('Available:') + err(listConverters(converters.list())) + return 2 + } + out(converterHelp(converter)) + return 0 +} + +async function runConvert(positional, flags, out, err) { + const [converterId, ...inputs] = positional + if (!converterId) { + err('Missing converter id') + err('Usage: mifkit convert --output= [options]') + return 2 + } + const converter = converters.get(converterId) + if (!converter) { + err(`Unknown converter: ${converterId}`) + err('') + err('Available:') + err(listConverters(converters.list())) + return 2 + } + if (!inputs.length) { + err(`Missing input path(s) for ${converterId}`) + err(`Try: mifkit help ${converterId}`) + return 2 + } + + const output = flags.output || flags.o + if (!output || typeof output !== 'string') { + err('Missing --output=') + return 2 + } + + const { options: rawOptions, errors: coerceErrors } = coerceOptions( + [...converter.options], + flags, + RESERVED, + ) + if (coerceErrors.length) { + for (const e of coerceErrors) err(e) + return 2 + } + + const validated = converters.validateOptions(converter, rawOptions) + if (validated.errors.length) { + for (const e of validated.errors) err(e) + return 2 + } + + const ctx = { + log: (msg) => err(msg), + progress: () => {}, + } + + try { + const result = await converter.run( + { inputs, output, options: validated.merged }, + ctx, + ) + + const stats = result.stats || { processed: 0, skipped: 0, errors: [] } + err(`Processed: ${stats.processed}, Skipped: ${stats.skipped}`) + + for (const o of result.outputs || []) { + out(o) + } + + if (stats.errors && stats.errors.length) { + for (const e of stats.errors) { + err(` ${e.file}: ${e.error}`) + } + return 1 + } + return 0 + } catch (error) { + err(`FATAL: ${error && error.message ? error.message : String(error)}`) + return 1 + } +} + +module.exports = { runCli } diff --git a/src/cli/parseArgs.js b/src/cli/parseArgs.js new file mode 100644 index 0000000..473a49f --- /dev/null +++ b/src/cli/parseArgs.js @@ -0,0 +1,57 @@ +/** + * Minimal CLI argument parser, schema-agnostic. + * + * Conventions: + * --key=value key set to "value" + * --key key set to true (boolean shorthand) + * --no-key key set to false + * -h, --help parsed as { help: true } + * -v, --version parsed as { version: true } + * + * Anything not starting with "-" becomes positional. The first positional is + * exposed as `command`; the rest stay in `positional`. + * + * @param {string[]} argv + * @returns {{ command: string, positional: string[], flags: Object }} + */ +function parseArgs(argv) { + const positional = [] + const flags = {} + const args = Array.isArray(argv) ? argv.slice() : [] + + for (const arg of args) { + if (arg === undefined || arg === null) continue + const token = String(arg) + + if (token === '-h' || token === '--help') { + flags.help = true + continue + } + if (token === '-v' || token === '--version') { + flags.version = true + continue + } + if (token.startsWith('--')) { + const body = token.slice(2) + const eq = body.indexOf('=') + if (eq === -1) { + if (body.startsWith('no-')) { + flags[body.slice(3)] = false + } else { + flags[body] = true + } + } else { + const key = body.slice(0, eq) + const value = body.slice(eq + 1) + flags[key] = value + } + continue + } + positional.push(token) + } + + const [command = '', ...rest] = positional + return { command, positional: rest, flags } +} + +module.exports = { parseArgs } diff --git a/test/cli/coerceOptions.test.js b/test/cli/coerceOptions.test.js new file mode 100644 index 0000000..6297de5 --- /dev/null +++ b/test/cli/coerceOptions.test.js @@ -0,0 +1,65 @@ +const test = require('node:test') +const assert = require('node:assert') + +const { coerceOptions } = require('../../src/cli/coerceOptions') + +const SCHEMA = [ + { key: 'flat', type: 'boolean', default: false }, + { key: 'charset', type: 'enum', values: ['A', 'B'], default: 'A' }, + { key: 'width', type: 'number', default: 1 }, + { key: 'name', type: 'string', default: '' }, +] + +test('coerceOptions parses booleans from --no-* / --key shorthand', () => { + const { options, errors } = coerceOptions(SCHEMA, { flat: true }) + assert.deepStrictEqual(errors, []) + assert.strictEqual(options.flat, true) + + const off = coerceOptions(SCHEMA, { flat: false }) + assert.strictEqual(off.options.flat, false) +}) + +test('coerceOptions parses boolean from string aliases (true/false/yes/no/1/0)', () => { + assert.strictEqual(coerceOptions(SCHEMA, { flat: 'true' }).options.flat, true) + assert.strictEqual(coerceOptions(SCHEMA, { flat: 'YES' }).options.flat, true) + assert.strictEqual(coerceOptions(SCHEMA, { flat: '1' }).options.flat, true) + assert.strictEqual(coerceOptions(SCHEMA, { flat: 'no' }).options.flat, false) + assert.strictEqual(coerceOptions(SCHEMA, { flat: '0' }).options.flat, false) +}) + +test('coerceOptions parses numbers and reports non-numeric input', () => { + const ok = coerceOptions(SCHEMA, { width: '5' }) + assert.deepStrictEqual(ok.errors, []) + assert.strictEqual(ok.options.width, 5) + + const bad = coerceOptions(SCHEMA, { width: 'abc' }) + assert.match(bad.errors[0], /expects a number/) +}) + +test('coerceOptions accepts string and enum values as plain strings', () => { + const { options, errors } = coerceOptions(SCHEMA, { charset: 'B', name: 'hello' }) + assert.deepStrictEqual(errors, []) + assert.strictEqual(options.charset, 'B') + assert.strictEqual(options.name, 'hello') +}) + +test('coerceOptions errors on a string flag passed without value (--key but no =)', () => { + const { errors } = coerceOptions(SCHEMA, { charset: true }) + assert.match(errors[0], /expects a value/) +}) + +test('coerceOptions reports unknown options', () => { + const { errors } = coerceOptions(SCHEMA, { bogus: 'x' }) + assert.match(errors[0], /Unknown option --bogus/) +}) + +test('coerceOptions ignores reserved and meta flags', () => { + const reserved = new Set(['output', 'o']) + const { options, errors } = coerceOptions( + SCHEMA, + { output: './out', o: './out', help: true, version: true, flat: true }, + reserved, + ) + assert.deepStrictEqual(errors, []) + assert.deepStrictEqual(options, { flat: true }) +}) diff --git a/test/cli/parseArgs.test.js b/test/cli/parseArgs.test.js new file mode 100644 index 0000000..409e9a3 --- /dev/null +++ b/test/cli/parseArgs.test.js @@ -0,0 +1,36 @@ +const test = require('node:test') +const assert = require('node:assert') + +const { parseArgs } = require('../../src/cli/parseArgs') + +test('parseArgs splits command, positional, and flags', () => { + const result = parseArgs(['convert', 'kml-to-mif', 'a.kmz', 'b.kmz', '--output=./out', '--flat']) + assert.strictEqual(result.command, 'convert') + assert.deepStrictEqual(result.positional, ['kml-to-mif', 'a.kmz', 'b.kmz']) + assert.deepStrictEqual(result.flags, { output: './out', flat: true }) +}) + +test('parseArgs treats --no-key as false', () => { + const result = parseArgs(['convert', 'mif-to-xlsx', './in', '--output=./out', '--no-paint-rows', '--skip-black']) + assert.strictEqual(result.flags['paint-rows'], false) + assert.strictEqual(result.flags['skip-black'], true) +}) + +test('parseArgs parses --key=value with embedded equals', () => { + const result = parseArgs(['--combined-name=2026 regions=production']) + assert.strictEqual(result.flags['combined-name'], '2026 regions=production') +}) + +test('parseArgs treats -h, --help, -v, --version as known flags', () => { + assert.deepStrictEqual(parseArgs(['-h']).flags, { help: true }) + assert.deepStrictEqual(parseArgs(['--help']).flags, { help: true }) + assert.deepStrictEqual(parseArgs(['-v']).flags, { version: true }) + assert.deepStrictEqual(parseArgs(['--version']).flags, { version: true }) +}) + +test('parseArgs returns an empty command when argv is empty', () => { + const result = parseArgs([]) + assert.strictEqual(result.command, '') + assert.deepStrictEqual(result.positional, []) + assert.deepStrictEqual(result.flags, {}) +}) diff --git a/test/cli/runCli.test.js b/test/cli/runCli.test.js new file mode 100644 index 0000000..01a28e5 --- /dev/null +++ b/test/cli/runCli.test.js @@ -0,0 +1,121 @@ +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 { runCli } = require('../../src/cli') + +const FIXTURES = path.join(__dirname, '..', 'fixtures') + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'mifkit-cli-')) +} + +function captureIO() { + const out = [] + const err = [] + return { + out: (s) => out.push(s), + err: (s) => err.push(s), + stdout: () => out.join('\n'), + stderr: () => err.join('\n'), + } +} + +test('runCli with no args prints top-level help', async () => { + const io = captureIO() + const code = await runCli([], io) + assert.strictEqual(code, 0) + assert.match(io.stdout(), /MifKit/) + assert.match(io.stdout(), /list/) + assert.match(io.stdout(), /help/) + assert.match(io.stdout(), /convert/) +}) + +test('runCli --version prints package version only', async () => { + const io = captureIO() + const code = await runCli(['--version'], io) + assert.strictEqual(code, 0) + assert.match(io.stdout(), /^\d+\.\d+\.\d+$/m) +}) + +test('runCli list shows every registered converter', async () => { + const io = captureIO() + const code = await runCli(['list'], io) + assert.strictEqual(code, 0) + assert.match(io.stdout(), /mif-to-xlsx/) + assert.match(io.stdout(), /kml-to-mif/) +}) + +test('runCli help shows that converter\'s schema', async () => { + const io = captureIO() + const code = await runCli(['help', 'kml-to-mif'], io) + assert.strictEqual(code, 0) + assert.match(io.stdout(), /kml-to-mif/) + assert.match(io.stdout(), /--flat/) + assert.match(io.stdout(), /--charset/) +}) + +test('runCli help exits 2 and lists alternatives', async () => { + const io = captureIO() + const code = await runCli(['help', 'no-such-converter'], io) + assert.strictEqual(code, 2) + assert.match(io.stderr(), /Unknown converter/) + assert.match(io.stderr(), /mif-to-xlsx/) +}) + +test('runCli convert without args exits 2 with a hint', async () => { + const io = captureIO() + const code = await runCli(['convert'], io) + assert.strictEqual(code, 2) + assert.match(io.stderr(), /Missing converter id/) +}) + +test('runCli convert without --output exits 2', async () => { + const io = captureIO() + const code = await runCli(['convert', 'kml-to-mif', '/tmp/in.kmz'], io) + assert.strictEqual(code, 2) + assert.match(io.stderr(), /Missing --output/) +}) + +test('runCli convert rejects unknown options before invoking the converter', async () => { + const io = captureIO() + const code = await runCli( + ['convert', 'kml-to-mif', '/tmp/in.kmz', '--output=/tmp/out', '--bogus=1'], + io, + ) + assert.strictEqual(code, 2) + assert.match(io.stderr(), /Unknown option --bogus/) +}) + +test('runCli convert kml-to-mif end-to-end produces MIF/MID', async () => { + const inputDir = mkTmp() + const outputDir = mkTmp() + fs.copyFileSync(path.join(FIXTURES, 'sample.kml'), path.join(inputDir, 'sample.kml')) + + const io = captureIO() + const code = await runCli( + [ + 'convert', + 'kml-to-mif', + path.join(inputDir, 'sample.kml'), + `--output=${outputDir}`, + '--flat=true', + '--charset=Neutral', + ], + io, + ) + + assert.strictEqual(code, 0, `expected exit 0, got ${code}, stderr: ${io.stderr()}`) + const outputs = fs.readdirSync(outputDir).sort() + assert.deepStrictEqual(outputs, [ + 'Layer_A.mid', + 'Layer_A.mif', + 'Layer_A__Sublayer.mid', + 'Layer_A__Sublayer.mif', + ]) + // stdout lists output paths + const outputLines = io.stdout().split('\n').filter(Boolean) + assert.ok(outputLines.length >= 4) +})