FileBrowser Folder
This commit is contained in:
@@ -84,6 +84,21 @@ body {
|
|||||||
border-radius: 10px;
|
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 ===== */
|
||||||
#program-list {
|
#program-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -22,8 +22,11 @@
|
|||||||
<span class="badge" id="prog-count">–</span>
|
<span class="badge" id="prog-count">–</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
<div id="breadcrumb" class="breadcrumb"></div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button id="btn-refresh" title="Aktualisieren">⟳</button>
|
<button id="btn-refresh" title="Aktualisieren">⟳</button>
|
||||||
|
<button id="btn-new-file" title="Neue Datei erstellen">📄+</button>
|
||||||
<button id="btn-new-folder" title="Neuen Ordner erstellen">📁+</button>
|
<button id="btn-new-folder" title="Neuen Ordner erstellen">📁+</button>
|
||||||
<button id="btn-delete-selected" title="Ausgewähltes löschen" disabled>🗑</button>
|
<button id="btn-delete-selected" title="Ausgewähltes löschen" disabled>🗑</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,6 +100,8 @@
|
|||||||
let selectedType = null; // 'file' | 'folder'
|
let selectedType = null; // 'file' | 'folder'
|
||||||
let pendingDeletes = new Set(); // Indices markierter Zeilen (noch nicht gespeichert)
|
let pendingDeletes = new Set(); // Indices markierter Zeilen (noch nicht gespeichert)
|
||||||
let cachedState = null; // letzter bekannter Serverzustand (für Abbrechen)
|
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 ───────────────────────────────────────────────────────────
|
// ─── DOM-Referenzen ───────────────────────────────────────────────────────────
|
||||||
const elProgCount = document.getElementById('prog-count');
|
const elProgCount = document.getElementById('prog-count');
|
||||||
@@ -190,11 +195,15 @@
|
|||||||
|
|
||||||
elProgList.innerHTML = folderHtml + fileHtml;
|
elProgList.innerHTML = folderHtml + fileHtml;
|
||||||
|
|
||||||
// Klick → auswählen + bei Dateien sofort laden
|
// Klick → auswählen; Datei laden; Ordner navigieren
|
||||||
elProgList.querySelectorAll('.prog-item').forEach(el => {
|
elProgList.querySelectorAll('.prog-item').forEach(el => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
setSelected(el.dataset.id, el.dataset.type);
|
if (el.dataset.type === 'folder') {
|
||||||
if (el.dataset.type === 'file') loadProgram(el.dataset.id);
|
navigateInto(el.dataset.id);
|
||||||
|
} else {
|
||||||
|
setSelected(el.dataset.id, el.dataset.type);
|
||||||
|
loadProgram(el.dataset.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -300,15 +309,47 @@
|
|||||||
updateEditBar();
|
updateEditBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Breadcrumb ──────────────────────────────────────────────────────────────
|
||||||
|
function renderBreadcrumb() {
|
||||||
|
const el = document.getElementById('breadcrumb');
|
||||||
|
let html = `<span class="bc-seg" data-depth="-1">GCodeFiles</span>`;
|
||||||
|
currentPath.forEach((seg, i) => {
|
||||||
|
html += ` / <span class="bc-seg" data-depth="${i}">${esc(seg)}</span>`;
|
||||||
|
});
|
||||||
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Daten laden ─────────────────────────────────────────────────────────────
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (pendingDeletes.size > 0) return; // edit-Modus schützen
|
if (pendingDeletes.size > 0) return; // edit-Modus schützen
|
||||||
|
const dirQ = currentDir ? `?dir=${encodeURIComponent(currentDir)}` : '';
|
||||||
try {
|
try {
|
||||||
const [programs, active] = await Promise.all([
|
const [programs, folders, active] = await Promise.all([
|
||||||
apiFetch('GET', '/api/programs'),
|
apiFetch('GET', `/api/programs${dirQ}`),
|
||||||
|
apiFetch('GET', `/api/folders${dirQ}`),
|
||||||
apiFetch('GET', '/api/active'),
|
apiFetch('GET', '/api/active'),
|
||||||
]);
|
]);
|
||||||
renderProgList(programs.programs || [], [], active.programId);
|
renderBreadcrumb();
|
||||||
|
renderProgList(programs.programs || [], folders.folders || [], active.programId);
|
||||||
renderLines(active);
|
renderLines(active);
|
||||||
setStatus(elListStatus, '');
|
setStatus(elListStatus, '');
|
||||||
setStatus(elActStatus, '');
|
setStatus(elActStatus, '');
|
||||||
@@ -318,10 +359,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Programm laden (FLoad-Äquivalent) ───────────────────────────────────────
|
// ─── Programm laden (FLoad-Äquivalent) ───────────────────────────────────────
|
||||||
async function loadProgram(id) {
|
async function loadProgram(id, dir) {
|
||||||
setStatus(elActStatus, `Lade '${id}'…`);
|
setStatus(elActStatus, `Lade '${id}'…`);
|
||||||
try {
|
try {
|
||||||
const state = await apiFetch('PUT', '/api/active', { id });
|
const state = await apiFetch('PUT', '/api/active', { id, dir: dir ?? currentDir });
|
||||||
renderLines(state);
|
renderLines(state);
|
||||||
await refresh();
|
await refresh();
|
||||||
setStatus(elActStatus, `'${id}' geladen`);
|
setStatus(elActStatus, `'${id}' geladen`);
|
||||||
@@ -379,22 +420,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Programm löschen (aus Toolbar) ─────────────────────────────────────────
|
// ─── Neue Datei anlegen ──────────────────────────────────────────────────────
|
||||||
async function deleteSelected() {
|
async function createFile() {
|
||||||
if (!selectedId) return;
|
const name = prompt('Name der neuen Datei (ohne .gcode):');
|
||||||
if (selectedType === 'folder') {
|
if (!name || !name.trim()) return;
|
||||||
alert(`Ordner-Löschung ist noch nicht implementiert.\n('${selectedId}')`);
|
setStatus(elListStatus, `Erstelle '${name.trim()}'…`);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm(`Programm '${selectedId}' wirklich löschen?`)) return;
|
|
||||||
setStatus(elListStatus, `Lösche '${selectedId}'…`);
|
|
||||||
try {
|
try {
|
||||||
await apiFetch('DELETE', `/api/programs/${encodeURIComponent(selectedId)}`);
|
const meta = await apiFetch('POST', '/api/programs', { name: name.trim(), dir: currentDir });
|
||||||
setStatus(elListStatus, `'${selectedId}' gelöscht`);
|
setStatus(elListStatus, `'${meta.id}' erstellt`);
|
||||||
selectedId = null;
|
await loadProgram(meta.id, currentDir);
|
||||||
selectedType = null;
|
|
||||||
elBtnDeleteSelected.disabled = true;
|
|
||||||
await refresh();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(elListStatus, `Fehler: ${err.message}`, true);
|
setStatus(elListStatus, `Fehler: ${err.message}`, true);
|
||||||
}
|
}
|
||||||
@@ -402,11 +436,54 @@
|
|||||||
|
|
||||||
// ─── Neuen Ordner anlegen ────────────────────────────────────────────────────
|
// ─── Neuen Ordner anlegen ────────────────────────────────────────────────────
|
||||||
async function createFolder() {
|
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 ──────────────────────────────────────────────────────────
|
// ─── Event-Listener ──────────────────────────────────────────────────────────
|
||||||
document.getElementById('btn-refresh').addEventListener('click', refresh);
|
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-new-folder').addEventListener('click', createFolder);
|
||||||
document.getElementById('btn-cancel-delete').addEventListener('click', cancelDeletes);
|
document.getElementById('btn-cancel-delete').addEventListener('click', cancelDeletes);
|
||||||
document.getElementById('btn-save-delete').addEventListener('click', saveDeletes);
|
document.getElementById('btn-save-delete').addEventListener('click', saveDeletes);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class ActiveState {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.programId = null;
|
this.programId = null;
|
||||||
this.name = null;
|
this.name = null;
|
||||||
// gespeicherte Zeilen (Grad, mit ;<epoch>-Kommentar, OHNE '!' — Cursor separat)
|
this.dir = ''; // relativer Unterordner-Pfad (z. B. '' oder 'training/runs')
|
||||||
this.lines = [];
|
this.lines = [];
|
||||||
this.cursor = 0;
|
this.cursor = 0;
|
||||||
this.playing = false;
|
this.playing = false;
|
||||||
@@ -51,6 +51,7 @@ class ActiveState {
|
|||||||
const currentLine = this.lines.length ? units.toExecutable(this.lines[this.cursor]) : null;
|
const currentLine = this.lines.length ? units.toExecutable(this.lines[this.cursor]) : null;
|
||||||
return {
|
return {
|
||||||
programId: this.programId,
|
programId: this.programId,
|
||||||
|
dir: this.dir,
|
||||||
cursor: this.cursor,
|
cursor: this.cursor,
|
||||||
lineCount: this.lines.length,
|
lineCount: this.lines.length,
|
||||||
currentLine,
|
currentLine,
|
||||||
@@ -70,31 +71,35 @@ class ActiveState {
|
|||||||
/**
|
/**
|
||||||
* Setzt ein Programm aktiv (FLoad). Existiert es nicht, wird es leer angelegt
|
* Setzt ein Programm aktiv (FLoad). Existiert es nicht, wird es leer angelegt
|
||||||
* (nötig für Teaching). Ein vorher aktives Programm wird zuvor persistiert.
|
* (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.assertValidId(id);
|
||||||
|
store.assertValidDir(dir);
|
||||||
await this._persistIfActive();
|
await this._persistIfActive();
|
||||||
|
|
||||||
if (!(await store.exists(id))) {
|
if (!(await store.exists(id, dir))) {
|
||||||
await store.write(id, { name: name || id, lines: [] });
|
await store.write(id, { name: name || id, lines: [] }, dir);
|
||||||
this.programId = id;
|
this.programId = id;
|
||||||
this.name = name || id;
|
this.name = name || id;
|
||||||
|
this.dir = dir;
|
||||||
this.lines = [];
|
this.lines = [];
|
||||||
this.cursor = 0;
|
this.cursor = 0;
|
||||||
this.playing = false;
|
this.playing = false;
|
||||||
this._touch();
|
this._touch();
|
||||||
log.info(`load '${id}' → neu (leer angelegt)`);
|
log.info(`load '${id}' (dir='${dir}') → neu (leer angelegt)`);
|
||||||
return this.getState();
|
return this.getState();
|
||||||
}
|
}
|
||||||
|
|
||||||
const prog = await store.read(id);
|
const prog = await store.read(id, dir);
|
||||||
this.programId = id;
|
this.programId = id;
|
||||||
this.name = prog.name;
|
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.cursor = Math.min(prog.cursor ?? 0, Math.max(0, prog.lines.length - 1));
|
||||||
this.playing = false;
|
this.playing = false;
|
||||||
this._touch();
|
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();
|
return this.getState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,18 +220,19 @@ class ActiveState {
|
|||||||
|
|
||||||
// ---- Speichern ----
|
// ---- Speichern ----
|
||||||
|
|
||||||
/** Speichert den aktiven Puffer unter einem (neuen) Namen (FSave). */
|
/** Speichert den aktiven Puffer unter einem (neuen) Namen (FSave).
|
||||||
async saveAs(name) {
|
* targetDir: Zielverzeichnis (Standard: Wurzel). */
|
||||||
|
async saveAs(name, targetDir = '') {
|
||||||
this._requireActive();
|
this._requireActive();
|
||||||
const id = store.slugify(name);
|
const id = store.slugify(name);
|
||||||
if (!id) throw new ApiError(400, 'INVALID_NAME', `invalid name: ${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 };
|
return { id: meta.id, lineCount: meta.lineCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
async _persist() {
|
async _persist() {
|
||||||
if (!this.programId) return;
|
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() {
|
async _persistIfActive() {
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ router.put(
|
|||||||
'/',
|
'/',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
asyncH(async (req, res) => {
|
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');
|
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 || ''));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
32
src/routes/folders.js
Normal file
32
src/routes/folders.js
Normal file
@@ -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;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Programm-Verwaltung (FList/FShow/FSave/…). Storage-agnostisch über id/Name.
|
// Programm-Verwaltung (FList/FShow/FSave/…). Storage-agnostisch über id/Name.
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const fsp = require('fs/promises');
|
||||||
const store = require('../store/fileStore');
|
const store = require('../store/fileStore');
|
||||||
const { active } = require('../active/activeState');
|
const { active } = require('../active/activeState');
|
||||||
const requireAuth = require('../auth');
|
const requireAuth = require('../auth');
|
||||||
@@ -8,19 +9,26 @@ const { ApiError } = require('../errors');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
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(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
asyncH(async (req, res) => {
|
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(
|
router.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
asyncH(async (req, res) => {
|
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({
|
res.json({
|
||||||
id: prog.id,
|
id: prog.id,
|
||||||
name: prog.name,
|
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(
|
router.get(
|
||||||
'/:id/download',
|
'/:id/download',
|
||||||
asyncH(async (req, res) => {
|
asyncH(async (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const prog = await store.read(id); // wirft 404 wenn nicht vorhanden
|
const dir = getDir(req);
|
||||||
const filePath = store.gcodePath(id);
|
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-Type', 'text/plain; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${id}.gcode"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${id}.gcode"`);
|
||||||
// sendFile braucht absoluten Pfad — wir haben ihn direkt aus dem Store
|
res.send(await fsp.readFile(filePath, 'utf8'));
|
||||||
const fsp = require('fs/promises');
|
|
||||||
const body = await fsp.readFile(filePath, 'utf8');
|
|
||||||
res.send(body);
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -51,39 +57,41 @@ router.post(
|
|||||||
'/',
|
'/',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
asyncH(async (req, res) => {
|
asyncH(async (req, res) => {
|
||||||
|
const dir = getDir(req);
|
||||||
const { name, fromActive, lines } = req.body || {};
|
const { name, fromActive, lines } = req.body || {};
|
||||||
if (!name) throw new ApiError(400, 'INVALID_NAME', 'name required');
|
if (!name) throw new ApiError(400, 'INVALID_NAME', 'name required');
|
||||||
if (fromActive) {
|
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);
|
const id = store.slugify(name);
|
||||||
if (!id) throw new ApiError(400, 'INVALID_NAME', `invalid name: ${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 });
|
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(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
asyncH(async (req, res) => {
|
asyncH(async (req, res) => {
|
||||||
|
const dir = getDir(req);
|
||||||
const { name, lines } = req.body || {};
|
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, {
|
const meta = await store.write(req.params.id, {
|
||||||
name: name || existing.name,
|
name: name || existing.name,
|
||||||
lines: Array.isArray(lines) ? lines : existing.lines,
|
lines: Array.isArray(lines) ? lines : existing.lines,
|
||||||
});
|
}, dir);
|
||||||
res.json({ id: meta.id, lineCount: meta.lineCount });
|
res.json({ id: meta.id, lineCount: meta.lineCount });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE /api/programs/:id
|
// DELETE /api/programs/:id?dir=
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
asyncH(async (req, res) => {
|
asyncH(async (req, res) => {
|
||||||
await store.remove(req.params.id);
|
await store.remove(req.params.id, getDir(req));
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const programsRouter = require('./routes/programs');
|
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 { errorMiddleware, envelope } = require('./errors');
|
||||||
const log = require('./log');
|
const log = require('./log');
|
||||||
|
|
||||||
@@ -24,7 +25,8 @@ function createApp() {
|
|||||||
|
|
||||||
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'appRobotFileservice' }));
|
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'appRobotFileservice' }));
|
||||||
app.use('/api/programs', programsRouter);
|
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)
|
// Web-UI: statische Dateien aus public/ (index.html, index.css)
|
||||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Datei-basierte Persistenz der Programme:
|
* Datei-basierte Persistenz der Programme:
|
||||||
* <id>.<ext> — G-Code (Grad), standardnah; Zeitstempel als ;<epoch>-Kommentar,
|
* <storageDir>/<dir>/<id>.<ext> — G-Code (Grad), Cursor-Zeile mit ';!'-Marker
|
||||||
* Cursor-Zeile zusätzlich mit '!' (z. B. ;1234567890!)
|
* <storageDir>/<dir>/<id>.json — Sidecar mit Metadaten
|
||||||
* <id>.json — Sidecar mit Metadaten (Name, Zeiten, lineCount, angleUnit)
|
*
|
||||||
|
* 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).
|
* 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 fsp = require('fs/promises');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -17,7 +16,8 @@ const units = require('../gcode/units');
|
|||||||
const log = require('../log');
|
const log = require('../log');
|
||||||
const { ApiError } = require('../errors');
|
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 '../'). */
|
/** Wandelt einen Anzeigenamen in eine sichere id (keine Pfade, kein '../'). */
|
||||||
function slugify(name) {
|
function slugify(name) {
|
||||||
@@ -29,7 +29,6 @@ function slugify(name) {
|
|||||||
.slice(0, 100);
|
.slice(0, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stellt sicher, dass eine id keine Pfad-Trenner o. Ä. enthält. */
|
|
||||||
function assertValidId(id) {
|
function assertValidId(id) {
|
||||||
if (!ID_RE.test(String(id || ''))) {
|
if (!ID_RE.test(String(id || ''))) {
|
||||||
throw new ApiError(400, 'INVALID_NAME', `invalid program id: ${id}`);
|
throw new ApiError(400, 'INVALID_NAME', `invalid program id: ${id}`);
|
||||||
@@ -37,16 +36,37 @@ function assertValidId(id) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gcodePath = (id) => path.join(cfg.storageDir, `${id}.${cfg.fileExt}`);
|
/** Validiert dir-Pfad (keine .. , keine absoluten Pfade, max 5 Ebenen). */
|
||||||
const jsonPath = (id) => path.join(cfg.storageDir, `${id}.json`);
|
function assertValidDir(dir) {
|
||||||
|
if (!dir) return;
|
||||||
async function ensureDir() {
|
const parts = String(dir).split('/');
|
||||||
await fsp.mkdir(cfg.storageDir, { recursive: true });
|
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 {
|
try {
|
||||||
await fsp.access(gcodePath(id));
|
await fsp.access(gcodePath(id, dir));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -62,20 +82,20 @@ function splitLines(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Liest ein Programm: { id, name, cursor, lines (Grad, sauber ohne '!'), meta }. */
|
/** Liest ein Programm: { id, name, cursor, lines (Grad, sauber ohne '!'), meta }. */
|
||||||
async function read(id) {
|
async function read(id, dir = '') {
|
||||||
assertValidId(id);
|
assertValidId(id);
|
||||||
if (!(await exists(id))) {
|
assertValidDir(dir);
|
||||||
throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'`);
|
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 = {};
|
let meta = {};
|
||||||
try {
|
try {
|
||||||
meta = JSON.parse(await fsp.readFile(jsonPath(id), 'utf8'));
|
meta = JSON.parse(await fsp.readFile(jsonPath(id, dir), 'utf8'));
|
||||||
} catch {
|
} catch {
|
||||||
/* Sidecar ist optional */
|
/* Sidecar ist optional */
|
||||||
}
|
}
|
||||||
// Primärquelle: ';!'-Marker im .gcode (genau einer pro Datei).
|
// Primärquelle: ';!'-Marker im .gcode. Fallback: cursor aus .json.
|
||||||
// Fallback: cursor aus .json (ältere Dateien ohne Marker).
|
|
||||||
let fileCursor = null;
|
let fileCursor = null;
|
||||||
const lines = splitLines(text).map((line, i) => {
|
const lines = splitLines(text).map((line, i) => {
|
||||||
if (units.hasCursorMarker(line)) { fileCursor = i; return units.removeCursorMarker(line); }
|
if (units.hasCursorMarker(line)) { fileCursor = i; return units.removeCursorMarker(line); }
|
||||||
@@ -87,16 +107,17 @@ async function read(id) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Schreibt .gcode + .json.
|
* Schreibt .gcode + .json.
|
||||||
* lines = saubere G-Code-Zeilen (Grad, ohne '!'-Marker) — der Marker wird hier gesetzt.
|
* lines = saubere G-Code-Zeilen (ohne '!') — Marker wird hier gesetzt.
|
||||||
* cursor = Cursor-Index: die entsprechende Zeile bekommt im .gcode ein '!' angehängt.
|
* 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);
|
assertValidId(id);
|
||||||
await ensureDir();
|
assertValidDir(dir);
|
||||||
|
await ensureDir(dir);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
let createdAt = now;
|
let createdAt = now;
|
||||||
try {
|
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;
|
createdAt = prev.createdAt || now;
|
||||||
} catch {
|
} catch {
|
||||||
/* neues Programm */
|
/* neues Programm */
|
||||||
@@ -110,36 +131,37 @@ async function write(id, { name, lines, cursor = 0 }) {
|
|||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
// Cursor-Zeile im .gcode mit ';!'-Marker — sichtbar in jedem Text-Editor.
|
|
||||||
const withCursor = lines.map((line, i) =>
|
const withCursor = lines.map((line, i) =>
|
||||||
i === cursor && line.length > 0 ? units.addCursorMarker(line) : line
|
i === cursor && line.length > 0 ? units.addCursorMarker(line) : line
|
||||||
);
|
);
|
||||||
const body = withCursor.join('\n') + (withCursor.length ? '\n' : '');
|
const body = withCursor.join('\n') + (withCursor.length ? '\n' : '');
|
||||||
await fsp.writeFile(gcodePath(id), body, 'utf8');
|
await fsp.writeFile(gcodePath(id, dir), body, 'utf8');
|
||||||
await fsp.writeFile(jsonPath(id), JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
await fsp.writeFile(jsonPath(id, dir), JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||||
log.info(`write ${gcodePath(id)} (${lines.length} Zeilen, cursor ${cursor})`);
|
log.info(`write ${gcodePath(id, dir)} (${lines.length} Zeilen, cursor ${cursor})`);
|
||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id) {
|
async function remove(id, dir = '') {
|
||||||
assertValidId(id);
|
assertValidId(id);
|
||||||
if (!(await exists(id))) {
|
assertValidDir(dir);
|
||||||
|
if (!(await exists(id, dir))) {
|
||||||
throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'`);
|
throw new ApiError(404, 'PROGRAM_NOT_FOUND', `no program '${id}'`);
|
||||||
}
|
}
|
||||||
await fsp.rm(gcodePath(id), { force: true });
|
await fsp.rm(gcodePath(id, dir), { force: true });
|
||||||
await fsp.rm(jsonPath(id), { force: true });
|
await fsp.rm(jsonPath(id, dir), { force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liste aller Programme (id, name, lineCount). */
|
/** Liste aller Programme (id, name, lineCount) in dir. */
|
||||||
async function list() {
|
async function list(dir = '') {
|
||||||
await ensureDir();
|
assertValidDir(dir);
|
||||||
const entries = await fsp.readdir(cfg.storageDir);
|
await ensureDir(dir);
|
||||||
|
const entries = await fsp.readdir(resolvedDir(dir));
|
||||||
const ext = `.${cfg.fileExt}`;
|
const ext = `.${cfg.fileExt}`;
|
||||||
const ids = entries.filter((f) => f.endsWith(ext)).map((f) => f.slice(0, -ext.length));
|
const ids = entries.filter((f) => f.endsWith(ext)).map((f) => f.slice(0, -ext.length));
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
try {
|
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 });
|
out.push({ id, name, lineCount: meta.lineCount ?? lines.length });
|
||||||
} catch {
|
} catch {
|
||||||
/* defekte Einträge überspringen */
|
/* defekte Einträge überspringen */
|
||||||
@@ -148,14 +170,55 @@ async function list() {
|
|||||||
return out;
|
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 = {
|
module.exports = {
|
||||||
slugify,
|
slugify,
|
||||||
assertValidId,
|
assertValidId,
|
||||||
|
assertValidDir,
|
||||||
exists,
|
exists,
|
||||||
read,
|
read,
|
||||||
write,
|
write,
|
||||||
remove,
|
remove,
|
||||||
list,
|
list,
|
||||||
|
listDirs,
|
||||||
|
createDir,
|
||||||
|
removeDir,
|
||||||
ensureDir,
|
ensureDir,
|
||||||
gcodePath,
|
gcodePath,
|
||||||
jsonPath,
|
jsonPath,
|
||||||
|
|||||||
Reference in New Issue
Block a user