Initiales Projekt-Skelett appRobotFileservice
Ausgelagertes Programm-/File-Handling (vormals GCode.receiveFC im appRobotDriver, ToDo_4 / ToDo_6b). Express-Service mit .gcode + .json-Storage, aktivem Programm + Cursor, Teaching (FPoint) und Playback. Speicherung in Grad, driver-nativ (Radian) zum Driver. Konzept/API unter doc/draft_filehandeling*.md. Tests: jest (13 gruen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
220
src/active/activeState.js
Normal file
220
src/active/activeState.js
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Aktives Programm + Cursor — Single Source of Truth (doc/draft_filehandeling.md §9).
|
||||
*
|
||||
* Der Cursor lebt zur Laufzeit als In-Memory-Index (schnelles Stepping ohne
|
||||
* Datei-Neuschreiben). Beim Laden wird er aus dem '!'-Kommentar gelesen, beim
|
||||
* Speichern/Entladen als '!' in die Cursor-Zeile zurückgeschrieben.
|
||||
*
|
||||
* Inhaltliche Änderungen (FPoint, Editieren) werden direkt persistiert (durables
|
||||
* Teaching); reine Cursor-Bewegungen NICHT.
|
||||
*/
|
||||
const store = require('../store/fileStore');
|
||||
const units = require('../gcode/units');
|
||||
const { ApiError } = require('../errors');
|
||||
|
||||
class ActiveState {
|
||||
constructor() {
|
||||
this.programId = null;
|
||||
this.name = null;
|
||||
// gespeicherte Zeilen (Grad, mit ;<epoch>-Kommentar, OHNE '!' — Cursor separat)
|
||||
this.lines = [];
|
||||
this.cursor = 0;
|
||||
this.playing = false;
|
||||
this.version = 0;
|
||||
}
|
||||
|
||||
_touch() {
|
||||
this.version += 1;
|
||||
}
|
||||
|
||||
_requireActive() {
|
||||
if (!this.programId) throw new ApiError(409, 'NO_ACTIVE_PROGRAM', 'no active program');
|
||||
}
|
||||
|
||||
/** API-Repräsentation (ActiveState). currentLine = driver-nativ (Radian). */
|
||||
getState() {
|
||||
const currentLine = this.lines.length ? units.toExecutable(this.lines[this.cursor]) : null;
|
||||
return {
|
||||
programId: this.programId,
|
||||
cursor: this.cursor,
|
||||
lineCount: this.lines.length,
|
||||
currentLine,
|
||||
playing: this.playing,
|
||||
version: this.version,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt ein Programm aktiv (FLoad). Existiert es nicht, wird es leer angelegt
|
||||
* (nötig für Teaching). Ein vorher aktives Programm wird zuvor persistiert.
|
||||
*/
|
||||
async load(id, name) {
|
||||
store.assertValidId(id);
|
||||
await this._persistIfActive();
|
||||
|
||||
if (!(await store.exists(id))) {
|
||||
await store.write(id, { name: name || id, lines: [] });
|
||||
this.programId = id;
|
||||
this.name = name || id;
|
||||
this.lines = [];
|
||||
this.cursor = 0;
|
||||
this.playing = false;
|
||||
this._touch();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
const prog = await store.read(id);
|
||||
let cursor = 0;
|
||||
const lines = prog.lines.map((line, i) => {
|
||||
if (units.hasCursorMarker(line)) cursor = i;
|
||||
return units.removeCursorMarker(line);
|
||||
});
|
||||
this.programId = id;
|
||||
this.name = prog.name;
|
||||
this.lines = lines;
|
||||
this.cursor = Math.min(cursor, Math.max(0, lines.length - 1));
|
||||
this.playing = false;
|
||||
this._touch();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
/** Leert das aktive Programm (FClear). */
|
||||
async clear() {
|
||||
this._requireActive();
|
||||
this.lines = [];
|
||||
this.cursor = 0;
|
||||
this.playing = false;
|
||||
this._touch();
|
||||
await this._persist();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
// ---- Stepping (reine Cursor-Bewegung, gibt die ausführbare Zeile zurück) ----
|
||||
|
||||
_gotoIndex(index) {
|
||||
this._requireActive();
|
||||
if (!this.lines.length) throw new ApiError(409, 'EMPTY_PROGRAM', 'active program is empty');
|
||||
if (index < 0 || index >= this.lines.length) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'CURSOR_OUT_OF_RANGE',
|
||||
`index ${index} out of range 0..${this.lines.length - 1}`
|
||||
);
|
||||
}
|
||||
this.cursor = index;
|
||||
this._touch();
|
||||
return { cursor: this.cursor, line: units.toExecutable(this.lines[this.cursor]) };
|
||||
}
|
||||
|
||||
next() { return this._gotoIndex(this.cursor + 1); }
|
||||
prev() { return this._gotoIndex(this.cursor - 1); }
|
||||
first() { return this._gotoIndex(0); }
|
||||
last() { return this._gotoIndex(this.lines.length - 1); }
|
||||
goto(index) { return this._gotoIndex(Number(index)); }
|
||||
|
||||
// ---- Teaching / Editieren (persistiert) ----
|
||||
|
||||
/** Hängt die aktuelle Pose als G-Code-Zeile an (FPoint). pose: a/b/c/e in RADIAN. */
|
||||
async appendPoint(pose, feedrate) {
|
||||
this._requireActive();
|
||||
if (!pose) throw new ApiError(400, 'FILE_ERROR', 'pose required');
|
||||
const line = units.formatPointLine(pose, feedrate);
|
||||
this.lines.push(line);
|
||||
this.cursor = this.lines.length - 1;
|
||||
this._touch();
|
||||
await this._persist();
|
||||
return { index: this.cursor, line };
|
||||
}
|
||||
|
||||
/** Hängt eine rohe Zeile an oder fügt sie an atIndex ein. */
|
||||
async appendLine(line, atIndex) {
|
||||
this._requireActive();
|
||||
if (!line) throw new ApiError(400, 'FILE_ERROR', 'line required');
|
||||
const clean = units.removeCursorMarker(String(line));
|
||||
if (atIndex == null) {
|
||||
this.lines.push(clean);
|
||||
this.cursor = this.lines.length - 1;
|
||||
} else {
|
||||
const i = Math.max(0, Math.min(Number(atIndex), this.lines.length));
|
||||
this.lines.splice(i, 0, clean);
|
||||
this.cursor = i;
|
||||
}
|
||||
this._touch();
|
||||
await this._persist();
|
||||
return { index: this.cursor, line: clean };
|
||||
}
|
||||
|
||||
async replaceLine(index, line) {
|
||||
this._requireActive();
|
||||
const i = Number(index);
|
||||
if (i < 0 || i >= this.lines.length) {
|
||||
throw new ApiError(409, 'CURSOR_OUT_OF_RANGE', `index ${i} out of range`);
|
||||
}
|
||||
this.lines[i] = units.removeCursorMarker(String(line));
|
||||
this._touch();
|
||||
await this._persist();
|
||||
return { index: i, line: this.lines[i] };
|
||||
}
|
||||
|
||||
async deleteLine(index) {
|
||||
this._requireActive();
|
||||
const i = Number(index);
|
||||
if (i < 0 || i >= this.lines.length) {
|
||||
throw new ApiError(409, 'CURSOR_OUT_OF_RANGE', `index ${i} out of range`);
|
||||
}
|
||||
this.lines.splice(i, 1);
|
||||
if (this.cursor >= this.lines.length) this.cursor = Math.max(0, this.lines.length - 1);
|
||||
this._touch();
|
||||
await this._persist();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
// ---- Playback (passiv: der Driver führt die Zeilen aus) ----
|
||||
|
||||
/** Liefert die ausführbaren Zeilen ab Cursor (bzw. ab 0). Setzt playing. */
|
||||
play({ mode = 'run', fromStart = false } = {}) {
|
||||
this._requireActive();
|
||||
if (!this.lines.length) throw new ApiError(409, 'EMPTY_PROGRAM', 'active program is empty');
|
||||
if (fromStart) this.cursor = 0;
|
||||
this.playing = true;
|
||||
this._touch();
|
||||
if (mode === 'step') {
|
||||
return { mode, cursor: this.cursor, lines: [units.toExecutable(this.lines[this.cursor])] };
|
||||
}
|
||||
return { mode, cursor: this.cursor, lines: this.lines.slice(this.cursor).map(units.toExecutable) };
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.playing = false;
|
||||
this._touch();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
// ---- Speichern ----
|
||||
|
||||
/** Speichert den aktiven Puffer unter einem (neuen) Namen (FSave). */
|
||||
async saveAs(name) {
|
||||
this._requireActive();
|
||||
const id = store.slugify(name);
|
||||
if (!id) throw new ApiError(400, 'INVALID_NAME', `invalid name: ${name}`);
|
||||
const meta = await store.write(id, { name, lines: this._linesWithCursor() });
|
||||
return { id: meta.id, lineCount: meta.lineCount };
|
||||
}
|
||||
|
||||
/** In-Memory-Zeilen mit '!' an der Cursor-Position (für die Persistenz). */
|
||||
_linesWithCursor() {
|
||||
return this.lines.map((line, i) => (i === this.cursor ? units.addCursorMarker(line) : line));
|
||||
}
|
||||
|
||||
async _persist() {
|
||||
if (!this.programId) return;
|
||||
await store.write(this.programId, { name: this.name, lines: this._linesWithCursor() });
|
||||
}
|
||||
|
||||
async _persistIfActive() {
|
||||
if (this.programId) await this._persist();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton — gemeinsamer Zustand für alle Anfragen (Single Source of Truth).
|
||||
module.exports = { ActiveState, active: new ActiveState() };
|
||||
15
src/auth.js
Normal file
15
src/auth.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const cfg = require('./config');
|
||||
const { envelope } = require('./errors');
|
||||
|
||||
/**
|
||||
* Bearer-Auth für schreibende Endpoints. Ohne gesetzten FILE_API_KEY offen (Dev).
|
||||
* Anlehnung an ROBOT_API_KEY im Driver.
|
||||
*/
|
||||
function requireAuth(req, res, next) {
|
||||
if (!cfg.apiKey) return next();
|
||||
const header = req.get('authorization') || '';
|
||||
if (header === `Bearer ${cfg.apiKey}`) return next();
|
||||
return res.status(401).json(envelope('UNAUTHORIZED', 'invalid or missing bearer token'));
|
||||
}
|
||||
|
||||
module.exports = requireAuth;
|
||||
17
src/config.js
Normal file
17
src/config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// Zentrale Konfiguration aus Umgebungsvariablen (mit sinnvollen Defaults).
|
||||
// Die appRobotFileservice ist passiv und braucht KEINEN Driver-Zugang.
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
// Port des HTTP-Service.
|
||||
port: Number(process.env.FILE_SERVICE_PORT) || 2100,
|
||||
// Verzeichnis für die Programm-Dateien (.gcode) + Sidecars (.json).
|
||||
storageDir: process.env.STORAGE_DIR || path.join(__dirname, '..', 'GCodeFiles'),
|
||||
// Datei-Endung der Programme: 'gcode' (Default) oder 'ngc'.
|
||||
fileExt: (process.env.FILE_EXT || 'gcode').replace(/^\./, ''),
|
||||
// Einheit, in der Winkel gespeichert werden (standardnahe .gcode → Grad).
|
||||
storeAngleUnit: process.env.STORE_ANGLE_UNIT || 'deg',
|
||||
// Optionaler Bearer-Token für schreibende Endpoints.
|
||||
// Fehlt er, sind Schreibzugriffe offen (Dev-Modus).
|
||||
apiKey: process.env.FILE_API_KEY || null,
|
||||
};
|
||||
32
src/errors.js
Normal file
32
src/errors.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// Fehler-Modell — Envelope konsistent mit dem Driver (doc/ToDo_5_API.md):
|
||||
// { type: 'error', code, message, input }
|
||||
|
||||
/** Fehler mit HTTP-Status + maschinenlesbarem Code. */
|
||||
class ApiError extends Error {
|
||||
constructor(status, code, message) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Baut den maschinenlesbaren Fehler-Envelope. */
|
||||
function envelope(code, message, input = null) {
|
||||
return { type: 'error', code, message, input };
|
||||
}
|
||||
|
||||
/** Express-Fehler-Middleware. */
|
||||
function errorMiddleware(err, req, res, _next) {
|
||||
if (err instanceof ApiError) {
|
||||
return res.status(err.status).json(envelope(err.code, err.message));
|
||||
}
|
||||
// Ungültiger JSON-Body (vom express.json-Parser)
|
||||
if (err && err.type === 'entity.parse.failed') {
|
||||
return res.status(400).json(envelope('FILE_ERROR', 'invalid JSON body'));
|
||||
}
|
||||
console.error(err);
|
||||
return res.status(500).json(envelope('FILE_ERROR', err.message || 'internal error'));
|
||||
}
|
||||
|
||||
module.exports = { ApiError, envelope, errorMiddleware };
|
||||
110
src/gcode/units.js
Normal file
110
src/gcode/units.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* G-Code-Einheiten & Zeilenformat der appRobotFileservice.
|
||||
*
|
||||
* Vertrag (siehe doc/draft_filehandeling.md §7):
|
||||
* - GESPEICHERT wird in GRAD (standardnahe .gcode): a/b/c/e in Grad, x/y/z in mm.
|
||||
* - Der DRIVER erwartet am Eingang RADIAN für a/b/c/e.
|
||||
* - Diese Umrechnung passiert AUSSCHLIESSLICH hier (an der Storage-Grenze);
|
||||
* der Driver rechnet nie um.
|
||||
*
|
||||
* Zeitstempel und Cursor liegen im G-Code-Kommentarfeld (standardkonform):
|
||||
* - jede Zeile endet mit ';<epoch>'
|
||||
* - die Cursor-Zeile zusätzlich mit '!': ';<epoch>!'
|
||||
*/
|
||||
|
||||
// Achsen, die Winkel/Greifer sind und Grad↔Radian umgerechnet werden.
|
||||
const ANGLE_AXES = ['a', 'b', 'c', 'e'];
|
||||
|
||||
const degToRad = (deg) => (deg * Math.PI) / 180;
|
||||
const radToDeg = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
const isNumeric = (s) => s !== '' && s != null && !Number.isNaN(Number(s));
|
||||
|
||||
// Zahl ohne überflüssige Nachkommastellen (z. B. 90.000000 → 90, 1.500 → 1.5).
|
||||
const trimNum = (v, digits = 6) => String(Number(Number(v).toFixed(digits)));
|
||||
|
||||
/**
|
||||
* Zerlegt eine gespeicherte Zeile in Code-Teil, Kommentar und Cursor-Flag.
|
||||
* @returns {{code:string, comment:string, cursor:boolean}}
|
||||
*/
|
||||
function splitComment(line) {
|
||||
const s = String(line);
|
||||
const idx = s.indexOf(';');
|
||||
if (idx === -1) return { code: s.trim(), comment: '', cursor: false };
|
||||
const code = s.slice(0, idx).trim();
|
||||
let comment = s.slice(idx + 1).trim();
|
||||
const cursor = comment.endsWith('!');
|
||||
if (cursor) comment = comment.slice(0, -1).trim();
|
||||
return { code, comment, cursor };
|
||||
}
|
||||
|
||||
/** Baut eine gespeicherte Zeile aus Code-Teil, Kommentar und Cursor-Flag. */
|
||||
function buildLine(code, comment = '', cursor = false) {
|
||||
let out = String(code).trim();
|
||||
if (comment || cursor) out += ` ;${comment}${cursor ? '!' : ''}`;
|
||||
return out;
|
||||
}
|
||||
|
||||
const hasCursorMarker = (line) => splitComment(line).cursor;
|
||||
|
||||
const addCursorMarker = (line) => {
|
||||
const { code, comment } = splitComment(line);
|
||||
return buildLine(code, comment, true);
|
||||
};
|
||||
|
||||
const removeCursorMarker = (line) => {
|
||||
const { code, comment } = splitComment(line);
|
||||
return buildLine(code, comment, false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wandelt eine gespeicherte Zeile (Grad, mit Kommentar) in eine driver-native,
|
||||
* ausführbare Zeile um (Radian, ohne Kommentar).
|
||||
*/
|
||||
function toExecutable(storedLine) {
|
||||
const { code } = splitComment(storedLine);
|
||||
return code
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((tok) => {
|
||||
const axis = tok[0].toLowerCase();
|
||||
const rest = tok.slice(1);
|
||||
if (ANGLE_AXES.includes(axis) && isNumeric(rest)) {
|
||||
return axis + trimNum(degToRad(Number(rest)));
|
||||
}
|
||||
return tok;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut eine zu speichernde G-Code-Zeile (Grad + Zeitstempel-Kommentar) aus einer
|
||||
* Driver-Pose (Radian).
|
||||
* @param {{x:number,y:number,z:number,a:number,b:number,c:number,e:number}} pose a/b/c/e in RADIAN
|
||||
* @param {number} feedrate
|
||||
* @param {number} epoch Zeitstempel (ms seit Epoch)
|
||||
*/
|
||||
function formatPointLine(pose, feedrate = 1000, epoch = Date.now()) {
|
||||
const deg = (r) => radToDeg(Number(r) || 0).toFixed(2);
|
||||
const mm = (v) => trimNum(Number(v) || 0, 3);
|
||||
const code = [
|
||||
'G90', 'G1',
|
||||
`x${mm(pose.x)}`, `y${mm(pose.y)}`, `z${mm(pose.z)}`,
|
||||
`a${deg(pose.a)}`, `b${deg(pose.b)}`, `c${deg(pose.c)}`, `e${deg(pose.e)}`,
|
||||
`f${trimNum(Number(feedrate) || 1000, 3)}`,
|
||||
].join(' ');
|
||||
return buildLine(code, String(Math.floor(epoch)), false);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ANGLE_AXES,
|
||||
degToRad,
|
||||
radToDeg,
|
||||
splitComment,
|
||||
buildLine,
|
||||
hasCursorMarker,
|
||||
addCursorMarker,
|
||||
removeCursorMarker,
|
||||
toExecutable,
|
||||
formatPointLine,
|
||||
};
|
||||
72
src/routes/active.js
Normal file
72
src/routes/active.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// Aktives Programm + Cursor (FLoad/FClear/FPlus/FMinus/FFirst/FLast/FGoto/
|
||||
// FPoint/FPlay/FStop). Die zurückgegebenen Playback-Zeilen sind driver-nativ
|
||||
// (Radian) — ausgeführt werden sie vom Driver.
|
||||
const express = require('express');
|
||||
const { active } = require('../active/activeState');
|
||||
const requireAuth = require('../auth');
|
||||
const { ApiError } = require('../errors');
|
||||
|
||||
const router = express.Router();
|
||||
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
// GET /api/active
|
||||
router.get('/', (req, res) => res.json(active.getState()));
|
||||
|
||||
// PUT /api/active (FLoad) — existiert nicht → leer anlegen (für Teaching)
|
||||
router.put(
|
||||
'/',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
const { id, name } = req.body || {};
|
||||
if (!id) throw new ApiError(400, 'INVALID_NAME', 'id required');
|
||||
res.json(await active.load(id, name));
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/active/clear (FClear)
|
||||
router.post('/clear', requireAuth, asyncH(async (req, res) => res.json(await active.clear())));
|
||||
|
||||
// Stepping (synchron; ApiError wird von Express an die Fehler-Middleware gereicht)
|
||||
router.post('/next', requireAuth, (req, res) => res.json(active.next()));
|
||||
router.post('/prev', requireAuth, (req, res) => res.json(active.prev()));
|
||||
router.post('/first', requireAuth, (req, res) => res.json(active.first()));
|
||||
router.post('/last', requireAuth, (req, res) => res.json(active.last()));
|
||||
router.post('/goto', requireAuth, (req, res) => res.json(active.goto((req.body || {}).index)));
|
||||
|
||||
// Teaching / Editieren
|
||||
router.post(
|
||||
'/points',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
const { pose, feedrate } = req.body || {};
|
||||
res.status(201).json(await active.appendPoint(pose, feedrate));
|
||||
})
|
||||
);
|
||||
router.post(
|
||||
'/lines',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
const { line, atIndex } = req.body || {};
|
||||
res.status(201).json(await active.appendLine(line, atIndex));
|
||||
})
|
||||
);
|
||||
router.put(
|
||||
'/lines/:index',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
res.json(await active.replaceLine(req.params.index, (req.body || {}).line));
|
||||
})
|
||||
);
|
||||
router.delete(
|
||||
'/lines/:index',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
res.json(await active.deleteLine(req.params.index));
|
||||
})
|
||||
);
|
||||
|
||||
// Playback
|
||||
router.post('/play', requireAuth, (req, res) => res.json(active.play(req.body || {})));
|
||||
router.post('/stop', requireAuth, (req, res) => res.json(active.stop()));
|
||||
|
||||
module.exports = router;
|
||||
75
src/routes/programs.js
Normal file
75
src/routes/programs.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// Programm-Verwaltung (FList/FShow/FSave/…). Storage-agnostisch über id/Name.
|
||||
const express = require('express');
|
||||
const store = require('../store/fileStore');
|
||||
const { active } = require('../active/activeState');
|
||||
const requireAuth = require('../auth');
|
||||
const { ApiError } = require('../errors');
|
||||
|
||||
const router = express.Router();
|
||||
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
// GET /api/programs (FList)
|
||||
router.get(
|
||||
'/',
|
||||
asyncH(async (req, res) => {
|
||||
res.json({ programs: await store.list() });
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/programs/:id (FShow) — Inhalt in Grad, wie gespeichert.
|
||||
router.get(
|
||||
'/:id',
|
||||
asyncH(async (req, res) => {
|
||||
const prog = await store.read(req.params.id);
|
||||
res.json({
|
||||
id: prog.id,
|
||||
name: prog.name,
|
||||
displayUnit: prog.meta.angleUnit || 'deg',
|
||||
lines: prog.lines,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/programs (FSave) — aus aktivem Puffer ODER expliziter Inhalt.
|
||||
router.post(
|
||||
'/',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
const { name, fromActive, lines } = req.body || {};
|
||||
if (!name) throw new ApiError(400, 'INVALID_NAME', 'name required');
|
||||
if (fromActive) {
|
||||
return res.status(201).json(await active.saveAs(name));
|
||||
}
|
||||
const id = store.slugify(name);
|
||||
if (!id) throw new ApiError(400, 'INVALID_NAME', `invalid name: ${name}`);
|
||||
const meta = await store.write(id, { name, lines: Array.isArray(lines) ? lines : [] });
|
||||
res.status(201).json({ id: meta.id, lineCount: meta.lineCount });
|
||||
})
|
||||
);
|
||||
|
||||
// PUT /api/programs/:id — Inhalt ersetzen / umbenennen.
|
||||
router.put(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
const { name, lines } = req.body || {};
|
||||
const existing = await store.read(req.params.id); // 404, falls nicht vorhanden
|
||||
const meta = await store.write(req.params.id, {
|
||||
name: name || existing.name,
|
||||
lines: Array.isArray(lines) ? lines : existing.lines,
|
||||
});
|
||||
res.json({ id: meta.id, lineCount: meta.lineCount });
|
||||
})
|
||||
);
|
||||
|
||||
// DELETE /api/programs/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
asyncH(async (req, res) => {
|
||||
await store.remove(req.params.id);
|
||||
res.status(204).end();
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
21
src/server.js
Normal file
21
src/server.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Express-App der appRobotFileservice. createApp() ist test-freundlich (kein listen).
|
||||
const express = require('express');
|
||||
const programsRouter = require('./routes/programs');
|
||||
const activeRouter = require('./routes/active');
|
||||
const { errorMiddleware, envelope } = require('./errors');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'appRobotFileservice' }));
|
||||
app.use('/api/programs', programsRouter);
|
||||
app.use('/api/active', activeRouter);
|
||||
|
||||
// Unbekannter Pfad → 404-Envelope
|
||||
app.use((req, res) => res.status(404).json(envelope('NOT_FOUND', 'unknown endpoint', req.path)));
|
||||
app.use(errorMiddleware);
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { createApp };
|
||||
139
src/store/fileStore.js
Normal file
139
src/store/fileStore.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Datei-basierte Persistenz der Programme:
|
||||
* <id>.<ext> — G-Code (Grad), standardnah, Zeitstempel/Cursor im Kommentar
|
||||
* <id>.json — Sidecar mit Metadaten (Name, Zeiten, lineCount, angleUnit)
|
||||
*
|
||||
* 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 { 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, lines (Grad, mit Kommentaren), 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');
|
||||
const lines = splitLines(text);
|
||||
let meta = {};
|
||||
try {
|
||||
meta = JSON.parse(await fsp.readFile(jsonPath(id), 'utf8'));
|
||||
} catch {
|
||||
/* Sidecar ist optional */
|
||||
}
|
||||
return { id, name: meta.name || id, lines, meta };
|
||||
}
|
||||
|
||||
/** Schreibt .gcode + .json. lines = gespeicherte Zeilen (Grad, inkl. Kommentar/Cursor). */
|
||||
async function write(id, { name, lines }) {
|
||||
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,
|
||||
angleUnit: cfg.storeAngleUnit,
|
||||
createdAt,
|
||||
updatedAt: now,
|
||||
};
|
||||
const body = lines.join('\n') + (lines.length ? '\n' : '');
|
||||
await fsp.writeFile(gcodePath(id), body, 'utf8');
|
||||
await fsp.writeFile(jsonPath(id), JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user