From 7ccd2eb972bf028783cf3fed9f4150b70424a78f Mon Sep 17 00:00:00 2001
From: chk <79915315+ChKendel@users.noreply.github.com>
Date: Sun, 14 Jun 2026 22:46:21 +0200
Subject: [PATCH] FileBrowser Folder
---
public/index.css | 15 ++++
public/index.html | 125 +++++++++++++++++++++++++-------
src/active/activeState.js | 30 ++++----
src/routes/active.js | 4 +-
src/routes/folders.js | 32 +++++++++
src/routes/programs.js | 46 +++++++-----
src/server.js | 6 +-
src/store/fileStore.js | 145 +++++++++++++++++++++++++++-----------
8 files changed, 303 insertions(+), 100 deletions(-)
create mode 100644 src/routes/folders.js
diff --git a/public/index.css b/public/index.css
index 82358c7..53a89f0 100644
--- a/public/index.css
+++ b/public/index.css
@@ -84,6 +84,21 @@ body {
border-radius: 10px;
}
+/* ===== BREADCRUMB ===== */
+.breadcrumb {
+ font-size: 12px;
+ color: var(--muted);
+ margin-bottom: 8px;
+ min-height: 18px;
+}
+.bc-seg {
+ cursor: pointer;
+ color: var(--accent);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.bc-seg:hover { color: var(--text); }
+
/* ===== PROGRAM LIST ===== */
#program-list {
display: flex;
diff --git a/public/index.html b/public/index.html
index 3e03f50..19f6a44 100644
--- a/public/index.html
+++ b/public/index.html
@@ -22,8 +22,11 @@
β
+
+
+
@@ -97,6 +100,8 @@
let selectedType = null; // 'file' | 'folder'
let pendingDeletes = new Set(); // Indices markierter Zeilen (noch nicht gespeichert)
let cachedState = null; // letzter bekannter Serverzustand (fΓΌr Abbrechen)
+ let currentDir = ''; // aktuelles Verzeichnis (relativer Pfad, z. B. 'training/run1')
+ let currentPath = []; // Segmente des currentDir als Array (fΓΌr Breadcrumb)
// βββ DOM-Referenzen βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const elProgCount = document.getElementById('prog-count');
@@ -190,11 +195,15 @@
elProgList.innerHTML = folderHtml + fileHtml;
- // Klick β auswΓ€hlen + bei Dateien sofort laden
+ // Klick β auswΓ€hlen; Datei laden; Ordner navigieren
elProgList.querySelectorAll('.prog-item').forEach(el => {
el.addEventListener('click', () => {
- setSelected(el.dataset.id, el.dataset.type);
- if (el.dataset.type === 'file') loadProgram(el.dataset.id);
+ if (el.dataset.type === 'folder') {
+ navigateInto(el.dataset.id);
+ } else {
+ setSelected(el.dataset.id, el.dataset.type);
+ loadProgram(el.dataset.id);
+ }
});
});
}
@@ -300,15 +309,47 @@
updateEditBar();
}
+ // βββ Breadcrumb ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ function renderBreadcrumb() {
+ const el = document.getElementById('breadcrumb');
+ let html = `GCodeFiles`;
+ currentPath.forEach((seg, i) => {
+ html += ` / ${esc(seg)}`;
+ });
+ el.innerHTML = html;
+ el.querySelectorAll('.bc-seg').forEach(span => {
+ span.addEventListener('click', () => {
+ const depth = Number(span.dataset.depth);
+ if (depth === -1) { currentPath = []; currentDir = ''; }
+ else { currentPath = currentPath.slice(0, depth + 1); currentDir = currentPath.join('/'); }
+ selectedId = null; selectedType = null;
+ elBtnDeleteSelected.disabled = true;
+ refresh();
+ });
+ });
+ }
+
+ // βββ In Ordner navigieren ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ function navigateInto(folderName) {
+ currentPath.push(folderName);
+ currentDir = currentPath.join('/');
+ selectedId = null; selectedType = null;
+ elBtnDeleteSelected.disabled = true;
+ refresh();
+ }
+
// βββ Daten laden βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function refresh() {
if (pendingDeletes.size > 0) return; // edit-Modus schΓΌtzen
+ const dirQ = currentDir ? `?dir=${encodeURIComponent(currentDir)}` : '';
try {
- const [programs, active] = await Promise.all([
- apiFetch('GET', '/api/programs'),
+ const [programs, folders, active] = await Promise.all([
+ apiFetch('GET', `/api/programs${dirQ}`),
+ apiFetch('GET', `/api/folders${dirQ}`),
apiFetch('GET', '/api/active'),
]);
- renderProgList(programs.programs || [], [], active.programId);
+ renderBreadcrumb();
+ renderProgList(programs.programs || [], folders.folders || [], active.programId);
renderLines(active);
setStatus(elListStatus, '');
setStatus(elActStatus, '');
@@ -318,10 +359,10 @@
}
// βββ Programm laden (FLoad-Γquivalent) βββββββββββββββββββββββββββββββββββββββ
- async function loadProgram(id) {
+ async function loadProgram(id, dir) {
setStatus(elActStatus, `Lade '${id}'β¦`);
try {
- const state = await apiFetch('PUT', '/api/active', { id });
+ const state = await apiFetch('PUT', '/api/active', { id, dir: dir ?? currentDir });
renderLines(state);
await refresh();
setStatus(elActStatus, `'${id}' geladen`);
@@ -379,22 +420,15 @@
}
}
- // βββ Programm lΓΆschen (aus Toolbar) βββββββββββββββββββββββββββββββββββββββββ
- async function deleteSelected() {
- if (!selectedId) return;
- if (selectedType === 'folder') {
- alert(`Ordner-LΓΆschung ist noch nicht implementiert.\n('${selectedId}')`);
- return;
- }
- if (!confirm(`Programm '${selectedId}' wirklich lΓΆschen?`)) return;
- setStatus(elListStatus, `LΓΆsche '${selectedId}'β¦`);
+ // βββ Neue Datei anlegen ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ async function createFile() {
+ const name = prompt('Name der neuen Datei (ohne .gcode):');
+ if (!name || !name.trim()) return;
+ setStatus(elListStatus, `Erstelle '${name.trim()}'β¦`);
try {
- await apiFetch('DELETE', `/api/programs/${encodeURIComponent(selectedId)}`);
- setStatus(elListStatus, `'${selectedId}' gelΓΆscht`);
- selectedId = null;
- selectedType = null;
- elBtnDeleteSelected.disabled = true;
- await refresh();
+ const meta = await apiFetch('POST', '/api/programs', { name: name.trim(), dir: currentDir });
+ setStatus(elListStatus, `'${meta.id}' erstellt`);
+ await loadProgram(meta.id, currentDir);
} catch (err) {
setStatus(elListStatus, `Fehler: ${err.message}`, true);
}
@@ -402,11 +436,54 @@
// βββ Neuen Ordner anlegen ββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function createFolder() {
- alert('Ordner-Verwaltung ist noch nicht implementiert.\n(kommt in Phase 3 des Roadmaps)');
+ const name = prompt('Name des neuen Ordners:');
+ if (!name || !name.trim()) return;
+ const slug = name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '');
+ if (!slug) { alert('UngΓΌltiger Ordnername.'); return; }
+ setStatus(elListStatus, `Erstelle Ordner '${slug}'β¦`);
+ try {
+ await apiFetch('POST', '/api/folders', { name: slug, dir: currentDir });
+ setStatus(elListStatus, `Ordner '${slug}' erstellt`);
+ await refresh();
+ } catch (err) {
+ setStatus(elListStatus, `Fehler: ${err.message}`, true);
+ }
+ }
+
+ // βββ Auswahl lΓΆschen (Toolbar-π) ββββββββββββββββββββββββββββββββββββββββββββ
+ async function deleteSelected() {
+ if (!selectedId) return;
+ const dirQ = currentDir ? `?dir=${encodeURIComponent(currentDir)}` : '';
+ if (selectedType === 'folder') {
+ if (!confirm(`Ordner '${selectedId}' und seinen gesamten Inhalt wirklich lΓΆschen?`)) return;
+ setStatus(elListStatus, `LΓΆsche Ordner '${selectedId}'β¦`);
+ try {
+ await apiFetch('DELETE', `/api/folders/${encodeURIComponent(selectedId)}${dirQ}`);
+ setStatus(elListStatus, `Ordner '${selectedId}' gelΓΆscht`);
+ selectedId = null; selectedType = null;
+ elBtnDeleteSelected.disabled = true;
+ await refresh();
+ } catch (err) {
+ setStatus(elListStatus, `Fehler: ${err.message}`, true);
+ }
+ return;
+ }
+ if (!confirm(`Programm '${selectedId}' wirklich lΓΆschen?`)) return;
+ setStatus(elListStatus, `LΓΆsche '${selectedId}'β¦`);
+ try {
+ await apiFetch('DELETE', `/api/programs/${encodeURIComponent(selectedId)}${dirQ}`);
+ setStatus(elListStatus, `'${selectedId}' gelΓΆscht`);
+ selectedId = null; selectedType = null;
+ elBtnDeleteSelected.disabled = true;
+ await refresh();
+ } catch (err) {
+ setStatus(elListStatus, `Fehler: ${err.message}`, true);
+ }
}
// βββ Event-Listener ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
document.getElementById('btn-refresh').addEventListener('click', refresh);
+ document.getElementById('btn-new-file').addEventListener('click', createFile);
document.getElementById('btn-new-folder').addEventListener('click', createFolder);
document.getElementById('btn-cancel-delete').addEventListener('click', cancelDeletes);
document.getElementById('btn-save-delete').addEventListener('click', saveDeletes);
diff --git a/src/active/activeState.js b/src/active/activeState.js
index 27738c6..fff442f 100644
--- a/src/active/activeState.js
+++ b/src/active/activeState.js
@@ -16,7 +16,7 @@ class ActiveState {
constructor() {
this.programId = null;
this.name = null;
- // gespeicherte Zeilen (Grad, mit ;-Kommentar, OHNE '!' β Cursor separat)
+ this.dir = ''; // relativer Unterordner-Pfad (z. B. '' oder 'training/runs')
this.lines = [];
this.cursor = 0;
this.playing = false;
@@ -51,6 +51,7 @@ class ActiveState {
const currentLine = this.lines.length ? units.toExecutable(this.lines[this.cursor]) : null;
return {
programId: this.programId,
+ dir: this.dir,
cursor: this.cursor,
lineCount: this.lines.length,
currentLine,
@@ -70,31 +71,35 @@ class ActiveState {
/**
* Setzt ein Programm aktiv (FLoad). Existiert es nicht, wird es leer angelegt
* (nΓΆtig fΓΌr Teaching). Ein vorher aktives Programm wird zuvor persistiert.
+ * dir = optionaler relativer Unterordner-Pfad (z. B. '' oder 'training').
*/
- async load(id, name) {
+ async load(id, name, dir = '') {
store.assertValidId(id);
+ store.assertValidDir(dir);
await this._persistIfActive();
- if (!(await store.exists(id))) {
- await store.write(id, { name: name || id, lines: [] });
+ if (!(await store.exists(id, dir))) {
+ await store.write(id, { name: name || id, lines: [] }, dir);
this.programId = id;
this.name = name || id;
+ this.dir = dir;
this.lines = [];
this.cursor = 0;
this.playing = false;
this._touch();
- log.info(`load '${id}' β neu (leer angelegt)`);
+ log.info(`load '${id}' (dir='${dir}') β neu (leer angelegt)`);
return this.getState();
}
- const prog = await store.read(id);
+ const prog = await store.read(id, dir);
this.programId = id;
this.name = prog.name;
- this.lines = prog.lines; // bereits sauber (kein '!'-Marker)
+ this.dir = dir;
+ this.lines = prog.lines;
this.cursor = Math.min(prog.cursor ?? 0, Math.max(0, prog.lines.length - 1));
this.playing = false;
this._touch();
- log.info(`load '${id}' β ${prog.lines.length} Zeilen von Disk, cursor ${this.cursor}`);
+ log.info(`load '${id}' (dir='${dir}') β ${prog.lines.length} Zeilen, cursor ${this.cursor}`);
return this.getState();
}
@@ -215,18 +220,19 @@ class ActiveState {
// ---- Speichern ----
- /** Speichert den aktiven Puffer unter einem (neuen) Namen (FSave). */
- async saveAs(name) {
+ /** Speichert den aktiven Puffer unter einem (neuen) Namen (FSave).
+ * targetDir: Zielverzeichnis (Standard: Wurzel). */
+ async saveAs(name, targetDir = '') {
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.lines, cursor: this.cursor });
+ const meta = await store.write(id, { name, lines: this.lines, cursor: this.cursor }, targetDir);
return { id: meta.id, lineCount: meta.lineCount };
}
async _persist() {
if (!this.programId) return;
- await store.write(this.programId, { name: this.name, lines: this.lines, cursor: this.cursor });
+ await store.write(this.programId, { name: this.name, lines: this.lines, cursor: this.cursor }, this.dir);
}
async _persistIfActive() {
diff --git a/src/routes/active.js b/src/routes/active.js
index 76145aa..40b378d 100644
--- a/src/routes/active.js
+++ b/src/routes/active.js
@@ -17,9 +17,9 @@ router.put(
'/',
requireAuth,
asyncH(async (req, res) => {
- const { id, name } = req.body || {};
+ const { id, name, dir } = req.body || {};
if (!id) throw new ApiError(400, 'INVALID_NAME', 'id required');
- res.json(await active.load(id, name));
+ res.json(await active.load(id, name, dir || ''));
})
);
diff --git a/src/routes/folders.js b/src/routes/folders.js
new file mode 100644
index 0000000..8491b0b
--- /dev/null
+++ b/src/routes/folders.js
@@ -0,0 +1,32 @@
+// Ordner-Verwaltung: Liste, Anlegen, LΓΆschen (rekursiv).
+const express = require('express');
+const store = require('../store/fileStore');
+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);
+
+function getDir(req) {
+ return String(req.query.dir || '').replace(/^\/|\/$/g, '');
+}
+
+// GET /api/folders?dir= β Unterordner einer Ebene
+router.get('/', asyncH(async (req, res) => {
+ res.json({ folders: await store.listDirs(getDir(req)) });
+}));
+
+// POST /api/folders β neuen Ordner anlegen
+router.post('/', requireAuth, asyncH(async (req, res) => {
+ const { name, dir } = req.body || {};
+ if (!name) throw new ApiError(400, 'INVALID_NAME', 'name required');
+ res.status(201).json(await store.createDir(name, dir || ''));
+}));
+
+// DELETE /api/folders/:name?dir= β Ordner rekursiv lΓΆschen
+router.delete('/:name', requireAuth, asyncH(async (req, res) => {
+ await store.removeDir(req.params.name, getDir(req));
+ res.status(204).end();
+}));
+
+module.exports = router;
diff --git a/src/routes/programs.js b/src/routes/programs.js
index 6b04c9f..30a6d81 100644
--- a/src/routes/programs.js
+++ b/src/routes/programs.js
@@ -1,5 +1,6 @@
// Programm-Verwaltung (FList/FShow/FSave/β¦). Storage-agnostisch ΓΌber id/Name.
const express = require('express');
+const fsp = require('fs/promises');
const store = require('../store/fileStore');
const { active } = require('../active/activeState');
const requireAuth = require('../auth');
@@ -8,19 +9,26 @@ 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)
+/** Liest dir aus Query-Param (GET/DELETE) oder Body (POST/PUT). */
+function getDir(req) {
+ const raw = req.query.dir || (req.body && req.body.dir) || '';
+ return String(raw).replace(/^\/|\/$/g, ''); // fΓΌhrende/trailing Slashes entfernen
+}
+
+// GET /api/programs?dir= (FList)
router.get(
'/',
asyncH(async (req, res) => {
- res.json({ programs: await store.list() });
+ res.json({ programs: await store.list(getDir(req)) });
})
);
-// GET /api/programs/:id (FShow) β Inhalt in Grad, wie gespeichert.
+// GET /api/programs/:id?dir= (FShow) β Inhalt in Grad, wie gespeichert.
router.get(
'/:id',
asyncH(async (req, res) => {
- const prog = await store.read(req.params.id);
+ const dir = getDir(req);
+ const prog = await store.read(req.params.id, dir);
res.json({
id: prog.id,
name: prog.name,
@@ -30,19 +38,17 @@ router.get(
})
);
-// GET /api/programs/:id/download β rohe .gcode-Datei als Download
+// GET /api/programs/:id/download?dir= β rohe .gcode-Datei als Download
router.get(
'/:id/download',
asyncH(async (req, res) => {
- const id = req.params.id;
- const prog = await store.read(id); // wirft 404 wenn nicht vorhanden
- const filePath = store.gcodePath(id);
+ const id = req.params.id;
+ const dir = getDir(req);
+ await store.read(id, dir); // wirft 404 wenn nicht vorhanden
+ const filePath = store.gcodePath(id, dir);
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${id}.gcode"`);
- // sendFile braucht absoluten Pfad β wir haben ihn direkt aus dem Store
- const fsp = require('fs/promises');
- const body = await fsp.readFile(filePath, 'utf8');
- res.send(body);
+ res.send(await fsp.readFile(filePath, 'utf8'));
})
);
@@ -51,39 +57,41 @@ router.post(
'/',
requireAuth,
asyncH(async (req, res) => {
+ const dir = getDir(req);
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));
+ return res.status(201).json(await active.saveAs(name, dir));
}
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 : [] });
+ const meta = await store.write(id, { name, lines: Array.isArray(lines) ? lines : [] }, dir);
res.status(201).json({ id: meta.id, lineCount: meta.lineCount });
})
);
-// PUT /api/programs/:id β Inhalt ersetzen / umbenennen.
+// PUT /api/programs/:id?dir= β Inhalt ersetzen / umbenennen.
router.put(
'/:id',
requireAuth,
asyncH(async (req, res) => {
+ const dir = getDir(req);
const { name, lines } = req.body || {};
- const existing = await store.read(req.params.id); // 404, falls nicht vorhanden
+ const existing = await store.read(req.params.id, dir);
const meta = await store.write(req.params.id, {
name: name || existing.name,
lines: Array.isArray(lines) ? lines : existing.lines,
- });
+ }, dir);
res.json({ id: meta.id, lineCount: meta.lineCount });
})
);
-// DELETE /api/programs/:id
+// DELETE /api/programs/:id?dir=
router.delete(
'/:id',
requireAuth,
asyncH(async (req, res) => {
- await store.remove(req.params.id);
+ await store.remove(req.params.id, getDir(req));
res.status(204).end();
})
);
diff --git a/src/server.js b/src/server.js
index 72194a5..afdbf54 100644
--- a/src/server.js
+++ b/src/server.js
@@ -2,7 +2,8 @@
const path = require('path');
const express = require('express');
const programsRouter = require('./routes/programs');
-const activeRouter = require('./routes/active');
+const activeRouter = require('./routes/active');
+const foldersRouter = require('./routes/folders');
const { errorMiddleware, envelope } = require('./errors');
const log = require('./log');
@@ -24,7 +25,8 @@ function createApp() {
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'appRobotFileservice' }));
app.use('/api/programs', programsRouter);
- app.use('/api/active', activeRouter);
+ app.use('/api/folders', foldersRouter);
+ app.use('/api/active', activeRouter);
// Web-UI: statische Dateien aus public/ (index.html, index.css)
app.use(express.static(path.join(__dirname, '..', 'public')));
diff --git a/src/store/fileStore.js b/src/store/fileStore.js
index ab1b701..a9777b1 100644
--- a/src/store/fileStore.js
+++ b/src/store/fileStore.js
@@ -1,14 +1,13 @@
/**
* 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)
+ * //. β G-Code (Grad), Cursor-Zeile mit ';!'-Marker
+ * //.json β Sidecar mit Metadaten
+ *
+ * dir = optionaler relativer Unterordner-Pfad (z. B. '' oder 'training' oder 'a/b').
+ * Jedes Segment muss /^[a-z0-9_-]+$/ erfΓΌllen. Max. 5 Ebenen.
+ * id = Dateiname ohne Endung; muss /^[a-z0-9_]+$/ erfΓΌllen.
*
* 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');
@@ -17,7 +16,8 @@ const units = require('../gcode/units');
const log = require('../log');
const { ApiError } = require('../errors');
-const ID_RE = /^[a-z0-9_]+$/;
+const ID_RE = /^[a-z0-9_]+$/;
+const DIR_SEG_RE = /^[a-z0-9_-]+$/;
/** Wandelt einen Anzeigenamen in eine sichere id (keine Pfade, kein '../'). */
function slugify(name) {
@@ -29,7 +29,6 @@ function slugify(name) {
.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}`);
@@ -37,16 +36,37 @@ function assertValidId(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 });
+/** Validiert dir-Pfad (keine .. , keine absoluten Pfade, max 5 Ebenen). */
+function assertValidDir(dir) {
+ if (!dir) return;
+ const parts = String(dir).split('/');
+ for (const part of parts) {
+ if (!part || !DIR_SEG_RE.test(part)) {
+ throw new ApiError(400, 'INVALID_DIR', `invalid directory path: "${dir}"`);
+ }
+ }
+ if (parts.length > 5) {
+ throw new ApiError(400, 'INVALID_DIR', 'directory nesting too deep (max 5 levels)');
+ }
}
-async function exists(id) {
+/** Sicherer Pfad relativ zu storageDir. */
+function resolvedDir(dir) {
+ if (!dir) return cfg.storageDir;
+ return path.join(cfg.storageDir, ...String(dir).split('/'));
+}
+
+const gcodePath = (id, dir = '') => path.join(resolvedDir(dir), `${id}.${cfg.fileExt}`);
+const jsonPath = (id, dir = '') => path.join(resolvedDir(dir), `${id}.json`);
+
+async function ensureDir(dir = '') {
+ assertValidDir(dir);
+ await fsp.mkdir(resolvedDir(dir), { recursive: true });
+}
+
+async function exists(id, dir = '') {
try {
- await fsp.access(gcodePath(id));
+ await fsp.access(gcodePath(id, dir));
return true;
} catch {
return false;
@@ -62,20 +82,20 @@ function splitLines(text) {
}
/** Liest ein Programm: { id, name, cursor, lines (Grad, sauber ohne '!'), meta }. */
-async function read(id) {
+async function read(id, dir = '') {
assertValidId(id);
- if (!(await exists(id))) {
- throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'`);
+ assertValidDir(dir);
+ if (!(await exists(id, dir))) {
+ throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'${dir ? ` in '${dir}'` : ''}`);
}
- const text = await fsp.readFile(gcodePath(id), 'utf8');
+ const text = await fsp.readFile(gcodePath(id, dir), 'utf8');
let meta = {};
try {
- meta = JSON.parse(await fsp.readFile(jsonPath(id), 'utf8'));
+ meta = JSON.parse(await fsp.readFile(jsonPath(id, dir), 'utf8'));
} catch {
/* Sidecar ist optional */
}
- // PrimΓ€rquelle: ';!'-Marker im .gcode (genau einer pro Datei).
- // Fallback: cursor aus .json (Γ€ltere Dateien ohne Marker).
+ // PrimΓ€rquelle: ';!'-Marker im .gcode. Fallback: cursor aus .json.
let fileCursor = null;
const lines = splitLines(text).map((line, i) => {
if (units.hasCursorMarker(line)) { fileCursor = i; return units.removeCursorMarker(line); }
@@ -87,16 +107,17 @@ async function read(id) {
/**
* 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.
+ * lines = saubere G-Code-Zeilen (ohne '!') β Marker wird hier gesetzt.
+ * cursor = Cursor-Index: entsprechende Zeile bekommt ';!' angehΓ€ngt.
*/
-async function write(id, { name, lines, cursor = 0 }) {
+async function write(id, { name, lines, cursor = 0 }, dir = '') {
assertValidId(id);
- await ensureDir();
+ assertValidDir(dir);
+ await ensureDir(dir);
const now = new Date().toISOString();
let createdAt = now;
try {
- const prev = JSON.parse(await fsp.readFile(jsonPath(id), 'utf8'));
+ const prev = JSON.parse(await fsp.readFile(jsonPath(id, dir), 'utf8'));
createdAt = prev.createdAt || now;
} catch {
/* neues Programm */
@@ -110,36 +131,37 @@ async function write(id, { name, lines, cursor = 0 }) {
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})`);
+ await fsp.writeFile(gcodePath(id, dir), body, 'utf8');
+ await fsp.writeFile(jsonPath(id, dir), JSON.stringify(meta, null, 2) + '\n', 'utf8');
+ log.info(`write ${gcodePath(id, dir)} (${lines.length} Zeilen, cursor ${cursor})`);
return meta;
}
-async function remove(id) {
+async function remove(id, dir = '') {
assertValidId(id);
- if (!(await exists(id))) {
+ assertValidDir(dir);
+ if (!(await exists(id, dir))) {
throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'`);
}
- await fsp.rm(gcodePath(id), { force: true });
- await fsp.rm(jsonPath(id), { force: true });
+ await fsp.rm(gcodePath(id, dir), { force: true });
+ await fsp.rm(jsonPath(id, dir), { force: true });
}
-/** Liste aller Programme (id, name, lineCount). */
-async function list() {
- await ensureDir();
- const entries = await fsp.readdir(cfg.storageDir);
+/** Liste aller Programme (id, name, lineCount) in dir. */
+async function list(dir = '') {
+ assertValidDir(dir);
+ await ensureDir(dir);
+ const entries = await fsp.readdir(resolvedDir(dir));
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);
+ const { name, lines, meta } = await read(id, dir);
out.push({ id, name, lineCount: meta.lineCount ?? lines.length });
} catch {
/* defekte EintrΓ€ge ΓΌberspringen */
@@ -148,14 +170,55 @@ async function list() {
return out;
}
+/** Liste aller Unterordner (eine Ebene) in dir. */
+async function listDirs(dir = '') {
+ assertValidDir(dir);
+ await ensureDir(dir);
+ const entries = await fsp.readdir(resolvedDir(dir), { withFileTypes: true });
+ return entries
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
+ .map((e) => ({ id: e.name, name: e.name }));
+}
+
+/** Legt einen neuen Unterordner an. */
+async function createDir(name, parentDir = '') {
+ if (!DIR_SEG_RE.test(String(name || ''))) {
+ throw new ApiError(400, 'INVALID_NAME', `invalid folder name: "${name}"`);
+ }
+ assertValidDir(parentDir);
+ const fullPath = path.join(resolvedDir(parentDir), name);
+ const alreadyExists = await fsp.stat(fullPath).then((s) => s.isDirectory()).catch(() => false);
+ if (alreadyExists) throw new ApiError(409, 'DIR_EXISTS', `folder '${name}' already exists`);
+ await fsp.mkdir(fullPath, { recursive: true });
+ log.info(`createDir ${fullPath}`);
+ return { id: name, name };
+}
+
+/** LΓΆscht einen Ordner rekursiv (inkl. aller Dateien darin). */
+async function removeDir(name, parentDir = '') {
+ if (!DIR_SEG_RE.test(String(name || ''))) {
+ throw new ApiError(400, 'INVALID_NAME', `invalid folder name: "${name}"`);
+ }
+ assertValidDir(parentDir);
+ const fullPath = path.join(resolvedDir(parentDir), name);
+ const isDir = await fsp.stat(fullPath).then((s) => s.isDirectory()).catch(() => false);
+ if (!isDir) throw new ApiError(404, 'DIR_NOT_FOUND', `folder '${name}' not found`);
+ await fsp.rm(fullPath, { recursive: true, force: true });
+ log.info(`removeDir ${fullPath}`);
+}
+
module.exports = {
slugify,
assertValidId,
+ assertValidDir,
exists,
read,
write,
remove,
list,
+ listDirs,
+ createDir,
+ removeDir,
ensureDir,
gcodePath,
jsonPath,