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};
|
||||||
@@ -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};
|
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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><path></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>"><New Entry></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>
|
||||||
@@ -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 => ({"<": "<", ">": ">", "/": "/"})[rpl])}')">${name}</a>${post}`
|
|
||||||
: ref.reference.replaceAll(
|
|
||||||
/[\<\>]/g,
|
|
||||||
rpl => ({"<": "<", ">": ">"})[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
|
|
||||||
};
|
|
||||||
@@ -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
@@ -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
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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};
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user