|
#!/usr/bin/env node |
|
|
|
const fs = require('fs'); |
|
const path = require('path'); |
|
|
|
function usage() { |
|
console.log([ |
|
'Usage:', |
|
' node scripts/potensic-to-litchi-csv.js <input> [output] [--format csv|kml|json] [--no-hover-action] [--no-tour]', |
|
'', |
|
'Forward conversion (Potensic JSON -> Litchi CSV/KML):', |
|
' node scripts/potensic-to-litchi-csv.js 1771856078198.json', |
|
' node scripts/potensic-to-litchi-csv.js 1771856078198.json --format kml', |
|
'', |
|
'Reverse conversion (Litchi LCHM -> Potensic JSON):', |
|
' node scripts/potensic-to-litchi-csv.js 1771856078198.litchi.lchm --format json' |
|
].join('\n')); |
|
} |
|
|
|
function toNumber(value, fieldName, index) { |
|
const n = Number(value); |
|
if (!Number.isFinite(n)) { |
|
throw new Error(`Invalid numeric value for ${fieldName} at waypoint ${index + 1}: ${value}`); |
|
} |
|
return n; |
|
} |
|
|
|
function normalizeHeading(deg) { |
|
const wrapped = deg % 360; |
|
return wrapped < 0 ? wrapped + 360 : wrapped; |
|
} |
|
|
|
function denormalizeHeading(deg) { |
|
const n = normalizeHeading(deg); |
|
return n > 180 ? n - 360 : n; |
|
} |
|
|
|
function inferCameraAction(action) { |
|
switch ((action || '').toUpperCase()) { |
|
case 'PHOTO': |
|
return { type: 1, param: 0 }; // Take Photo |
|
case 'START_RECORD': |
|
return { type: 2, param: 0 }; // Start Recording |
|
case 'STOP_RECORD': |
|
return { type: 3, param: 0 }; // Stop Recording |
|
default: |
|
return null; |
|
} |
|
} |
|
|
|
function actionTypeToPotensic(actionTypes) { |
|
if (actionTypes.includes(2)) { |
|
return 'START_RECORD'; |
|
} |
|
if (actionTypes.includes(3)) { |
|
return 'STOP_RECORD'; |
|
} |
|
if (actionTypes.includes(1)) { |
|
return 'PHOTO'; |
|
} |
|
return 'NONE'; |
|
} |
|
|
|
function csvEscape(value) { |
|
const s = String(value); |
|
if (s.includes(',') || s.includes('"') || s.includes('\n')) { |
|
return '"' + s.replace(/"/g, '""') + '"'; |
|
} |
|
return s; |
|
} |
|
|
|
function xmlEscape(value) { |
|
return String(value) |
|
.replace(/&/g, '&') |
|
.replace(/</g, '<') |
|
.replace(/>/g, '>') |
|
.replace(/"/g, '"') |
|
.replace(/'/g, '''); |
|
} |
|
|
|
function haversineMeters(aLat, aLng, bLat, bLng) { |
|
const r = 6371000; |
|
const dLat = ((bLat - aLat) * Math.PI) / 180; |
|
const dLng = ((bLng - aLng) * Math.PI) / 180; |
|
const la1 = (aLat * Math.PI) / 180; |
|
const la2 = (bLat * Math.PI) / 180; |
|
const s1 = Math.sin(dLat / 2); |
|
const s2 = Math.sin(dLng / 2); |
|
const a = s1 * s1 + Math.cos(la1) * Math.cos(la2) * s2 * s2; |
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); |
|
return r * c; |
|
} |
|
|
|
function clamp(value, min, max) { |
|
return Math.min(max, Math.max(min, value)); |
|
} |
|
|
|
function gimbalPitchToTilt(gimbalPitch) { |
|
return clamp(90 + gimbalPitch, 0, 90); |
|
} |
|
|
|
function roundTo(value, decimals) { |
|
const p = 10 ** decimals; |
|
return Math.round(value * p) / p; |
|
} |
|
|
|
function buildCsvHeader() { |
|
const cols = [ |
|
'latitude', |
|
'longitude', |
|
'altitude(m)', |
|
'heading(deg)', |
|
'curvesize(m)', |
|
'rotationdir', |
|
'gimbalmode', |
|
'gimbalpitchangle' |
|
]; |
|
|
|
for (let i = 1; i <= 15; i += 1) { |
|
cols.push(`actiontype${i}`, `actionparam${i}`); |
|
} |
|
|
|
cols.push( |
|
'altitudemode', |
|
'speed(m/s)', |
|
'poi_latitude', |
|
'poi_longitude', |
|
'poi_altitude(m)', |
|
'poi_altitudemode', |
|
'photo_timeinterval', |
|
'photo_distinterval' |
|
); |
|
|
|
return cols; |
|
} |
|
|
|
function buildCsvWaypointRow(wp, index, includeHoverAction) { |
|
const lat = toNumber(wp.lat, 'lat', index); |
|
const lng = toNumber(wp.lng, 'lng', index); |
|
const alt = toNumber(wp.height, 'height', index); |
|
const yaw = toNumber(wp.yaw ?? 0, 'yaw', index); |
|
const gimbalPitch = toNumber(wp.gimbalPitch ?? 0, 'gimbalPitch', index); |
|
const speed = Number.isFinite(Number(wp.speed)) ? Number(wp.speed) : 0; |
|
const hoverTimeSec = Number.isFinite(Number(wp.hoverTime)) ? Number(wp.hoverTime) : 0; |
|
|
|
const row = [ |
|
lat, |
|
lng, |
|
alt, |
|
normalizeHeading(yaw), |
|
0, |
|
0, |
|
2, |
|
gimbalPitch |
|
]; |
|
|
|
const actions = []; |
|
|
|
if (includeHoverAction && hoverTimeSec > 0) { |
|
actions.push({ type: 0, param: Math.round(hoverTimeSec * 1000) }); |
|
} |
|
|
|
const cameraAction = inferCameraAction(wp.action); |
|
if (cameraAction) { |
|
actions.push(cameraAction); |
|
} |
|
|
|
while (actions.length < 15) { |
|
actions.push({ type: -1, param: 0 }); |
|
} |
|
|
|
actions.slice(0, 15).forEach((a) => { |
|
row.push(a.type, a.param); |
|
}); |
|
|
|
row.push( |
|
0, |
|
speed, |
|
0, |
|
0, |
|
0, |
|
0, |
|
0, |
|
0 |
|
); |
|
|
|
return row; |
|
} |
|
|
|
function buildCsv(parsed, includeHoverAction) { |
|
const header = buildCsvHeader(); |
|
const rows = [header]; |
|
|
|
parsed.forEach((wp, idx) => { |
|
rows.push(buildCsvWaypointRow(wp, idx, includeHoverAction)); |
|
}); |
|
|
|
return rows.map((r) => r.map(csvEscape).join(',')).join('\n') + '\n'; |
|
} |
|
|
|
function buildKmlTour(parsed, defaultSpeed) { |
|
const lines = []; |
|
lines.push(' <gx:Tour>'); |
|
lines.push(' <name>Auto Fly Tour</name>'); |
|
lines.push(' <gx:Playlist>'); |
|
|
|
parsed.forEach((wp, idx) => { |
|
const lat = toNumber(wp.lat, 'lat', idx); |
|
const lng = toNumber(wp.lng, 'lng', idx); |
|
const alt = toNumber(wp.height, 'height', idx); |
|
const yaw = Number.isFinite(Number(wp.yaw)) ? normalizeHeading(Number(wp.yaw)) : 0; |
|
const gimbal = Number.isFinite(Number(wp.gimbalPitch)) ? Number(wp.gimbalPitch) : 0; |
|
const speed = Number.isFinite(Number(wp.speed)) && Number(wp.speed) > 0 ? Number(wp.speed) : defaultSpeed; |
|
|
|
let duration = 1.5; |
|
if (idx > 0) { |
|
const prev = parsed[idx - 1]; |
|
const dist = haversineMeters(Number(prev.lat), Number(prev.lng), lat, lng); |
|
duration = clamp(dist / speed, 0.5, 8.0); |
|
} |
|
|
|
const tilt = gimbalPitchToTilt(gimbal); |
|
const range = clamp(Math.max(20, alt * 2), 20, 500); |
|
|
|
lines.push(' <gx:FlyTo>'); |
|
lines.push(` <gx:duration>${duration.toFixed(2)}</gx:duration>`); |
|
lines.push(' <gx:flyToMode>smooth</gx:flyToMode>'); |
|
lines.push(' <LookAt>'); |
|
lines.push(` <longitude>${lng}</longitude>`); |
|
lines.push(` <latitude>${lat}</latitude>`); |
|
lines.push(` <altitude>${alt}</altitude>`); |
|
lines.push(` <heading>${yaw}</heading>`); |
|
lines.push(` <tilt>${tilt}</tilt>`); |
|
lines.push(` <range>${range}</range>`); |
|
lines.push(' <altitudeMode>relativeToGround</altitudeMode>'); |
|
lines.push(' </LookAt>'); |
|
lines.push(' </gx:FlyTo>'); |
|
}); |
|
|
|
lines.push(' </gx:Playlist>'); |
|
lines.push(' </gx:Tour>'); |
|
|
|
return lines; |
|
} |
|
|
|
function buildKml(parsed, docName, includeTour) { |
|
const lines = []; |
|
lines.push('<?xml version="1.0" encoding="UTF-8"?>'); |
|
lines.push('<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">'); |
|
lines.push(' <Document>'); |
|
lines.push(` <name>${xmlEscape(docName)}</name>`); |
|
lines.push(' <Style id="wp"><IconStyle><scale>0.8</scale><Icon><href>http://maps.google.com/mapfiles/kml/paddle/wht-circle.png</href></Icon></IconStyle></Style>'); |
|
lines.push(' <Style id="path"><LineStyle><color>ff00a5ff</color><width>3</width></LineStyle></Style>'); |
|
lines.push(' <Placemark>'); |
|
lines.push(' <name>Flight Path</name>'); |
|
lines.push(' <styleUrl>#path</styleUrl>'); |
|
lines.push(' <LineString>'); |
|
lines.push(' <tessellate>1</tessellate>'); |
|
lines.push(' <altitudeMode>relativeToGround</altitudeMode>'); |
|
lines.push(' <coordinates>'); |
|
|
|
parsed.forEach((wp, idx) => { |
|
const lng = toNumber(wp.lng, 'lng', idx); |
|
const lat = toNumber(wp.lat, 'lat', idx); |
|
const alt = toNumber(wp.height, 'height', idx); |
|
lines.push(` ${lng},${lat},${alt}`); |
|
}); |
|
|
|
lines.push(' </coordinates>'); |
|
lines.push(' </LineString>'); |
|
lines.push(' </Placemark>'); |
|
|
|
parsed.forEach((wp, idx) => { |
|
const lng = toNumber(wp.lng, 'lng', idx); |
|
const lat = toNumber(wp.lat, 'lat', idx); |
|
const alt = toNumber(wp.height, 'height', idx); |
|
const yaw = Number.isFinite(Number(wp.yaw)) ? Number(wp.yaw) : 0; |
|
const gimbal = Number.isFinite(Number(wp.gimbalPitch)) ? Number(wp.gimbalPitch) : 0; |
|
const speed = Number.isFinite(Number(wp.speed)) ? Number(wp.speed) : 0; |
|
const hover = Number.isFinite(Number(wp.hoverTime)) ? Number(wp.hoverTime) : 0; |
|
const action = xmlEscape(wp.action || 'NONE'); |
|
const fileName = xmlEscape(wp.fileName || ''); |
|
|
|
lines.push(' <Placemark>'); |
|
lines.push(` <name>WP ${String(idx + 1).padStart(2, '0')} - ${action}</name>`); |
|
lines.push(' <styleUrl>#wp</styleUrl>'); |
|
lines.push(' <description><![CDATA[' + |
|
`file=${fileName}<br/>speed=${speed} m/s<br/>yaw=${yaw} deg<br/>gimbalPitch=${gimbal} deg<br/>hover=${hover}s` + |
|
']]></description>'); |
|
lines.push(' <Point>'); |
|
lines.push(' <altitudeMode>relativeToGround</altitudeMode>'); |
|
lines.push(` <coordinates>${lng},${lat},${alt}</coordinates>`); |
|
lines.push(' </Point>'); |
|
lines.push(' </Placemark>'); |
|
}); |
|
|
|
if (includeTour) { |
|
buildKmlTour(parsed, 5).forEach((line) => lines.push(line)); |
|
} |
|
|
|
lines.push(' </Document>'); |
|
lines.push('</kml>'); |
|
|
|
return lines.join('\n') + '\n'; |
|
} |
|
|
|
function readInt32BE(buffer, offset) { |
|
return buffer.readInt32BE(offset); |
|
} |
|
|
|
function readUInt32BE(buffer, offset) { |
|
return buffer.readUInt32BE(offset); |
|
} |
|
|
|
function readFloatBE(buffer, offset) { |
|
return buffer.readFloatBE(offset); |
|
} |
|
|
|
function readDoubleBE(buffer, offset) { |
|
return buffer.readDoubleBE(offset); |
|
} |
|
|
|
function isFiniteInRange(value, min, max) { |
|
return Number.isFinite(value) && value >= min && value <= max; |
|
} |
|
|
|
function looksLikeWaypointCore(buffer, offset) { |
|
if (offset < 0 || offset + 56 > buffer.length) { |
|
return false; |
|
} |
|
|
|
const height = readFloatBE(buffer, offset + 0); |
|
const yaw = readFloatBE(buffer, offset + 8); |
|
const speed = readFloatBE(buffer, offset + 12); |
|
const lat = readDoubleBE(buffer, offset + 20); |
|
const lng = readDoubleBE(buffer, offset + 28); |
|
const gimbalPitch = readInt32BE(buffer, offset + 44); |
|
|
|
if (!isFiniteInRange(lat, -90, 90) || !isFiniteInRange(lng, -180, 180)) { |
|
return false; |
|
} |
|
if (!isFiniteInRange(height, -200, 2000) || !isFiniteInRange(speed, 0, 100)) { |
|
return false; |
|
} |
|
if (!isFiniteInRange(yaw, -720, 720) || !isFiniteInRange(gimbalPitch, -180, 180)) { |
|
return false; |
|
} |
|
|
|
return true; |
|
} |
|
|
|
function looksLikeActionBlock72(buffer, offset) { |
|
if (offset < 0 || offset + 72 > buffer.length) { |
|
return false; |
|
} |
|
|
|
const actionType1 = readInt32BE(buffer, offset + 52); |
|
const actionType2 = readInt32BE(buffer, offset + 64); |
|
const actionParam1 = readInt32BE(buffer, offset + 60); |
|
const actionParam2 = readInt32BE(buffer, offset + 68); |
|
|
|
const actionTypeOk = isFiniteInRange(actionType1, -1, 20) && isFiniteInRange(actionType2, -1, 20); |
|
const actionParamOk = isFiniteInRange(actionParam1, -1, 2000000) && isFiniteInRange(actionParam2, -1, 2000000); |
|
|
|
return actionTypeOk && actionParamOk; |
|
} |
|
|
|
function parseLchm(buffer) { |
|
if (buffer.length < 44) { |
|
throw new Error('LCHM file is too small.'); |
|
} |
|
|
|
const magic = buffer.toString('ascii', 0, 4); |
|
if (magic !== 'lchm') { |
|
throw new Error('Input is not an LCHM file (missing lchm magic).'); |
|
} |
|
|
|
const waypointCount = readUInt32BE(buffer, 40); |
|
const recordOffset = 44; |
|
const fullRecordSize = 72; |
|
const shortRecordSize = 56; |
|
|
|
if (waypointCount === 0) { |
|
return []; |
|
} |
|
|
|
const points = []; |
|
let cursor = recordOffset; |
|
|
|
for (let i = 0; i < waypointCount; i += 1) { |
|
if (!looksLikeWaypointCore(buffer, cursor)) { |
|
let found = -1; |
|
for (let delta = 4; delta <= 1024; delta += 4) { |
|
const candidate = cursor + delta; |
|
if (looksLikeWaypointCore(buffer, candidate)) { |
|
found = candidate; |
|
break; |
|
} |
|
} |
|
if (found === -1) { |
|
throw new Error(`Unable to locate waypoint record ${i + 1} near byte offset ${cursor}.`); |
|
} |
|
cursor = found; |
|
} |
|
|
|
const o = cursor; |
|
let recordSize = fullRecordSize; |
|
|
|
const height = readFloatBE(buffer, o + 0); |
|
const yaw = readFloatBE(buffer, o + 8); |
|
const speed = readFloatBE(buffer, o + 12); |
|
const lat = readDoubleBE(buffer, o + 20); |
|
const lng = readDoubleBE(buffer, o + 28); |
|
const gimbalPitch = readInt32BE(buffer, o + 44); |
|
|
|
let actionType1 = -1; |
|
let actionParam1 = 0; |
|
let actionType2 = -1; |
|
let actionParam2 = 0; |
|
|
|
const hasActionBlock = looksLikeActionBlock72(buffer, o); |
|
const hasShortNext = looksLikeWaypointCore(buffer, o + shortRecordSize); |
|
if (hasActionBlock && !hasShortNext) { |
|
actionType1 = readInt32BE(buffer, o + 52); |
|
actionParam1 = readInt32BE(buffer, o + 60); |
|
actionType2 = readInt32BE(buffer, o + 64); |
|
actionParam2 = readInt32BE(buffer, o + 68); |
|
recordSize = fullRecordSize; |
|
} else if (hasActionBlock && hasShortNext) { |
|
const nextFullAlsoValid = looksLikeWaypointCore(buffer, o + fullRecordSize); |
|
if (nextFullAlsoValid) { |
|
actionType1 = readInt32BE(buffer, o + 52); |
|
actionParam1 = readInt32BE(buffer, o + 60); |
|
actionType2 = readInt32BE(buffer, o + 64); |
|
actionParam2 = readInt32BE(buffer, o + 68); |
|
recordSize = fullRecordSize; |
|
} else { |
|
recordSize = shortRecordSize; |
|
} |
|
} else if (!hasActionBlock && hasShortNext) { |
|
recordSize = shortRecordSize; |
|
} else if (hasActionBlock) { |
|
actionType1 = readInt32BE(buffer, o + 52); |
|
actionParam1 = readInt32BE(buffer, o + 60); |
|
actionType2 = readInt32BE(buffer, o + 64); |
|
actionParam2 = readInt32BE(buffer, o + 68); |
|
recordSize = fullRecordSize; |
|
} else { |
|
recordSize = shortRecordSize; |
|
} |
|
|
|
const actionTypes = [actionType1, actionType2].filter((t) => t >= 0); |
|
const hoverMsCandidates = []; |
|
if (actionType1 === 0) { |
|
hoverMsCandidates.push(actionParam1); |
|
} |
|
if (actionType2 === 0) { |
|
hoverMsCandidates.push(actionParam2); |
|
} |
|
|
|
const hoverMs = hoverMsCandidates.length > 0 ? Math.max(...hoverMsCandidates) : 0; |
|
|
|
points.push({ |
|
action: actionTypeToPotensic(actionTypes), |
|
fileName: 'point.jpg', |
|
gimbalPitch: Math.round(gimbalPitch), |
|
gimbalType: 'DEFINE', |
|
height: roundTo(height, 1), |
|
hoverTime: roundTo(hoverMs / 1000, 3), |
|
lat: roundTo(lat, 10), |
|
lng: roundTo(lng, 10), |
|
poiHeight: 50.0, |
|
poiLat: 0.0, |
|
poiLng: 0.0, |
|
poiType: 0, |
|
speed: roundTo(speed, 2), |
|
speedType: 'GLOBAL', |
|
yaw: Math.round(denormalizeHeading(yaw)), |
|
yawType: 'DEFINE', |
|
zoomRatio: 1.0, |
|
zoomType: 'DEFINE' |
|
}); |
|
|
|
cursor += recordSize; |
|
} |
|
|
|
while (points.length > 0) { |
|
const last = points[points.length - 1]; |
|
const isTrailingEmpty = last.action === 'NONE' |
|
&& last.lat === 0 |
|
&& last.lng === 0 |
|
&& last.height === 0 |
|
&& last.speed === 0 |
|
&& last.hoverTime === 0; |
|
if (!isTrailingEmpty) { |
|
break; |
|
} |
|
points.pop(); |
|
} |
|
|
|
return points; |
|
} |
|
|
|
function parseFormat(args, outputPath) { |
|
const idx = args.indexOf('--format'); |
|
if (idx !== -1) { |
|
const value = args[idx + 1]; |
|
if (!value) { |
|
throw new Error('Missing value for --format. Use csv, kml, or json.'); |
|
} |
|
if (value !== 'csv' && value !== 'kml' && value !== 'json') { |
|
throw new Error(`Unsupported format: ${value}. Use csv, kml, or json.`); |
|
} |
|
return value; |
|
} |
|
|
|
const lower = outputPath.toLowerCase(); |
|
if (lower.endsWith('.kml')) { |
|
return 'kml'; |
|
} |
|
if (lower.endsWith('.json')) { |
|
return 'json'; |
|
} |
|
|
|
return 'csv'; |
|
} |
|
|
|
function parsePositional(args) { |
|
const positional = []; |
|
for (let i = 0; i < args.length; i += 1) { |
|
const a = args[i]; |
|
if (a === '--format') { |
|
i += 1; |
|
continue; |
|
} |
|
if (!a.startsWith('--')) { |
|
positional.push(a); |
|
} |
|
} |
|
return positional; |
|
} |
|
|
|
function main() { |
|
const args = process.argv.slice(2); |
|
|
|
if (args.length < 1 || args.includes('--help') || args.includes('-h')) { |
|
usage(); |
|
process.exit(args.length < 1 ? 1 : 0); |
|
} |
|
|
|
const includeHoverAction = !args.includes('--no-hover-action'); |
|
const includeTour = !args.includes('--no-tour'); |
|
|
|
const positional = parsePositional(args); |
|
|
|
if (positional.length < 1) { |
|
throw new Error('Missing input path.'); |
|
} |
|
|
|
const inputPath = path.resolve(positional[0]); |
|
const inputExt = path.extname(inputPath).toLowerCase(); |
|
const outputPathArg = positional[1] ? path.resolve(positional[1]) : null; |
|
|
|
const baseNoExt = path.resolve(path.dirname(inputPath), path.basename(inputPath, path.extname(inputPath))); |
|
const inferredOutput = outputPathArg |
|
|| (inputExt === '.lchm' ? `${baseNoExt}.potensic.json` : `${baseNoExt}.litchi.csv`); |
|
|
|
const format = parseFormat(args, inferredOutput); |
|
|
|
if (inputExt === '.json') { |
|
if (format === 'json') { |
|
throw new Error('JSON input cannot be converted to JSON output in this tool. Use csv or kml.'); |
|
} |
|
|
|
const raw = fs.readFileSync(inputPath, 'utf8'); |
|
const parsed = JSON.parse(raw); |
|
|
|
if (!Array.isArray(parsed) || parsed.length === 0) { |
|
throw new Error('Input JSON must be a non-empty array of waypoint objects.'); |
|
} |
|
|
|
const finalOutput = outputPathArg || (format === 'kml' ? `${baseNoExt}.kml` : `${baseNoExt}.litchi.csv`); |
|
const outputText = format === 'kml' |
|
? buildKml(parsed, path.basename(baseNoExt), includeTour) |
|
: buildCsv(parsed, includeHoverAction); |
|
|
|
fs.writeFileSync(finalOutput, outputText, 'utf8'); |
|
console.log(`Wrote ${parsed.length} waypoints to ${finalOutput}`); |
|
|
|
if (format === 'csv') { |
|
console.log('Import in Litchi Mission Hub, then set: Heading Mode = Custom (WD), Path Mode = Straight Lines.'); |
|
} else if (includeTour) { |
|
console.log('KML includes gx:Tour. In Google Earth Pro, open Places and click Play Tour on "Auto Fly Tour".'); |
|
} else { |
|
console.log('KML generated without gx:Tour.'); |
|
} |
|
return; |
|
} |
|
|
|
if (inputExt === '.lchm') { |
|
if (format !== 'json') { |
|
throw new Error('LCHM input currently supports only --format json.'); |
|
} |
|
|
|
const buffer = fs.readFileSync(inputPath); |
|
const parsed = parseLchm(buffer); |
|
const finalOutput = outputPathArg || `${baseNoExt}.potensic.json`; |
|
|
|
fs.writeFileSync(finalOutput, JSON.stringify(parsed, null, 2) + '\n', 'utf8'); |
|
console.log(`Wrote ${parsed.length} waypoints to ${finalOutput}`); |
|
console.log('Reverse-converted from LCHM waypoint table to Potensic-style JSON schema.'); |
|
return; |
|
} |
|
|
|
throw new Error(`Unsupported input extension: ${inputExt}. Use .json or .lchm.`); |
|
} |
|
|
|
main(); |