Files
appRobotFileservice/src/store/fileStore.js
2026-06-14 22:21:00 +02:00

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,
};