163 lines
4.8 KiB
JavaScript
163 lines
4.8 KiB
JavaScript
/**
|
|
* Datei-basierte Persistenz der Programme:
|
|
* <id>.<ext> — G-Code (Grad), standardnah; Zeitstempel als ;<epoch>-Kommentar,
|
|
* Cursor-Zeile zusätzlich mit '!' (z. B. ;1234567890!)
|
|
* <id>.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,
|
|
};
|