Implementing API - WIP frontend

This commit is contained in:
2026-07-26 11:37:03 +02:00
parent e989594b96
commit 8b1d3288c0
12 changed files with 2222 additions and 519 deletions
+801
View File
@@ -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};
-118
View File
@@ -1,118 +0,0 @@
/**
* Module for parsing GPX (GPS Exchange Format) route files.
* Extracts track points, elevations, and metadata from XML-based GPX files.
*
* @module gpxParser
*
* @requires xml2js
* @requires node:fs/promises
*
* @see {@link https://www.topografix.com/GPX/1/1/}
*/
import fs from "node:fs/promises";
import {parseStringPromise} from "xml2js";
import {VMDReference} from "./vmdParser.js";
/**
* Represents a single GPS track point from a GPX file.
*
* @typedef {Object} TrackPoint
* @property {number} lat - Latitude coordinate (decimal degrees, WGS84)
* @property {number} lon - Longitude coordinate (decimal degrees, WGS84)
* @property {number|null} elevation - Elevation in meters, or null if unavailable
*/
/**
* Represents the parsed GPX file result.
*
* @typedef {Object} GpxParseResult
* @property {VMDReference} Reference - External reference identifier passed during parsing
* @property {string} Title - Route name from GPX metadata
* @property {TrackPoint[]} points - Array of extracted track points
*/
/**
* Parse a GPX file and extract track data.
* Reads an XML-based GPX file and converts it to structured JSON with track points,
* elevations, and route metadata.
*
* @async
* @function gpxParser
*
* @param {string} path - Absolute or relative file path to the GPX file
* @param {VMDReference} ref - Reference identifier to associate with the parsed data
*
* @returns {Promise<GpxParseResult>} Object containing reference, title, and track points
*
* @throws {Error} When file cannot be read (ENOENT, EACCES)
* @throws {Error} When GPX structure is invalid or missing required elements
* @throws {Error} When XML parsing fails due to malformed content
*
* @property {Error.code} err.code - File system error code if applicable
*
* @example
* const result = await gpxParser("data/routes/route.gpx", "route_001");
* result.points.length; // Number of track points
* result.Title; // Route name
*
* @example
* // Handle missing elevation data
* const elevationSum = result.points
* .filter(p => p.elevation !== null)
* .reduce((sum, p) => sum + p.elevation, 0);
*/
async function gpxParser(path, ref) {
if (typeof path !== "string" || !path.trim())
throw new TypeError("Path must be a non-empty string");
if (!(ref instanceof VMDReference)) throw new TypeError("Reference must be a VMDReference");
try {
const xml = await fs.readFile(path, "utf-8");
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");
/**
* @type {TrackPoint[]}
*/
const points = trkpt.map(elem => {
const attrs = elem["$"] || {};
const ele = elem.ele ? parseFloat(elem.ele[0]) : null;
// Validate coordinates
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 with fallback
const metadata = result.gpx.metadata?.[0] || {};
const name = metadata.name ? metadata.name[0] : "Untitled Route";
return {Reference: ref, Title: name, points};
} catch (error) {
if (error.code) throw new Error(`File error (${path}): ${error.code} - ${error.message}`); // File system error
if (error.message.includes("GPX")) throw error; // Already our validation error
throw new Error(`Failed to parse GPX file: ${error.message}`);
}
}
export {gpxParser};
-2
View File
@@ -12,7 +12,6 @@
*/ */
import Webserver from "./webserver.js"; import Webserver from "./webserver.js";
import {gpxParser} from "./gpxParser.js";
/** /**
* Command handler for stdin input. * Command handler for stdin input.
@@ -65,7 +64,6 @@ process.stdin.on("data", async data => {
const server = new Webserver({ const server = new Webserver({
port: 4399, port: 4399,
publicDir: "app/public", publicDir: "app/public",
dataDir: "",
maxBodySize: 5242880, // 5MB maxBodySize: 5242880, // 5MB
timeout: 10000, // 10 seconds timeout: 10000, // 10 seconds
}); });
+710
View File
@@ -0,0 +1,710 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VMD Editor</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.panel {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.step {
margin-bottom: 20px;
padding-left: 15px;
border-left: 3px solid #6d4aff;
}
.step-title {
font-weight: 600;
color: #555;
margin-bottom: 10px;
}
select,
input[type="text"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
margin-bottom: 10px;
}
select:focus,
input:focus {
outline: none;
border-color: #6d4aff;
}
.hidden {
display: none !important;
}
button {
background: #6d4aff;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin-right: 10px;
}
button:hover {
background: #5a3fd4;
}
button.secondary {
background: #6c757d;
}
button.secondary:hover {
background: #5a6268;
}
button.danger {
background: #dc3545;
}
button.danger:hover {
background: #c82333;
}
.status {
margin-top: 15px;
padding: 10px;
border-radius: 4px;
display: none;
}
.status.success {
background: #d4edda;
color: #155724;
display: block;
}
.status.error {
background: #f8d7da;
color: #721c24;
display: block;
}
.preview {
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
white-space: pre-wrap;
font-family: monospace;
max-height: 400px;
overflow-y: auto;
border: 1px solid #dee2e6;
}
.form-row {
display: flex;
gap: 15px;
}
.form-row > * {
flex: 1;
}
.breadcrumb {
background: #e9ecef;
padding: 10px 15px;
border-radius: 4px;
margin-bottom: 20px;
font-size: 14px;
}
.breadcrumb span {
color: #6d4aff;
font-weight: 600;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #555;
font-size: 14px;
}
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-right: 8px;
}
.type-destinations {
background: #28a745;
color: white;
}
.type-routes {
background: #17a2b8;
color: white;
}
.type-utilities {
background: #ffc107;
color: black;
}
.type-other {
background: #6c757d;
color: white;
}
</style>
</head>
<body>
<h1>VMD Reference Editor</h1>
<div id="breadcrumb" class="breadcrumb hidden">
Navigation: <span id="breadcrumb-path"></span>
</div>
<div class="panel">
<form id="vmdForm">
<!-- Step 1: Type Selection -->
<div class="step">
<div class="step-title">1. Entry Type</div>
<select id="typeSelect" name="type" required>
<option value="">Select type...</option>
<option value="destinations">Destination</option>
<option value="routes">Route</option>
<option value="utilities">Utility</option>
<option value="other">Other</option>
</select>
</div>
<!-- Step 2: Country Selection -->
<div class="step hidden" id="countryStep">
<div class="step-title">2. Country</div>
<select id="existingCountrySelect" onchange="handleCountrySelect()">
<option value="">Select existing country...</option>
<!-- Populated dynamically -->
</select>
<select id="countryDropdown" class="hidden" onchange="handleNewCountry()">
<option value="">Select new country...</option>
</select>
<input
type="text"
id="newCountryInput"
class="hidden"
placeholder="Enter 2-letter country code..."
maxlength="2"
style="text-transform: uppercase" />
</div>
<!-- Step 3: Entry Name -->
<div class="step hidden" id="nameStep">
<div class="step-title">3. Entry Name</div>
<select
id="existingEntrySelect"
onchange="handleExistingEntry()"
class="hidden">
<option value="">Select existing entry...</option>
</select>
<select id="newEntryType" onchange="toggleNewEntryInput()" class="hidden">
<option value="">Select what to create...</option>
<option value="folder">Folder</option>
<option value="file">File</option>
</select>
<input
type="text"
id="typedNameInput"
class="hidden"
placeholder="Type.Name (e.g., City.Oslo or Capital.København)" />
</div>
<!-- Step 4: Subentry (optional) -->
<div class="step hidden" id="subentryStep">
<div class="step-title">4. Subentry (Optional)</div>
<select
id="parentSelect"
onchange="handleParentSelect()"
style="margin-bottom: 10px">
<option value="">No parent - create root entry</option>
<!-- Populated dynamically from existing entries -->
</select>
<select id="subentryAction" onchange="toggleSubentryCreate()">
<option value="none">No subentry</option>
<option value="create">Create new subentry</option>
<option value="edit">Edit existing subentry</option>
</select>
<select id="subentrySelect" class="hidden">
<option value="">Select subentry...</option>
</select>
<div id="subentryCreateFields" class="hidden">
<select id="subentryType">
<option value="">What type of subentry?</option>
<option value="folder">Folder</option>
<option value="file">File</option>
</select>
<input type="text" id="subentryTypedName" placeholder="Type.Name" />
</div>
</div>
<!-- Step 5: Properties -->
<div class="step hidden" id="propertiesStep">
<p style="font-size: 12px; color: #666; margin-top: 5px">
Typed names format: <code>Type.Name</code>. Valid types:
<span class="type-badge type-destinations">City</span>
<span class="type-badge type-destinations">Capital</span>
<span class="type-badge type-destinations">Town</span>
<span class="type-badge type-routes">Gravel</span>
<span class="type-badge type-routes">Asphalt</span>
<span class="type-badge type-routes">Dirt</span>
<span class="type-badge type-utilities">Toilet</span>
</p>
<div class="step-title">5. Properties</div>
<div class="form-row">
<div>
<label>Geographic ($)</label>
<input
type="text"
id="propGeo"
placeholder="$divisions:DK,Hovedstaden" />
</div>
<div>
<label>Transport (~)</label>
<input
type="text"
id="propTrans"
placeholder="~train:<city>;city,..." />
</div>
</div>
<div class="form-row">
<div>
<label>POI/Culture (¤)</label>
<input
type="text"
id="propPoi"
placeholder="¤museum:price;type" />#tags">
</div>
</div>
<textarea
id="tagsInput"
placeholder="#tags (one per line) - e.g. #Museum #Lake #Playground"
style="width: 100%; min-height: 60px"></textarea>
</div>
<!-- Step 6: Content -->
<div class="step hidden" id="contentStep">
<div class="step-title">6. Markdown Content</div>
<textarea
id="markdownContent"
placeholder="Enter markdown content here..."
style="width: 100%; height: 200px; font-family: monospace"></textarea>
<p style="font-size: 12px; color: #666; margin-top: 5px">
Use sections: <code>---LANG:XX</code>, <code>---INFO:GENERAL</code>,
<code>---MODE:BIKE</code><br />
References: <code>&lt;path&gt;</code> (destinations),
<code>{path}</code> (routes), <code>(path)</code> (utilities)
</p>
</div>
<div style="margin-top: 20px">
<button type="submit" id="saveBtn">Save VMD</button>
<button type="button" class="secondary" id="loadBtn">Load Existing</button>
<button type="button" class="danger" id="deleteBtn" class="hidden">
Delete
</button>
</div>
<div id="status" class="status"></div>
</form>
</div>
<div class="panel" id="previewPanel">
<h2>Preview</h2>
<div id="preview" class="preview">Complete the form to see VMD preview</div>
</div>
<script type="module">
import {reference2json, loadVMDFile, listCountries, listEntries} from "./vmd.mjs";
let currentData = null;
let existingCountries = [];
let existingEntries = new Map();
// Initialize on load
document.addEventListener("DOMContentLoaded", async () => {
setupEventListeners();
});
async function loadExistingCountries() {
try {
existingCountries = await listCountries(
document.getElementById("typeSelect").value
);
const countrySelect = document.getElementById("existingCountrySelect");
countrySelect.innerHTML =
'<option value="">Select existing country...</option>';
existingCountries.forEach(country => {
const opt = document.createElement("option");
opt.value = country;
opt.textContent = `${country.toUpperCase()} - ${getCountryName(country)}`;
countrySelect.appendChild(opt);
});
} catch (err) {
console.warn("Could not load countries:", err);
}
}
function getCountryName(code) {
const names = {
DK: "Denmark",
NO: "Norway",
SE: "Sweden",
FI: "Finland",
IS: "Iceland",
DE: "Germany",
NL: "Netherlands",
BE: "Belgium",
FR: "France",
IT: "Italy",
ES: "Spain",
PT: "Portugal",
GB: "United Kingdom",
CH: "Switzerland",
AT: "Austria",
PL: "Poland",
CZ: "Czech Republic",
HU: "Hungary",
HR: "Croatia",
SI: "Slovenia",
SK: "Slovakia",
LT: "Lithuania",
LV: "Latvia",
EE: "Estonia",
};
return names[code.toUpperCase()] || code.toUpperCase();
}
function setupEventListeners() {
const form = document.getElementById("vmdForm");
document.getElementById("typeSelect").addEventListener("change", e => {
if (e.target.value) {
document.getElementById("countryStep").classList.remove("hidden");
loadExistingCountries(e.target.value);
}
});
document.getElementById("saveBtn").addEventListener("click", handleSubmit);
document.getElementById("loadBtn").addEventListener("click", handleLoad);
document.getElementById("deleteBtn").addEventListener("click", handleDelete);
}
window.handleCountrySelect = async function () {
const select = document.getElementById("existingCountrySelect");
const val = select.value;
if (!val) return;
// Hide new country inputs
document.getElementById("countryDropdown").classList.add("hidden");
document.getElementById("newCountryInput").classList.add("hidden");
// Load entries for this country
await loadCountryEntries(val);
document.getElementById("nameStep").classList.remove("hidden");
};
window.handleNewCountry = function () {
const dropdown = document.getElementById("countryDropdown");
const val = dropdown.value;
if (val === "<NEW>") {
dropdown.classList.add("hidden");
document.getElementById("newCountryInput").classList.remove("hidden");
document.getElementById("newCountryInput").focus();
} else if (val) {
document.getElementById("newCountryInput").classList.add("hidden");
loadCountryEntries(val);
document.getElementById("nameStep").classList.remove("hidden");
}
};
async function loadCountryEntries(country) {
try {
existingEntries = await listEntries(
document.getElementById("typeSelect").value,
country
);
const entrySelect = document.getElementById("existingEntrySelect");
entrySelect.innerHTML =
'<option value="">Select existing entry...</option><option value="<NEW>">&ltNew Entry&gt</option>';
existingEntries.forEach(entry => {
const opt = document.createElement("option");
opt.value = entry.reference;
opt.textContent = `${entry.name}`;
entrySelect.appendChild(opt);
});
entrySelect.classList.remove("hidden");
document.getElementById("newEntryType").classList.add("hidden");
document.getElementById("typedNameInput").classList.add("hidden");
} catch (err) {
console.warn("Could not load entries:", err);
}
}
window.handleExistingEntry = async function () {
const select = document.getElementById("existingEntrySelect");
const val = select.value;
if (val === "<NEW>") {
select.classList.add("hidden");
document.getElementById("newEntryType").classList.remove("hidden");
document.getElementById("typedNameInput").classList.remove("hidden");
} else if (val) {
await loadEntry(val);
}
};
window.toggleNewEntryInput = function () {
const val = document.getElementById("newEntryType").value;
if (val) {
document.getElementById("typedNameInput").classList.remove("hidden");
}
};
window.handleParentSelect = async function () {
const parent = document.getElementById("parentSelect").value;
const subentries = parent ? await listChildren(parent) : [];
const subSelect = document.getElementById("subentrySelect");
subSelect.innerHTML = '<option value="">Select subentry...</option>';
subentries.forEach(entry => {
const opt = document.createElement("option");
opt.value = entry.reference;
opt.textContent = entry.name;
subSelect.appendChild(opt);
});
subSelect.classList.toggle(
"hidden",
document.getElementById("subentryAction").value === "create"
);
};
window.toggleSubentryCreate = function () {
const action = document.getElementById("subentryAction").value;
if (action === "create") {
document.getElementById("subentrySelect").classList.add("hidden");
document.getElementById("subentryCreateFields").classList.remove("hidden");
} else if (action === "edit") {
document.getElementById("subentrySelect").classList.remove("hidden");
document.getElementById("subentryCreateFields").classList.add("hidden");
} else {
document.getElementById("subentrySelect").classList.add("hidden");
document.getElementById("subentryCreateFields").classList.add("hidden");
}
};
async function loadEntry(ref) {
try {
currentData = await loadVMDFile(ref);
populateForm(currentData);
updatePreview();
showStatus("Loaded successfully!");
} catch (err) {
showStatus("Failed to load: " + err.message, true);
}
}
function populateForm(data) {
const type = data.Reference?.type || "other";
const ref = data.Reference?.reference || "";
document.getElementById("typeSelect").value = type;
const countryMatch = ref.match(/^([A-Z]{2})\//i);
if (countryMatch) {
document.getElementById("existingCountrySelect").value = countryMatch[1];
}
const nameMatch = ref.match(/\/(.+)$/);
if (nameMatch) {
const name = nameMatch[1];
const typedMatch = name.match(/^(.+?)\.(.+)$/);
if (typedMatch) {
document.getElementById("typedNameInput").value = name;
} else {
document.getElementById("typedNameInput").value = name;
}
}
// Fill content if available
const lang = "DA",
section = "INFO",
subsection = "ABOUT";
document.getElementById("markdownContent").value =
data.Content[lang][section][subsection];
// Show all steps
document.getElementById("countryStep").classList.remove("hidden");
document.getElementById("nameStep").classList.remove("hidden");
document.getElementById("propertiesStep").classList.remove("hidden");
document.getElementById("contentStep").classList.remove("hidden");
}
async function handleSubmit(e) {
if (e) e.preventDefault();
try {
const type = document.getElementById("typeSelect").value;
const country =
document.getElementById("newCountryInput").value ||
document.getElementById("existingCountrySelect").value;
const typedName = document.getElementById("typedNameInput").value;
if (!country || !typedName) {
throw new Error("Please complete country and name fields");
}
const reference = `${country}/${typedName}`;
const path = `${type}/${country}/${typedName}.vmd`;
// Build VMD data structure
const data = {
Reference: {type: type, reference: reference, path: path},
Content: {},
Properties: {},
};
// Add properties
const geoProp = document.getElementById("propGeo").value;
const transProp = document.getElementById("propTrans").value;
const poiProp = document.getElementById("propPoi").value;
const tags = document
.getElementById("tagsInput")
.value.split("\n")
.filter(t => t.trim())
.map(t => t.trim().replace(/^#/, "#"));
if (geoProp) data.Properties["$"] = geoProp;
if (transProp) data.Properties["~"] = transProp;
if (poiProp) data.Properties["¤"] = poiProp;
if (tags.length) data.Tags = tags;
// Add content
const mdContent = document.getElementById("markdownContent").value;
if (mdContent.trim()) {
data["---"] = {content: mdContent};
}
// Save
const saved = await saveVMDReference(reference, data);
showStatus("VMD saved successfully!", false);
currentData = saved;
updatePreview();
} catch (err) {
showStatus("Error: " + err.message, true);
}
}
async function handleLoad() {
const ref = getInputReference();
if (!ref) {
showStatus("Please complete type, country, and name fields first", true);
return;
}
await loadEntry(ref);
}
async function handleDelete() {
const ref = getInputReference();
if (!ref) return;
if (!confirm(`Delete ${ref}? This cannot be undone.`)) return;
try {
const {deleteVMDReference} = await import("./vmdAPI.js");
await deleteVMDReference(ref);
showStatus("Deleted successfully!", false);
currentData = null;
updatePreview();
} catch (err) {
showStatus("Failed to delete: " + err.message, true);
}
}
function getInputReference() {
const type = document.getElementById("typeSelect").value;
const country =
document.getElementById("newCountryInput").value ||
document.getElementById("existingCountrySelect").value;
const typedName = document.getElementById("typedNameInput").value;
if (!type || !country || !typedName) return null;
return `${country}/${typedName}`;
}
function updatePreview() {
if (!currentData) {
document.getElementById("preview").textContent =
"Complete the form to see VMD preview";
return;
}
const preview = [];
const data = currentData;
// Properties
if (data.Properties["$"]) preview.push(data.Properties["$"]);
if (data.Properties["~"]) preview.push(data.Properties["~"]);
if (data.Properties["¤"]) preview.push(data.Properties["¤"]);
// Tags
if (data.Tags) {
data.Tags.forEach(tag => preview.push(tag));
}
// Content
if (data["---"]) {
preview.push("---");
preview.push(data["---"].content || "");
}
// Language sections would go here if implemented
document.getElementById("preview").textContent = preview.join("\n");
}
function showStatus(message, isError = false) {
const statusDiv = document.getElementById("status");
statusDiv.textContent = message;
statusDiv.className = "status " + (isError ? "error" : "success");
setTimeout(() => {
statusDiv.className = "status";
}, 5000);
}
// Helper functions that need API implementation
window.listCountries = async function () {
// Placeholder - would call API endpoint
return ["DK", "NO", "SE"];
};
window.listEntries = async function (country, type) {
// Placeholder - would call API endpoint
return [
{reference: `${country}/City.Oslo`, name: "Oslo", type},
{reference: `${country}/Capital.København`, name: "København", type},
];
};
window.listChildren = async function (parent) {
// Placeholder - would call API endpoint
return [];
};
</script>
</body>
</html>
-53
View File
@@ -1,53 +0,0 @@
const updateHTML = data => {
fileoutput.innerText = "Bad or empty file";
QUICKR.replaceChildren(
...Object.values(data.Connections.Bike).flatMap(v =>
v.map(
/** @param {{reference: string, path?: string}} ref */ ref => {
const a = document.createElement("a");
const [_, pre, reff, name, post] =
ref.reference.match(/(.*)(\<.+\/(.+)\>)(.*)/) || [];
a.innerHTML = reff
? `${pre}<a href="#${reff}" onclick="getFile('${reff.replaceAll(/<|>|\//g, rpl => ({"<": "&lt;", ">": "&gt;", "/": "&#47;"})[rpl])}')">${name}</a>${post}`
: ref.reference.replaceAll(
/[\<\>]/g,
rpl => ({"<": "&lt", ">": "&gt"})[rpl]
);
if (ref.path) {
a.href = `#${ref.reference}`;
a.onclick = () => getFile(ref.reference);
}
return a;
}
)
)
);
const languages = Object.keys(data.Content);
LANGSO.replaceChildren(
...languages.map(lang => {
const option = document.createElement("OPTION");
option.value = lang;
option.innerHTML = lang;
return option;
})
);
if (LANGSO.children.length === 0)
LANGSO.innerHTML = "<option value='NO FILE LOADED' disabled>NO FILE LOADED</option>";
LANGSO.onchange = ev => {
if (!data.Content[ev.target.value]) return;
fileoutput.innerHTML = [
`<h1>${data.Content[ev.target.value].Title}</h1>`,
...(data.Content[ev.target.value].INFO
? Object.keys(data.Content[ev.target.value].INFO).map(type => {
const content = data.Content[ev.target.value].INFO[type].split(/\r?\n/, 2);
if (content.length === 1) return `<p>${content.shift()}</p>`;
return `<h2>${content.shift().replace("## ", "")}</h2><p>${content.shift()}</p>`;
})
: []),
].join("");
};
LANGSO.onchange({target: LANGSO}); // Dirty hack to trigger onchange
};
+5 -3
View File
@@ -1,4 +1,4 @@
import {requestVMDReference} from "./requestVMDReference.mjs"; import {reference2json} from "./vmd.mjs";
import * as L from "leaflet"; import * as L from "leaflet";
//import "leaflet-providers"; //import "leaflet-providers";
@@ -89,7 +89,9 @@ class Graph {
this.edges = new Map(); this.edges = new Map();
this.canvas = document.createElement("div"); this.canvas = document.createElement("div");
this.canvas.id = "map"; this.canvas.id = "map";
document.body.appendChild(this.canvas); document
.getElementById("map-container")
.replaceChild(this.canvas, document.getElementById("map"));
this.renderer = L.canvas({padding: 5}); this.renderer = L.canvas({padding: 5});
this.map = L.map(this.canvas, {renderer: this.renderer, preferCanvas: true}).setView( this.map = L.map(this.canvas, {renderer: this.renderer, preferCanvas: true}).setView(
[60, 0], [60, 0],
@@ -282,7 +284,7 @@ class Graph {
async deepAdd(iref, depth = 1) { async deepAdd(iref, depth = 1) {
let element = this.nodes.get(iref); let element = this.nodes.get(iref);
if (!element) { if (!element) {
element = await requestVMDReference(iref); element = await reference2json(iref);
switch (element.Reference.type) { switch (element.Reference.type) {
case "destinations": case "destinations":
element = new Node(element); element = new Node(element);
+154 -2
View File
@@ -3,7 +3,159 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title> <title>Route Explorer - Outdoor Adventures</title>
<style>
:root {
--box-color: rgba(156, 156, 156, 0.21);
--box-header: rgb(201, 219, 1);
--box-text: rgb(0, 0, 0);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #72da29 0%, #173606 100%);
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
header {
text-align: center;
padding: 3rem 0;
color: var(--box-header);
}
h1 {
font-size: 3rem;
margin-bottom: 1rem;
}
.tagline {
font-size: 1.3rem;
opacity: 0.9;
margin-bottom: 2rem;
}
.hero-section {
background: var(--box-color);
border-radius: 20px;
padding: 3rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
margin-bottom: 2rem;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin: 3rem 0;
}
.feature-card {
background: var(--box-color);
color: var(--box-text);
padding: 2rem;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
transition: transform 0.3s ease;
}
.feature-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.feature-title {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: var(--box-header);
}
.cta-button {
display: inline-block;
background: var(--box-header);
color: var(--box-text);
padding: 1rem 2rem;
border-radius: 50px;
text-decoration: none;
font-weight: bold;
font-size: 1.1rem;
transition: all 0.3s ease;
}
footer {
text-align: center;
color: white;
padding: 2rem 0;
margin-top: 3rem;
opacity: 0.8;
}
@media (max-width: 768px) {
h1 {
font-size: 2rem;
}
.hero-section {
padding: 2rem;
}
}
</style>
</head> </head>
<body></body> <body>
<div class="container">
<header>
<h1>Velout</h1>
<p class="tagline">Become an adventurer</p>
</header>
<main class="hero-section">
<h2>Dont let plans stop you from going on adventures</h2>
<p style="margin: 1rem 0; font-size: 1.1rem">
Select a starting point, and an end point and get a route for your adventure
whether you're bikepacking, hiking, kayaking, etc. or a combination. Customize
daily distance required services along the way and other critical considerations
on multiday adventures. Get a detailed overview of destinations, attactions and
areas you pass through or near.
</p>
<a href="/map" class="cta-button">Open map</a>
</main>
<section class="features">
<div class="feature-card">
<div class="feature-icon">🌍️</div>
<h3 class="feature-title">Worldwide</h3>
<p>
Community driven data collection, contribute what you know and access what
others knows.
</p>
</div>
<div class="feature-card">
<div class="feature-icon">🥈</div>
<h3 class="feature-title">Prepare</h3>
<p>
You can not plan for everything, but you can prepare. Get alternative places
for breaks and overnights in you plan and adjust minimum and maximum
distance and elevation for one or multiple days.
</p>
</div>
</section>
<footer>
<p>Built with passion for outdoor adventures</p>
</footer>
</div>
</body>
</html> </html>
+230 -5
View File
@@ -8,11 +8,102 @@
body { body {
margin: 0; margin: 0;
border: 0; border: 0;
display: flex;
flex-direction: column;
height: 100vh;
font-family:
system-ui,
-apple-system,
sans-serif;
} }
.container {
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-rows: 1fr;
height: 100%;
gap: 8px;
padding: 8px;
box-sizing: border-box;
}
#map-container {
grid-column: 1 / 2;
grid-row: 1 / 2;
display: flex;
flex-direction: column;
gap: 8px;
}
#map { #map {
width: 600px; flex: 1;
height: 600px; min-height: 0;
margin: auto; border: 1px solid #ccc;
border-radius: 4px;
}
#file-content {
flex: 1;
min-height: 0;
border: 1px solid #ccc;
border-radius: 4px;
padding: 16px;
overflow-y: auto;
background: #fafafa;
}
#plan-container {
grid-column: 2 / 3;
grid-row: 1 / 2;
border: 1px solid #ccc;
border-radius: 4px;
padding: 16px;
overflow-y: auto;
background: #f5f5f5;
}
#file-content h1,
#file-content h2,
#file-content h3 {
margin-top: 0;
}
#file-content p {
line-height: 1.6;
}
.plan-item {
background: white;
padding: 12px;
margin-bottom: 8px;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.plan-item h4 {
margin: 0 0 8px 0;
color: #333;
}
.add-to-plan-btn {
background: #6d4aff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-top: 8px;
}
.add-to-plan-btn:hover {
background: #5a3fd6;
}
.empty-state {
color: #999;
text-align: center;
padding: 32px;
} }
</style> </style>
<link rel="stylesheet" href="/node_modules/leaflet/dist/leaflet.css" /> <link rel="stylesheet" href="/node_modules/leaflet/dist/leaflet.css" />
@@ -29,10 +120,144 @@
<script src="graph.mjs" type="module"></script> <script src="graph.mjs" type="module"></script>
</head> </head>
<body> <body>
<script type="module"> <div class="container">
import {Graph} from "./graph.mjs" <div id="map-container">
<div id="map"></div>
<div id="file-content">
<div class="empty-state">Click on a destination marker to view details</div>
</div>
</div>
<div id="plan-container">
<h3>My Plan</h3>
<div id="plan-items">
<div class="empty-state">No destinations added yet</div>
</div>
</div>
</div>
<script defer type="module">
import {Graph} from "./graph.mjs";
const planItems = [];
function displayContent(data) {
const contentDiv = document.getElementById("file-content");
if (!data || !data.Content) {
contentDiv.innerHTML = '<div class="empty-state">No content available</div>';
return;
}
let html = "";
// Show global content first if available
if (data.Content.global) {
html += `<h1>${data.Content.global.Title || "Untitled"}</h1>`;
for (const [section, subsections] of Object.entries(data.Content.global)) {
if (section === "Title") continue;
html += `<h2>${section}</h2>`;
if (typeof subsections === "object") {
for (const [sub, content] of Object.entries(subsections)) {
html += `<h3>${sub}</h3><p>${content}</p>`;
}
} else {
html += `<p>${subsections}</p>`;
}
}
}
// Show language-specific content
for (const [lang, content] of Object.entries(data.Content)) {
if (lang === "global") continue;
html += `<h2>${lang}</h2>`;
if (content.Title) {
html += `<h1>${content.Title}</h1>`;
}
for (const [section, subsections] of Object.entries(content)) {
if (section === "Title") continue;
html += `<h3>${section}</h3>`;
if (typeof subsections === "object") {
for (const [sub, text] of Object.entries(subsections)) {
html += `<h4>${sub}</h4><p>${text}</p>`;
}
} else {
html += `<p>${subsections}</p>`;
}
}
}
// Add "Add to Plan" button
const ref = data.Reference?.reference || "unknown";
const name =
data.Content?.global?.Title ||
Object.values(data.Content)?.[0]?.Title ||
"Destination";
html += `
<button class="add-to-plan-btn" onclick="addToPlan('${ref}', '${name}')">
Add to Plan
</button>
`;
contentDiv.innerHTML = html;
}
window.addToPlan = function (ref, name) {
if (planItems.some(item => item.ref === ref)) {
alert("Already in plan!");
return;
}
planItems.push({ref, name});
updatePlanDisplay();
};
function updatePlanDisplay() {
const planDiv = document.getElementById("plan-items");
if (planItems.length === 0) {
planDiv.innerHTML = '<div class="empty-state">No destinations added yet</div>';
return;
}
let html = "";
for (const item of planItems) {
html += `
<div class="plan-item">
<h4>${item.name}</h4>
<small>${item.ref}</small>
</div>
`;
}
planDiv.innerHTML = html;
}
(async () => { (async () => {
const network = new Graph(); const network = new Graph();
// Override the click handler to display content instead of loading connected pins
const originalGet = network.get.bind(network);
network.get = async function (ref) {
const element = await originalGet(ref);
// If this is a node (destination), display its content
if (element && element.data && element.data.Content) {
displayContent(element.data);
}
return element;
};
// Also override the Node.draw method to use our custom click handler
const originalNodeDraw = network.nodesConstructor
? network.nodesConstructor.prototype.draw
: null;
// Reinitialize with our custom behavior
const t = await network.get("<NO/Lunde>"); const t = await network.get("<NO/Lunde>");
network.draw(); network.draw();
})(); })();
-59
View File
@@ -1,59 +0,0 @@
/**
* Module for requesting VMD references from the server API.
* Handles HTTP POST requests to retrieve reference data.
*
* @module requestVMDReference
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API}
*/
/**
* Request a VMD reference from the server endpoint.
* Sends a POST request with the reference identifier and returns parsed JSON data.
*
* @async
* @function requestVMDReference
*
* @param {string} ref - The reference identifier to look up. Must be a non-empty string.
*
* @returns {Promise<Object>} Parsed JSON response containing the VMD reference data
*
* @throws {TypeError} When ref is not a valid string
* @throws {Error} When network request fails or server returns non-success status
*
* @property {number} response.status - HTTP status code
* @property {boolean} response.ok - Indicates successful response (2xx)
*
* @example
* try {
* const data = await requestVMDReference("NO_Mjolkevegen_5");
* // Use data
* } catch (error) {
* // Handle fail
* }
*/
async function requestVMDReference(ref) {
if (typeof ref !== "string" || !ref.trim())
throw new TypeError("Reference must be a non-empty string");
const endpoint = "/"; //"/api/vmd/reference";
try {
const response = await fetch(endpoint, {
headers: {"Content-Type": "vmd/reference"},
method: "POST",
body: ref,
});
if (!response.ok)
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
return await response.json();
} catch (error) {
if (error instanceof TypeError && error.message.includes("fetch"))
throw new Error("Network error: Unable to reach server");
throw error;
}
}
export {requestVMDReference};
+186
View File
@@ -0,0 +1,186 @@
/**
* Module for requesting VMD references from the server API.
* Handles HTTP requests to retrieve and save reference data.
*
* @module vmd
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API}
*/
/**
* Request a VMD reference from the server endpoint (GET).
* Sends a GET request with the reference identifier and returns parsed JSON data.
*
* @async
* @function reference2json
*
* @param {string} ref - The reference identifier to look up. Must be a non-empty string.
*
* @returns {Promise<Object>} Parsed JSON response containing the VMD reference data
*
* @throws {TypeError} When ref is not a valid string
* @throws {Error} When network request fails or server returns non-success status
*/
export async function reference2json(ref) {
if (typeof ref !== "string" || !ref.trim())
throw new TypeError("Reference must be a non-empty string");
const endpoint = `/api/vmd/reference/`;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {"Accept": "application/json", "Content-Type": "vmd/reference"},
body: ref,
});
if (!response.ok)
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
return await response.json();
} catch (error) {
if (error instanceof TypeError && error.message.includes("fetch"))
throw new Error("Network error: Unable to reach server");
throw error;
}
}
// /**
// * Save a VMD reference to the server endpoint (POST/PUT).
// * Sends a POST request with the VMD data and returns the saved data.
// *
// * @async
// * @function saveVMDReference
// *
// * @param {string} ref - The reference identifier. Must be a non-empty string.
// * @param {Object} data - The VMD data object to save
// *
// * @returns {Promise<Object>} The saved VMD data
// *
// * @throws {TypeError} When ref is not a valid string or data is not an object
// * @throws {Error} When network request fails or server returns non-success status
// */
// export async function saveVMDReference(ref, data) {
// if (typeof ref !== "string" || !ref.trim())
// throw new TypeError("Reference must be a non-empty string");
// if (!data || typeof data !== "object") throw new TypeError("Data must be a valid object");
// const endpoint = "/api/vmd/reference";
// try {
// const response = await fetch(endpoint, {
// method: "POST",
// headers: {"Content-Type": "application/json"},
// body: JSON.stringify({reference: ref, data}),
// });
// if (!response.ok)
// throw new Error(`Request failed: ${response.status} ${response.statusText}`);
// return await response.json();
// } catch (error) {
// if (error instanceof TypeError && error.message.includes("fetch"))
// throw new Error("Network error: Unable to reach server");
// throw error;
// }
// }
/**
* Save a VMD reference to the server endpoint (POST/PUT).
* Sends a POST request with the VMD data and returns the saved data.
*
* @async
* @function loadVMDFile
*
* @param {string} ref - The reference identifier. Must be a non-empty string.
*
* @returns {Promise<Object>} The saved VMD data
*
* @throws {TypeError} When ref is not a valid string or data is not an object
* @throws {Error} When network request fails or server returns non-success status
*/
export async function loadVMDFile(ref) {
if (typeof ref !== "string" || !ref.trim())
throw new TypeError("Reference must be a non-empty string");
const endpoint = "/api/vmd/entry";
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: ref,
});
if (!response.ok)
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
return await response.json();
} catch (error) {
if (error instanceof TypeError && error.message.includes("fetch"))
throw new Error("Network error: Unable to reach server");
throw error;
}
}
/**
* Client-side wrapper for VMD API endpoints.
* Exports functions that communicate with the server API.
*
* @module vmdAPI
*/
/**
* List all countries with VMD entries.
*
* @async
* @function listCountries
*
* @returns {Promise<Array>} Array of country codes
*/
export async function listCountries(type) {
const url = new URL("/api/vmd/countries", window.location.origin);
if (type) url.searchParams.set("type", type);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
/**
* List entries within a country.
*
* @async
* @function listEntries
*
* @param {string} country - Country code
* @param {string} [type] - Entry type filter
*
* @returns {Promise<Array>} Array of entry objects
*/
export async function listEntries(type, country) {
const url = new URL("/api/vmd/entries", window.location.origin);
url.searchParams.set("type", type);
url.searchParams.set("country", country);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
/**
* List child entries under a parent.
*
* @async
* @function listChildren
*
* @param {string} parent - Parent reference
*
* @returns {Promise<Array>} Array of child entry objects
*/
export async function listChildren(parent) {
const response = await fetch(`/api/vmd/children/${encodeURIComponent(parent)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
-235
View File
@@ -1,235 +0,0 @@
import fs from "node:fs";
import {resolve, join} from "node:path";
class VMDReference {
path = null;
nested = [];
name = "";
type;
/**
*
* @param {string} vmdRef relative path or name inclosed in `<>` `{}` `[]` `()`
*/
constructor(vmdRef) {
this.reference = vmdRef;
this.validate();
return this;
}
/**
* Validates the reference
* @returns aboslute path if valid or error on non-valid string
*/
validate() {
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 = []; // Store {depth, startIndex}
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}`);
// Top-level bracket types determine root folder
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;
}
}
// Mark nested content start
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}'`);
// Capture nested content when returning from depth 2 to 1
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-identifyer brackets <..>, {..}, [..] or (..)`,
});
if (stack.length > 0)
throw new Error(`Unclosed brackets: ${stack.join("")}`, {
cause: {reference: this.reference, stack},
});
//this.reference = cleanChars.join("");
const root = join("data", this.type);
let filePath = join(root, this.reference.slice(1, -1).trim());
this.nested = nestedContent.map(ref => {
const nested = new VMDReference(ref);
filePath = filePath.replace(ref, nested.name);
return nested;
});
// Search the data folder for the file:
// It can be a path with root folder data,
// It can be a partial path, with multiple parent folders missing,
// The .vmd extension can always be omitted,
// The file name can be omitted entirely if the name matches the parent folder
// the folders can have properties in thier name, the name is always the string after the last `.`
// Remove .vmd extension if present (can be omitted)
if (filePath.indexOf(".vmd") !== -1) filePath = filePath.slice(filePath.indexOf(".vmd"), 4);
// Extract the core name (string after the last '.')
const parts = filePath.split("/");
const lastSegment = parts[parts.length - 1];
const fileName = "" + lastSegment + ".vmd"; //lastSegment.includes(".") ? lastSegment.split(".").pop() : lastSegment;
filePath = parts.slice(0, -1).join("/") + "/" + parts.pop();
// Search strategy: try multiple path variations
const searchPaths = [filePath + ".vmd", join(filePath, fileName), filePath + ".gpx"];
// Try each path variation until we find a match
for (const path of searchPaths) {
if (fs.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;
//throw new Error(`VMD file not found. Tried: ${searchPaths.join(", ")}`);
}
}
/**
*
* @param {string} data
* @returns
*/
function vmdParser(data, ref) {
const mapdet = data.match(/\*(.+)\:(.+)/g);
const soceco = data.match(/\$(.+)\:(.+)/g);
const routes = data.match(/\~(.+)\:(.+)/g);
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)+$/, "");
}
}
// Remove global section if it contains no content
if (hasLocalized) delete languages.global;
const structure = {
"Reference": ref || "nullref",
"Map details": {
Cordinates: mapdet
? mapdet
.find(v => v.startsWith("*cordinates"))
.split(":")
.pop()
.split(",")
.map(Number)
: [null, null],
},
"Social economic": {
Classification: soceco
? soceco
.find(v => v.startsWith("$classification"))
.split(":")
.pop()
: [],
Divisions: soceco
? {
...soceco
.find(v => v.startsWith("$divisions"))
.split(":")
.pop()
.split(","),
}
: {},
},
"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))];
})
: []
),
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))];
})
: []
),
},
"Content": languages,
};
return structure;
}
export {VMDReference, vmdParser};
+135 -41
View File
@@ -7,18 +7,17 @@
* @requires node:http * @requires node:http
* @requires node:fs/promises * @requires node:fs/promises
* @requires node:path * @requires node:path
* @requires ./vmdParser.js * @requires ./files.js
* @requires ./gpxParser.js
* *
* @see {@link https://nodejs.org/api/http.html} * @see {@link https://nodejs.org/api/http.html}
* @see {@link https://nodejs.org/api/fs.html} * @see {@link https://nodejs.org/api/fs.html}
*/ */
import * as http from "node:http"; import * as http from "node:http";
import {URL} from "node:url";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import {join, resolve, extname} from "node:path"; import {join, resolve, extname} from "node:path";
import {vmdParser, VMDReference} from "./vmdParser.js"; import {DataFile, GpxFile, VmdFile, VMDReference} from "./fileHandler.js";
import {gpxParser} from "./gpxParser.js";
/** /**
* Configuration options for the web server. * Configuration options for the web server.
@@ -26,7 +25,7 @@ import {gpxParser} from "./gpxParser.js";
* @typedef {Object} WebserverOptions * @typedef {Object} WebserverOptions
* @property {number} [port=4399] - Port number to listen on * @property {number} [port=4399] - Port number to listen on
* @property {string} [publicDir='app/public'] - Directory for static assets * @property {string} [publicDir='app/public'] - Directory for static assets
* @property {string} [dataDir='data/routes'] - Directory for route data files * @property {string} [dataDir='data'] - Path to data directory
* @property {number} [maxBodySize=1048576] - Maximum request body size in bytes (1MB) * @property {number} [maxBodySize=1048576] - Maximum request body size in bytes (1MB)
* @property {number} [timeout=30000] - Request timeout in milliseconds (30s) * @property {number} [timeout=30000] - Request timeout in milliseconds (30s)
*/ */
@@ -154,7 +153,7 @@ export default class Webserver {
* maxBodySize: 2097152 * maxBodySize: 2097152
* }); * });
*/ */
constructor(options = {}) { constructor(options = {dataDir: null}) {
this.#validateOptions(options); this.#validateOptions(options);
this.port = options.port ?? 4399; this.port = options.port ?? 4399;
@@ -195,6 +194,9 @@ export default class Webserver {
if (options.publicDir !== undefined && typeof options.publicDir !== "string") if (options.publicDir !== undefined && typeof options.publicDir !== "string")
throw new TypeError("publicDir must be a string"); throw new TypeError("publicDir must be a string");
if (options.dataDir !== undefined && typeof options.dataDir !== "string")
throw new TypeError("dataDir must be a string");
if ( if (
options.maxBodySize !== undefined && options.maxBodySize !== undefined &&
(typeof options.maxBodySize !== "number" || options.maxBodySize <= 0) (typeof options.maxBodySize !== "number" || options.maxBodySize <= 0)
@@ -342,8 +344,7 @@ export default class Webserver {
* // Returns "text/html; charset=utf-8" * // Returns "text/html; charset=utf-8"
*/ */
#getMimeType(filePath) { #getMimeType(filePath) {
const ext = extname(filePath).toLowerCase(); return MIME_TYPES[extname(filePath).toLowerCase()] || "application/text";
return MIME_TYPES[ext] || "application/text";
} }
/** /**
@@ -398,20 +399,28 @@ export default class Webserver {
*/ */
async #handleGpxRequest(req, res) { async #handleGpxRequest(req, res) {
const relativePath = req.url.slice(1); // Remove leading slash const relativePath = req.url.slice(1); // Remove leading slash
const safePath = this.#resolveSafePath(this.#dataDir, relativePath);
if (!safePath) {
this.#sendErrorPage(res, STATUS_CODES.FORBIDDEN, "Invalid path");
return;
}
try { try {
const data = await gpxParser(safePath, "auto-generated-ref"); // Use DataFile.open() which returns GpxFile for .gpx extensions
const file = DataFile.open(relativePath, this.#dataDir);
// Parse the GPX file
await file.parse(null);
// Return parsed data
res.writeHead(STATUS_CODES.OK, {"Content-Type": "application/json; charset=utf-8"}); res.writeHead(STATUS_CODES.OK, {"Content-Type": "application/json; charset=utf-8"});
res.end(JSON.stringify(data, null, 2)); res.end(
JSON.stringify(
{Reference: file.reference, Title: file.title, points: file.points},
null,
2
)
);
} catch (error) { } catch (error) {
console.error("[GPX ERROR]", error.message); console.error("[GPX ERROR]", error.message);
this.#sendErrorPage(res, STATUS_CODES.INTERNAL_ERROR, "Failed to parse GPX file"); if (error.message.includes("not found"))
this.#sendErrorPage(res, STATUS_CODES.NOT_FOUND, error.message);
else this.#sendErrorPage(res, STATUS_CODES.INTERNAL_ERROR, "Failed to parse GPX file");
} }
} }
@@ -502,6 +511,82 @@ export default class Webserver {
} }
} }
/**
* Handle API calls
*
* @private
* @method handleAPIcall
* @param {http.IncomingMessage} req - Request object
* @param {http.ServerResponse} res - Response object
* @param {string} body - Parsed request body content
*
* @returns {Promise<void>}
*/
async #handleAPIcall(req, res, body) {
const apiurl = new URL(req.url, `api://localhost:${this.port}/`);
const [contentType, action] = apiurl.pathname.replace("/api/", "").split("/");
switch (contentType) {
case "vmd": {
switch (action) {
case "countries": {
const list = await DataFile.list(
apiurl.searchParams.get("type"),
this.#dataDir,
{onlyfiles: false}
);
res.writeHead(STATUS_CODES.OK, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(list));
break;
}
case "entries": {
const relativePath =
apiurl.searchParams.get("type") +
"/" +
apiurl.searchParams.get("country");
console.log(relativePath);
const list = await VmdFile.list(relativePath, this.#dataDir, {depth: 1});
res.writeHead(STATUS_CODES.OK, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(list));
break;
}
case "entry": {
const vmdobj = new VmdFile(body, this.#dataDir);
const t = await vmdobj.parse();
res.writeHead(STATUS_CODES.OK, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(vmdobj));
break;
}
case "reference": {
res.writeHead(STATUS_CODES.OK, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(new VMDReference(body, this.#dataDir)));
break;
}
default:
res.writeHead(STATUS_CODES.NOT_FOUND, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(
JSON.stringify({error: "endpoint does not exist or is not implemented"})
);
break;
}
break;
}
}
return;
}
/** /**
* Handle VMD reference requests. * Handle VMD reference requests.
* Processes POST requests with VMD reference data. * Processes POST requests with VMD reference data.
@@ -517,7 +602,7 @@ export default class Webserver {
*/ */
async #handleVmdRequest(req, res, body) { async #handleVmdRequest(req, res, body) {
try { try {
const vmd = new VMDReference(body); const vmd = new VMDReference(body, this.#dataDir);
if (!vmd.path) { if (!vmd.path) {
const defaultResponse = { const defaultResponse = {
@@ -532,26 +617,35 @@ export default class Webserver {
return; return;
} }
const safePath = this.#resolveSafePath(this.#dataDir, vmd.path); // Use DataFile.open() which returns VmdFile or GpxFile based on extension
const file = DataFile.open(vmd.path, this.#dataDir);
if (!safePath) // Parse the file (handles both VMD and GPX)
return this.#sendErrorPage(res, STATUS_CODES.FORBIDDEN, "Invalid VMD path"); await file.parse(vmd);
if (vmd.path.endsWith(".vmd")) { // Return parsed data
const data = await fs.readFile(safePath, "utf-8"); let responseData;
const vmdObject = vmdParser(data, vmd); if (file instanceof VmdFile) {
responseData = file.toJSON();
} else if (file instanceof GpxFile) {
responseData = {Reference: file.reference, Title: file.title, points: file.points};
} else {
// Generic file
responseData = {content: file.content};
}
res.writeHead(STATUS_CODES.OK, {"Content-Type": "application/json; charset=utf-8"}); res.writeHead(STATUS_CODES.OK, {"Content-Type": "application/json; charset=utf-8"});
res.end(JSON.stringify(vmdObject, null, 2)); res.end(JSON.stringify(responseData, null, 2));
} else if (vmd.path.endsWith(".gpx")) {
const data = await gpxParser(safePath, vmd);
res.writeHead(STATUS_CODES.OK, {"Content-Type": "application/json; charset=utf-8"});
res.end(JSON.stringify(data, null, 2));
} else this.#sendErrorPage(res, STATUS_CODES.BAD_REQUEST, "Unsupported file type");
} catch (error) { } catch (error) {
console.error("[VMD ERROR]", error.message); console.error("[VMD ERROR]", error.message);
this.#sendErrorPage(res, STATUS_CODES.INTERNAL_ERROR, "Failed to process VMD request"); if (error.message.includes("not found"))
this.#sendErrorPage(res, STATUS_CODES.NOT_FOUND, error.message);
else
this.#sendErrorPage(
res,
STATUS_CODES.INTERNAL_ERROR,
"Failed to process VMD request"
);
} }
} }
@@ -632,6 +726,11 @@ export default class Webserver {
return; return;
} }
if (decodedUrl.startsWith("/api/")) {
const body = await this.#collectBody(req);
return await this.#handleAPIcall(req, res, body);
}
// 2. POST requests with VMD reference // 2. POST requests with VMD reference
if (req.method === "POST" && req.headers["content-type"] === "vmd/reference") { if (req.method === "POST" && req.headers["content-type"] === "vmd/reference") {
const body = await this.#collectBody(req); const body = await this.#collectBody(req);
@@ -646,24 +745,19 @@ export default class Webserver {
} }
// 4. Static public files // 4. Static public files
const relativePath = decodedUrl.slice(1) || "index.html"; let relativePath = decodedUrl.slice(1) || "index.html";
let filePath = join(this.#publicDir, relativePath);
// Append .html extension if no extension present // Append .html extension if no extension present
if (!extname(relativePath)) { if (!extname(relativePath)) relativePath += ".html";
filePath = join(this.#publicDir, relativePath + ".html");
}
const safePath = this.#resolveSafePath(this.#publicDir, relativePath); const safePath = this.#resolveSafePath(this.#publicDir, relativePath);
if (!safePath) { if (!safePath) return this.#sendErrorPage(res, STATUS_CODES.FORBIDDEN, "Invalid path");
this.#sendErrorPage(res, STATUS_CODES.FORBIDDEN, "Invalid path");
return;
}
await this.#serveStaticFile(res, safePath, true); await this.#serveStaticFile(res, safePath, true);
} catch (error) { } catch (error) {
console.error("[REQUEST ERROR]", error.message); console.error("[REQUEST ERROR]", error.message);
throw error;
this.#sendErrorPage(res, STATUS_CODES.INTERNAL_ERROR, "An unexpected error occurred"); this.#sendErrorPage(res, STATUS_CODES.INTERNAL_ERROR, "An unexpected error occurred");
} finally { } finally {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;