Grotti 2.10.0: Orca-Senden, Slice-Ordner, ED-Strom, Hygiene.

Slice-Watch mit Ordnern/Filtern, Orca-Nachbearbeitung, Cockpit-Verlauf
mit ED, Zeitzone im Browser. Tasten-Grenze und README-API angeglichen,
toter Code entfernt, Requirements gepinnt.
This commit is contained in:
Dominic Gundel committed 2026-10-18 00:41:15 +02:00
1 parent efd0801cd1
commit 9e22cebb81
48 files changed
+2259 -422

No files matched your search

+267 -18
View File
@@ -11,6 +11,7 @@
</select>
<label class="button ghost" for="upload-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
<input id="upload-file" type="file" accept=".gcode,.3mf,.gcode.3mf" multiple hidden>
<button type="button" class="ghost" id="folder-create" hidden><svg class="ico"><use href="#i-folder"/></svg>Ordner anlegen</button>
<button type="button" class="ghost" id="rescan"><svg class="ico"><use href="#i-refresh"/></svg>Ordner neu lesen</button>
</div>
</div>
@@ -22,7 +23,7 @@
<span class="count" id="lib-count">0</span>
</div>
<div class="lib-tools">
<input id="file-search" type="search" placeholder="Datei suchen…" autocomplete="off">
<input id="file-search" type="search" placeholder="Name, Material, Projekt…" autocomplete="off">
<select id="file-filter-family">
<option value="">Alle Maschinen</option>
<option value="kobra_3">Kobra 3</option>
@@ -31,6 +32,20 @@
<select id="file-filter-project">
<option value="">Alle Projekte</option>
</select>
<select id="file-filter-material" title="Material">
<option value="">Alle Materialien</option>
</select>
<select id="file-filter-folder" title="Ordner">
<option value="">Alle Ordner</option>
</select>
<select id="file-sort" title="Sortierung">
<option value="mtime">Neueste</option>
<option value="name">Name A–Z</option>
<option value="material">Material</option>
<option value="folder">Ordner</option>
<option value="time">Druckzeit</option>
<option value="weight">Gewicht</option>
</select>
</div>
<p id="file-note" class="tiny"></p>
<div class="lib-list" id="lib-list">
@@ -152,8 +167,11 @@
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
const ABLAUF_KEY = "grotti-ablauf-v2";
const SEND_KEY = "grotti-send-v1";
const LIB_KEY = "grotti-lib-v1";
const STEP_TITLES = { print: "Drucken", cool: "Abkühlen", sweep: "Schieben", check: "Tasten" };
let files = [];
let knownFolders = [];
let hasWatchDir = false;
let printers = [];
let spools = [];
let projects = [];
@@ -470,17 +488,101 @@ function collectAceMaps() {
/* ---------- Bibliothek ---------- */
function projectNameOf(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id));
return project ? project.name : "";
}
function materialKey(file) {
return materialText(file) || "";
}
function folderKey(file) {
const folder = file.folder;
if (folder === "Upload") return "Upload";
if (folder) return folder;
return "";
}
function folderLabel(folder) {
if (folder === "Upload") return "Upload";
if (folder) return folder;
return "Wurzel";
}
function saveLibMemory() {
try {
localStorage.setItem(LIB_KEY, JSON.stringify({
search: document.getElementById("file-search").value || "",
family: document.getElementById("file-filter-family").value || "",
project: document.getElementById("file-filter-project").value || "",
material: document.getElementById("file-filter-material").value || "",
folder: document.getElementById("file-filter-folder").value || "",
sort: document.getElementById("file-sort").value || "mtime",
}));
} catch (error) { /* ignore */ }
}
function loadLibMemory() {
try {
const data = JSON.parse(localStorage.getItem(LIB_KEY) || "{}");
const set = (id, value) => {
const node = document.getElementById(id);
if (!node || value == null) return;
node.value = value;
};
set("file-search", data.search || "");
set("file-filter-family", data.family || "");
set("file-filter-project", data.project || "");
set("file-filter-material", data.material || "");
set("file-filter-folder", data.folder || "");
set("file-sort", data.sort || "mtime");
} catch (error) { /* ignore */ }
}
function filteredFiles() {
const q = (document.getElementById("file-search").value || "").trim().toLowerCase();
const family = document.getElementById("file-filter-family").value;
const project = document.getElementById("file-filter-project").value;
return files.filter((file) => {
const material = document.getElementById("file-filter-material").value;
const folder = document.getElementById("file-filter-folder").value;
const sort = document.getElementById("file-sort").value || "mtime";
const shown = files.filter((file) => {
if (family && file.printer_family !== family) return false;
if (project === "none" && file.project_id) return false;
if (project && project !== "none" && String(file.project_id) !== project) return false;
if (material === "none" && materialKey(file)) return false;
if (material && material !== "none") {
const types = ((file.filaments) || []).map((f) => f.type).filter(Boolean);
if (!types.includes(material)) return false;
}
if (folder === "root" && folderKey(file)) return false;
if (folder && folder !== "root" && folderKey(file) !== folder) return false;
if (!q) return true;
return String(file.name || "").toLowerCase().includes(q);
const hay = [
file.name || "",
materialKey(file),
projectNameOf(file),
folderKey(file),
].join(" ").toLowerCase();
return hay.includes(q);
});
const cmpStr = (a, b) => String(a || "").localeCompare(String(b || ""), "de", { sensitivity: "base" });
shown.sort((a, b) => {
if (sort === "name") return cmpStr(a.name, b.name) || (Number(b.mtime) || 0) - (Number(a.mtime) || 0);
if (sort === "material") {
return cmpStr(materialKey(a) || "\uffff", materialKey(b) || "\uffff")
|| cmpStr(a.name, b.name);
}
if (sort === "folder") {
return cmpStr(folderLabel(folderKey(a)), folderLabel(folderKey(b)))
|| cmpStr(a.name, b.name);
}
if (sort === "time") return (Number(b.estimated_s) || 0) - (Number(a.estimated_s) || 0) || cmpStr(a.name, b.name);
if (sort === "weight") return (Number(b.weight_g) || 0) - (Number(a.weight_g) || 0) || cmpStr(a.name, b.name);
return (Number(b.mtime) || 0) - (Number(a.mtime) || 0) || cmpStr(a.name, b.name);
});
return shown;
}
function fillProjectFilter() {
@@ -496,6 +598,70 @@ function fillProjectFilter() {
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function fillMaterialFilter() {
const select = document.getElementById("file-filter-material");
const current = select.value;
const types = [...new Set(files.flatMap((f) => ((f.filaments) || []).map((x) => x.type).filter(Boolean)))];
types.sort((a, b) => a.localeCompare(b, "de", { sensitivity: "base" }));
select.innerHTML = '<option value="">Alle Materialien</option><option value="none">Ohne Material</option>';
types.forEach((type) => {
const option = document.createElement("option");
option.value = type;
option.textContent = type;
select.appendChild(option);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function allFolderNames() {
const fromFiles = files.map((f) => folderKey(f)).filter((f) => f && f !== "Upload");
return [...new Set([...knownFolders, ...fromFiles])]
.sort((a, b) => folderLabel(a).localeCompare(folderLabel(b), "de", { sensitivity: "base" }));
}
function fillFolderFilter() {
const select = document.getElementById("file-filter-folder");
const current = select.value;
const folders = allFolderNames();
select.innerHTML = '<option value="">Alle Ordner</option><option value="root">Wurzel</option>';
folders.forEach((folder) => {
const option = document.createElement("option");
option.value = folder;
option.textContent = folderLabel(folder);
select.appendChild(option);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function moveSelectHtml(file) {
if (!hasWatchDir || file.source !== "watch") return "";
const current = folderKey(file);
const opts = [
`<option value="" selected disabled>Verschieben…</option>`,
`<option value="__root__">Wurzel</option>`,
...allFolderNames().map((folder) =>
`<option value="${escapeHtml(folder)}"${folder === current ? " disabled" : ""}>${escapeHtml(folderLabel(folder))}</option>`
),
`<option value="__new__">Neuer Ordner…</option>`,
];
return `<select class="lib-move" data-move="${file.id}" title="In Ordner verschieben" onclick="event.stopPropagation()">${opts.join("")}</select>`;
}
async function promptCreateFolder() {
const name = window.prompt("Neuer Ordner unter dem Slice-Pfad (z. B. PETG oder PLA/tests):", "");
if (name == null) return null;
const trimmed = String(name).trim();
if (!trimmed) {
toast("Ordnername fehlt.", "bad");
return null;
}
const result = await api("/dateien/ordner", { method: "POST", json: { folder: trimmed } });
knownFolders = result.folders || [];
fillFolderFilter();
toast("Ordner „" + (result.folder || trimmed) + "“ angelegt.", "good");
return result.folder || trimmed;
}
function fillProjects() {
const opts = projects.map((row) =>
`<option value="${escapeHtml(row.id)}">${escapeHtml(row.name)}</option>`
@@ -585,9 +751,21 @@ function thumbHtml(file) {
: `<span class="ph">${icon("cube")}</span>`;
}
function libItemHtml(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id));
const del = file.source === "upload"
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)}
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
<span class="side">${moveSelectHtml(file)}${infoPopupHtml(file)}${del}</span>
</div>`;
}
function renderLibrary() {
const list = document.getElementById("lib-list");
const shown = filteredFiles();
const sort = document.getElementById("file-sort").value || "mtime";
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
if (!files.length) {
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
@@ -597,16 +775,19 @@ function renderLibrary() {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
return;
}
list.innerHTML = shown.map((file) => {
const project = projects.find((p) => String(p.id) === String(file.project_id));
const del = file.source === "upload"
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)}
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
<span class="side">${infoPopupHtml(file)}${del}</span>
</div>`;
}).join("");
const parts = [];
let lastGroup = null;
shown.forEach((file) => {
let group = null;
if (sort === "material") group = materialKey(file) || "Ohne Material";
else if (sort === "folder") group = folderLabel(folderKey(file));
if (group != null && group !== lastGroup) {
parts.push(`<div class="lib-group">${escapeHtml(group)}</div>`);
lastGroup = group;
}
parts.push(libItemHtml(file));
});
list.innerHTML = parts.join("");
}
/* ---------- Flow rechts ---------- */
@@ -795,18 +976,23 @@ function selectFile(id) {
/* ---------- Laden ---------- */
async function load() {
const [settings, fileRows, printerRows, spoolRows, projectRows, defaults] = await Promise.all([
const [settings, fileRows, printerRows, spoolRows, projectRows, defaults, folderPayload] = await Promise.all([
api("/einstellungen"),
api("/dateien"),
api("/drucker?live=1"),
api("/spulen"),
api("/projekte").catch(() => []),
api("/abraeumen-defaults").catch(() => null),
api("/dateien/ordner").catch(() => ({ folders: [] })),
]);
document.getElementById("watch").textContent = settings.watch_dir
hasWatchDir = !!(settings.watch_dir);
document.getElementById("watch").textContent = hasWatchDir
? "Slice-Ordner: " + settings.watch_dir
: "Kein Slice-Ordner — Dateien hier hochladen.";
const createBtn = document.getElementById("folder-create");
if (createBtn) createBtn.hidden = !hasWatchDir;
files = fileRows;
knownFolders = (folderPayload && folderPayload.folders) || [];
printers = printerRows;
spools = spoolRows;
projects = projectRows || [];
@@ -816,11 +1002,26 @@ async function load() {
if (sel.fileId && !selectedFile()) sel.fileId = null;
fillProjects();
fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
loadLibMemory();
fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
renderLibrary();
renderTabs();
refreshFlow();
const file = selectedFile();
if (file) showToolpath(file, 0);
// Deep-Link von Orca /orca_senden: Senden in den Blick, Optionen offen für ACE
if (new URLSearchParams(location.search).get("file") && file) {
sel.tab = "send";
renderTabs();
const opts = document.getElementById("options");
if (opts) opts.open = true;
const panel = document.querySelector(".send-panel");
if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
async function refreshPrinters() {
@@ -1161,6 +1362,10 @@ async function doAblauf() {
/* ---------- Ereignisse ---------- */
document.getElementById("lib-list").addEventListener("click", (event) => {
if (event.target.closest(".lib-move")) {
event.stopPropagation();
return;
}
const del = event.target.closest("[data-delete]");
if (del) {
event.stopPropagation();
@@ -1179,6 +1384,29 @@ document.getElementById("lib-list").addEventListener("click", (event) => {
const item = event.target.closest("[data-file]");
if (item) selectFile(item.dataset.file);
});
document.getElementById("lib-list").addEventListener("change", async (event) => {
const select = event.target.closest("select.lib-move");
if (!select) return;
event.stopPropagation();
const fileId = select.dataset.move;
let folder = select.value;
select.value = "";
if (!folder || !fileId) return;
try {
if (folder === "__new__") {
folder = await promptCreateFolder();
if (!folder) return;
} else if (folder === "__root__") {
folder = "";
}
await api("/dateien/" + fileId + "/verschieben", { method: "POST", json: { folder } });
toast(folder ? ("Nach „" + folder + "“ verschoben.") : "In die Wurzel verschoben.", "good");
await load();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
document.getElementById("lib-list").addEventListener("keydown", (event) => {
const item = event.target.closest("[data-file]");
if (item && (event.key === "Enter" || event.key === " ")) {
@@ -1186,9 +1414,22 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
selectFile(item.dataset.file);
}
});
["file-search", "file-filter-family", "file-filter-project"].forEach((id) => {
document.getElementById(id).addEventListener("input", renderLibrary);
document.getElementById(id).addEventListener("change", renderLibrary);
[
"file-search",
"file-filter-family",
"file-filter-project",
"file-filter-material",
"file-filter-folder",
"file-sort",
].forEach((id) => {
const node = document.getElementById(id);
if (!node) return;
const onChange = () => {
saveLibMemory();
renderLibrary();
};
node.addEventListener("input", onChange);
node.addEventListener("change", onChange);
});
document.getElementById("targets").addEventListener("click", (event) => {
@@ -1323,6 +1564,14 @@ const libCard = document.getElementById("lib-card");
}));
libCard.addEventListener("drop", (event) => uploadFiles(event.dataTransfer.files));
document.getElementById("folder-create")?.addEventListener("click", async () => {
try {
await promptCreateFolder();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
document.getElementById("rescan").addEventListener("click", async () => {
try {
const result = await api("/dateien/neu-lesen", { method: "POST" });