Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion PROJECT_CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
83 changes: 53 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,53 @@
# 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

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
Expand All @@ -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.
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions src/core/converters/index.js
Original file line number Diff line number Diff line change
@@ -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,
}
69 changes: 69 additions & 0 deletions src/core/converters/mif-to-xlsx/index.js
Original file line number Diff line number Diff line change
@@ -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
131 changes: 131 additions & 0 deletions src/core/converters/registry.js
Original file line number Diff line number Diff line change
@@ -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,
}
Loading
Loading