Files
grotti/web/templates/dateien.html
T

1873 lines
73 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Dateien</h1>
<p class="tiny" id="watch"></p>
</div>
<div class="page-head-side">
<select id="upload-project" title="Projekt für Upload" style="max-width:10rem">
<option value="">kein Projekt</option>
</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>
<div class="send-layout">
<section class="card lib-card" id="lib-card">
<div class="card-head lib-head">
<svg class="ico head-ico"><use href="#i-folder-open"/></svg>
<h2>Bibliothek</h2>
<span class="count-pill" id="lib-count">0</span>
</div>
<div class="toolbar lib-tools">
<div class="search-wrap">
<svg class="ico"><use href="#i-search"/></svg>
<input id="file-search" type="search" placeholder="Name, Material, Projekt…" autocomplete="off">
</div>
<select id="file-filter-family">
<option value="">Alle Maschinen</option>
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
</select>
<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">
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
</div>
<div class="pager" id="lib-pager" hidden></div>
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
</section>
<section class="card send-panel">
<div class="tabs" role="tablist">
<button type="button" class="on" data-tab="send" role="tab"><svg class="ico"><use href="#i-send"/></svg>Senden</button>
<button type="button" data-tab="ablauf" role="tab"><svg class="ico"><use href="#i-sweep"/></svg>Ablauf <small>Kobra 3</small></button>
</div>
<div class="flow-step" id="step-file">
<h3><span class="step-num">1</span>Datei</h3>
<div id="picked"><div class="picked-empty"><svg class="ico"><use href="#i-file"/></svg><span>Datei aus der Bibliothek wählen.</span></div></div>
</div>
<div class="flow-step" id="step-target">
<h3><span class="step-num">2</span>Drucker</h3>
<div class="targets" id="targets"></div>
<p class="tiny" id="target-hint"></p>
</div>
<div class="flow-step ok" data-pane="send" id="step-mode">
<h3><span class="step-num">3</span>Start</h3>
<div class="segmented" id="mode" role="radiogroup">
<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>
<div class="flow-step ok" data-pane="ablauf" id="step-ablauf" hidden>
<h3><span class="step-num">3</span>Schritte</h3>
<p class="tiny">Drucken → abkühlen → schieben → Mitte tasten. Kiste vor dem Bett.</p>
<div id="ablauf-preview" class="ablauf-preview"></div>
<div class="sweep-preview-wrap" id="sweep-preview-wrap" hidden>
<canvas id="sweep-canvas" class="sweep-canvas" width="320" height="240" aria-label="Schiebe-Vorschau"></canvas>
<p class="tiny">Draufsicht · orange = Vorderkante</p>
</div>
<div id="ablauf-steps" class="ablauf-steps"></div>
<div class="actions ablauf-add">
<button type="button" class="ghost tiny-btn" id="ablauf-add-print">+ Drucken</button>
<button type="button" class="ghost tiny-btn" id="ablauf-add-cool">+ Abkühlen</button>
<button type="button" class="ghost tiny-btn" id="ablauf-add-sweep">+ Schieben</button>
<button type="button" class="ghost tiny-btn" id="ablauf-add-check">+ Tasten</button>
</div>
<div class="repeat-row">
<label for="ablauf-repeat">Durchläufe</label>
<div class="stepper">
<button type="button" class="ghost" data-repeat="-1" aria-label="weniger">−</button>
<input id="ablauf-repeat" type="number" min="1" max="99" value="1">
<button type="button" class="ghost" data-repeat="1" aria-label="mehr">+</button>
</div>
</div>
</div>
<details class="options" id="options">
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Optionen</span><span class="tiny" id="options-sum"></span></summary>
<div class="opt-body">
<div id="loaded-spools" class="loaded-spools"></div>
<p class="tiny">Verbrauch geht von der Rolle am Drucker. Wechsel unter <a href="/material">Material</a>.</p>
<label class="check"><input id="send-bed-level" type="checkbox"> Bettnetz vor dem Druck messen</label>
<label class="check"><input id="send-override" type="checkbox"> Auch senden, wenn die Maschine nicht passt</label>
<label for="send-project">Projekt</label>
<select id="send-project"><option value="">keins</option></select>
<div id="ace-box" class="ace-box" hidden>
<h3>ACE-Zuordnung <span class="tip" title="Datei-Farbe auf ACE-Platz. Ohne Zuordnung startet der ACE oft nicht."><svg class="ico"><use href="#i-info"/></svg></span></h3>
<p class="tiny" id="ace-trailer-warn" hidden>Mehrfarb ohne Slicer-Trailer — Grotti ergänzt `; referenced_tools` beim Senden.</p>
<div id="ace-maps"></div>
</div>
</div>
</details>
<p class="msg" id="spool-warn" hidden></p>
<div class="go-row" data-pane="send">
<button type="button" class="go-btn" id="send-go" disabled><svg class="ico"><use href="#i-play"/></svg><span>Starten</span></button>
<p class="go-hint" id="send-hint"></p>
</div>
<div class="go-row" data-pane="ablauf" hidden>
<button type="button" class="go-btn" id="ablauf-go" disabled><svg class="ico"><use href="#i-queue"/></svg><span>Ablauf einreihen</span></button>
<p class="go-hint" id="ablauf-hint"></p>
<div class="test-row">
<button type="button" class="ghost tiny-btn" id="ablauf-test-sweep"><svg class="ico"><use href="#i-sweep"/></svg>Schieben testen</button>
<label class="check"><input id="ablauf-test-empty" type="checkbox"> Bett ist leer</label>
</div>
<p id="ablauf-note"></p>
</div>
<div class="send-progress" id="send-progress" hidden>
<div id="send-progress-list"></div>
</div>
<p id="send-note"></p>
</section>
</div>
<section class="card viewer-card" id="viewer-card" hidden>
<div class="page-head">
<div>
<h2>3D-Pfad</h2>
<p class="tiny" id="viewer-caption">Datei wählen</p>
</div>
<span class="tiny" id="viewer-meta"></span>
</div>
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
<canvas id="gcode-canvas" class="gcode-canvas"></canvas>
</section>
{% endblock %}
{% block script %}
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
<script>
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 = [];
let sendPoll = null;
let watchedIds = [];
let pathViewer = null;
let viewerFileId = null;
let ablaufDefaults = {
cool: { bed_max: 30, nozzle_max: 45, fan_percent: 100, timeout_min: 25 },
sweep: { z_push: 2, z_travel: 20, x_from: 25, x_to: 225, x_step: 20, y_from: 250, y_to: 5, speed: 25 },
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: "this", tab: "send" };
let aceHostsKey = "";
/* ---------- 3D-Pfad ---------- */
function ensureViewer() {
if (pathViewer) return pathViewer;
const canvas = document.getElementById("gcode-canvas");
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return null;
pathViewer = GrottiGcodeViewer.create(canvas);
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");
const meta = document.getElementById("viewer-meta");
if (!file || file.kind === "3mf") {
card.hidden = true;
return;
}
card.hidden = false;
caption.textContent = file.name;
const viewer = ensureViewer();
if (!viewer) {
meta.textContent = "Viewer nicht geladen.";
return;
}
if (viewerFileId !== file.id) {
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";
} catch (error) {
meta.textContent = error.message || String(error);
return;
}
}
if (progress != null) viewer.setProgress(progress);
else viewer.setProgress(null);
}
/* ---------- Auswahl ---------- */
function selectedHosts() {
if (sel.mode === "next" && sel.tab === "send") return [];
return printers.filter((p) => sel.hosts.has(p.host)).map((p) => p.host);
}
function selectedPrinters() {
const hosts = new Set(selectedHosts());
return printers.filter((printer) => hosts.has(printer.host));
}
function selectedFile() {
return files.find((item) => String(item.id) === String(sel.fileId));
}
function selectedKobra3() {
return printers.filter((p) => sel.hosts.has(p.host) && p.family === "kobra_3");
}
function saveSendMemory() {
try {
localStorage.setItem(SEND_KEY, JSON.stringify({ mode: sel.mode, tab: sel.tab, hosts: [...sel.hosts] }));
} catch (error) { /* ignore */ }
}
function loadSendMemory() {
try {
const data = JSON.parse(localStorage.getItem(SEND_KEY) || "{}");
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 */ }
const q = new URLSearchParams(location.search);
if (q.get("file")) sel.fileId = q.get("file");
if (q.get("host")) sel.hosts = new Set([q.get("host")]);
if (q.get("tab") === "ablauf") sel.tab = "ablauf";
}
/* ---------- ACE / Spule ---------- */
function spoolAtSlot(host, place) {
return spoolsOnHost(host).find((s) => Number(s.ace_slot) === Number(place)) || null;
}
function slotLabel(host, slot) {
const place = Number(slot.index) + 1;
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4);
const spool = spoolAtSlot(host, place);
if (spool) {
const bits = [box];
const kind = spool.material_type || slot.type || "";
if (kind) bits.push(kind);
if (spool.color) bits.push(spool.color);
if (spool.name) bits.push(spool.name);
bits.push(grams(spool.remaining_g));
return bits.join(" · ");
}
const kind = slot.type ? " · " + slot.type : "";
return box + kind;
}
function slotTitle(host, slot) {
const place = Number(slot.index) + 1;
const spool = spoolAtSlot(host, place);
if (!spool) return slot.type ? String(slot.type) : "";
const bits = [];
if (spool.brand) bits.push(spool.brand);
if (spool.name) bits.push(spool.name);
if (spool.material_type) bits.push(spool.material_type);
if (spool.color) bits.push(spool.color);
bits.push(grams(spool.remaining_g) + " Rest");
if (spool.chargen_nr) bits.push(spool.chargen_nr);
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 paint = paintIndexOf(filament, index);
const kind = filament.profile || filament.type || "Filament";
const color = filament.color ? " · " + filament.color : "";
return "T" + paint + " · " + kind + color;
}
function canStartNow(p) {
return isFree(p) && !p.needs_clear;
}
function spoolsOnHost(host) {
return spools.filter((s) => s.printer_host === host);
}
function spoolWarnText() {
const file = selectedFile();
const need = file ? Number(file.weight_g) || 0 : 0;
if (!file || need <= 0) return "";
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));
const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"];
function profileHasVariant(want) {
return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || ""));
}
function profileBaseType(want) {
const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/);
return m ? m[1] : "";
}
function profileOk(spoolRows) {
if (!fileProfiles.length) return true;
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (!names.length && !types.length) return true;
return fileProfiles.every((want) => {
if (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 !PROFILE_VARIANTS.includes(first);
})) return true;
// Basis-Profil (nur „… PLA“): Typ reicht, Marke egal — keine Meta/Matte-Spule
if (profileHasVariant(want)) return false;
const kind = profileBaseType(want);
if (!kind) return true;
return spoolRows.some((s) => {
const t = (s.material_type || "").toLowerCase();
const n = (s.name || "").toLowerCase();
return t === kind && !profileHasVariant(n);
});
});
}
for (const host of hosts) {
const name = (printers.find((p) => p.host === host) || {}).name || host;
const mapping = maps[host] || [];
const onHost = spoolsOnHost(host);
if (mapping.length) {
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 (!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;
}
const external = onHost.filter((s) => !s.ace_slot);
if (!external.length && !onHost.length) {
return name + ": keine Spule eingelegt — Abzug entfällt. Unter Material einlegen.";
}
if (external.length) {
const left = external.reduce((sum, s) => sum + (Number(s.remaining_g) || 0), 0);
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 (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", Drucker " + types.join("/") + ").";
}
}
}
return "";
}
function renderLoadedSpools() {
const box = document.getElementById("loaded-spools");
if (!box) return;
const chosen = selectedPrinters();
if (!chosen.length) {
box.innerHTML = '<p class="tiny">Drucker wählen — dann siehst du die eingelegte Rolle.</p>';
return;
}
box.innerHTML = chosen.map((p) => {
const rows = spoolsOnHost(p.host);
if (!rows.length) {
return `<div class="loaded-host"><b>${escapeHtml(p.name)}</b><span class="tiny msg">keine Spule — <a href="/material">einlegen</a></span></div>`;
}
return `<div class="loaded-host"><b>${escapeHtml(p.name)}</b>${rows.map((s) => {
const slot = s.ace_slot ? "Platz " + s.ace_slot + " · " : "";
return `<span class="loaded-spool">${escapeHtml(slot + s.name)} · ${grams(s.remaining_g)}${s.material_type ? " · " + escapeHtml(s.material_type) : ""}</span>`;
}).join("")}</div>`;
}).join("");
}
function updateSpoolWarn() {
const node = document.getElementById("spool-warn");
const text = spoolWarnText();
node.hidden = !text;
node.textContent = text;
}
function filePaints(file) {
const rows = (file && file.filaments) || [];
if (rows.length) return rows;
return [{ type: "PLA", color: "" }];
}
function renderAceMaps(force) {
const box = document.getElementById("ace-box");
const root = document.getElementById("ace-maps");
const file = selectedFile();
const chosen = selectedPrinters().filter((printer) => printer.slots && printer.slots.length);
const key = (file ? file.id : "") + "|" + chosen.map((p) => p.host).join(",");
if (!force && key === aceHostsKey) return;
aceHostsKey = key;
const trailerWarn = document.getElementById("ace-trailer-warn");
if (!file || !chosen.length) {
box.hidden = true;
root.innerHTML = "";
if (trailerWarn) trailerWarn.hidden = true;
return;
}
const paints = filePaints(file);
box.hidden = false;
document.getElementById("options").open = true;
// Mono: Trailer fehlt oft und ist kein Fehler — Grotti setzt ihn beim Senden mit ACE-Map.
if (trailerWarn) {
trailerWarn.hidden = !!file.ace_trailer || paints.length <= 1;
}
root.innerHTML = "";
chosen.forEach((printer) => {
const block = document.createElement("div");
block.className = "ace-printer";
block.dataset.host = printer.host;
const title = document.createElement("p");
title.className = "ace-printer-name";
title.textContent = printer.name;
block.appendChild(title);
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, uiIndex);
const select = document.createElement("select");
select.dataset.paint = String(paint);
select.innerHTML = '<option value="">Platz wählen</option>';
printer.slots.forEach((slot) => {
const option = document.createElement("option");
option.value = String(slot.index);
option.textContent = slotLabel(printer.host, slot);
option.title = slotTitle(printer.host, slot);
const spool = spoolAtSlot(printer.host, Number(slot.index) + 1);
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 || "";
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[uiIndex]) {
select.value = String(printer.slots[uiIndex].index);
}
select.addEventListener("change", updateSpoolWarn);
row.appendChild(swatch);
row.appendChild(text);
row.appendChild(select);
block.appendChild(row);
});
root.appendChild(block);
});
}
function collectAceMaps() {
const maps = {};
document.querySelectorAll("#ace-maps .ace-printer").forEach((block) => {
const host = block.dataset.host;
const mapping = [];
block.querySelectorAll("select[data-paint]").forEach((select) => {
if (select.value === "") return;
mapping.push({
paint_index: Number(select.dataset.paint),
ams_index: Number(select.value),
});
});
maps[host] = mapping;
});
return maps;
}
/* ---------- 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 folderMatches(fileFolder, selected) {
if (!selected) return true;
if (selected === "root") return !fileFolder;
if (!fileFolder) return false;
return fileFolder === selected || fileFolder.startsWith(selected + "/");
}
function releaseRootForProject() {
const project = document.getElementById("file-filter-project").value;
const folder = document.getElementById("file-filter-folder");
if (!folder || !project || project === "none" || folder.value !== "root") return;
folder.value = "";
}
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;
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 (!folderMatches(folderKey(file), folder)) return false;
if (!q) return true;
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() {
const select = document.getElementById("file-filter-project");
const current = select.value;
select.innerHTML = '<option value="">Alle Projekte</option><option value="none">Ohne Projekt</option>';
projects.forEach((project) => {
const option = document.createElement("option");
option.value = project.id;
option.textContent = project.name;
select.appendChild(option);
});
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) 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>`,
...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>`
).join("");
const send = document.getElementById("send-project");
if (send) {
const keep = send.value;
send.innerHTML = '<option value="">keins</option>' + opts;
if (keep) send.value = keep;
}
const upload = document.getElementById("upload-project");
if (upload) {
const keep = upload.value;
upload.innerHTML = '<option value="">kein Projekt</option>' + opts;
if (keep) upload.value = keep;
}
}
function sizeText(bytes) {
const n = Number(bytes) || 0;
if (!n) return "";
return n >= 1048576 ? (n / 1048576).toFixed(1).replace(".", ",") + " MB" : Math.round(n / 1024) + " kB";
}
function swatchesHtml(file) {
const colors = ((file && file.filaments) || []).map((f) => f.color).filter(Boolean);
if (!colors.length) return "";
return `<span class="mini-swatches">${colors.slice(0, 6).map((c) => `<i style="background:${escapeHtml(c)}"></i>`).join("")}</span>`;
}
function sliceOf(file) {
return (file && file.slice_meta) || {};
}
function materialText(file) {
const labels = [...new Set(((file && file.filaments) || []).map((f) => f.profile || f.type).filter(Boolean))];
return labels.join("+");
}
function layerHeightText(file) {
const height = Number(sliceOf(file).layer_height) || 0;
if (!height) return "";
return String(height).replace(".", ",") + " mm";
}
function alwaysMeta(file, project) {
const bits = [
materialText(file),
file.estimated_s ? hours(file.estimated_s) : "",
file.weight_g ? grams(file.weight_g) : "",
layerHeightText(file),
families[file.printer_family] || "",
];
if (!file.weight_g && !file.estimated_s) bits.push(sizeText(file.size_bytes));
const spans = bits.filter(Boolean).map((t) => `<span>${escapeHtml(t)}</span>`);
if (project) {
spans.push(`<a class="lib-proj" href="/projekte#${encodeURIComponent(project.id)}" onclick="event.stopPropagation()">${escapeHtml(project.name)}</a>`);
}
return spans.join("") + swatchesHtml(file);
}
function detailMeta(file) {
const sm = sliceOf(file);
return [
sm.layers ? sm.layers + " Schichten" : "",
sm.max_z_mm ? "Z max " + String(sm.max_z_mm).replace(".", ",") + " mm" : "",
sm.nozzle_mm ? "Düse Ø " + String(sm.nozzle_mm).replace(".", ",") + " mm" : "",
sm.temp_nozzle ? "Düse " + Math.round(sm.temp_nozzle) + "°" : "",
sm.temp_bed ? "Bett " + Math.round(sm.temp_bed) + "°" : "",
sizeText(file.size_bytes),
file.mtime ? new Date(Number(file.mtime) * 1000).toLocaleString("de-DE") : "",
sm.slicer || "",
file.kind === "3mf" ? "nur 3MF" : "",
file.ace_trailer ? "ACE-Trailer" : "",
].filter(Boolean);
}
function infoPopupHtml(file) {
const rows = detailMeta(file);
if (!rows.length) return "";
return `<details class="lib-info" onclick="event.stopPropagation()">
<summary class="icon-btn" title="Details">${icon("info")}</summary>
<div class="lib-info-pop">${rows.map((row) => `<div>${escapeHtml(row)}</div>`).join("")}</div>
</details>`;
}
function placeLibInfoPop(details) {
const pop = details.querySelector(".lib-info-pop");
const anchor = details.querySelector("summary");
if (!pop || !anchor) return;
const margin = 6;
const r = anchor.getBoundingClientRect();
pop.style.left = "0px";
pop.style.top = "0px";
const pw = pop.offsetWidth;
const ph = pop.offsetHeight;
let left = r.right - pw;
left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));
let top = r.bottom + margin;
if (top + ph > window.innerHeight - margin && r.top - margin - ph >= margin) {
top = r.top - margin - ph;
} else {
top = Math.min(top, Math.max(margin, window.innerHeight - ph - margin));
}
pop.style.left = left + "px";
pop.style.top = top + "px";
}
function closeOpenLibInfo(except) {
document.querySelectorAll("details.lib-info[open]").forEach((node) => {
if (node !== except) node.removeAttribute("open");
});
}
function repositionOpenLibInfo() {
const open = document.querySelector("details.lib-info[open]");
if (open) placeLibInfoPop(open);
}
function thumbHtml(file) {
return file.preview
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
: `<span class="ph">${icon("cube")}</span>`;
}
function libItemHtml(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id));
const rename = `<button type="button" class="icon-btn" data-rename="${file.id}" title="Umbenennen">${icon("edit")}</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>
<span class="side">${moveSelectHtml(file)}${infoPopupHtml(file)}${rename}${del}</span>
</div>`;
}
let libPage = 1;
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>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return;
}
if (!shown.length) {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return;
}
const paged = pageSlice(shown, libPage, DEFAULT_PAGE_SIZE);
libPage = paged.page;
const parts = [];
let lastGroup = null;
paged.items.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("");
renderPager(document.getElementById("lib-pager"), paged, (p) => {
libPage = p;
renderLibrary();
});
}
/* ---------- Flow rechts ---------- */
function renderPicked() {
const file = selectedFile();
const box = document.getElementById("picked");
document.getElementById("step-file").classList.toggle("ok", !!file);
if (!file) {
box.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Datei aus der Bibliothek wählen.</span></div>';
return;
}
const project = projects.find((p) => String(p.id) === String(file.project_id));
const details = detailMeta(file).map((t) => `<span>${escapeHtml(t)}</span>`).join("");
box.innerHTML = `<div class="picked-file">${thumbHtml(file)}<div style="min-width:0"><div class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</div><div class="m">${alwaysMeta(file, project)}</div>${details ? `<div class="m picked-details">${details}</div>` : ""}</div></div>`;
}
function targetState(p) {
const st = String(p.state || "").toLowerCase();
if (p.ok === false || st === "offline") return { st: "offline", text: "offline", bad: true };
if (p.queue_running) {
const job = p.queue_running;
const step = job.step_label_current ? job.step_label_current + " · " : "";
return { st: "printing", text: (job.steps && job.steps.length ? "Ablauf · " : "") + step + (repeatText(job) || "läuft") };
}
if (st === "printing" || st === "paused") {
const pct = Math.round((Number(p.progress) || 0) * 100);
const rest = p.remain > 0 ? " · noch " + hours(p.remain) : "";
return { st, text: (STATE_LABELS[st] || st) + " " + pct + " %" + rest };
}
if (st === "error") return { st, text: "Fehler — auf der Wand löschen", bad: true };
if (p.needs_clear) return { st: "waiting", text: "Platte belegt", bad: true };
return { st: st || "standby", text: STATE_LABELS[st] || "bereit" };
}
function isFree(p) {
const st = String(p.state || "").toLowerCase();
return p.ok !== false && ["standby", "complete", "cancelled"].includes(st) && !p.queue_running;
}
function familyMismatch(p, file) {
return !!(file && file.printer_family && p.family && file.printer_family !== p.family);
}
function renderTargets() {
const box = document.getElementById("targets");
const file = selectedFile();
const override = document.getElementById("send-override").checked;
const nextMode = sel.tab === "send" && sel.mode === "next";
box.classList.toggle("dimmed", nextMode);
box.innerHTML = printers.map((p) => {
const s = targetState(p);
const mismatch = familyMismatch(p, file);
const notK3 = sel.tab === "ablauf" && p.family !== "kobra_3";
const off = (mismatch && !override) || notK3;
const on = !nextMode && sel.hosts.has(p.host) && !notK3;
const waiting = p.queue_waiting ? `<span class="count">${p.queue_waiting}</span>` : "";
const why = notK3 ? "Ablauf nur Kobra 3" : mismatch ? "andere Maschine" : s.text;
return `<button type="button" class="target ${on ? "on" : ""} ${off ? "off" : ""}" data-host="${escapeHtml(p.host)}" aria-pressed="${on}">
<span class="row1"><span class="chip pip ${escapeHtml(s.st)}"></span><span class="n">${escapeHtml(p.name)}</span>${waiting}${p.slots && p.slots.length ? '<span class="tiny">ACE</span>' : ""}</span>
<span class="row2 ${s.bad || off ? "bad" : ""}">${escapeHtml(why)}</span>
</button>`;
}).join("") || '<div class="picked-empty">' + icon("printer") + '<span>Noch kein Drucker.</span><span class="tiny"><a href="/einstellungen">In Einstellungen anlegen</a></span></div>';
document.getElementById("step-target").classList.toggle("ok", nextMode || selectedHosts().length > 0);
}
function familyOfSelection() {
const file = selectedFile();
return file && families[file.printer_family] ? families[file.printer_family] : "Drucker";
}
function namesText(list) {
if (!list.length) return "";
if (list.length <= 2) return list.map((p) => p.name).join(" und ");
return list.length + " Druckern";
}
function updateSendButton() {
const file = selectedFile();
const chosen = selectedPrinters();
const button = document.getElementById("send-go");
const hint = document.getElementById("send-hint");
const label = button.querySelector("span");
const targetHint = document.getElementById("target-hint");
if (button.dataset.armed === "1") return;
let text = "Starten";
let problem = "";
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 === "this") {
if (!problem && !chosen.length) problem = "Einen oder mehrere Drucker wählen.";
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 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) {
problem = "Datei passt nicht zu " + namesText(mismatch) + ". Abwählen oder unter Optionen erlauben.";
}
label.textContent = text;
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 nach Sorte." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
}
function updateAblaufButton() {
const file = selectedFile();
const k3 = selectedKobra3();
const button = document.getElementById("ablauf-go");
const hint = document.getElementById("ablauf-hint");
if (button.dataset.armed === "1") return;
const repeat = Math.max(1, Number(document.getElementById("ablauf-repeat").value) || 1);
let problem = "";
if (!ablaufSteps.length) problem = "Mindestens einen Schritt anlegen.";
else if (!ablaufSteps.some((s) => s.type === "print" && s.file_id)) problem = "Ein Druck-Schritt braucht eine Datei.";
else if (!k3.length) problem = "Einen oder mehrere Kobra 3 wählen.";
button.querySelector("span").textContent = k3.length
? "Ablauf einreihen auf " + namesText(k3) + (repeat > 1 ? " · " + repeat + "×" : "")
: "Ablauf einreihen";
button.disabled = !!problem;
hint.textContent = problem || (file ? "Startet, sobald der Drucker frei ist. In der Seitenleiste abbrechbar." : "");
hint.classList.toggle("bad", !!problem);
}
function updateOptionsSummary() {
const parts = [];
const hosts = selectedHosts();
const loaded = hosts.reduce((n, h) => n + spoolsOnHost(h).length, 0);
if (loaded) parts.push(loaded + (loaded === 1 ? " Spule" : " Spulen"));
if (document.getElementById("send-bed-level").checked) parts.push("Bettnetz");
if (document.getElementById("send-override").checked) parts.push("Maschine egal");
const project = projects.find((p) => String(p.id) === document.getElementById("send-project")?.value);
if (project) parts.push(project.name);
if (!document.getElementById("ace-box").hidden) parts.push("ACE");
document.getElementById("options-sum").textContent = parts.join(" · ");
}
function renderTabs() {
document.querySelectorAll(".tabs [data-tab]").forEach((b) => b.classList.toggle("on", b.dataset.tab === sel.tab));
document.querySelectorAll("[data-pane]").forEach((node) => { node.hidden = node.dataset.pane !== sel.tab; });
document.querySelectorAll("#mode [data-mode]").forEach((b) => b.classList.toggle("on", b.dataset.mode === sel.mode));
if (sel.tab === "ablauf") {
ensureAblaufDefaults();
renderAblauf();
}
}
function refreshFlow() {
renderPicked();
renderTargets();
renderAceMaps();
renderLoadedSpools();
updateSpoolWarn();
updateOptionsSummary();
updateSendButton();
updateAblaufButton();
}
function selectFile(id) {
sel.fileId = id;
const file = selectedFile();
if (file && file.project_id && document.getElementById("send-project")) {
document.getElementById("send-project").value = String(file.project_id);
}
if (file && ablaufSteps.length && ablaufSteps[0].type === "print") {
ablaufSteps[0].file_id = file.id;
saveAblaufMemory();
if (sel.tab === "ablauf") renderAblauf();
}
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
refreshFlow();
scheduleToolpath(file, 0, 0);
if (window.matchMedia("(max-width: 1080px)").matches) {
document.querySelector(".send-panel").scrollIntoView({ behavior: "smooth", block: "start" });
}
}
/* ---------- Laden ---------- */
async function load() {
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: [] })),
]);
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 || [];
if (defaults && defaults.cool) ablaufDefaults.cool = defaults.cool;
if (defaults && defaults.sweep) ablaufDefaults.sweep = defaults.sweep;
if (defaults && defaults.check) ablaufDefaults.check = defaults.check;
if (sel.fileId && !selectedFile()) sel.fileId = null;
fillProjects();
fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
loadLibMemory();
fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
const deepProject = new URLSearchParams(location.search).get("project");
if (deepProject) {
const pf = document.getElementById("file-filter-project");
if (pf && [...pf.options].some((o) => o.value === deepProject)) pf.value = deepProject;
}
releaseRootForProject();
renderLibrary();
renderTabs();
refreshFlow();
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 {
printers = await api("/drucker?live=1");
renderTargets();
updateSendButton();
updateAblaufButton();
} catch (error) { /* nächster Versuch */ }
}
/* ---------- Senden ---------- */
function showJobsProgress(jobs) {
const box = document.getElementById("send-progress");
const list = document.getElementById("send-progress-list");
const note = document.getElementById("send-note");
box.hidden = false;
list.innerHTML = jobs.map((job) => {
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
const cls = job.status === "error" ? "bad" : job.status === "done" ? "good" : "";
return `<div class="send-job">
<div class="send-job-head"><strong>${escapeHtml(job.printer || "")}</strong><span>${escapeHtml(job.message || "")}</span></div>
<div class="send-progress-track"><div class="send-progress-bar ${cls}" style="width:${pct}%"></div></div>
</div>`;
}).join("");
const errors = jobs.filter((job) => job.status === "error");
const pending = jobs.filter((job) => job.status === "pending" || job.status === "running");
const done = jobs.filter((job) => job.status === "done");
note.className = "";
note.textContent = "";
if (pending.length) return;
if (errors.length && !done.length) {
toast(errors.map((job) => job.printer + ": " + job.message).join(" · "), "bad");
} else if (errors.length) {
toast(done.length + " gestartet, " + errors.length + " fehlgeschlagen.", "bad");
} else {
const warn = done.some((job) => (job.result && job.result.warnings && job.result.warnings.length) || (job.message || "").includes("ACE-Nachschub"));
toast(done.length === 1 ? (done[0].printer + ": " + (done[0].message || "gestartet")) : done.length + " Drucker gestartet.", "good");
if (warn) {
note.textContent = "ACE-Nachschub braucht Slicer-Metadaten (Trailer).";
note.className = "msg";
}
}
}
function stopSendPoll() {
if (sendPoll) {
clearInterval(sendPoll);
sendPoll = null;
}
}
async function watchJobs(ids) {
stopSendPoll();
watchedIds = ids.slice();
const tick = async () => {
try {
const jobs = await Promise.all(watchedIds.map((id) => api("/senden/" + id)));
showJobsProgress(jobs);
const busy = jobs.some((job) => job.status === "pending" || job.status === "running");
if (!busy) {
stopSendPoll();
updateSendButton();
refreshPrinters();
if (typeof refreshSendQueue === "function") refreshSendQueue();
}
} catch (error) {
stopSendPoll();
updateSendButton();
showNote("send-note", error);
}
};
sendPoll = setInterval(tick, 500);
updateSendButton();
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");
button.disabled = true;
document.getElementById("send-note").textContent = "";
try {
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 */ }
}
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();
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
refreshPrinters();
return;
}
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(nowJobs.map((job) => job.id));
} catch (error) {
updateSendButton();
toast(error.message || String(error), "bad");
}
}
/* ---------- Ablauf ---------- */
function loadAblaufMemory() {
try {
const raw = localStorage.getItem(ABLAUF_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (Array.isArray(data.steps) && data.steps.length) {
// altes 3×3 (probe_count/mesh_*) streichen; Mitte-Tasten behalten/ergänzen
ablaufSteps = data.steps.filter((s) => !(s.type === "check" && (s.probe_count != null || s.mesh_min_x != null)));
ablaufSteps = ablaufSteps.map((s) => {
if (s.type === "check" && (Number(s.max_delta_mm) === 2.5 || Number(s.max_delta_mm) === 1.0))
return { ...s, max_delta_mm: 3.0 };
return s;
});
const types = ablaufSteps.map((s) => s.type).join(",");
if (types === "print,cool,sweep") ablaufSteps = [...ablaufSteps, defaultCheckStep()];
}
if (data.repeat) document.getElementById("ablauf-repeat").value = String(data.repeat);
} catch (error) { /* ignore */ }
}
function saveAblaufMemory() {
try {
localStorage.setItem(ABLAUF_KEY, JSON.stringify({
steps: ablaufSteps,
repeat: Number(document.getElementById("ablauf-repeat").value) || 1,
}));
} catch (error) { /* ignore */ }
}
function defaultPrintStep() {
const file = selectedFile();
return {
type: "print",
file_id: file ? file.id : (files[0] && files[0].id) || null,
bed_leveling: true,
};
}
function defaultCoolStep() { return { type: "cool", ...ablaufDefaults.cool }; }
function defaultSweepStep() { return { type: "sweep", ...ablaufDefaults.sweep }; }
function defaultCheckStep() { return { type: "check", ...ablaufDefaults.check }; }
function ensureAblaufDefaults() {
if (!ablaufSteps.length) {
ablaufSteps = [defaultPrintStep(), defaultCoolStep(), defaultSweepStep(), defaultCheckStep()];
}
}
function numField(label, key, value, step) {
return `<label class="ablauf-field">${escapeHtml(label)}<input type="number" data-key="${escapeHtml(key)}" value="${escapeHtml(String(value))}" step="${step || "1"}"></label>`;
}
function stepSummary(step) {
if (step.type === "print") {
const file = files.find((f) => String(f.id) === String(step.file_id));
return file ? file.name : "keine Datei";
}
if (step.type === "cool") return "Bett ≤ " + step.bed_max + " °C · Düse ≤ " + step.nozzle_max + " °C";
if (step.type === "sweep") return "X " + step.x_from + "–" + step.x_to + " · Z " + step.z_push + " mm";
if (step.type === "check") {
return "Tast " + (step.x != null ? step.x : 125) + "/" + (step.y != null ? step.y : 125)
+ " · max Z " + step.max_delta_mm + " mm";
}
return "";
}
let sweepViewer = null;
function syncSweepPreview() {
const wrap = document.getElementById("sweep-preview-wrap");
const canvas = document.getElementById("sweep-canvas");
if (!wrap || !canvas || typeof SweepViewer !== "function") return;
const sweep = ablaufSteps.find((step) => step.type === "sweep");
wrap.hidden = !sweep;
if (!sweep) return;
if (!sweepViewer) sweepViewer = new SweepViewer(canvas);
sweepViewer.setSweep(sweep);
}
function renderAblauf() {
const root = document.getElementById("ablauf-steps");
if (!root) return;
document.getElementById("ablauf-preview").innerHTML = stepsHtml(ablaufSteps.map((s) => STEP_TITLES[s.type] || s.type), -1);
root.innerHTML = ablaufSteps.map((step, index) => {
const kind = step.type;
const title = STEP_TITLES[kind] || kind;
let body = "";
if (kind === "print") {
const options = files.map((file) =>
`<option value="${file.id}" ${String(file.id) === String(step.file_id) ? "selected" : ""}>${escapeHtml(file.name)}</option>`
).join("");
body = `<label class="ablauf-field wide">Datei<select data-key="file_id">${options}</select></label>
<label class="check"><input type="checkbox" data-key="bed_leveling" ${step.bed_leveling ? "checked" : ""}> Bettnetz messen</label>`;
} else if (kind === "cool") {
body = [
numField("Bett max °C", "bed_max", step.bed_max, "1"),
numField("Düse max °C", "nozzle_max", step.nozzle_max, "1"),
numField("Lüfter %", "fan_percent", step.fan_percent, "1"),
numField("Timeout min", "timeout_min", step.timeout_min, "1"),
].join("");
} else if (kind === "check") {
body = [
numField("Max Z mm", "max_delta_mm", step.max_delta_mm, "0.1"),
numField("Tast X", "x", step.x != null ? step.x : 125, "1"),
numField("Tast Y", "y", step.y != null ? step.y : 125, "1"),
numField("Bezug X", "ref_x", step.ref_x != null ? step.ref_x : 25, "1"),
numField("Bezug Y", "ref_y", step.ref_y != null ? step.ref_y : 125, "1"),
numField("Timeout min", "timeout_min", step.timeout_min, "1"),
].join("");
} else {
body = [
numField("Z schieben mm", "z_push", step.z_push, "0.1"),
numField("Z umsetzen mm", "z_travel", step.z_travel, "0.1"),
numField("X von", "x_from", step.x_from, "1"),
numField("X bis", "x_to", step.x_to, "1"),
numField("X Schritt", "x_step", step.x_step, "1"),
numField("Y hinten", "y_from", step.y_from, "1"),
numField("Y vorn", "y_to", step.y_to, "1"),
numField("Tempo mm/s", "speed", step.speed, "1"),
].join("");
}
return `<details class="ablauf-step" data-index="${index}" ${kind === "print" ? "open" : ""}>
<summary class="ablauf-step-head">
<span class="num-dot">${index + 1}</span>
<strong>${title}</strong>
<span class="ablauf-step-sum">${escapeHtml(stepSummary(step))}</span>
<span class="ablauf-step-tools">
<button type="button" class="icon-btn" data-move="-1" ${index === 0 ? "disabled" : ""} title="nach oben">${icon("up")}</button>
<button type="button" class="icon-btn" data-move="1" ${index === ablaufSteps.length - 1 ? "disabled" : ""} title="nach unten">${icon("down")}</button>
<button type="button" class="icon-btn" data-remove="1" title="entfernen">${icon("x")}</button>
</span>
</summary>
<div class="ablauf-step-body">${body}</div>
</details>`;
}).join("");
root.querySelectorAll(".ablauf-step").forEach((node) => {
const index = Number(node.dataset.index);
node.querySelectorAll("[data-key]").forEach((input) => {
const apply = () => {
const key = input.dataset.key;
if (input.type === "checkbox") ablaufSteps[index][key] = input.checked;
else ablaufSteps[index][key] = Number(input.value);
node.querySelector(".ablauf-step-sum").textContent = stepSummary(ablaufSteps[index]);
saveAblaufMemory();
updateAblaufButton();
if (ablaufSteps[index].type === "sweep") syncSweepPreview();
};
input.addEventListener("change", apply);
input.addEventListener("input", apply);
});
node.querySelectorAll("[data-move]").forEach((btn) => {
btn.addEventListener("click", (event) => {
event.preventDefault();
const target = index + Number(btn.dataset.move);
if (target < 0 || target >= ablaufSteps.length) return;
[ablaufSteps[index], ablaufSteps[target]] = [ablaufSteps[target], ablaufSteps[index]];
saveAblaufMemory();
renderAblauf();
});
});
node.querySelector("[data-remove]").addEventListener("click", (event) => {
event.preventDefault();
ablaufSteps.splice(index, 1);
saveAblaufMemory();
renderAblauf();
});
});
syncSweepPreview();
updateAblaufButton();
}
function addStep(step) {
ablaufSteps.push(step);
saveAblaufMemory();
renderAblauf();
}
function collectAblaufPayload() {
const steps = ablaufSteps.map((step) => {
if (step.type !== "print") return { ...step };
return {
type: "print",
file_id: Number(step.file_id),
bed_leveling: !!step.bed_leveling,
override_machine: document.getElementById("send-override").checked,
ace_map: [],
};
});
return {
hosts: selectedKobra3().map((printer) => printer.host),
steps,
ace_maps: collectAceMaps(),
repeat_total: Math.max(1, Math.min(99, Number(document.getElementById("ablauf-repeat").value) || 1)),
confirm: true,
override_machine: document.getElementById("send-override").checked,
bed_leveling: document.getElementById("send-bed-level").checked,
file_id: Number(sel.fileId) || null,
};
}
async function doAblauf() {
const note = document.getElementById("ablauf-note");
note.textContent = "";
try {
const payload = await api("/ablauf", { method: "POST", json: collectAblaufPayload() });
toast((payload.count || 0) > 1 ? payload.count + " Abläufe eingereiht." : "Ablauf eingereiht. Steht unter „Wartet“.", "good");
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
refreshPrinters();
} catch (error) {
toast(error.message || String(error), "bad");
}
updateAblaufButton();
}
/* ---------- Ereignisse ---------- */
document.getElementById("lib-list").addEventListener("toggle", (event) => {
const details = event.target;
if (!(details instanceof HTMLDetailsElement) || !details.classList.contains("lib-info")) return;
if (details.open) {
closeOpenLibInfo(details);
placeLibInfoPop(details);
}
}, true);
document.getElementById("lib-list").addEventListener("scroll", repositionOpenLibInfo, { passive: true });
window.addEventListener("resize", repositionOpenLibInfo);
document.addEventListener("pointerdown", (event) => {
if (event.target.closest("details.lib-info")) return;
closeOpenLibInfo();
});
document.getElementById("lib-list").addEventListener("click", (event) => {
if (event.target.closest(".lib-move")) {
event.stopPropagation();
return;
}
const ren = event.target.closest("[data-rename]");
if (ren) {
event.stopPropagation();
const file = files.find((f) => String(f.id) === String(ren.dataset.rename));
if (!file) return;
const next = window.prompt("Neuer Dateiname:", file.name);
if (next == null) return;
const trimmed = String(next).trim();
if (!trimmed || trimmed === file.name) return;
api("/dateien/" + file.id + "/umbenennen", { method: "POST", json: { name: trimmed } })
.then(async (saved) => {
toast("Umbenannt in „" + (saved.name || trimmed) + "“.", "good");
await load();
if (saved && saved.id) selectFile(saved.id);
})
.catch((error) => toast(error.message || String(error), "bad"));
return;
}
const del = event.target.closest("[data-delete]");
if (del) {
event.stopPropagation();
armButton(del, "löschen?", async () => {
try {
await api("/dateien/" + del.dataset.delete, { method: "DELETE" });
if (String(sel.fileId) === del.dataset.delete) sel.fileId = null;
toast("Datei gelöscht.", "good");
load();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
return;
}
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 === " ")) {
event.preventDefault();
selectFile(item.dataset.file);
}
});
[
"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 = () => {
if (id === "file-filter-project") releaseRootForProject();
libPage = 1;
saveLibMemory();
renderLibrary();
};
node.addEventListener("input", onChange);
node.addEventListener("change", onChange);
});
document.getElementById("targets").addEventListener("click", (event) => {
const button = event.target.closest("[data-host]");
if (!button) return;
const host = button.dataset.host;
const p = printers.find((x) => x.host === host);
if (sel.tab === "ablauf" && p && p.family !== "kobra_3") {
toast("Abläufe gehen nur auf Kobra 3.", "bad");
return;
}
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();
renderTabs();
refreshFlow();
});
document.getElementById("mode").addEventListener("click", (event) => {
const button = event.target.closest("[data-mode]");
if (!button) return;
sel.mode = button.dataset.mode;
saveSendMemory();
renderTabs();
refreshFlow();
});
document.querySelector(".tabs").addEventListener("click", (event) => {
const button = event.target.closest("[data-tab]");
if (!button) return;
sel.tab = button.dataset.tab;
if (sel.tab === "ablauf" && !selectedKobra3().length) {
const firstK3 = printers.find((p) => p.family === "kobra_3" && isFree(p)) || printers.find((p) => p.family === "kobra_3");
if (firstK3) sel.hosts.add(firstK3.host);
}
saveSendMemory();
renderTabs();
refreshFlow();
});
document.getElementById("send-bed-level").addEventListener("change", updateOptionsSummary);
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
document.getElementById("send-go").addEventListener("click", () => {
doSend();
});
document.getElementById("ablauf-go").addEventListener("click", () => {
doAblauf();
});
document.getElementById("ablauf-add-print").addEventListener("click", () => addStep(defaultPrintStep()));
document.getElementById("ablauf-add-cool").addEventListener("click", () => addStep(defaultCoolStep()));
document.getElementById("ablauf-add-sweep").addEventListener("click", () => addStep(defaultSweepStep()));
document.getElementById("ablauf-add-check").addEventListener("click", () => addStep(defaultCheckStep()));
document.getElementById("ablauf-repeat").addEventListener("change", () => { saveAblaufMemory(); updateAblaufButton(); });
document.querySelectorAll("[data-repeat]").forEach((button) => {
button.addEventListener("click", () => {
const input = document.getElementById("ablauf-repeat");
input.value = String(Math.max(1, Math.min(99, (Number(input.value) || 1) + Number(button.dataset.repeat))));
saveAblaufMemory();
updateAblaufButton();
});
});
document.getElementById("ablauf-test-sweep").addEventListener("click", async () => {
const k3 = selectedKobra3();
if (!k3.length) {
toast("Einen Kobra 3 wählen.", "bad");
return;
}
if (!document.getElementById("ablauf-test-empty").checked) {
toast("Erst „Bett ist leer“ anhaken. Die Düse fährt 2 mm über dem Bett.", "bad");
return;
}
const sweepStep = ablaufSteps.find((step) => step.type === "sweep") || defaultSweepStep();
try {
const result = await api("/schieben-testen", {
method: "POST",
json: { host: k3[0].host, confirm_empty: true, sweep: sweepStep },
});
toast(k3[0].name + ": " + (result.message || "Bahn gefahren."), "good");
} catch (error) {
toast(error.message || String(error), "bad");
}
});
async function uploadFiles(list) {
const items = Array.from(list || []);
if (!items.length) return;
const note = document.getElementById("file-note");
const projectId = document.getElementById("upload-project")?.value || "";
try {
for (const [i, file] of items.entries()) {
note.textContent = "Lade hoch " + (i + 1) + "/" + items.length + ": " + file.name;
const form = new FormData();
form.append("file", file);
if (projectId) form.append("project_id", projectId);
await api("/dateien", { method: "POST", body: form });
}
note.textContent = "";
toast(items.length === 1 ? items[0].name + " hochgeladen." : items.length + " Dateien hochgeladen.", "good");
await load();
} catch (error) {
note.textContent = "";
toast(error.message || String(error), "bad");
}
}
document.getElementById("upload-file").addEventListener("change", async (event) => {
await uploadFiles(event.target.files);
event.target.value = "";
});
const libCard = document.getElementById("lib-card");
["dragenter", "dragover"].forEach((name) => libCard.addEventListener(name, (event) => {
event.preventDefault();
libCard.classList.add("drop");
}));
["dragleave", "drop"].forEach((name) => libCard.addEventListener(name, (event) => {
event.preventDefault();
if (name === "dragleave" && libCard.contains(event.relatedTarget)) return;
libCard.classList.remove("drop");
}));
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" });
toast(result.hinweis || (result.gelesen + " Dateien gelesen."), "good");
load();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
window.onQueueChanged = refreshPrinters;
loadSendMemory();
loadAblaufMemory();
load().catch((error) => toast(error.message || String(error), "bad"));
setInterval(refreshPrinters, 5000);
setInterval(pullOrcaFocus, 2000);
</script>
{% endblock %}