1749 lines
69 KiB
HTML
1749 lines
69 KiB
HTML
{% 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>
|
||
<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));
|
||
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] || [];
|
||
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 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 (folder === "root" && folderKey(file)) return false;
|
||
if (folder && folder !== "root" && 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] || "",
|
||
project ? project.name : "",
|
||
];
|
||
if (!file.weight_g && !file.estimated_s) bits.push(sizeText(file.size_bytes));
|
||
return bits.filter(Boolean).map((t) => `<span>${escapeHtml(t)}</span>`).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 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 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)}${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>';
|
||
return;
|
||
}
|
||
if (!shown.length) {
|
||
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
|
||
return;
|
||
}
|
||
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 ---------- */
|
||
|
||
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();
|
||
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("click", (event) => {
|
||
if (event.target.closest(".lib-move")) {
|
||
event.stopPropagation();
|
||
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 = () => {
|
||
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 %}
|