Grotti 3.3.0: Projekte mit Ordner, Datei-Löschen/Verschieben, Material-Archiv, UI-Polish.

This commit is contained in:
Dominic Gundel committed 2026-10-18 20:10:32 +02:00
1 parent 4ddffbbeb1
commit 67a89d1794
45 files changed
+2856 -320

No files matched your search

+221 -68
View File
@@ -78,11 +78,10 @@
</div>
<div class="flow-step ok" data-pane="send" id="step-mode">
<h3><span class="step-num">3</span>Wann</h3>
<h3><span class="step-num">3</span>Start</h3>
<div class="segmented" id="mode" role="radiogroup">
<button type="button" data-mode="now" class="on">Jetzt<small>sofort starten</small></button>
<button type="button" data-mode="free">Wenn frei<small>auf diesem Drucker</small></button>
<button type="button" data-mode="next">Nächster freier<small>Grotti wählt</small></button>
<button type="button" data-mode="this" class="on">Dieser Drucker<small>frei → start, sonst Warteschlange</small></button>
<button type="button" data-mode="next">Nächster passender<small>Grotti wählt nach Sorte</small></button>
</div>
</div>
@@ -189,7 +188,7 @@ let ablaufDefaults = {
check: { max_delta_mm: 3.0, timeout_min: 2, x: 125, y: 125, ref_x: 25, ref_y: 125, z_clear: 20 },
};
let ablaufSteps = [];
const sel = { fileId: null, hosts: new Set(), mode: "now", tab: "send" };
const sel = { fileId: null, hosts: new Set(), mode: "this", tab: "send" };
let aceHostsKey = "";
/* ---------- 3D-Pfad ---------- */
@@ -202,6 +201,25 @@ function ensureViewer() {
return pathViewer;
}
let toolpathGen = 0;
function scheduleToolpath(file, progress, deferMs) {
const gen = ++toolpathGen;
const run = () => {
if (gen !== toolpathGen) return;
showToolpath(file, progress);
};
if (deferMs > 0) {
if (window.requestIdleCallback) {
requestIdleCallback(run, { timeout: Math.max(800, deferMs) });
} else {
setTimeout(run, deferMs);
}
return;
}
run();
}
async function showToolpath(file, progress) {
const card = document.getElementById("viewer-card");
const caption = document.getElementById("viewer-caption");
@@ -221,6 +239,7 @@ async function showToolpath(file, progress) {
meta.textContent = "Pfad wird gelesen …";
try {
const data = await api("/dateien/" + file.id + "/pfad?tp=8");
if (String(sel.fileId) !== String(file.id)) return;
viewer.setPath(data);
viewerFileId = file.id;
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
@@ -262,7 +281,9 @@ function saveSendMemory() {
function loadSendMemory() {
try {
const data = JSON.parse(localStorage.getItem(SEND_KEY) || "{}");
if (["now", "free", "next"].includes(data.mode)) sel.mode = data.mode;
let mode = data.mode;
if (mode === "now" || mode === "free") mode = "this";
if (["this", "next"].includes(mode)) sel.mode = mode;
if (["send", "ablauf"].includes(data.tab)) sel.tab = data.tab;
if (Array.isArray(data.hosts)) sel.hosts = new Set(data.hosts);
} catch (error) { /* ignore */ }
@@ -309,10 +330,22 @@ function slotTitle(host, slot) {
return bits.join(" · ");
}
function paintIndexOf(filament, fallback) {
const raw = filament && filament.paint_index;
if (raw === 0 || raw === "0") return 0;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
function filamentLabel(filament, index) {
const kind = filament.type || "Filament";
const paint = paintIndexOf(filament, index);
const kind = filament.profile || filament.type || "Filament";
const color = filament.color ? " · " + filament.color : "";
return "Farbe " + (index + 1) + " · " + kind + color;
return "T" + paint + " · " + kind + color;
}
function canStartNow(p) {
return isFree(p) && !p.needs_clear;
}
function spoolsOnHost(host) {
@@ -326,6 +359,19 @@ function spoolWarnText() {
const maps = collectAceMaps();
const hosts = selectedHosts();
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean));
function profileOk(spoolRows) {
if (!fileProfiles.length) return true;
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
if (!names.length) return true;
return fileProfiles.every((want) => names.some((n) => {
if (n === want) return true;
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
return !["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first);
}));
}
for (const host of hosts) {
const name = (printers.find((p) => p.host === host) || {}).name || host;
const mapping = maps[host] || [];
@@ -334,18 +380,23 @@ function spoolWarnText() {
let left = 0;
let any = false;
const types = [];
const mappedSpools = [];
mapping.forEach((item) => {
const slot = Number(item.ams_index) + 1;
const spool = onHost.find((s) => Number(s.ace_slot) === slot);
if (spool) {
any = true;
left += Number(spool.remaining_g) || 0;
mappedSpools.push(spool);
if (spool.material_type) types.push(spool.material_type.toLowerCase());
}
});
if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt.";
if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
if (!profileOk(mappedSpools)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", ACE " + types.join("/") + ").";
}
continue;
@@ -359,8 +410,11 @@ function spoolWarnText() {
if (left + 0.5 < need) {
return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
}
if (!profileOk(external)) {
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
}
const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", Drucker " + types.join("/") + ").";
}
}
@@ -432,16 +486,17 @@ function renderAceMaps(force) {
title.className = "ace-printer-name";
title.textContent = printer.name;
block.appendChild(title);
paints.forEach((filament, paintIndex) => {
paints.forEach((filament, uiIndex) => {
const paint = paintIndexOf(filament, uiIndex);
const row = document.createElement("label");
row.className = "ace-row";
const swatch = document.createElement("span");
swatch.className = "ace-swatch";
if (filament.color) swatch.style.background = filament.color;
const text = document.createElement("span");
text.textContent = filamentLabel(filament, paintIndex);
text.textContent = filamentLabel(filament, uiIndex);
const select = document.createElement("select");
select.dataset.paint = String(paintIndex);
select.dataset.paint = String(paint);
select.innerHTML = '<option value="">Platz wählen</option>';
printer.slots.forEach((slot) => {
const option = document.createElement("option");
@@ -452,16 +507,30 @@ function renderAceMaps(force) {
const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || "";
if (hex) option.style.borderLeft = "4px solid " + hex;
const spoolType = (spool && spool.material_type) || slot.type || "";
// Vorauswahl: gleiche Sorte (Grotti-Spule bevorzugt), sonst Platz in Reihenfolge
if (!select.dataset.picked && filament.type && spoolType
const spoolName = ((spool && spool.name) || "").toLowerCase();
const wantProfile = (filament.profile || "").toLowerCase();
// Vorauswahl: Katalog-Sorte exakt (Meta ≠ Matte), sonst grober Typ
let profileHit = false;
if (wantProfile && spoolName) {
if (spoolName === wantProfile) profileHit = true;
else if (spoolName.startsWith(wantProfile) && !/[a-z0-9]/i.test(spoolName.charAt(wantProfile.length) || "")) {
const rest = spoolName.slice(wantProfile.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
if (!["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first)) profileHit = true;
}
}
if (!select.dataset.picked && profileHit) {
option.selected = true;
select.dataset.picked = "1";
} else if (!select.dataset.picked && !wantProfile && filament.type && spoolType
&& filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
option.selected = true;
select.dataset.picked = "1";
}
select.appendChild(option);
});
if (!select.dataset.picked && printer.slots[paintIndex]) {
select.value = String(printer.slots[paintIndex].index);
if (!select.dataset.picked && printer.slots[uiIndex]) {
select.value = String(printer.slots[uiIndex].index);
}
select.addEventListener("change", updateSpoolWarn);
row.appendChild(swatch);
@@ -638,8 +707,9 @@ function fillFolderFilter() {
}
function moveSelectHtml(file) {
if (!hasWatchDir || file.source !== "watch") return "";
const current = folderKey(file);
if (!hasWatchDir) return "";
if (file.source !== "watch" && file.source !== "upload") return "";
const current = file.source === "upload" ? "" : folderKey(file);
const opts = [
`<option value="" selected disabled>Verschieben…</option>`,
`<option value="__root__">Wurzel</option>`,
@@ -701,8 +771,8 @@ function sliceOf(file) {
}
function materialText(file) {
const types = [...new Set(((file && file.filaments) || []).map((f) => f.type).filter(Boolean))];
return types.join("+");
const labels = [...new Set(((file && file.filaments) || []).map((f) => f.profile || f.type).filter(Boolean))];
return labels.join("+");
}
function layerHeightText(file) {
@@ -757,8 +827,7 @@ function thumbHtml(file) {
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>` : "";
const del = `<button type="button" class="icon-btn" data-delete="${file.id}" title="Datei löschen">${icon("trash")}</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>
@@ -882,17 +951,25 @@ function updateSendButton() {
let note = "";
if (!file) problem = "Erst eine Datei wählen.";
else if (file.kind === "3mf") problem = "Die 3MF hat keinen G-code. Mit G-code slicen.";
if (sel.mode === "now") {
if (sel.mode === "this") {
if (!problem && !chosen.length) problem = "Einen oder mehrere Drucker wählen.";
const busy = chosen.filter((p) => !isFree(p));
if (!problem && busy.length) note = namesText(busy) + " " + (busy.length === 1 ? "ist" : "sind") + " nicht frei. „Wenn frei“ reiht stattdessen ein.";
text = chosen.length ? "Jetzt starten auf " + namesText(chosen) : "Jetzt starten";
} else if (sel.mode === "free") {
text = chosen.length ? "Einreihen für " + namesText(chosen) : "Einreihen: nächster freier " + familyOfSelection();
note = chosen.length ? "Startet von selbst, sobald der Drucker bereit und die Platte frei ist." : "Ohne Drucker: der nächste freie passende übernimmt.";
const ready = chosen.filter(canStartNow);
const later = chosen.filter((p) => !canStartNow(p));
if (ready.length && !later.length) {
text = "Jetzt starten auf " + namesText(ready);
note = "";
} else if (!ready.length && later.length) {
text = "Einreihen für " + namesText(later);
note = "Startet, sobald der Drucker bereit und die Platte frei ist.";
} else if (ready.length && later.length) {
text = "Starten / einreihen auf " + namesText(chosen);
note = namesText(ready) + " sofort, " + namesText(later) + " in die Warteschlange.";
} else {
text = "Starten";
}
} else {
text = "Einreihen: nächster freier " + familyOfSelection();
note = "Grotti nimmt den ersten freien Drucker mit passender Maschine.";
text = "Einreihen: nächster passender " + familyOfSelection();
note = "Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. PLA Meta ≠ Matte).";
}
const mismatch = chosen.filter((p) => familyMismatch(p, file));
if (!problem && mismatch.length && !document.getElementById("send-override").checked) {
@@ -902,7 +979,7 @@ function updateSendButton() {
button.disabled = !!problem || !!sendPoll;
hint.textContent = problem || note;
hint.classList.toggle("bad", !!problem);
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti nach Sorte." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
}
function updateAblaufButton() {
@@ -971,7 +1048,7 @@ function selectFile(id) {
}
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
refreshFlow();
showToolpath(file, 0);
scheduleToolpath(file, 0, 0);
if (window.matchMedia("(max-width: 1080px)").matches) {
document.querySelector(".send-panel").scrollIntoView({ behavior: "smooth", block: "start" });
}
@@ -1015,19 +1092,72 @@ async function load() {
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) {
let file = selectedFile();
// Orca/Anycubic: Metadaten oft ohne paint_index → ACE zeigt T0/T1 statt T1/T10
if (file && file.id && filamentsNeedPaintIndex(file.filaments)) {
try {
const fresh = await api("/dateien/" + file.id + "/meta", { method: "POST" });
if (fresh && fresh.id) {
const idx = files.findIndex((row) => String(row.id) === String(fresh.id));
if (idx >= 0) files[idx] = fresh;
else files.unshift(fresh);
file = selectedFile();
renderLibrary();
refreshFlow();
}
} catch (error) { /* manuell: Ordner neu lesen */ }
}
const q = new URLSearchParams(location.search);
const deepLink = !!(q.get("file") || q.get("orca"));
// Deep-Link / Orca-App: ACE/Senden zuerst — 3D-Pfad friert sonst den Tab ein
if (file) scheduleToolpath(file, 0, deepLink ? 900 : 0);
if (deepLink) {
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" });
if (file) lastOrcaFocusId = String(file.id);
if (q.get("orca")) pullOrcaFocus();
}
}
let lastOrcaFocusId = null;
async function pullOrcaFocus() {
try {
const data = await api("/orca-fokus");
if (!data || data.file_id == null) return;
const id = String(data.file_id);
const age = Date.now() / 1000 - Number(data.ts || 0);
if (age > 180) return;
const already = id === String(sel.fileId) && selectedFile();
if (id === lastOrcaFocusId && already) return;
const switched = lastOrcaFocusId != null && id !== lastOrcaFocusId;
lastOrcaFocusId = id;
if (already) return;
let file = files.find((row) => String(row.id) === id);
if (!file) {
files = await api("/dateien");
renderLibrary();
file = files.find((row) => String(row.id) === id);
}
if (file) {
selectFile(file.id);
sel.tab = "send";
renderTabs();
if (switched) toast("Neue Slice-Datei ausgewählt.", "good");
}
} catch (error) { /* nächster Tick */ }
}
function filamentsNeedPaintIndex(filaments) {
const rows = filaments || [];
if (rows.length < 2) return false;
return rows.some((item) => !item || item.paint_index == null);
}
async function refreshPrinters() {
if (document.hidden) return;
try {
@@ -1105,32 +1235,36 @@ async function watchJobs(ids) {
await tick();
}
async function postSenden(hosts, whenFree) {
return api("/senden", {
method: "POST",
json: {
file_id: Number(sel.fileId),
hosts,
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked,
ace_maps: collectAceMaps(),
when_free: whenFree,
},
});
}
async function doSend() {
const button = document.getElementById("send-go");
const whenFree = sel.mode !== "now";
button.disabled = true;
document.getElementById("send-note").textContent = "";
try {
const payload = await api("/senden", {
method: "POST",
json: {
file_id: Number(sel.fileId),
hosts: selectedHosts(),
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked,
ace_maps: collectAceMaps(),
when_free: whenFree,
},
});
const projectId = document.getElementById("send-project")?.value;
if (projectId && sel.fileId) {
try {
await api("/dateien/" + sel.fileId, { method: "PATCH", json: { project_id: Number(projectId) } });
} catch (error) { /* Projektbindung optional */ }
}
const jobs = payload.jobs || [];
if (whenFree || payload.mode === "when_free") {
if (sel.mode === "next") {
const payload = await postSenden([], true);
const jobs = payload.jobs || [];
document.getElementById("send-progress").hidden = true;
toast((jobs.length > 1 ? jobs.length + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
updateSendButton();
@@ -1138,9 +1272,35 @@ async function doSend() {
refreshPrinters();
return;
}
showJobsProgress(jobs);
const chosen = selectedPrinters();
const ready = chosen.filter(canStartNow).map((p) => p.host);
const later = chosen.filter((p) => !canStartNow(p)).map((p) => p.host);
let queued = 0;
let nowJobs = [];
if (later.length) {
const payload = await postSenden(later, true);
queued += (payload.jobs || []).length;
}
if (ready.length) {
const payload = await postSenden(ready, false);
nowJobs = payload.jobs || [];
}
if (queued && !nowJobs.length) {
document.getElementById("send-progress").hidden = true;
toast((queued > 1 ? queued + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
updateSendButton();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
refreshPrinters();
return;
}
if (queued && nowJobs.length) {
toast(queued + " eingereiht, " + nowJobs.length + " startet jetzt.", "good");
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
}
showJobsProgress(nowJobs);
if (typeof refreshSendQueue === "function") refreshSendQueue();
await watchJobs(jobs.map((job) => job.id));
await watchJobs(nowJobs.map((job) => job.id));
} catch (error) {
updateSendButton();
toast(error.message || String(error), "bad");
@@ -1445,7 +1605,7 @@ document.getElementById("targets").addEventListener("click", (event) => {
toast("Abläufe gehen nur auf Kobra 3.", "bad");
return;
}
if (sel.tab === "send" && sel.mode === "next") sel.mode = "free";
if (sel.tab === "send" && sel.mode === "next") sel.mode = "this";
if (sel.hosts.has(host)) sel.hosts.delete(host);
else sel.hosts.add(host);
saveSendMemory();
@@ -1479,19 +1639,11 @@ document.getElementById("send-bed-level").addEventListener("change", updateOptio
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
document.getElementById("send-go").addEventListener("click", (event) => {
const button = event.currentTarget;
const warn = spoolWarnText();
if (sel.mode === "now") {
armButton(button, warn ? "Warnung — trotzdem starten?" : "Wirklich starten? Nochmal klicken", doSend);
} else if (warn) {
armButton(button, "Warnung — trotzdem einreihen?", doSend);
} else {
doSend();
}
document.getElementById("send-go").addEventListener("click", () => {
doSend();
});
document.getElementById("ablauf-go").addEventListener("click", (event) => {
armButton(event.currentTarget, "Ablauf wirklich einreihen? Nochmal klicken", doAblauf);
document.getElementById("ablauf-go").addEventListener("click", () => {
doAblauf();
});
document.getElementById("ablauf-add-print").addEventListener("click", () => addStep(defaultPrintStep()));
@@ -1591,5 +1743,6 @@ loadSendMemory();
loadAblaufMemory();
load().catch((error) => toast(error.message || String(error), "bad"));
setInterval(refreshPrinters, 5000);
setInterval(pullOrcaFocus, 2000);
</script>
{% endblock %}