Implementing API - WIP frontend
This commit is contained in:
@@ -0,0 +1,801 @@
|
||||
/**
|
||||
* File handler module for data directory operations.
|
||||
* Provides file classes with built-in parsing for GPX and VMD formats.
|
||||
*
|
||||
* @module fileHandler
|
||||
*
|
||||
* @requires node:fs/promises
|
||||
* @requires node:fs
|
||||
* @requires node:path
|
||||
* @requires xml2js
|
||||
*
|
||||
* @see {@link https://nodejs.org/api/fs.html}
|
||||
* @see {@link https://www.topografix.com/GPX/1/1/}
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import {join, resolve, extname, dirname, basename, relative as pathRelative} from "node:path";
|
||||
import {parseStringPromise} from "xml2js";
|
||||
|
||||
/**
|
||||
* Allowed file extensions for data operations.
|
||||
* @readonly
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
const ALLOWED_EXTENSIONS = new Set([".vmd", ".gpx", ".json", ".txt", ".xml"]);
|
||||
|
||||
/**
|
||||
* Maximum file size for read/write operations (10MB).
|
||||
* @readonly
|
||||
* @type {number}
|
||||
*/
|
||||
const MAX_FILE_SIZE = 10485760;
|
||||
|
||||
/**
|
||||
* Resolve a safe file path within a base directory.
|
||||
* Prevents directory traversal and null byte injection.
|
||||
*
|
||||
* @param {string} baseDir - Base directory for resolution
|
||||
* @param {string} relativePath - User-provided relative path
|
||||
* @returns {string|null} Resolved absolute path, or null if unsafe
|
||||
*/
|
||||
function resolveSafePath(baseDir, relativePath) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(relativePath);
|
||||
if (decoded.includes("\0")) return null;
|
||||
const resolved = resolve(baseDir, decoded);
|
||||
const normalizedBase = resolve(baseDir);
|
||||
if (!resolved.startsWith(normalizedBase + "/") && resolved !== normalizedBase) return null;
|
||||
return resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file extension is allowed.
|
||||
*
|
||||
* @param {string} filePath - Path to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidExtension(filePath) {
|
||||
return ALLOWED_EXTENSIONS.has(extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VMDReference
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Represents a VMD reference parsed from bracket notation.
|
||||
* Resolves references like `<DK/Capital.København>` to file paths on disk.
|
||||
*
|
||||
* @class VMDReference
|
||||
*/
|
||||
class VMDReference {
|
||||
path = null;
|
||||
nested = [];
|
||||
name = "";
|
||||
type;
|
||||
reference = "";
|
||||
|
||||
/**
|
||||
* @param {string} vmdRef - Relative path or name enclosed in `<>` `{}` `[]` `()`
|
||||
*/
|
||||
constructor(vmdRef, dataDir) {
|
||||
this.reference = vmdRef;
|
||||
this.validate(dataDir);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the reference string and resolves the file path.
|
||||
* Supports bracket nesting up to depth 2.
|
||||
*
|
||||
* @returns {this}
|
||||
* @throws {Error} On invalid brackets, missing type, or unclosed brackets
|
||||
*/
|
||||
validate(dataDir) {
|
||||
const bracketPairs = {"<": ">", "{": "}", "[": "]", "(": ")"};
|
||||
const openingBrackets = new Set(Object.keys(bracketPairs));
|
||||
const closingBrackets = new Set(Object.values(bracketPairs));
|
||||
|
||||
let stack = [];
|
||||
let maxDepth = 0;
|
||||
let cleanChars = [];
|
||||
let nestedContent = [];
|
||||
let nestedPositions = [];
|
||||
|
||||
for (let i = 0; i < this.reference.length; i++) {
|
||||
const char = this.reference[i];
|
||||
if (openingBrackets.has(char)) {
|
||||
stack.push(char);
|
||||
maxDepth = Math.max(maxDepth, stack.length);
|
||||
|
||||
if (maxDepth > 2)
|
||||
throw new Error(`Bracket nesting depth exceeds maximum of 2 at position ${i}`);
|
||||
|
||||
if (stack.length === 1) {
|
||||
cleanChars.push(char);
|
||||
switch (char) {
|
||||
case "<":
|
||||
this.type = "destinations";
|
||||
break;
|
||||
case "{":
|
||||
this.type = "routes";
|
||||
break;
|
||||
case "[":
|
||||
case "(":
|
||||
this.type = "other";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.length > 1) {
|
||||
nestedPositions.push({depth: stack.length, startIndex: i});
|
||||
}
|
||||
} else if (closingBrackets.has(char)) {
|
||||
if (stack.length === 0)
|
||||
throw new Error(`Unmatched closing bracket '${char}' at position ${i}`);
|
||||
|
||||
const lastOpen = stack.pop();
|
||||
if (bracketPairs[lastOpen] !== char)
|
||||
throw new Error(`Mismatched brackets: '${lastOpen}' vs '${char}'`);
|
||||
|
||||
if (nestedPositions.length > 0 && stack.length === 1) {
|
||||
const pos = nestedPositions.pop();
|
||||
nestedContent.push(this.reference.slice(pos.startIndex, i + 1).trim());
|
||||
}
|
||||
if (stack.length === 0) cleanChars.push(char);
|
||||
} else {
|
||||
cleanChars.push(char);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.type === undefined)
|
||||
throw new Error(`${this.reference} is not a reference`, {
|
||||
cause: `Missing type-identifier brackets <..>, {..}, [..] or (..)`,
|
||||
});
|
||||
|
||||
if (stack.length > 0)
|
||||
throw new Error(`Unclosed brackets: ${stack.join("")}`, {
|
||||
cause: {reference: this.reference, stack},
|
||||
});
|
||||
|
||||
const root = join(dataDir, this.type);
|
||||
let filePath = join(root, this.reference.slice(1, -1).trim());
|
||||
|
||||
this.nested = nestedContent.map(ref => {
|
||||
const nested = new VMDReference(ref, dataDir);
|
||||
filePath = filePath.replace(ref, nested.name);
|
||||
return nested;
|
||||
});
|
||||
|
||||
// Remove .vmd extension if present (can be omitted)
|
||||
if (filePath.indexOf(".vmd") !== -1) filePath = filePath.replace(".vmd", "");
|
||||
|
||||
// Build candidate file name
|
||||
const parts = filePath.split("/");
|
||||
const lastSegment = parts[parts.length - 1];
|
||||
const fileName = lastSegment + ".vmd";
|
||||
|
||||
// Search strategies: direct file, file in same-named folder, or .gpx
|
||||
const searchPaths = [filePath + ".vmd", join(filePath, fileName), filePath + ".gpx"];
|
||||
|
||||
for (const path of searchPaths) {
|
||||
if (fsSync.existsSync(resolve(path))) {
|
||||
this.path = path;
|
||||
this.name = path.match(/.*\/(.+?)\.(vmd|gpx)$/);
|
||||
if (this.name !== null) this.name = this.name[1];
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a VMDReference from a relative path string.
|
||||
* Validates the path to prevent directory traversal outside the 'data' folder
|
||||
* before instantiating the reference.
|
||||
*
|
||||
* @static
|
||||
* @param {string} relativePath - The relative path to the file or reference string.
|
||||
* @returns {VMDReference} A new VMDReference instance.
|
||||
* @throws {Error} If the path attempts to escape the data directory or is invalid.
|
||||
*/
|
||||
static fromRelativePath(relativePath) {
|
||||
if (typeof relativePath !== "string" || !relativePath.trim()) {
|
||||
throw new TypeError("Path must be a non-empty string");
|
||||
}
|
||||
|
||||
// Resolve the absolute path relative to the project root
|
||||
const attemptedPath = resolve(process.cwd(), "data", relativePath);
|
||||
const dataDir = resolve(process.cwd(), "data");
|
||||
|
||||
// Security check: Ensure the resolved path starts with the data directory
|
||||
// This prevents "../" exploits trying to access files outside data/
|
||||
if (!attemptedPath.startsWith(dataDir + "/") && attemptedPath !== dataDir) {
|
||||
throw new Error(
|
||||
`Path traversal blocked: "${relativePath}" resolves outside the data directory`
|
||||
);
|
||||
}
|
||||
|
||||
// Create the instance. The constructor will handle bracket parsing and further validation.
|
||||
// We pass the original relative string so the internal logic can parse brackets correctly.
|
||||
return new VMDReference(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DataFile (base)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Base file class for data directory operations.
|
||||
* Handles path resolution, raw read/write, and metadata.
|
||||
*
|
||||
* @class DataFile
|
||||
*
|
||||
* @example
|
||||
* const file = DataFile.open("routes/DK/example.gpx", "data");
|
||||
* await file.read();
|
||||
*
|
||||
* @example
|
||||
* const file = new DataFile("routes/DK/example.vmd", "data");
|
||||
* await file.write("new content");
|
||||
*/
|
||||
class DataFile {
|
||||
/** @type {string} */
|
||||
dataDir;
|
||||
|
||||
/** @type {string|null} */
|
||||
#safePath;
|
||||
|
||||
/** @type {string} */
|
||||
#relativePath;
|
||||
|
||||
/** Raw file content after read().
|
||||
* @type {string|null}
|
||||
*/
|
||||
content = null;
|
||||
|
||||
/**
|
||||
* Create a new DataFile instance.
|
||||
*
|
||||
* @param {string} relativePath - Relative path within the data directory
|
||||
* @param {string} [dataDir] - Base data directory (defaults to cwd/data)
|
||||
*
|
||||
* @throws {Error} When path is unsafe or extension not allowed
|
||||
*/
|
||||
constructor(relativePath, dataDir = join(process.cwd(), "data")) {
|
||||
this.dataDir = dataDir;
|
||||
this.#relativePath = relativePath;
|
||||
this.#safePath = resolveSafePath(dataDir, relativePath);
|
||||
|
||||
if (!this.#safePath) throw new Error(`Invalid path: ${relativePath}`);
|
||||
if (!isValidExtension(this.#safePath))
|
||||
throw new Error(`Invalid file type: ${extname(this.#safePath)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method — opens a file and returns the appropriate subclass.
|
||||
*
|
||||
* @param {string} relativePath - Relative path within the data directory
|
||||
* @param {string} [dataDir] - Base data directory
|
||||
* @returns {DataFile|GpxFile|VmdFile}
|
||||
*
|
||||
* @example
|
||||
* const file = DataFile.open("routes/DK/myroute.gpx", "data");
|
||||
* // Returns GpxFile instance
|
||||
*/
|
||||
static open(relativePath, dataDir) {
|
||||
const ext = extname(relativePath).toLowerCase();
|
||||
if (ext === ".gpx") return new GpxFile(relativePath, dataDir);
|
||||
if (ext === ".vmd") return new VmdFile(relativePath, dataDir);
|
||||
return new DataFile(relativePath, dataDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read raw file content.
|
||||
* @returns {Promise<string|Buffer>}
|
||||
*
|
||||
* @throws {Error} When file not found, too large, or access denied
|
||||
*/
|
||||
async read() {
|
||||
try {
|
||||
const stats = await fs.stat(this.#safePath);
|
||||
|
||||
if (stats.isDirectory()) throw new Error("Cannot read directory");
|
||||
if (stats.size > MAX_FILE_SIZE)
|
||||
throw new Error(`File too large: ${stats.size} bytes (max: ${MAX_FILE_SIZE})`);
|
||||
|
||||
this.content = await fs.readFile(this.#safePath, "utf-8");
|
||||
return this.content;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") throw new Error(`File not found: ${this.#safePath}`);
|
||||
if (error.code === "EACCES") throw new Error(`Access denied: ${this.#safePath}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write content to the file.
|
||||
* Creates parent directories if needed.
|
||||
*
|
||||
* @param {string|Buffer} content - Content to write
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async write(content) {
|
||||
const contentSize = Buffer.byteLength(content, "utf-8");
|
||||
if (contentSize > MAX_FILE_SIZE)
|
||||
throw new Error(`Content too large: ${contentSize} bytes (max: ${MAX_FILE_SIZE})`);
|
||||
|
||||
try {
|
||||
await fs.mkdir(dirname(this.#safePath), {recursive: true});
|
||||
await fs.writeFile(this.#safePath, content, "utf-8");
|
||||
this.content = content;
|
||||
} catch (error) {
|
||||
if (error.code === "EACCES") throw new Error(`Access denied: ${this.#safePath}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the file exists on disk.
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async exists() {
|
||||
try {
|
||||
const stats = await fs.stat(this.#safePath);
|
||||
return stats.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the file from disk.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} When file not found or access denied
|
||||
*/
|
||||
async delete() {
|
||||
try {
|
||||
await fs.unlink(this.#safePath);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") throw new Error(`File not found: ${this.#safePath}`);
|
||||
if (error.code === "EACCES") throw new Error(`Access denied: ${this.#safePath}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata.
|
||||
*
|
||||
* @returns {Promise<Object>} Metadata object with size, mtime, etc.
|
||||
*/
|
||||
async getMeta() {
|
||||
try {
|
||||
const stats = await fs.stat(this.#safePath);
|
||||
return {
|
||||
size: stats.size,
|
||||
mtime: stats.mtime,
|
||||
created: stats.birthtime,
|
||||
isDirectory: stats.isDirectory(),
|
||||
isFile: stats.isFile(),
|
||||
extension: extname(this.#safePath).toLowerCase(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") throw new Error(`File not found: ${this.#safePath}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory within the data folder.
|
||||
*
|
||||
* @static
|
||||
* @param {string} relativePath - Relative path to the directory
|
||||
* @param {string} [dataDir] - Base data directory
|
||||
* @param {Object} [options={}] - Listing options
|
||||
* @param {number} [options.depth=0] - Include n deep subdirectories
|
||||
* @param {boolean} [options.onlyfiles=true] - List files, or if false list directories
|
||||
* @param {string[]} [options.extensions] - Filter by extensions
|
||||
* @returns {Promise<string[]>} Array of relative file paths
|
||||
*
|
||||
* @example
|
||||
* const files = await DataFile.list("routes", "data", { extensions: [".vmd"] });
|
||||
*/
|
||||
static async list(relativePath, dataDir, options = {}) {
|
||||
const {depth = 0, extensions, onlyfiles = true} = options;
|
||||
const safePath = resolveSafePath(dataDir, relativePath);
|
||||
|
||||
if (!safePath) throw new Error("Invalid path: Access denied");
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(safePath);
|
||||
if (!stats.isDirectory()) throw new Error("Not a directory");
|
||||
|
||||
const files = [];
|
||||
|
||||
const scanDir = async (dir, prefix = "", n = depth) => {
|
||||
const entries = await fs.readdir(dir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isFile() && onlyfiles) {
|
||||
if (!extensions || extensions.includes(extname(entry.name).toLowerCase())) {
|
||||
if (relativePath.match("destinations"))
|
||||
files.push(
|
||||
new VMDReference(
|
||||
`<${relativePath.replace("destinations/", "") + "/" + rel}>`,
|
||||
dataDir
|
||||
)
|
||||
);
|
||||
else if (relativePath.match("routes"))
|
||||
files.push(
|
||||
new VMDReference(
|
||||
`{${relativePath.replace("routes/", "") + "/" + rel}}`,
|
||||
dataDir
|
||||
)
|
||||
);
|
||||
else files.push(rel);
|
||||
}
|
||||
} else if (entry.isDirectory()) {
|
||||
if (!onlyfiles) files.push(entry.name);
|
||||
if (n > 0) await scanDir(fullPath, rel, n--);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await scanDir(safePath);
|
||||
return files;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") throw new Error(`Directory not found: ${safePath}`);
|
||||
if (error.code === "EACCES") throw new Error(`Access denied: ${safePath}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a file by trying multiple path variations.
|
||||
* Mirrors VMDReference search strategy.
|
||||
*
|
||||
* @static
|
||||
* @param {string} searchPattern - File name or pattern
|
||||
* @param {string} [dataDir] - Base data directory
|
||||
* @param {string} [subDir] - Optional subdirectory to search in
|
||||
* @returns {Promise<string|null>} Relative path to found file, or null
|
||||
*/
|
||||
static async lookup(searchPattern, dataDir = join(process.cwd(), "data"), subDir = "") {
|
||||
const base = subDir ? join(dataDir, subDir) : dataDir;
|
||||
|
||||
const candidates = [
|
||||
join(base, `${searchPattern}.vmd`),
|
||||
join(base, searchPattern, `${searchPattern}.vmd`),
|
||||
join(base, `${searchPattern}.gpx`),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stats = await fs.stat(candidate);
|
||||
if (stats.isFile()) return pathRelative(dataDir, candidate);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Absolute resolved path. */
|
||||
get path() {
|
||||
return this.#safePath;
|
||||
}
|
||||
|
||||
/** Relative path from data directory. */
|
||||
get relativePath() {
|
||||
return this.#relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GpxFile
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Represents a single GPS track point from a GPX file.
|
||||
*
|
||||
* @typedef {Object} TrackPoint
|
||||
* @property {number} lat - Latitude (decimal degrees, WGS84)
|
||||
* @property {number} lon - Longitude (decimal degrees, WGS84)
|
||||
* @property {number|null} elevation - Elevation in meters, or null
|
||||
*/
|
||||
|
||||
/**
|
||||
* File class for GPX (GPS Exchange Format) files.
|
||||
* Incorporates parsing logic from gpxParser.js.
|
||||
*
|
||||
* @class GpxFile
|
||||
* @extends DataFile
|
||||
*
|
||||
* @example
|
||||
* const file = new GpxFile("routes/DK/myroute.gpx", "data");
|
||||
* await file.parse(ref);
|
||||
* console.log(file.points);
|
||||
*/
|
||||
class GpxFile extends DataFile {
|
||||
/** @type {TrackPoint[]} */
|
||||
points = [];
|
||||
|
||||
/** @type {string} */
|
||||
title = "Untitled Route";
|
||||
|
||||
/** @type {VMDReference|*} */
|
||||
reference = null;
|
||||
|
||||
/**
|
||||
* Read and parse the GPX file.
|
||||
*
|
||||
* @returns {Promise<this>}
|
||||
* @throws {Error} When file cannot be read, XML is malformed, or GPX structure invalid
|
||||
*/
|
||||
async parse() {
|
||||
try {
|
||||
const xml = await this.read();
|
||||
const result = await parseStringPromise(xml, {
|
||||
preserveChildrenOrder: true,
|
||||
explicitArray: true,
|
||||
});
|
||||
|
||||
// Validate GPX structure
|
||||
if (!result.gpx || !result.gpx.trk || result.gpx.trk.length === 0)
|
||||
throw new Error("Invalid GPX: No tracks found in file");
|
||||
|
||||
const tracks = result.gpx.trk;
|
||||
const trkseg = tracks[0].trkseg;
|
||||
if (!trkseg || trkseg.length === 0)
|
||||
throw new Error("Invalid GPX: No track segments found");
|
||||
|
||||
const trkpt = trkseg[0].trkpt;
|
||||
if (!trkpt || trkpt.length === 0) throw new Error("Invalid GPX: No track points found");
|
||||
|
||||
this.points = trkpt.map(elem => {
|
||||
const attrs = elem["$"] || {};
|
||||
const ele = elem.ele ? parseFloat(elem.ele[0]) : null;
|
||||
const lat = parseFloat(attrs.lat);
|
||||
const lon = parseFloat(attrs.lon);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon))
|
||||
throw new Error("Invalid track point: Missing latitude or longitude");
|
||||
|
||||
return {elevation: isNaN(ele) ? null : ele, lat, lon};
|
||||
});
|
||||
|
||||
// Extract metadata
|
||||
const metadata = result.gpx.metadata?.[0] || {};
|
||||
this.title = metadata.name ? metadata.name[0] : "Untitled Route";
|
||||
|
||||
return this;
|
||||
} catch (error) {
|
||||
if (error.code)
|
||||
throw new Error(`File error (${this.safePath}): ${error.code} - ${error.message}`);
|
||||
if (error.message.includes("GPX")) throw error;
|
||||
throw new Error(`Failed to parse GPX file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VmdFile
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* File class for VMD (Velout MetaData) files.
|
||||
* Incorporates parsing logic from vmdParser.js.
|
||||
*
|
||||
* @class VmdFile
|
||||
* @extends DataFile
|
||||
*
|
||||
* @example
|
||||
* const file = new VmdFile("destinations/DK/Capital.København/København.vmd", "data");
|
||||
* await file.parse(ref);
|
||||
* console.log(file.content);
|
||||
*/
|
||||
class VmdFile extends DataFile {
|
||||
/** @type {VMDReference|*} */
|
||||
reference = null;
|
||||
|
||||
/** @type {Object} */
|
||||
mapDetails = {cordinates: [null, null]};
|
||||
|
||||
/** @type {Object} */
|
||||
socialEconomic = {classification: [], divisions: {}};
|
||||
|
||||
/** @type {Object} */
|
||||
connections = {trains: {}, bike: {}};
|
||||
|
||||
/** @type {Object} Localized content sections. */
|
||||
content = {};
|
||||
|
||||
constructor(ref, dataDir) {
|
||||
ref = typeof ref === "string" ? new VMDReference(ref, dataDir) : ref;
|
||||
super(ref.path);
|
||||
this.reference = ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory within the data folder.
|
||||
*
|
||||
* @static
|
||||
* @param {string} relativePath - Relative path to the directory
|
||||
* @param {string} [dataDir] - Base data directory
|
||||
* @param {Object} [options={}] - Listing options
|
||||
* @param {boolean} [options.recursive=false] - Include subdirectories
|
||||
* @param {string[]} [options.extensions] - Filter by extensions
|
||||
* @returns {Promise<string[]>} Array of relative file paths
|
||||
*
|
||||
* @example
|
||||
* const files = await VmdFile.list("routes/DK");
|
||||
*/
|
||||
static async list(relativePath, dataDir, options = {}) {
|
||||
return await super.list(relativePath, dataDir, {extensions: [".vmd"], ...options});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse the VMD file.
|
||||
*
|
||||
* @param {VMDReference|*} [ref] - Reference identifier to associate with parsed data
|
||||
* @returns {Promise<this>}
|
||||
* @throws {Error} When file cannot be read or parsing fails
|
||||
*/
|
||||
async parse() {
|
||||
try {
|
||||
const data = await this.read();
|
||||
|
||||
// ── Properties (lines starting with *, $, ~) ──
|
||||
|
||||
const mapdet = data.match(/\*(.+)\:(.+)/g);
|
||||
const soceco = data.match(/\$(.+)\:(.+)/g);
|
||||
const routes = data.match(/\~(.+)\:(.+)/g);
|
||||
|
||||
// ── Body / language sections ──
|
||||
|
||||
const bodyStart = data.indexOf("---");
|
||||
const body = data.slice(bodyStart) + "\r\n---\r\n---LANG";
|
||||
|
||||
const languages = {};
|
||||
const langsecregex =
|
||||
/---(?:LANG:(?<Language>..(?:-..)?))?\r?\n(?<Content>(?:.*(?:\r?\n)?)*?)(?=---LANG)/g;
|
||||
|
||||
let match;
|
||||
let hasLocalized = false;
|
||||
|
||||
while ((match = langsecregex.exec(body)) !== null) {
|
||||
match = match.groups;
|
||||
const index = match.Language === undefined ? "global" : match.Language;
|
||||
languages[index] = {};
|
||||
if (index !== "global") hasLocalized = true;
|
||||
|
||||
if (match.Content.match(/^# (.+?)\r?\n/)) {
|
||||
languages[index]["Title"] = match.Content.match(/^# (.+?)\r?\n/)?.pop();
|
||||
match.Content = match.Content.replace(/^# (.+)$/, "");
|
||||
} else if (index !== "global") {
|
||||
languages[index]["Title"] = languages["global"].Title;
|
||||
}
|
||||
|
||||
const sections = match.Content.matchAll(
|
||||
/---(?<Section>.+?)(?:\:(?<Subsection>.+))?\r?\n(?<Content>(?:.*(?:\r?\n))*?)(?=---)/g
|
||||
);
|
||||
|
||||
for (let s of sections) {
|
||||
s = s.groups;
|
||||
if (languages[index][s.Section] === undefined) languages[index][s.Section] = {};
|
||||
if (s.Subsection)
|
||||
languages[index][s.Section][s.Subsection] = s.Content.replace(
|
||||
/(\r?\n)+$/,
|
||||
""
|
||||
);
|
||||
else languages[index][s.Section] = s.Content.replace(/(\r?\n)+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLocalized) delete languages.global;
|
||||
|
||||
// ── Build structured properties ──
|
||||
|
||||
this.mapDetails = {
|
||||
cordinates: mapdet
|
||||
? mapdet
|
||||
.find(v => v.startsWith("*cordinates"))
|
||||
.split(":")
|
||||
.pop()
|
||||
.split(",")
|
||||
.map(Number)
|
||||
: [null, null],
|
||||
};
|
||||
|
||||
this.socialEconomic = {
|
||||
classification: soceco
|
||||
? soceco
|
||||
.find(v => v.startsWith("$classification"))
|
||||
.split(":")
|
||||
.pop()
|
||||
: [],
|
||||
divisions: soceco
|
||||
? {
|
||||
...soceco
|
||||
.find(v => v.startsWith("$divisions"))
|
||||
.split(":")
|
||||
.pop()
|
||||
.split(","),
|
||||
}
|
||||
: {},
|
||||
};
|
||||
|
||||
this.connections = {
|
||||
trains: Object.fromEntries(
|
||||
routes
|
||||
? routes
|
||||
.filter(v => v.startsWith("~train"))
|
||||
.map(v => {
|
||||
const [type, entries] = v.replace(/\~train-?/, "").split(":");
|
||||
return [
|
||||
type,
|
||||
entries
|
||||
.split(";")
|
||||
.map(v => new VMDReference(v, this.dataDir)),
|
||||
];
|
||||
})
|
||||
: []
|
||||
),
|
||||
bike: Object.fromEntries(
|
||||
routes
|
||||
? routes
|
||||
.filter(v => v.startsWith("~bike"))
|
||||
.map(v => {
|
||||
const [type, entries] = v.replace(/\~bike-?/, "").split(":");
|
||||
return [
|
||||
type,
|
||||
entries
|
||||
.split(";")
|
||||
.map(v => new VMDReference(v, this.dataDir)),
|
||||
];
|
||||
})
|
||||
: []
|
||||
),
|
||||
};
|
||||
|
||||
this.content = languages;
|
||||
|
||||
return this;
|
||||
} catch (error) {
|
||||
if (error.code) throw error; //new Error(
|
||||
//`File error (${this.relativePath}): ${error.code} - ${error.message}`
|
||||
//);
|
||||
throw new Error(`Failed to parse VMD file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export parsed data as a plain object (compatible with old vmdParser output).
|
||||
*
|
||||
* @returns {Object} Structured VMD data
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
"Reference": this.reference,
|
||||
"Map details": this.mapDetails,
|
||||
"Social economic": this.socialEconomic,
|
||||
"Connections": this.connections,
|
||||
"Content": this.content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {DataFile, GpxFile, VmdFile, VMDReference};
|
||||
Reference in New Issue
Block a user