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,