From 0bcb76f7bff3e4ffcbad6b46ed31605b1689b349 Mon Sep 17 00:00:00 2001 From: Bjornmossa Date: Sat, 9 Nov 2024 22:30:17 +0300 Subject: [PATCH] feat: add package for converting gpx to csv --- lib/arguments.js | 20 ++++++++ lib/file.js | 35 +++++++++++++ lib/gpx.js | 122 ++++++++++++++++++++++++++++++++++++++++++++++ lib/result.js | 12 +++++ main.js | 53 ++++++++++++++++++++ package-lock.json | 28 +++++++++++ package.json | 22 +++++++++ 7 files changed, 292 insertions(+) create mode 100644 lib/arguments.js create mode 100644 lib/file.js create mode 100644 lib/gpx.js create mode 100644 lib/result.js mode change 100644 => 100755 main.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/lib/arguments.js b/lib/arguments.js new file mode 100644 index 0000000..660323b --- /dev/null +++ b/lib/arguments.js @@ -0,0 +1,20 @@ +/** @module arguments */ +/// +import { ERROR, OK } from "./result.js"; + +const NO_INPUT_ERROR_MESSAGE = "No file to process. Enter path to file"; + +/** + * Read argument from nodejs process + * @returns {Result} result of reading argument + */ +function readCliArgument() { + const maybeArgument = process.argv[2]; + if (!maybeArgument || maybeArgument.length === 0) { + return [ERROR, NO_INPUT_ERROR_MESSAGE]; + } + + return [OK, maybeArgument]; +} + +export { readCliArgument }; diff --git a/lib/file.js b/lib/file.js new file mode 100644 index 0000000..4d666d0 --- /dev/null +++ b/lib/file.js @@ -0,0 +1,35 @@ +/** @module file */ +/// +import { ERROR, OK } from "./result.js"; +import { readFile as readF, writeFile as writeF } from "fs/promises"; + +const FILE_READ_ERROR_MESSAGE = "Error reading file from disk"; +const FILE_WRITE_ERROR_MESSAGE = "Error writing file to disk"; + +/** + * Try to read file from pathname + * @returns {Result} result of reading file + */ +async function readFile(filePath) { + try { + const data = await readF(filePath, "utf8"); + return [OK, data]; + } catch (err) { + return [ERROR, `${FILE_READ_ERROR_MESSAGE}: ${err}`]; + } +} + +/** + * Try to write file to disk + * @returns {Result} result of writing file + */ +async function writeFile(filename, content) { + try { + await writeF(filename, content); + return [OK, `File ${filename} created`]; + } catch (err) { + return [ERROR, `${FILE_WRITE_ERROR_MESSAGE}: ${err}`]; + } +} + +export { readFile, writeFile }; diff --git a/lib/gpx.js b/lib/gpx.js new file mode 100644 index 0000000..ee9acab --- /dev/null +++ b/lib/gpx.js @@ -0,0 +1,122 @@ +/** @module gpx */ +/// + +/** + * @typedef {[String, String, String]} Segment + * A tuple of latitude, longtitude and time. + */ + +import { parseXml } from "@rgrove/parse-xml"; +import { ERROR, OK } from "./result.js"; + +/** + * Checks if the given JSON object is an element. Utility function for working + * with JsonObject converted from XML. + * + * @param {JsonObject} object object to check. + * @returns {boolean} True if the object.type is an element, false otherwise. + */ +const isElement = (object) => object?.type === "element"; + +/** + * Checks if the given JSON object is an GPS track object. + * Utility function for working with JsonObject converted from XML. + * + * @param {JsonObject} object to check. + * @returns {boolean} True if the object.name is trk, false otherwise. + */ +const isTrack = (object) => object?.name === "trk"; + +/** + * Checks if the given JSON object is an GPS track segment object. + * Utility function for working with JsonObject converted from XML. + * + * @param {JsonObject} object to check. + * @returns {boolean} True if the object.name is trkseg, false otherwise. + */ +const isTrackSegment = (object) => object?.name === "trkseg"; + +/** + * Checks if the given JSON object is an GPS track segment time object. + * Utility function for working with JsonObject converted from XML. + * + * @param {JsonObject} object to check. + * @returns {boolean} True if the object.name is time, false otherwise. + */ +const isSegmentTime = (object) => object?.name === "time"; + +/** + * Checks if the given JSON object have all nesessary data. + * Utility function for working with JsonObject converted from XML. + * + * @param {JsonObject} object to check. + * @returns {boolean} True if the object have latitude longtitude and time. + */ +const isProperSegment = (object) => { + const haveLat = object?.attributes?.lat !== undefined; + const haveLon = object?.attributes?.lon !== undefined; + const children = object?.children ?? []; + const timeObject = children.filter(isSegmentTime)?.[0]; + const haveTime = + timeObject !== undefined && timeObject.children[0].text.length > 0; + + return haveLat && haveLon && haveTime; +}; + +/** + * Take longtitude, latitude and time from JSON object + * and return it as Segment tuple. + * + * @param {JsonObject} object to check. + * @returns {Segment} True if the object have latitude longtitude and time. + */ +const getSegmentData = (object) => { + const segmentTime = object.children.filter(isSegmentTime)[0].children[0].text; + return [object.attributes.lat, object.attributes.lon, segmentTime]; +}; + +/** + * Parse XML document, turn it into JSON and find track segments, + * then transform every object to Segment tuple + * + * @param {String} xmlDoc string representation of XML document. + * @returns {Result<[Segment]>} + */ +function parse(xmlDoc) { + // Check input argument + if (!xmlDoc || xmlDoc.length === 0) { + return [ERROR, "Can not parse empty document"]; + } + + const gpxObject = parseXml(xmlDoc); + const root = gpxObject.toJSON().children?.[0]; + + if (!root) { + return [ERROR, "Document doesn't contain root element"]; + } + + const track = root.children.filter(isElement).filter(isTrack)?.[0]; + + if (!track) { + return [ERROR, "Document doesn't contain track element"]; + } + + const segments = track.children + .filter(isElement) + .filter(isTrackSegment) + .reduce( + (seg1, seg2) => [...(seg1?.children ?? []), ...(seg2?.children ?? [])], + [], + ) + .filter(isElement) + .filter(isProperSegment) + .map(getSegmentData); + + if (segments.length === 0) { + return [ERROR, "There are no track segments to process"]; + } + + return [OK, segments]; +} + +export { parse }; diff --git a/lib/result.js b/lib/result.js new file mode 100644 index 0000000..2304af9 --- /dev/null +++ b/lib/result.js @@ -0,0 +1,12 @@ +/** @module result */ + +/** + * @template T + * @typedef {[Symbol, T]} Result + * A tuple that contains a Symbol (OK or ERROR) and any value of type T. + */ + +const OK = Symbol("OK"); +const ERROR = Symbol("ERROR"); + +export { OK, ERROR }; diff --git a/main.js b/main.js old mode 100644 new mode 100755 index e69de29..f0203df --- a/main.js +++ b/main.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import { exit } from "process"; +import { resolve, basename } from "path"; +import { readCliArgument } from "./lib/arguments.js"; +import { ERROR } from "./lib/result.js"; +import { readFile, writeFile } from "./lib/file.js"; +import { parse } from "./lib/gpx.js"; + +async function main() { + // Read cli argument. Exit if there are no one. + const argumentReadingResult = readCliArgument(); + + if (argumentReadingResult[0] === ERROR) { + console.error(argumentReadingResult[1]); + exit(1); + } + + const filePath = resolve(argumentReadingResult[1]); + + // Try to open file. Exit if some errors. + const fileReadingResult = await readFile(filePath); + + if (fileReadingResult[0] === ERROR) { + console.error(fileReadingResult[1]); + exit(1); + } + + const parsedSegments = parse(fileReadingResult[1]); + + if (parsedSegments[0] === ERROR) { + console.error(parsedSegments[1]); + exit(1); + } + + const segmentsToCsv = parsedSegments[1] + .map((segment) => segment.join(",")) + .join("\n"); + + const baseFileName = basename(filePath, ".gpx"); + const fileName = resolve(`${baseFileName}.csv`); + const writeFileResult = await writeFile(fileName, segmentsToCsv); + + if (writeFileResult[0] === ERROR) { + console.error(writeFileResult[1]); + exit(1); + } else { + console.log(writeFileResult[1]); + exit(0); + } +} + +main(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e08d048 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "gpx-sequencer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gpx-sequencer", + "version": "1.0.0", + "license": "GPL-3.0-or-later", + "dependencies": { + "@rgrove/parse-xml": "^4.2.0" + }, + "bin": { + "gpx-sequencer": "main.js" + } + }, + "node_modules/@rgrove/parse-xml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@rgrove/parse-xml/-/parse-xml-4.2.0.tgz", + "integrity": "sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==", + "license": "ISC", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..be7e807 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "gpx-sequencer", + "version": "1.0.0", + "description": "Tiny project for converting gpx data to other formats thac can be readable with SuperCollider software and used as a source for music composition", + "main": "main.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://gitea.bjornmossa.net/Bjornmossa/gpx-sequencer.git" + }, + "bin": { + "gpx-sequencer": "./main.js" + }, + "author": "Bjornmossa", + "license": "GPL-3.0-or-later", + "type": "module", + "dependencies": { + "@rgrove/parse-xml": "^4.2.0" + } +}