/** * Datei-basierte Persistenz der Programme: * . — G-Code (Grad), standardnah; Zeitstempel als ;-Kommentar, * Cursor-Zeile zusätzlich mit '!' (z. B. ;1234567890!) * .json — Sidecar mit Metadaten (Name, Zeiten, lineCount, angleUnit) * * Cursor-Primärquelle ist das ';!'-Marker im .gcode (lesbar, portierbar). * Der Sidecar enthält cursor als Fallback und für schnellen Zugriff. * * Nach außen werden Programme NUR über die id angesprochen — niemals über Pfade. * Storage-Details bleiben hier gekapselt (Konzept §8/§10). */ const fsp = require('fs/promises'); const path = require('path'); const cfg = require('../config'); const units = require('../gcode/units'); const log = require('../log'); const { ApiError } = require('../errors'); const ID_RE = /^[a-z0-9_]+$/; /** Wandelt einen Anzeigenamen in eine sichere id (keine Pfade, kein '../'). */ function slugify(name) { return String(name || '') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 100); } /** Stellt sicher, dass eine id keine Pfad-Trenner o. Ä. enthält. */ function assertValidId(id) { if (!ID_RE.test(String(id || ''))) { throw new ApiError(400, 'INVALID_NAME', `invalid program id: ${id}`); } return id; } const gcodePath = (id) => path.join(cfg.storageDir, `${id}.${cfg.fileExt}`); const jsonPath = (id) => path.join(cfg.storageDir, `${id}.json`); async function ensureDir() { await fsp.mkdir(cfg.storageDir, { recursive: true }); } async function exists(id) { try { await fsp.access(gcodePath(id)); return true; } catch { return false; } } /** Zerlegt Datei-Text in nicht-leere Zeilen (CR/LF-tolerant). */ function splitLines(text) { return String(text) .split(/\r?\n/) .map((l) => l.replace(/\s+$/, '')) .filter((l) => l.trim().length > 0); } /** Liest ein Programm: { id, name, cursor, lines (Grad, sauber ohne '!'), meta }. */ async function read(id) { assertValidId(id); if (!(await exists(id))) { throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'`); } const text = await fsp.readFile(gcodePath(id), 'utf8'); let meta = {}; try { meta = JSON.parse(await fsp.readFile(jsonPath(id), 'utf8')); } catch { /* Sidecar ist optional */ } // Primärquelle: ';!'-Marker im .gcode (genau einer pro Datei). // Fallback: cursor aus .json (ältere Dateien ohne Marker). let fileCursor = null; const lines = splitLines(text).map((line, i) => { if (units.hasCursorMarker(line)) { fileCursor = i; return units.removeCursorMarker(line); } return line; }); const cursor = fileCursor ?? meta.cursor ?? 0; return { id, name: meta.name || id, cursor, lines, meta }; } /** * Schreibt .gcode + .json. * lines = saubere G-Code-Zeilen (Grad, ohne '!'-Marker) — der Marker wird hier gesetzt. * cursor = Cursor-Index: die entsprechende Zeile bekommt im .gcode ein '!' angehängt. */ async function write(id, { name, lines, cursor = 0 }) { assertValidId(id); await ensureDir(); const now = new Date().toISOString(); let createdAt = now; try { const prev = JSON.parse(await fsp.readFile(jsonPath(id), 'utf8')); createdAt = prev.createdAt || now; } catch { /* neues Programm */ } const meta = { id, name: name || id, lineCount: lines.length, cursor, angleUnit: cfg.storeAngleUnit, createdAt, updatedAt: now, }; // Cursor-Zeile im .gcode mit ';!'-Marker — sichtbar in jedem Text-Editor. const withCursor = lines.map((line, i) => i === cursor && line.length > 0 ? units.addCursorMarker(line) : line ); const body = withCursor.join('\n') + (withCursor.length ? '\n' : ''); await fsp.writeFile(gcodePath(id), body, 'utf8'); await fsp.writeFile(jsonPath(id), JSON.stringify(meta, null, 2) + '\n', 'utf8'); log.info(`write ${gcodePath(id)} (${lines.length} Zeilen, cursor ${cursor})`); return meta; } async function remove(id) { assertValidId(id); if (!(await exists(id))) { throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'`); } await fsp.rm(gcodePath(id), { force: true }); await fsp.rm(jsonPath(id), { force: true }); } /** Liste aller Programme (id, name, lineCount). */ async function list() { await ensureDir(); const entries = await fsp.readdir(cfg.storageDir); const ext = `.${cfg.fileExt}`; const ids = entries.filter((f) => f.endsWith(ext)).map((f) => f.slice(0, -ext.length)); const out = []; for (const id of ids) { try { const { name, lines, meta } = await read(id); out.push({ id, name, lineCount: meta.lineCount ?? lines.length }); } catch { /* defekte Einträge überspringen */ } } return out; } module.exports = { slugify, assertValidId, exists, read, write, remove, list, ensureDir, gcodePath, jsonPath, };