Raum-Modal, Zeitleiste und Automatik-Editor zeigten drei verschiedene Zeichen; bi-blinds gibt es in der Icon-Schrift gar nicht. Das neue Symbol liegt als CSS-Maske in solar.css und verhaelt sich wie ein Icon aus der Schrift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1548 lines
58 KiB
JavaScript
1548 lines
58 KiB
JavaScript
/**
|
||
* Editor und Uebersicht fuer die Automatiken.
|
||
*
|
||
* Der Editor arbeitet auf einem Modell und zeichnet daraus die Oberflaeche -
|
||
* nicht umgekehrt. Frueher stand die erste Bedingungszeile als festes HTML im
|
||
* PHP und alle weiteren wurden im JavaScript zusammengesetzt; beide Fassungen
|
||
* liefen auseinander, und beim Speichern musste der Server aus durchnummerierten
|
||
* Feldnamen wieder eine Struktur raten.
|
||
*
|
||
* Modell:
|
||
* autoModel.groups = [ [ {state_id, operator, value}, ... ], ... ]
|
||
* eine innere Liste ist ein UND-Block, die aeussere
|
||
* verknuepft die Bloecke mit ODER
|
||
* autoModel.actions = [ {command_id, params: {parameterID: wert}}, ... ]
|
||
*
|
||
* Der Geraetekatalog kommt zusammen mit dem Datensatz im selben Dokument
|
||
* (siehe ajax/AutoAction.php), es gibt also keine Nachladerei je Zeile.
|
||
*
|
||
* Eine Automatik kann eine andere ausloesen. Dafuer steht hier auffallend
|
||
* wenig: das gerechnete Geraet "Automatiken" fuehrt jede Automatik als
|
||
* Messwert, und damit ist es fuer den Editor ein Geraet wie jedes andere -
|
||
* er listet ohnehin Geraete und deren Messwerte. Nur die Uebersicht weiss
|
||
* davon (toggleAutomationGroup) und das Loeschen (deleteAutomation).
|
||
*/
|
||
|
||
let autoModel = null;
|
||
let autoDevices = [];
|
||
let autoLockouts = [];
|
||
let autoFerien = [];
|
||
let autoFeiertage = [];
|
||
let autoTagVorlagen = [];
|
||
let autoRooms = [];
|
||
// Die Etage, mit der der Editor aufgemacht wurde. Wird sie geaendert,
|
||
// braucht auch die Liste der alten Etage ein Nachladen.
|
||
let autoFloorVorher = "";
|
||
|
||
// --- Katalog-Helfer ------------------------------------------------------
|
||
|
||
function deviceById(id) {
|
||
return autoDevices.find(d => d.id === id) || null;
|
||
}
|
||
|
||
function stateById(id) {
|
||
for (const dev of autoDevices) {
|
||
const state = dev.states.find(s => s.id === id);
|
||
if (state) return { device: dev, state: state };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function commandById(id) {
|
||
for (const dev of autoDevices) {
|
||
const cmd = dev.commands.find(c => c.id === id);
|
||
if (cmd) return { device: dev, command: cmd };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function operatorLabel(state, op) {
|
||
const treffer = state.operators.find(o => o.value === op);
|
||
return treffer ? treffer.label : op;
|
||
}
|
||
|
||
/**
|
||
* Eine neue Zeile beginnt ohne Geraet.
|
||
*
|
||
* Frueher stand dort das erste Geraet mit seinem ersten Messwert - nach dem
|
||
* Geraete-Discovery also "Bad Links: Gesperrt = ", eine Bedingung, die
|
||
* niemand gemeint hat und die man erst wegklicken muss. Ohne Vorbelegung
|
||
* sagt die Zeile, was zu tun ist, und der Server lehnt eine unvollstaendige
|
||
* Bedingung ohnehin ab.
|
||
*/
|
||
function leereBedingung() {
|
||
return { state_id: 0, operator: "=", value: "" };
|
||
}
|
||
|
||
function leereAktion() {
|
||
return { command_id: 0, params: {} };
|
||
}
|
||
|
||
// --- Bausteine fuer die Oberflaeche --------------------------------------
|
||
|
||
function optionListe(select, eintraege, gewaehlt) {
|
||
select.innerHTML = "";
|
||
eintraege.forEach(e => select.appendChild(option(e, gewaehlt)));
|
||
}
|
||
|
||
function option(e, gewaehlt) {
|
||
const opt = document.createElement("option");
|
||
opt.value = e.value;
|
||
opt.text = e.text;
|
||
if (String(e.value) === String(gewaehlt)) opt.selected = true;
|
||
return opt;
|
||
}
|
||
|
||
/**
|
||
* Geraeteliste fuer die Auswahl: eine Ueberschrift je Etage, darin nach Raum
|
||
* sortiert.
|
||
*
|
||
* Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll ..."), eine
|
||
* Ueberschrift je Raum waeren aber zwanzig Stueck - in einer Liste, die
|
||
* fuenfzehn Zeilen hoch ist, saehe man dann mehr Ueberschrift als Geraet, und
|
||
* "OG · Bad", "EG · Bad", "UG · Bad" schrieben die Etage immer wieder
|
||
* dazu. Deshalb gruppiert nur die Etage; der Raum steht klein an jeder Zeile,
|
||
* und weil danach sortiert wird, stehen die Geraete eines Raums trotzdem
|
||
* beisammen.
|
||
*
|
||
* Der Raum steht nur da, wo er von Hand vergeben wurde (Zuordnung ueber das
|
||
* Zahnrad in der Karte "Automatismen"). Solange das niemand getan hat, gaebe
|
||
* die Etage nichts her - dann bleibt es bei der Ordnung nach Geraeteart.
|
||
*/
|
||
function geraeteGruppen(geraete) {
|
||
if (!geraete.some(d => d.room)) {
|
||
return nachGeraeteart(geraete);
|
||
}
|
||
const etagen = etagenReihenfolge();
|
||
// "Ohne Raum" und "Unbekannter Raum" stehen in keiner Raumliste. Ihr
|
||
// indexOf() waere -1 und damit ganz vorne; sie gehoeren aber ans Ende.
|
||
const platz = etage => etagen.includes(etage) ? etagen.indexOf(etage) : etagen.length;
|
||
const nach = new Map();
|
||
geraete.forEach(d => {
|
||
if (!nach.has(d.floorLabel)) nach.set(d.floorLabel, []);
|
||
nach.get(d.floorLabel).push(d);
|
||
});
|
||
return Array.from(nach.keys())
|
||
.sort((a, b) => platz(a) - platz(b) || a.localeCompare(b, "de"))
|
||
.map(etage => {
|
||
const liste = nach.get(etage);
|
||
liste.sort((a, b) => raumIndex(a.room) - raumIndex(b.room)
|
||
|| a.type.localeCompare(b.type, "de")
|
||
|| a.label.localeCompare(b.label, "de"));
|
||
return { titel: etage + " (" + liste.length + ")", geraete: liste };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Reihenfolge der Etagen-Ueberschriften, wie rooms.php sie notiert - erst OG,
|
||
* dann EG, dann UG, dann Aussen.
|
||
*/
|
||
function etagenReihenfolge() {
|
||
const etagen = [];
|
||
autoRooms.forEach(r => { if (!etagen.includes(r.floor)) etagen.push(r.floor); });
|
||
return etagen;
|
||
}
|
||
|
||
/**
|
||
* Platz eines Raums in rooms.php - die Sortierung innerhalb einer Etage.
|
||
*
|
||
* Alles ohne Raum bekommt denselben letzten Platz; dort entscheidet dann die
|
||
* Geraeteart, sonst stuenden Jalousie, Steckdose und Thermostat wild
|
||
* durcheinander.
|
||
*/
|
||
function raumIndex(key) {
|
||
const i = autoRooms.findIndex(r => r.key === key);
|
||
return i < 0 ? autoRooms.length : i;
|
||
}
|
||
|
||
/**
|
||
* Was klein hinter dem Geraetenamen steht.
|
||
*
|
||
* Unter einer Etagen-Ueberschrift genuegt der Raum. In der Trefferliste einer
|
||
* Suche steht keine Ueberschrift darueber, dort muss die Etage mit. Wo kein
|
||
* Raum vergeben ist, steht die Geraeteart - die ist immer da und sagt
|
||
* wenigstens, was fuer ein Ding das ist.
|
||
*/
|
||
function zeilenZusatz(d, mitEtage) {
|
||
if (!d.roomName) return d.type;
|
||
return mitEtage ? d.roomLabel : d.roomName;
|
||
}
|
||
|
||
/**
|
||
* Worueber die Suche laeuft: Name, Raum und Geraeteart in einem Zug.
|
||
*
|
||
* Der Raum gehoert dazu, seit die Liste nach Etagen gruppiert - "bad" soll
|
||
* auch den Handtuchtrockner finden, der im Bad haengt, ohne es im Namen zu
|
||
* tragen. Ohne Zuordnung faellt der Raum weg; "Nicht zugeordnet" als
|
||
* Suchbegriff waere kein Fund, sondern ein Versehen.
|
||
*/
|
||
function suchfeld(d) {
|
||
return (d.label + " " + (d.roomName ? d.roomLabel : "") + " " + d.type).toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Nach Geraeteart gruppieren - der Rueckfall, solange kein Raum vergeben ist.
|
||
*
|
||
* Gruppen aus einem einzigen Geraet fallen dabei unter "Einzelne" zusammen:
|
||
* fuenfzehn Ueberschriften mit je einem Eintrag waeren unuebersichtlicher als
|
||
* die flache Liste darunter.
|
||
*/
|
||
function nachGeraeteart(geraete) {
|
||
const nach = new Map();
|
||
geraete.forEach(d => {
|
||
if (!nach.has(d.type)) nach.set(d.type, []);
|
||
nach.get(d.type).push(d);
|
||
});
|
||
const gruppen = [];
|
||
const rest = [];
|
||
Array.from(nach.keys()).sort((a, b) => a.localeCompare(b, "de")).forEach(art => {
|
||
const liste = nach.get(art);
|
||
if (liste.length > 1) gruppen.push({ titel: art + " (" + liste.length + ")", geraete: liste });
|
||
else rest.push(liste[0]);
|
||
});
|
||
if (rest.length) {
|
||
rest.sort((a, b) => a.label.localeCompare(b.label, "de"));
|
||
gruppen.push({ titel: "Einzelne (" + rest.length + ")", geraete: rest });
|
||
}
|
||
return gruppen;
|
||
}
|
||
|
||
/**
|
||
* Geraeteauswahl mit Suchfeld.
|
||
*
|
||
* Bei ueber fuenfzig Geraeten hilft Tippen mehr als Blaettern - und es hilft
|
||
* quer zur Gruppierung: "bad" findet das Thermostat im Bad, die Jalousie
|
||
* "Bad Links" und alles andere, was einem Bad zugeordnet ist, "jalousie"
|
||
* findet alle elf auf einmal. Gesucht wird deshalb ueber Beschriftung, Raum
|
||
* und Geraeteart zugleich.
|
||
*
|
||
* Statt einer Auswahlliste steht ein Knopf in der Zeile; die Liste klappt
|
||
* darunter ueber die volle Breite auf. Auf dem Handy ist das mehr Platz als
|
||
* ein natives Auswahlfeld hergibt, und das Suchfeld waere darin ohnehin
|
||
* nicht unterzubringen.
|
||
*/
|
||
function geraeteWaehler(geraete, aktuelleId, beiAuswahl, klasse) {
|
||
const knopf = document.createElement("button");
|
||
knopf.type = "button";
|
||
// Die Aktionszeile braucht eine Auswahlflaeche in voller Hoehe, die
|
||
// Bedingungszeile nur eine matte Zeile ueber dem Messwert. Dasselbe
|
||
// Verhalten, zwei Auftritte.
|
||
knopf.className = klasse || "form-select text-start auto-geraet";
|
||
const dev = geraete.find(d => d.id === aktuelleId);
|
||
// Dieselbe Frage in Bedingungs- und Aktionszeile: es ist derselbe Knopf
|
||
// mit derselben Aufgabe.
|
||
knopf.textContent = dev ? dev.label : "Welches Gerät?";
|
||
if (!dev) knopf.classList.add("text-body-secondary");
|
||
knopf.onclick = () => oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl);
|
||
return knopf;
|
||
}
|
||
|
||
/** Die aufgeklappte Liste - es ist immer hoechstens eine offen. */
|
||
function schliesseWaehler() {
|
||
const offen = document.querySelector(".geraete-waehler");
|
||
if (offen) offen.remove();
|
||
}
|
||
|
||
function oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl) {
|
||
// Die Zeile, an die sich die Liste haengt. In der Aktionszeile ist das
|
||
// eine input-group, in der Bedingungszeile ein Raster - und dort steckt
|
||
// der Knopf ausserdem noch eine Ebene tiefer.
|
||
const zeile = knopf.closest(".input-group, .auto-zeile") || knopf.parentElement;
|
||
const schonOffen = zeile.nextElementSibling
|
||
&& zeile.nextElementSibling.classList.contains("geraete-waehler");
|
||
schliesseWaehler();
|
||
if (schonOffen) return; // zweiter Klick schliesst wieder
|
||
|
||
const panel = document.createElement("div");
|
||
panel.className = "geraete-waehler border rounded p-2 mt-1";
|
||
const suche = document.createElement("input");
|
||
suche.type = "search";
|
||
suche.className = "form-control form-control-sm mb-2";
|
||
suche.placeholder = "Suchen – Name, Raum oder Geräteart";
|
||
const liste = document.createElement("div");
|
||
liste.className = "list-group list-group-flush";
|
||
liste.style.maxHeight = "15rem";
|
||
liste.style.overflowY = "auto";
|
||
panel.appendChild(suche);
|
||
panel.appendChild(liste);
|
||
// Hinter die ganze Zeile haengen, nicht in sie hinein: in einer
|
||
// input-group stuende die Liste sonst als weitere Spalte daneben.
|
||
zeile.after(panel);
|
||
|
||
const eintrag = (d, mitEtage) => {
|
||
const zeile = document.createElement("button");
|
||
zeile.type = "button";
|
||
// Der Zusatz steht rechts am Rand statt gleich hinter dem Namen: so
|
||
// stehen die Raeume untereinander in einer Spalte und lassen sich
|
||
// ueberfliegen, statt bei jeder Zeile woanders anzufangen.
|
||
zeile.className = "list-group-item list-group-item-action py-1"
|
||
+ " d-flex justify-content-between align-items-center gap-2"
|
||
+ (d.id === aktuelleId ? " active" : "");
|
||
zeile.innerHTML = "<span class='text-truncate'>" + textSicher(d.label) + "</span>"
|
||
+ "<small class='opacity-75 text-nowrap'>"
|
||
+ textSicher(zeilenZusatz(d, mitEtage)) + "</small>";
|
||
zeile.onclick = () => { schliesseWaehler(); beiAuswahl(d); };
|
||
return zeile;
|
||
};
|
||
|
||
const zeichne = () => {
|
||
const suchtext = suche.value.trim().toLowerCase();
|
||
liste.innerHTML = "";
|
||
const passend = geraete.filter(d => !suchtext || suchfeld(d).includes(suchtext));
|
||
if (!passend.length) {
|
||
liste.innerHTML = "<div class='p-2 small text-body-secondary'>Nichts gefunden.</div>";
|
||
return;
|
||
}
|
||
// Beim Suchen die Zwischenueberschriften weglassen: die Treffer stehen
|
||
// dann quer ueber alle Etagen. Dafuer traegt jede Zeile die Etage mit -
|
||
// "Bad" allein sagt sonst nicht, welches der drei Baeder gemeint ist.
|
||
if (suchtext) {
|
||
passend.forEach(d => liste.appendChild(eintrag(d, true)));
|
||
return;
|
||
}
|
||
geraeteGruppen(passend).forEach(g => {
|
||
const kopf = document.createElement("div");
|
||
kopf.className = "px-2 pt-2 pb-1 small fw-bold text-body-secondary";
|
||
kopf.textContent = g.titel;
|
||
liste.appendChild(kopf);
|
||
g.geraete.forEach(d => liste.appendChild(eintrag(d, false)));
|
||
});
|
||
};
|
||
|
||
suche.oninput = zeichne;
|
||
suche.onkeydown = e => {
|
||
if (e.key === "Escape") { schliesseWaehler(); knopf.focus(); }
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
const erster = liste.querySelector("button.list-group-item");
|
||
if (erster) erster.click();
|
||
}
|
||
};
|
||
zeichne();
|
||
suche.focus();
|
||
}
|
||
|
||
// Klick daneben schliesst die Liste - sonst bliebe sie stehen, wenn man es
|
||
// sich anders ueberlegt.
|
||
//
|
||
// Erkannt wird der oeffnende Knopf an .auto-geraet, also daran, WAS er ist -
|
||
// nicht daran, wie er aussieht. Hier stand ".form-select.text-start", die
|
||
// Optik-Klassen der Aktionszeile; als die Bedingungszeile ihren Knopf zu
|
||
// einer matten Textzeile umbaute, fiel sie aus dieser Bedingung heraus. Der
|
||
// Knopf oeffnete die Liste dann brav, und dieser Horcher schloss sie im
|
||
// selben Klick wieder - von aussen sah es aus, als passiere gar nichts.
|
||
document.addEventListener("click", e => {
|
||
if (e.target.closest(".geraete-waehler") || e.target.closest(".auto-geraet")) return;
|
||
schliesseWaehler();
|
||
});
|
||
|
||
function textSicher(text) {
|
||
return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
}
|
||
|
||
/**
|
||
* Wertfeld passend zum Datentyp des Messwerts oder Parameters.
|
||
* Liefert das fertige Element; der Aufrufer haengt seinen Listener an.
|
||
*/
|
||
function wertFeld(spec, wert) {
|
||
let feld;
|
||
if (spec.input === "bool") {
|
||
feld = document.createElement("select");
|
||
feld.className = "form-select";
|
||
optionListe(feld, [{ value: "true", text: "JA" }, { value: "false", text: "NEIN" }],
|
||
wert === "" ? "true" : wert);
|
||
} else if (spec.input === "select") {
|
||
// Die Auswahl kommt fertig aus dem Katalog: bei Messwerten steht der
|
||
// Klartext auch als Wert, bei Geraeteparametern die Zahl dahinter.
|
||
feld = document.createElement("select");
|
||
feld.className = "form-select";
|
||
optionListe(feld, spec.options.map(o => ({ value: o.value, text: o.label })), wert);
|
||
} else {
|
||
feld = document.createElement("input");
|
||
feld.className = "form-control";
|
||
feld.type = spec.input;
|
||
if (spec.min !== undefined && spec.min !== null) feld.min = spec.min;
|
||
if (spec.max !== undefined && spec.max !== null) feld.max = spec.max;
|
||
feld.value = wert !== "" ? wert
|
||
: spec.input === "time" ? "00:00"
|
||
: spec.input === "date" ? new Date().toISOString().split("T")[0]
|
||
: spec.input === "number" ? "0" : "";
|
||
}
|
||
return feld;
|
||
}
|
||
|
||
/** Standardwert, wenn eine Zeile auf einen anderen Messwert umgestellt wird. */
|
||
function standardWert(spec) {
|
||
if (spec.input === "bool") return "true";
|
||
if (spec.input === "select") return spec.options.length ? spec.options[0].value : "";
|
||
if (spec.input === "time") return "00:00";
|
||
if (spec.input === "date") return new Date().toISOString().split("T")[0];
|
||
if (spec.input === "number") return "0";
|
||
return "";
|
||
}
|
||
|
||
// --- Ausloeser -----------------------------------------------------------
|
||
|
||
/**
|
||
* Ein Sinnbild je Geraeteart.
|
||
*
|
||
* Gibt der Zeile einen Anker fuers Auge und spart das Wort: ein Tropfen sagt
|
||
* "Wasser", bevor man "Gartenwasser hinten" gelesen hat. Die Arten stehen so
|
||
* in actors.type, wie der Geraetesuchlauf sie hinterlaesst; was fehlt,
|
||
* bekommt den Chip - lieber ein neutrales Zeichen als ein falsches.
|
||
*/
|
||
const GERAETE_SYMBOL = {
|
||
// Alles, was beschattet, traegt dasselbe Zeichen wie im Raum-Modal und in
|
||
// der Zeitleiste (bi-beschattung, css/solar.css).
|
||
"Außenjalousie": "bi-beschattung",
|
||
"Rollladen": "bi-beschattung",
|
||
"Screen": "bi-beschattung",
|
||
"Pergola": "bi-beschattung",
|
||
"ESP32 Thermostat": "bi-thermometer-half",
|
||
"Temperatur": "bi-thermometer-half",
|
||
"Heizung": "bi-fire",
|
||
"Licht": "bi-lightbulb",
|
||
"WLED": "bi-lightbulb",
|
||
"Wechselrichter": "bi-sun",
|
||
"Solaranlage": "bi-sun",
|
||
"Stromzähler": "bi-lightning-charge",
|
||
"Meter Digitizer": "bi-speedometer2",
|
||
"go-eCharger_V4": "bi-ev-station",
|
||
"ShellyRelay_SHSW-1": "bi-toggle-on",
|
||
"Bewässerung": "bi-droplet",
|
||
"Wetter": "bi-cloud-sun",
|
||
"Regen": "bi-cloud-rain",
|
||
"Garagentor": "bi-door-open",
|
||
"Alarm": "bi-bell",
|
||
"LOGIC": "bi-clock",
|
||
"MQTT": "bi-broadcast"
|
||
};
|
||
|
||
function symbolFuer(dev) {
|
||
return (dev && GERAETE_SYMBOL[dev.type]) || "bi-cpu";
|
||
}
|
||
|
||
/**
|
||
* Datentypen, bei denen der Browser kein Urteil faellt.
|
||
*
|
||
* Ob "Uhrzeit um 07:30" gerade zutrifft, entscheidet nicht ein Vergleich
|
||
* zweier Zahlen, sondern das Nachholfenster, der Tagesrand und bei `elapsed`
|
||
* der echte Abstand seit der letzten Ausloesung - all das steht im Runner
|
||
* (bedingung_erfuellt in autoaction_runner.py). Das hier nachzubauen hiesse,
|
||
* eine zweite Wahrheit zu pflegen, die irgendwann auseinanderlaeuft.
|
||
*
|
||
* Deshalb: bei diesen Typen zeigt die Zeile den Wert, aber keinen Punkt.
|
||
* Lieber keine Aussage als eine, die manchmal falsch ist.
|
||
*/
|
||
const ZEITARTEN = ["time", "date", "datetime", "deltatime", "elapsed"];
|
||
|
||
/** Die zuletzt geholten Messwerte, nach state_id. */
|
||
let autoWerte = {};
|
||
let autoWerteTakt = null;
|
||
const WERTE_TAKT_MS = 8000;
|
||
|
||
function werteIds() {
|
||
const ids = new Set();
|
||
autoModel.groups.forEach(g => g.forEach(b => { if (b.state_id) ids.add(b.state_id); }));
|
||
return Array.from(ids);
|
||
}
|
||
|
||
/**
|
||
* Die laufenden Werte nachholen.
|
||
*
|
||
* Ein eigener, winziger Endpunkt statt des ganzen Geraetekatalogs: der waegt
|
||
* ueber hundert Kilobyte, und gebraucht werden ein paar Zahlen. Faellt die
|
||
* Anfrage aus, bleibt der letzte Stand stehen - ein Fehlerbanner mitten im
|
||
* Editor waere schlimmer als eine Zahl, die zehn Sekunden alt ist.
|
||
*/
|
||
function werteHolen() {
|
||
const ids = werteIds();
|
||
if (!ids.length) return;
|
||
fetch("./ajax/AutoAction.php?action=werte&ids=" + ids.join(","),
|
||
{ headers: { "Requested-With-Ajax": "ajax" } })
|
||
.then(a => (a.ok ? a.json() : null))
|
||
.then(a => {
|
||
if (!a || !a.werte || !autoModel) return;
|
||
autoWerte = a.werte;
|
||
// Nur nachmalen, nicht neu aufbauen. renderConditions() ersetzt den
|
||
// ganzen Block - alle acht Sekunden waeren damit eine offene
|
||
// Geraeteliste weg und der Cursor aus einem Wertfeld, in das man
|
||
// gerade tippt. Die Struktur aendert der Takt nie, nur die Zahlen.
|
||
werteZeichnen();
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function werteTaktStarten() {
|
||
werteTaktStoppen();
|
||
werteHolen();
|
||
autoWerteTakt = setInterval(werteHolen, WERTE_TAKT_MS);
|
||
}
|
||
|
||
function werteTaktStoppen() {
|
||
if (autoWerteTakt) clearInterval(autoWerteTakt);
|
||
autoWerteTakt = null;
|
||
}
|
||
|
||
/**
|
||
* Was sagt dieser Messwert gerade, und trifft die Bedingung damit zu?
|
||
*
|
||
* `kennt` ist falsch, wenn wir es nicht entscheiden koennen - siehe
|
||
* ZEITARTEN oben - oder wenn noch gar kein Wert vorliegt.
|
||
*/
|
||
function zustandVon(state, bed) {
|
||
const roh = autoWerte[bed.state_id];
|
||
const wert = roh ? roh.value : (state ? state.value : null);
|
||
const einheit = (roh && roh.unit) || (state && state.unit) || "";
|
||
|
||
if (wert === null || wert === undefined || wert === "") {
|
||
return { text: "–", kennt: false };
|
||
}
|
||
const text = wertText(state, wert) + (einheit ? " " + einheit : "");
|
||
if (!state || ZEITARTEN.indexOf(state.type) >= 0) {
|
||
return { text: text, kennt: false };
|
||
}
|
||
return { text: text, kennt: true, erfuellt: vergleicht(state, wert, bed) };
|
||
}
|
||
|
||
/**
|
||
* Ein Rohwert so schreiben, wie ihn das Wertfeld daneben auch schreibt.
|
||
*
|
||
* In der Datenbank steht "false", im Auswahlfeld daneben steht "NEIN" - und
|
||
* "gerade false" neben "= NEIN" liest sich, als waeren es zwei verschiedene
|
||
* Dinge. Ebenso beim Dezimalpunkt: die Zahl kommt als "13.9" herein, im Rest
|
||
* der Oberflaeche steht ein Komma.
|
||
*/
|
||
function wertText(state, wert) {
|
||
const s = String(wert);
|
||
if (!state) return s;
|
||
if (state.type === "bool") {
|
||
return ["true", "1", "ja", "an"].indexOf(s.trim().toLowerCase()) >= 0 ? "JA" : "NEIN";
|
||
}
|
||
if (state.options && state.options.length) {
|
||
const treffer = state.options.find(o => String(o.value) === s);
|
||
if (treffer) return treffer.label;
|
||
}
|
||
if (state.type === "float") return s.replace(".", ",");
|
||
return s;
|
||
}
|
||
|
||
/** Der Vergleich fuer die Typen, bei denen er eindeutig ist. */
|
||
function vergleicht(state, ist, bed) {
|
||
if (state.type === "bool") {
|
||
const ja = v => ["true", "1", "ja", "an"].indexOf(String(v).trim().toLowerCase()) >= 0;
|
||
return bed.operator === "!=" ? ja(ist) !== ja(bed.value) : ja(ist) === ja(bed.value);
|
||
}
|
||
const zahl = state.type === "integer" || state.type === "float";
|
||
// Komma als Dezimaltrennzeichen kommt aus Geraeten, die deutsch formatieren.
|
||
const a = zahl ? Number(String(ist).replace(",", ".")) : String(ist);
|
||
const b = zahl ? Number(String(bed.value).replace(",", ".")) : String(bed.value);
|
||
if (zahl && (Number.isNaN(a) || Number.isNaN(b))) return false;
|
||
switch (bed.operator) {
|
||
case "=": return a === b;
|
||
case "!=": return a !== b;
|
||
case ">": return a > b;
|
||
case "<": return a < b;
|
||
case ">=": return a >= b;
|
||
case "<=": return a <= b;
|
||
default: return false;
|
||
}
|
||
}
|
||
|
||
/** Die Balkenreihe, ein Segment je Bedingung. */
|
||
function segmenteFuellen(reihe, zustaende) {
|
||
reihe.innerHTML = "";
|
||
zustaende.forEach(z => {
|
||
const s = document.createElement("span");
|
||
s.className = "auto-segment" + (z.kennt && z.erfuellt ? " an" : z.kennt ? "" : " offen");
|
||
s.title = z.kennt ? (z.erfuellt ? "trifft zu" : "trifft gerade nicht zu")
|
||
: "hängt am Zeitpunkt – das entscheidet der Runner";
|
||
reihe.appendChild(s);
|
||
});
|
||
}
|
||
|
||
function segmente(zustaende) {
|
||
const reihe = document.createElement("span");
|
||
reihe.className = "auto-segmente d-inline-flex gap-1";
|
||
segmenteFuellen(reihe, zustaende);
|
||
return reihe;
|
||
}
|
||
|
||
/** Wie eine Bedingung gerade dasteht - fuer eine ganze Gruppe. */
|
||
function zustaendeVon(gruppe) {
|
||
return gruppe.map(bed => {
|
||
const treffer = stateById(bed.state_id);
|
||
return zustandVon(treffer ? treffer.state : null, bed);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Die laufenden Zahlen nachziehen, ohne etwas neu zu bauen.
|
||
*
|
||
* Angefasst wird nur, was sich wirklich aendert: der Wert neben jeder
|
||
* Bedingung, ihr Punkt, die Balkenreihen und der Stand im Kopf des Bandes.
|
||
* Alles andere - Auswahlfelder, Eingaben, eine offene Geraeteliste - bleibt
|
||
* unberuehrt stehen.
|
||
*/
|
||
function werteZeichnen() {
|
||
const ziel = document.getElementById("condBlocks");
|
||
if (!ziel || !autoModel) return;
|
||
|
||
const proGruppe = [];
|
||
autoModel.groups.forEach((gruppe, gi) => {
|
||
const zustaende = zustaendeVon(gruppe);
|
||
proGruppe.push(zustaende);
|
||
|
||
const block = ziel.querySelector('[data-gruppe="' + gi + '"]');
|
||
if (!block) return;
|
||
|
||
gruppe.forEach((bed, bi) => {
|
||
const zeile = block.querySelector('.auto-zeile[data-bed="' + bi + '"]');
|
||
if (!zeile) return;
|
||
zeileZeichnen(zeile, zustaende[bi]);
|
||
});
|
||
|
||
const leiste = block.querySelector(".auto-segmente");
|
||
if (leiste) segmenteFuellen(leiste, zustaende);
|
||
});
|
||
|
||
renderStand(proGruppe);
|
||
}
|
||
|
||
/** Wert und Punkt einer einzelnen Zeile. */
|
||
function zeileZeichnen(zeile, zustand) {
|
||
const jetzt = zeile.querySelector(".wert-jetzt");
|
||
if (jetzt) {
|
||
jetzt.className = "wert-jetzt" + (zustand.kennt && zustand.erfuellt ? " erfuellt" : "");
|
||
jetzt.innerHTML = "gerade <b>" + textSicher(zustand.text) + "</b>";
|
||
}
|
||
const punkt = zeile.querySelector(".punkt");
|
||
if (punkt) {
|
||
punkt.className = "punkt" + (zustand.kennt && zustand.erfuellt ? " an" : "");
|
||
punkt.title = zustand.kennt
|
||
? (zustand.erfuellt ? "trifft zu" : "trifft gerade nicht zu")
|
||
: "hängt am Zeitpunkt – das entscheidet der Runner";
|
||
punkt.style.borderStyle = zustand.kennt ? "" : "dashed";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Zeichnet alle UND-Bloecke. Jeder Block hat sein eigenes "noch eine
|
||
* Bedingung", denn mit einem einzigen Knopf ganz unten kaeme man immer nur
|
||
* an den letzten Block heran und koennte eine bestehende Alternative nie
|
||
* mehr ergaenzen.
|
||
*/
|
||
function renderConditions() {
|
||
const ziel = document.getElementById("condBlocks");
|
||
if (!ziel) return;
|
||
ziel.innerHTML = "";
|
||
|
||
const proGruppe = [];
|
||
|
||
autoModel.groups.forEach((gruppe, gi) => {
|
||
if (gi > 0) {
|
||
const trenner = document.createElement("div");
|
||
trenner.className = "d-flex align-items-center my-2 text-body-secondary";
|
||
trenner.innerHTML = "<hr class='flex-grow-1 m-0'>"
|
||
+ "<span class='px-3 card-title mb-0 fs-6'>oder</span>"
|
||
+ "<hr class='flex-grow-1 m-0'>";
|
||
ziel.appendChild(trenner);
|
||
}
|
||
|
||
const block = document.createElement("div");
|
||
block.className = "border rounded-3 p-1";
|
||
// Kennung, damit werteZeichnen() die Zeile spaeter wiederfindet, ohne
|
||
// sie neu zu bauen.
|
||
block.dataset.gruppe = gi;
|
||
|
||
const zustaende = zustaendeVon(gruppe);
|
||
proGruppe.push(zustaende);
|
||
|
||
if (gruppe.length > 1) {
|
||
const kopf = document.createElement("div");
|
||
kopf.className = "d-flex align-items-center gap-2 px-2 pt-1 small text-body-secondary";
|
||
kopf.innerHTML = "<span>alles davon</span><span class='flex-grow-1'></span>";
|
||
kopf.appendChild(segmente(zustaende));
|
||
block.appendChild(kopf);
|
||
}
|
||
|
||
gruppe.forEach((bed, bi) => block.appendChild(conditionRow(gi, bi, bed, zustaende[bi])));
|
||
|
||
const add = document.createElement("button");
|
||
add.type = "button";
|
||
add.className = "btn btn-link btn-sm text-body-secondary text-decoration-none";
|
||
add.innerHTML = "<i class='bi bi-plus-lg'></i> noch eine Bedingung";
|
||
add.onclick = () => { gruppe.push(leereBedingung()); renderConditions(); };
|
||
block.appendChild(add);
|
||
|
||
ziel.appendChild(block);
|
||
});
|
||
|
||
renderStand(proGruppe);
|
||
}
|
||
|
||
function conditionRow(gi, bi, bed, zustand) {
|
||
const zeile = document.createElement("div");
|
||
zeile.className = "auto-zeile";
|
||
zeile.dataset.bed = bi;
|
||
|
||
const treffer = stateById(bed.state_id);
|
||
const dev = treffer ? treffer.device : null;
|
||
const state = treffer ? treffer.state : null;
|
||
|
||
const sym = document.createElement("i");
|
||
sym.className = "bi " + symbolFuer(dev) + " auto-symbol";
|
||
zeile.appendChild(sym);
|
||
|
||
// Geraet klein und matt ueber dem Messwert: es beantwortet "woher", nicht
|
||
// "was". Frueher standen beide als gleich breite Kaesten nebeneinander,
|
||
// und "Gartenwasser hinten" brach dabei jede zweite Zeile auf doppelte
|
||
// Hoehe um.
|
||
const was = document.createElement("div");
|
||
was.className = "auto-was";
|
||
|
||
const geraetGewaehlt = neu => {
|
||
bed.state_id = neu.states[0].id;
|
||
bed.operator = neu.states[0].operators[0].value;
|
||
bed.value = standardWert(neu.states[0]);
|
||
renderConditions();
|
||
};
|
||
const auswahl = autoDevices.filter(d => d.states.length);
|
||
|
||
if (!dev) {
|
||
// Solange kein Geraet feststeht, gibt es genau eine sinnvolle Handlung -
|
||
// und die bekommt die ganze Zeile.
|
||
//
|
||
// Vorher standen hier zwei Zeilen: ein klein geschriebenes "Gerät wählen"
|
||
// und darunter ein totes Auswahlfeld "Messwert". Wer auf das Feld klickte
|
||
// - und das ist das groessere, auffaelligere von beiden -, bekam keine
|
||
// Reaktion und musste selbst darauf kommen, dass die matte Zeile darueber
|
||
// die eigentliche Schaltflaeche ist. Ein Bedienelement, das auf Klick
|
||
// nichts tut, ist schlechter als gar keines.
|
||
const waehlen = geraeteWaehler(auswahl, 0, geraetGewaehlt,
|
||
"feld-als-text auto-geraet");
|
||
waehlen.classList.remove("text-body-secondary");
|
||
was.appendChild(waehlen);
|
||
zeile.classList.add("leer");
|
||
zeile.appendChild(was);
|
||
} else {
|
||
was.appendChild(geraeteWaehler(auswahl, dev.id, geraetGewaehlt, "auto-geraet"));
|
||
|
||
const messwert = document.createElement("select");
|
||
messwert.className = "feld-als-text auto-messwert";
|
||
optionListe(messwert, dev.states.map(s => ({ value: s.id, text: s.name })), bed.state_id);
|
||
messwert.onchange = () => {
|
||
const neu = dev.states.find(s => s.id === Number(messwert.value));
|
||
bed.state_id = neu.id;
|
||
bed.operator = neu.operators[0].value;
|
||
bed.value = standardWert(neu);
|
||
renderConditions();
|
||
};
|
||
was.appendChild(messwert);
|
||
zeile.appendChild(was);
|
||
}
|
||
|
||
if (state) {
|
||
const op = document.createElement("select");
|
||
op.className = "form-select form-select-sm text-center auto-op";
|
||
optionListe(op, state.operators.map(o => ({ value: o.value, text: o.label })), bed.operator);
|
||
op.onchange = () => { bed.operator = op.value; renderConditions(); };
|
||
zeile.appendChild(op);
|
||
|
||
// Wert und Einheit in einem Feld: die Einheit ist keine Eingabe und
|
||
// brauchte trotzdem einen eigenen Kasten in der Reihe.
|
||
const wertfeld = wertFeld(state, bed.value);
|
||
wertfeld.classList.add(wertfeld.tagName === "SELECT" ? "form-select-sm" : "form-control-sm");
|
||
wertfeld.onchange = () => { bed.value = wertfeld.value; renderConditions(); };
|
||
if (state.unit) {
|
||
const huelle = document.createElement("div");
|
||
huelle.className = "input-group input-group-sm auto-wert";
|
||
huelle.appendChild(wertfeld);
|
||
const einheit = document.createElement("span");
|
||
einheit.className = "input-group-text";
|
||
einheit.textContent = state.unit;
|
||
huelle.appendChild(einheit);
|
||
zeile.appendChild(huelle);
|
||
} else {
|
||
wertfeld.classList.add("auto-wert");
|
||
zeile.appendChild(wertfeld);
|
||
}
|
||
|
||
const jetzt = document.createElement("span");
|
||
jetzt.className = "wert-jetzt" + (zustand.kennt && zustand.erfuellt ? " erfuellt" : "");
|
||
jetzt.innerHTML = "gerade <b>" + textSicher(zustand.text) + "</b>";
|
||
zeile.appendChild(jetzt);
|
||
|
||
const punkt = document.createElement("span");
|
||
punkt.className = "punkt" + (zustand.kennt && zustand.erfuellt ? " an" : "");
|
||
punkt.title = zustand.kennt ? (zustand.erfuellt ? "trifft zu" : "trifft gerade nicht zu")
|
||
: "haengt am Zeitpunkt – das entscheidet der Runner";
|
||
if (!zustand.kennt) punkt.style.borderStyle = "dashed";
|
||
zeile.appendChild(punkt);
|
||
}
|
||
|
||
const weg = document.createElement("button");
|
||
weg.type = "button";
|
||
weg.className = "btn btn-sm auto-weg";
|
||
weg.title = "Bedingung entfernen";
|
||
weg.innerHTML = "<i class='bi bi-trash3'></i>";
|
||
weg.onclick = () => {
|
||
autoModel.groups[gi].splice(bi, 1);
|
||
// Ein leerer Block hat keine Bedeutung mehr und verschwindet mit.
|
||
if (!autoModel.groups[gi].length) autoModel.groups.splice(gi, 1);
|
||
renderConditions();
|
||
};
|
||
zeile.appendChild(weg);
|
||
|
||
return zeile;
|
||
}
|
||
|
||
/**
|
||
* Der Stand im Kopf des Bandes.
|
||
*
|
||
* Steht dort, wo frueher der Satz stand, der die Regel Wort fuer Wort
|
||
* nacherzaehlte - fuenf fette Zeilen, die genau das wiederholten, was
|
||
* darueber schon zu sehen war. Diese Zeile beantwortet stattdessen die
|
||
* Frage, die man wirklich hat: warum laeuft die Automatik gerade nicht?
|
||
*/
|
||
function renderStand(proGruppe) {
|
||
const ziel = document.getElementById("condStand");
|
||
if (!ziel) return;
|
||
|
||
const gefuellt = proGruppe.filter(z => z.length);
|
||
if (!gefuellt.length) {
|
||
ziel.innerHTML = "<span class='small text-warning'>"
|
||
+ "Noch kein Auslöser – die Automatik würde nie starten.</span>";
|
||
return;
|
||
}
|
||
|
||
// Das Geruest nur einmal bauen. Wuerde es der Takt jedes Mal ersetzen,
|
||
// finge die Atembewegung des Punktes alle acht Sekunden von vorn an - ein
|
||
// Zucken, das aussieht wie ein Fehler.
|
||
if (!ziel.querySelector(".punkt")) {
|
||
ziel.innerHTML = "";
|
||
ziel.appendChild(document.createElement("span")).className = "punkt atmet";
|
||
ziel.appendChild(document.createElement("span")).className = "small text-body-secondary";
|
||
ziel.appendChild(segmente([]));
|
||
}
|
||
|
||
// Bei mehreren Gruppen zaehlt die, die am weitesten ist: es genuegt ja,
|
||
// dass eine von ihnen vollstaendig zutrifft.
|
||
let beste = gefuellt[0];
|
||
let bestZahl = -1;
|
||
gefuellt.forEach(z => {
|
||
const n = z.filter(x => x.kennt && x.erfuellt).length;
|
||
if (n > bestZahl) { bestZahl = n; beste = z; }
|
||
});
|
||
|
||
const alle = beste.every(x => x.kennt && x.erfuellt);
|
||
const offen = beste.some(x => !x.kennt);
|
||
|
||
const punkt = ziel.querySelector(".punkt");
|
||
punkt.className = "punkt atmet" + (alle ? " an" : "");
|
||
|
||
const text = ziel.querySelector("span.small");
|
||
text.textContent = alle && !offen
|
||
? "alles erfüllt"
|
||
: "gerade " + bestZahl + " von " + beste.length
|
||
+ (gefuellt.length > 1 ? " in der besten Gruppe" : "");
|
||
text.title = offen
|
||
? "Zeitbedingungen sind nicht mitgezählt – die entscheidet der Runner."
|
||
: "";
|
||
|
||
segmenteFuellen(ziel.querySelector(".auto-segmente"), beste);
|
||
}
|
||
|
||
// --- Aktionen ------------------------------------------------------------
|
||
|
||
function renderActions() {
|
||
const ziel = document.getElementById("actionList");
|
||
ziel.innerHTML = "";
|
||
autoModel.actions.forEach((aktion, ai) => ziel.appendChild(actionRow(ai, aktion)));
|
||
}
|
||
|
||
function actionRow(ai, aktion) {
|
||
// Dasselbe Raster wie die Bedingungszeile, nur mit anderen Spalten:
|
||
// Sinnbild, was geschaltet wird, die Werte dazu, Papierkorb. Damit erben
|
||
// Aktionen alles mit, was drueben schon stimmt - Gehaenge beim
|
||
// Ueberfahren, abwechselnde Hinterlegung, das Umbrechen auf schmalen
|
||
// Schirmen.
|
||
const zeile = document.createElement("div");
|
||
zeile.className = "auto-zeile tat";
|
||
|
||
const treffer = commandById(aktion.command_id);
|
||
const dev = treffer ? treffer.device : null;
|
||
const cmd = treffer ? treffer.command : null;
|
||
|
||
const sym = document.createElement("i");
|
||
sym.className = "bi " + symbolFuer(dev) + " auto-symbol";
|
||
zeile.appendChild(sym);
|
||
|
||
const was = document.createElement("div");
|
||
was.className = "auto-was";
|
||
|
||
const geraetGewaehlt = neu => {
|
||
aktion.command_id = neu.commands[0].id;
|
||
aktion.params = standardParams(neu.commands[0]);
|
||
renderActions();
|
||
};
|
||
const auswahl = autoDevices.filter(d => d.commands.length);
|
||
|
||
if (!dev) {
|
||
// Wie in der Bedingungszeile: solange kein Geraet feststeht, gibt es nur
|
||
// eine Handlung, und die bekommt die ganze Breite.
|
||
const waehlen = geraeteWaehler(auswahl, 0, geraetGewaehlt,
|
||
"feld-als-text auto-geraet");
|
||
waehlen.classList.remove("text-body-secondary");
|
||
was.appendChild(waehlen);
|
||
zeile.classList.add("leer");
|
||
zeile.appendChild(was);
|
||
} else {
|
||
was.appendChild(geraeteWaehler(auswahl, dev.id, geraetGewaehlt, "auto-geraet"));
|
||
|
||
const befehl = document.createElement("select");
|
||
befehl.className = "feld-als-text auto-messwert";
|
||
optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id);
|
||
befehl.onchange = () => {
|
||
const neu = dev.commands.find(c => c.id === Number(befehl.value));
|
||
aktion.command_id = neu.id;
|
||
aktion.params = standardParams(neu);
|
||
renderActions();
|
||
};
|
||
was.appendChild(befehl);
|
||
zeile.appendChild(was);
|
||
|
||
// Je Parameter ein Feld - wie viele es sind, sagt das Geraet. Frueher gab
|
||
// es vier feste Felder "Wert 1" bis "Wert 4", die je nach Kommando
|
||
// versteckt wurden, ihren Inhalt aber trotzdem mitschickten.
|
||
const werte = document.createElement("div");
|
||
werte.className = "auto-werte d-flex flex-wrap align-items-center gap-2";
|
||
if (cmd) {
|
||
cmd.params.forEach(p => {
|
||
const paar = document.createElement("div");
|
||
paar.className = "d-flex align-items-center gap-1";
|
||
|
||
const name = document.createElement("span");
|
||
name.className = "small text-body-secondary text-nowrap";
|
||
name.textContent = p.name;
|
||
paar.appendChild(name);
|
||
|
||
const feld = wertFeld(p, aktion.params[p.id] !== undefined
|
||
? aktion.params[p.id] : standardWert(p));
|
||
feld.classList.add(feld.tagName === "SELECT" ? "form-select-sm" : "form-control-sm");
|
||
feld.onchange = () => { aktion.params[p.id] = feld.value; };
|
||
paar.appendChild(feld);
|
||
|
||
werte.appendChild(paar);
|
||
});
|
||
}
|
||
zeile.appendChild(werte);
|
||
}
|
||
|
||
const weg = document.createElement("button");
|
||
weg.type = "button";
|
||
weg.className = "btn btn-sm auto-weg";
|
||
weg.title = "Aktion entfernen";
|
||
weg.innerHTML = "<i class='bi bi-trash3'></i>";
|
||
weg.onclick = () => { autoModel.actions.splice(ai, 1); renderActions(); };
|
||
zeile.appendChild(weg);
|
||
|
||
return zeile;
|
||
}
|
||
|
||
function standardParams(cmd) {
|
||
const params = {};
|
||
cmd.params.forEach(p => { params[p.id] = standardWert(p); });
|
||
return params;
|
||
}
|
||
|
||
// --- Rahmenbedingungen ---------------------------------------------------
|
||
|
||
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||
const WOCHENTAGE_LANG = ["montags", "dienstags", "mittwochs", "donnerstags",
|
||
"freitags", "samstags", "sonntags"];
|
||
|
||
/**
|
||
* Ein Schalter im Bootstrap-Stil: verstecktes Eingabefeld plus Etikett, die
|
||
* beide direkt im .btn-group stehen muessen - ein umschliessendes <div>
|
||
* bricht die Gruppe optisch auf.
|
||
*/
|
||
function schalter(ziel, art, id, name, text, titel, gedrueckt, beiKlick) {
|
||
const feld = document.createElement("input");
|
||
feld.type = art; // "checkbox" oder "radio"
|
||
feld.className = "btn-check";
|
||
feld.id = id;
|
||
feld.autocomplete = "off";
|
||
if (name) feld.name = name;
|
||
feld.checked = gedrueckt;
|
||
feld.onchange = beiKlick;
|
||
|
||
const etikett = document.createElement("label");
|
||
etikett.className = "btn btn-outline-secondary";
|
||
etikett.htmlFor = id;
|
||
etikett.textContent = text;
|
||
if (titel) etikett.title = titel;
|
||
|
||
ziel.appendChild(feld);
|
||
ziel.appendChild(etikett);
|
||
}
|
||
|
||
function renderWeekdays() {
|
||
const ziel = document.getElementById("weekdayGroup");
|
||
ziel.innerHTML = "";
|
||
WOCHENTAGE.forEach((tag, i) => {
|
||
schalter(ziel, "checkbox", "day" + i, null, tag, WOCHENTAGE_LANG[i],
|
||
!!(autoModel.weekdays & (1 << i)), e => {
|
||
if (e.target.checked) autoModel.weekdays |= (1 << i);
|
||
else autoModel.weekdays &= ~(1 << i);
|
||
renderRahmen();
|
||
});
|
||
});
|
||
}
|
||
|
||
/** Ferien und Feiertage: nie - egal - immer. */
|
||
function renderKalendertage(zielId, auswahl, feld) {
|
||
const ziel = document.getElementById(zielId);
|
||
ziel.innerHTML = "";
|
||
auswahl.forEach(o => {
|
||
schalter(ziel, "radio", zielId + "-" + o.value, zielId, o.label, o.title,
|
||
autoModel[feld] === o.value, () => {
|
||
autoModel[feld] = o.value;
|
||
renderRahmen();
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Die drei Vorlagen setzen den ganzen Tagesrahmen, nicht nur die Wochentage:
|
||
* "werktags" heisst auch "nicht an Feiertagen".
|
||
*/
|
||
function renderTagVorlagen() {
|
||
const ziel = document.getElementById("tagVorlagen");
|
||
ziel.innerHTML = "";
|
||
autoTagVorlagen.forEach(v => {
|
||
const knopf = document.createElement("button");
|
||
knopf.type = "button";
|
||
// Die Vorlage, die gerade zutrifft, steht gedrueckt da. Sonst sagen die
|
||
// drei Knoepfe nur, was man tun koennte, und nie, wo man steht - und
|
||
// "werktags" ist immerhin drei Einstellungen auf einmal.
|
||
const passt = autoModel.weekdays === v.weekdays
|
||
&& autoModel.on_vacation === v.on_vacation
|
||
&& autoModel.on_holiday === v.on_holiday;
|
||
knopf.className = "btn btn-outline-secondary" + (passt ? " active" : "");
|
||
knopf.textContent = v.label;
|
||
knopf.title = v.title;
|
||
knopf.onclick = () => {
|
||
// Kein stopPropagation noetig - und es haette auch nichts genutzt: die
|
||
// Vorlagen stehen bewusst ausserhalb des Elements mit data-bs-toggle,
|
||
// siehe den Kommentar an der Kopfzeile in ajax/AutoAction.php.
|
||
autoModel.weekdays = v.weekdays;
|
||
autoModel.on_vacation = v.on_vacation;
|
||
autoModel.on_holiday = v.on_holiday;
|
||
renderRahmen();
|
||
};
|
||
ziel.appendChild(knopf);
|
||
});
|
||
}
|
||
|
||
/** "montags, mittwochs und freitags" */
|
||
function aufzaehlung(teile) {
|
||
if (teile.length < 2) return teile[0] || "";
|
||
return teile.slice(0, -1).join(", ") + " und " + teile[teile.length - 1];
|
||
}
|
||
|
||
/**
|
||
* Die Wochentagsmaske als Wort. Drei und mehr aufeinanderfolgende Tage
|
||
* werden zusammengezogen ("montags bis freitags"), zwei nicht - "montags und
|
||
* dienstags und samstags" haette sonst zwei verschiedene "und" nebeneinander.
|
||
*/
|
||
function tageText(maske) {
|
||
if (maske === 127) return "täglich";
|
||
const laeufe = [];
|
||
for (let i = 0; i < 7; i++) {
|
||
if (!(maske & (1 << i))) continue;
|
||
const letzter = laeufe[laeufe.length - 1];
|
||
if (letzter && letzter[1] === i - 1) letzter[1] = i;
|
||
else laeufe.push([i, i]);
|
||
}
|
||
const teile = [];
|
||
laeufe.forEach(([a, b]) => {
|
||
if (b - a >= 2) teile.push(WOCHENTAGE_LANG[a] + " bis " + WOCHENTAGE_LANG[b]);
|
||
else for (let i = a; i <= b; i++) teile.push(WOCHENTAGE_LANG[i]);
|
||
});
|
||
return aufzaehlung(teile);
|
||
}
|
||
|
||
/**
|
||
* Der ganze Tagesrahmen als Satz.
|
||
*
|
||
* Das ist der eigentliche Gewinn gegenueber den Schaltern: man muss das
|
||
* Bedienelement nicht entziffern, sondern liest, was dabei herausgekommen
|
||
* ist. Leerer Rueckgabewert heisst "laeuft nie" - dann steht statt des
|
||
* Satzes eine Warnung da, wie beim fehlenden Ausloeser.
|
||
*/
|
||
function rahmenText() {
|
||
const tage = autoModel.weekdays;
|
||
const dazu = [];
|
||
const verbote = [];
|
||
if (autoModel.on_vacation === 2) dazu.push("an allen Ferientagen");
|
||
if (autoModel.on_holiday === 2) dazu.push("an allen Feiertagen");
|
||
if (autoModel.on_vacation === 0) verbote.push("in den Ferien");
|
||
if (autoModel.on_holiday === 0) verbote.push("an Feiertagen");
|
||
|
||
if (!tage && !dazu.length) return "";
|
||
|
||
let satz;
|
||
if (!tage) {
|
||
satz = "Läuft nur " + aufzaehlung(dazu);
|
||
} else {
|
||
satz = "Läuft " + tageText(tage);
|
||
// Bei sieben angehakten Tagen traegt "dazu an allen Feiertagen" nichts
|
||
// bei - die sind schon dabei.
|
||
if (dazu.length && tage !== 127) satz += ", dazu " + aufzaehlung(dazu);
|
||
}
|
||
if (verbote.length) satz += ", aber nicht " + aufzaehlung(verbote);
|
||
// Vorabend: derselbe Satz, nur fuer morgen gerechnet. Vorne, damit auch
|
||
// "aber nicht an Feiertagen" als Folgetag gelesen wird.
|
||
if (autoModel.next_day) satz = satz.replace(/^Läuft /, "Läuft am Vortag, gerechnet für den Folgetag: ");
|
||
return satz + ".";
|
||
}
|
||
|
||
/**
|
||
* Der Rahmen in einer Zeile, fuer den zusammengefalteten Zustand.
|
||
*
|
||
* Solange nichts von der Vorgabe abweicht, steht dort matt "täglich · rund
|
||
* um die Uhr" und man liest darueber hinweg. Weicht etwas ab, wird die Zeile
|
||
* hell - dann steht dort etwas, das man wissen sollte.
|
||
*/
|
||
function rahmenKurzText() {
|
||
const teile = [tageText(autoModel.weekdays) || "an keinem Tag"];
|
||
if (autoModel.next_day) teile[0] = "Vorabend von " + teile[0];
|
||
if (autoModel.on_vacation === 0) teile.push("nicht in den Ferien");
|
||
if (autoModel.on_vacation === 2) teile.push("dazu Ferientage");
|
||
if (autoModel.on_holiday === 0) teile.push("nicht an Feiertagen");
|
||
if (autoModel.on_holiday === 2) teile.push("dazu Feiertage");
|
||
teile.push(autoModel.window_from === "00:00" && autoModel.window_to === "23:59"
|
||
? "rund um die Uhr"
|
||
: autoModel.window_from + "–" + autoModel.window_to);
|
||
if (autoModel.lockout_secs > 0) {
|
||
teile.push("Sperre " + (autoModel.lockout_secs >= 60
|
||
? Math.floor(autoModel.lockout_secs / 60) + " Min."
|
||
: autoModel.lockout_secs + " Sek."));
|
||
}
|
||
if (autoModel.once_per_day) teile.push("nur einmal am Tag");
|
||
if (autoModel.force_once) teile.push("am Ende auf jeden Fall");
|
||
return teile.join(" · ");
|
||
}
|
||
|
||
function rahmenIstVorgabe() {
|
||
return autoModel.weekdays === 127
|
||
&& autoModel.on_vacation === 1
|
||
&& autoModel.on_holiday === 1
|
||
&& !autoModel.next_day
|
||
&& autoModel.window_from === "00:00"
|
||
&& autoModel.window_to === "23:59"
|
||
&& !autoModel.once_per_day
|
||
&& !autoModel.force_once;
|
||
}
|
||
|
||
/** Schalter neu zeichnen und die beiden Zusammenfassungen nachziehen. */
|
||
function renderRahmen() {
|
||
renderTagVorlagen();
|
||
renderWeekdays();
|
||
renderKalendertage("ferienGroup", autoFerien, "on_vacation");
|
||
renderKalendertage("feiertagGroup", autoFeiertage, "on_holiday");
|
||
const text = rahmenText();
|
||
const satz = document.getElementById("rahmenSummary");
|
||
// Ohne waehlbaren Tag laeuft die Automatik nie - dann ist der Satz doch
|
||
// eine Warnung und faerbt sich entsprechend.
|
||
satz.className = "callout py-2 px-3 mt-0 mb-3 "
|
||
+ (text ? "callout-primary" : "callout-warning");
|
||
satz.innerHTML = text
|
||
? "<strong>" + text + "</strong>"
|
||
: "Kein Tag ausgewählt – die Automatik würde nie laufen.";
|
||
|
||
const kurz = document.getElementById("rahmenKurz");
|
||
if (kurz) {
|
||
kurz.textContent = rahmenKurzText();
|
||
kurz.className = rahmenIstVorgabe() ? "text-body-secondary" : "text-body";
|
||
}
|
||
}
|
||
|
||
function fuelleFormular() {
|
||
const sperre = document.getElementById("lockoutSecs");
|
||
optionListe(sperre, autoLockouts.map(o => ({ value: o.value, text: o.label })),
|
||
autoModel.lockout_secs);
|
||
document.getElementById("autoName").value = autoModel.name;
|
||
// Kennt die Liste die Etage nicht, bleibt eine Auswahlliste sonst leer
|
||
// stehen und schickt beim Speichern "" mit.
|
||
const etage = document.getElementById("autoFloor");
|
||
etage.value = autoModel.floor;
|
||
if (etage.selectedIndex < 0) {
|
||
etage.selectedIndex = 0;
|
||
autoModel.floor = etage.value;
|
||
}
|
||
document.getElementById("tSpanFrom").value = autoModel.window_from;
|
||
document.getElementById("tSpanTo").value = autoModel.window_to;
|
||
document.getElementById("runOnce").checked = autoModel.force_once;
|
||
document.getElementById("oncePerDay").checked = autoModel.once_per_day;
|
||
document.getElementById("nextDay").checked = autoModel.next_day;
|
||
renderRahmen();
|
||
rahmenFelderVerdrahten();
|
||
renderConditions();
|
||
renderActions();
|
||
werteTaktStarten();
|
||
}
|
||
|
||
/** Die Felder, die direkt am Formular haengen, ins Modell zurueckholen. */
|
||
function leseFormular() {
|
||
autoModel.name = document.getElementById("autoName").value.trim();
|
||
autoModel.floor = document.getElementById("autoFloor").value;
|
||
autoModel.window_from = document.getElementById("tSpanFrom").value;
|
||
autoModel.window_to = document.getElementById("tSpanTo").value;
|
||
// weekdays, on_vacation und on_holiday stehen schon im Modell: die
|
||
// Schalter schreiben beim Klicken direkt hinein, weil der Satz darunter
|
||
// sofort nachziehen muss.
|
||
autoModel.force_once = document.getElementById("runOnce").checked;
|
||
autoModel.once_per_day = document.getElementById("oncePerDay").checked;
|
||
autoModel.next_day = document.getElementById("nextDay").checked;
|
||
autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value);
|
||
}
|
||
|
||
/**
|
||
* Die Felder im aufgeklappten Rahmen aendern die Kurzfassung im Kopf mit.
|
||
* Ohne das stuende dort "rund um die Uhr", waehrend darunter 08:45 steht.
|
||
*/
|
||
function rahmenFelderVerdrahten() {
|
||
["tSpanFrom", "tSpanTo", "lockoutSecs", "runOnce", "oncePerDay", "nextDay"].forEach(id => {
|
||
const feld = document.getElementById(id);
|
||
if (feld) feld.addEventListener("change", () => { leseFormular(); renderRahmen(); });
|
||
});
|
||
|
||
// Zugeklappt steht in der Kopfzeile, was gilt; aufgeklappt stehen dort die
|
||
// drei Vorlagen. Beides gleichzeitig waere eine volle Zeile aus
|
||
// Kleinteilen - genau die Unruhe, die der zusammengefaltete Rahmen
|
||
// vermeiden soll.
|
||
const koerper = document.getElementById("rahmenBody");
|
||
if (!koerper) return;
|
||
const umschalten = offen => {
|
||
[["rahmenKurz", offen], ["rahmenAendern", offen],
|
||
["tagVorlagen", !offen], ["rahmenZu", !offen]].forEach(([id, versteckt]) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.hidden = versteckt;
|
||
});
|
||
};
|
||
koerper.addEventListener("show.bs.collapse", () => umschalten(true));
|
||
koerper.addEventListener("hide.bs.collapse", () => umschalten(false));
|
||
}
|
||
|
||
// --- Modal ---------------------------------------------------------------
|
||
|
||
function loadAutomatic() {
|
||
const daten = JSON.parse(document.getElementById("autoActionData").textContent);
|
||
autoDevices = daten.devices;
|
||
autoLockouts = daten.lockouts;
|
||
autoFerien = daten.ferien || [];
|
||
autoFeiertage = daten.feiertage || [];
|
||
autoTagVorlagen = daten.tagvorlagen || [];
|
||
autoRooms = daten.rooms || [];
|
||
|
||
const a = daten.automation;
|
||
autoFloorVorher = a.floor;
|
||
autoModel = {
|
||
id: a.id,
|
||
name: a.name,
|
||
floor: a.floor,
|
||
enabled: a.enabled,
|
||
window_from: a.window_from,
|
||
window_to: a.window_to,
|
||
weekdays: a.weekdays,
|
||
on_vacation: a.on_vacation,
|
||
on_holiday: a.on_holiday,
|
||
next_day: !!a.next_day,
|
||
force_once: a.force_once,
|
||
once_per_day: a.once_per_day,
|
||
lockout_secs: a.lockout_secs,
|
||
groups: [],
|
||
actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) }))
|
||
};
|
||
|
||
// Die flache Liste aus der Datenbank wieder in Bloecke fassen. Die
|
||
// Gruppennummern kommen aufsteigend und dicht, siehe saveAutomation().
|
||
const nachGruppe = new Map();
|
||
a.conditions.forEach(c => {
|
||
if (!nachGruppe.has(c.group)) nachGruppe.set(c.group, []);
|
||
nachGruppe.get(c.group).push({ state_id: c.state_id, operator: c.operator, value: c.value });
|
||
});
|
||
autoModel.groups = Array.from(nachGruppe.values());
|
||
|
||
// Eine neue Automatik startet mit je einer leeren Zeile, sonst steht man
|
||
// vor einem leeren Formular ohne erkennbaren Anfang.
|
||
if (!autoModel.groups.length) autoModel.groups = [[leereBedingung()]];
|
||
if (!autoModel.actions.length) autoModel.actions = [leereAktion()];
|
||
|
||
fuelleFormular();
|
||
}
|
||
|
||
function openAutoActionModal(params) {
|
||
const contentURL = "./ajax/AutoAction.php" + params;
|
||
document.getElementById("modal-title").innerHTML =
|
||
params.search("id=") > 0 ? "Automatik bearbeiten" : "Neue Automatik anlegen";
|
||
|
||
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
|
||
// Zuhoerer stellt sicher, dass nicht auch noch der Handler eines vorher
|
||
// geoeffneten Modals mitfeuert.
|
||
const alt = document.getElementById("modalSaveBtn");
|
||
const btn = alt.cloneNode(true);
|
||
alt.replaceWith(btn);
|
||
// Das Raum-Modal versteckt ihn; hier wird er gebraucht.
|
||
btn.hidden = false;
|
||
btn.addEventListener("click", submitAutoAction);
|
||
|
||
// Der Editor braucht mehr Breite als die Ladedialoge, gibt sie aber wieder
|
||
// her, damit die anderen Modals unveraendert aussehen.
|
||
const dialog = document.querySelector("#modalEV .modal-dialog");
|
||
dialog.classList.add("modal-xl");
|
||
document.getElementById("modalEV").addEventListener("hidden.bs.modal", () => {
|
||
dialog.classList.remove("modal-xl");
|
||
// Sonst fragte der geschlossene Editor weiter im Achtsekundentakt nach
|
||
// Werten, die niemand mehr sieht.
|
||
werteTaktStoppen();
|
||
}, { once: true });
|
||
|
||
const modalBodyElement = document.getElementById("modal-body");
|
||
modalBodyElement.innerHTML = loadingHTML("Wird geladen...");
|
||
modalEV.show();
|
||
|
||
fetch(contentURL, {
|
||
method: "GET",
|
||
headers: { "X-Requested-From-Modal": "a", "Requested-With-Ajax": "ajax" }
|
||
})
|
||
.then(response => response.text())
|
||
.then(html => {
|
||
modalBodyElement.innerHTML = html;
|
||
loadAutomatic();
|
||
document.getElementById("btnAddGroup").addEventListener("click", () => {
|
||
autoModel.groups.push([leereBedingung()]);
|
||
renderConditions();
|
||
});
|
||
document.getElementById("btnAddActor").addEventListener("click", () => {
|
||
autoModel.actions.push(leereAktion());
|
||
renderActions();
|
||
});
|
||
})
|
||
.catch(error => {
|
||
modalBodyElement.innerHTML = "Konnte den Editor nicht laden: " + error.message;
|
||
});
|
||
}
|
||
|
||
function submitAutoAction() {
|
||
leseFormular();
|
||
const nutzlast = {
|
||
id: autoModel.id,
|
||
name: autoModel.name,
|
||
floor: autoModel.floor,
|
||
enabled: autoModel.enabled,
|
||
window_from: autoModel.window_from,
|
||
window_to: autoModel.window_to,
|
||
weekdays: autoModel.weekdays,
|
||
on_vacation: autoModel.on_vacation,
|
||
on_holiday: autoModel.on_holiday,
|
||
next_day: autoModel.next_day,
|
||
force_once: autoModel.force_once,
|
||
once_per_day: autoModel.once_per_day,
|
||
lockout_secs: autoModel.lockout_secs,
|
||
conditions: [],
|
||
actions: autoModel.actions
|
||
};
|
||
autoModel.groups.forEach((gruppe, gi) => {
|
||
gruppe.forEach(bed => {
|
||
nutzlast.conditions.push({ group: gi, state_id: bed.state_id, operator: bed.operator, value: bed.value });
|
||
});
|
||
});
|
||
|
||
automationRequest("save", nutzlast).then(antwort => {
|
||
if (antwort.error) {
|
||
alert(antwort.error);
|
||
return;
|
||
}
|
||
modalEV.hide();
|
||
refreshAutomations(nutzlast.floor);
|
||
// Ein Etagenwechsel verschiebt die Automatik, er kopiert sie nicht - in
|
||
// der Datenbank steht weiter eine Zeile. Ohne dieses zweite Nachladen
|
||
// bliebe sie aber in der Tabelle der alten Etage stehen, und genau das
|
||
// sah wie eine Kopie aus.
|
||
if (autoFloorVorher && autoFloorVorher !== nutzlast.floor) {
|
||
refreshAutomations(autoFloorVorher);
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- Uebersicht ----------------------------------------------------------
|
||
|
||
function automationRequest(action, body) {
|
||
return fetch("./ajax/AutoAction.php?action=" + action, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
|
||
body: JSON.stringify(body)
|
||
})
|
||
.then(response => response.json())
|
||
.catch(error => ({ error: error.message }));
|
||
}
|
||
|
||
/**
|
||
* Die Tabelle einer Etage neu holen. Ohne Etage alle, die es gibt.
|
||
*
|
||
* Welche das sind, sagen die Reiter - home.php baut sie aus der Raumtabelle.
|
||
* Eine feste Liste hier haette beim Aussengelaende still eine Etage
|
||
* ausgelassen: die Tabelle waere leer geblieben, ohne Fehlermeldung.
|
||
*/
|
||
function refreshAutomations(floor) {
|
||
// Die Zeitleiste zeigt dieselben Automatiken - nach Speichern, Pausieren
|
||
// oder Loeschen muss sie genauso nachziehen wie die Tabelle.
|
||
if (typeof zeitleisteLaden === "function") zeitleisteLaden();
|
||
const etagen = floor ? [floor]
|
||
: Array.from(document.querySelectorAll("#actions-tabContent .tab-pane"))
|
||
.map(pane => pane.id.replace(/^actions-/, ""));
|
||
etagen.forEach(etage => {
|
||
const ziel = document.getElementById("actions-" + etage + "-list");
|
||
if (!ziel) return;
|
||
fetch("./ajax/AutoAction.php?action=list&floor=" + etage, {
|
||
method: "GET",
|
||
headers: { "Requested-With-Ajax": "ajax" }
|
||
})
|
||
.then(response => response.text())
|
||
.then(html => { ziel.innerHTML = html; })
|
||
.catch(() => { ziel.innerHTML = "<p class='text-danger'>Liste konnte nicht geladen werden.</p>"; });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Die von einer Automatik ausgeloesten Zeilen zeigen oder verbergen.
|
||
*
|
||
* Sie stehen schon in der Tabelle, nur mit d-none - das spart eine zweite
|
||
* Anfrage und haelt die Reihenfolge stabil, wenn zwischendurch etwas
|
||
* pausiert wird.
|
||
*/
|
||
function toggleAutomationGroup(id, knopf) {
|
||
const zeilen = Array.from(document.querySelectorAll(".auto-von-" + id));
|
||
if (!zeilen.length) return;
|
||
const aufklappen = zeilen[0].classList.contains("d-none");
|
||
if (aufklappen) {
|
||
zeilen.forEach(zeile => zeile.classList.remove("d-none"));
|
||
} else {
|
||
// Beim Zuklappen muessen auch die Enkel verschwinden. Sonst blieben sie
|
||
// sichtbar stehen, ohne dass noch zu sehen waere, woran sie haengen.
|
||
automationGruppeVerbergen(id);
|
||
}
|
||
pfeilStellen(knopf, aufklappen);
|
||
}
|
||
|
||
function automationGruppeVerbergen(id) {
|
||
document.querySelectorAll(".auto-von-" + id).forEach(zeile => {
|
||
zeile.classList.add("d-none");
|
||
const griff = zeile.querySelector("button[data-gruppe]");
|
||
if (griff) {
|
||
pfeilStellen(griff, false);
|
||
automationGruppeVerbergen(Number(griff.dataset.gruppe));
|
||
}
|
||
});
|
||
}
|
||
|
||
function pfeilStellen(knopf, offen) {
|
||
const pfeil = knopf && knopf.querySelector("i");
|
||
if (pfeil) pfeil.className = offen ? "bi bi-chevron-down" : "bi bi-chevron-right";
|
||
}
|
||
|
||
function toggleAutomation(id) {
|
||
automationRequest("toggle", { id: id }).then(antwort => {
|
||
if (antwort.error) alert(antwort.error);
|
||
else refreshAutomations(null);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Loeschen - und vorher fragen, was mit den abhaengigen Automatiken wird.
|
||
*
|
||
* Ohne diese Frage verschwaende mit dem Ausloeser still die Bedingung des
|
||
* Nachfolgers (fk_cond_state steht auf ON DELETE CASCADE). Aus "zehn Minuten
|
||
* nach dem Wecker UND es ist hell" wuerde dann ein blosses "es ist hell" -
|
||
* eine andere Regel, die niemand angeordnet hat.
|
||
*
|
||
* Drei Antworten, deshalb kein confirm(): mitloeschen, abhaengen, abbrechen.
|
||
*/
|
||
function deleteAutomation(id, name) {
|
||
fetch("./ajax/AutoAction.php?action=followers&id=" + id, {
|
||
headers: { "Requested-With-Ajax": "ajax" }
|
||
})
|
||
.then(antwort => antwort.json())
|
||
.then(daten => {
|
||
const folgen = (daten && daten.followers) || [];
|
||
if (!folgen.length) {
|
||
if (confirm("Automatik \"" + name + "\" wirklich löschen?")) {
|
||
automationLoeschen(id, "abhaengen");
|
||
}
|
||
return;
|
||
}
|
||
frageNachfolger(id, name, folgen);
|
||
})
|
||
.catch(() => {
|
||
// Auch ohne Auskunft loeschbar - dann eben mit der schonenden Variante.
|
||
if (confirm("Automatik \"" + name + "\" wirklich löschen?")) {
|
||
automationLoeschen(id, "abhaengen");
|
||
}
|
||
});
|
||
}
|
||
|
||
function automationLoeschen(id, modus) {
|
||
automationRequest("delete", { id: id, mode: modus }).then(antwort => {
|
||
if (antwort.error) alert(antwort.error);
|
||
else refreshAutomations(null);
|
||
});
|
||
}
|
||
|
||
/** Der Dialog mit den drei Antworten. Wird bei Bedarf gebaut. */
|
||
function frageNachfolger(id, name, folgen) {
|
||
const alt = document.getElementById("modalAutoLoeschen");
|
||
if (alt) alt.remove();
|
||
|
||
const liste = folgen
|
||
.map(f => "<li>" + textSicher(f.name)
|
||
+ (f.enabled ? "" : " <span class='text-body-secondary'>(pausiert)</span>")
|
||
+ "</li>")
|
||
.join("");
|
||
const wieViele = folgen.length === 1
|
||
? "Eine Automatik wird" : folgen.length + " Automatiken werden";
|
||
|
||
const huelle = document.createElement("div");
|
||
huelle.innerHTML =
|
||
"<div class='modal fade' id='modalAutoLoeschen' tabindex='-1'>"
|
||
+ "<div class='modal-dialog modal-dialog-centered'><div class='modal-content'>"
|
||
+ "<div class='modal-header'><h5 class='modal-title'>„" + textSicher(name) + "“ löschen</h5>"
|
||
+ "<button type='button' class='btn-close' data-bs-dismiss='modal'></button></div>"
|
||
+ "<div class='modal-body'><p>" + wieViele + " von dieser hier ausgelöst:</p>"
|
||
+ "<ul>" + liste + "</ul>"
|
||
+ "<p class='mb-0 text-body-secondary small'>Beim Abhängen verlieren sie nur diese "
|
||
+ "eine Bedingung. Damit sie danach nicht mit einer schwächeren Regel weiterlaufen, "
|
||
+ "werden sie pausiert.</p></div>"
|
||
+ "<div class='modal-footer'>"
|
||
+ "<button type='button' class='btn btn-secondary' data-bs-dismiss='modal'>Abbrechen</button>"
|
||
+ "<button type='button' class='btn btn-warning' id='btnAutoAbhaengen'>Abhängen und pausieren</button>"
|
||
+ "<button type='button' class='btn btn-danger' id='btnAutoMit'>Alle löschen</button>"
|
||
+ "</div></div></div></div>";
|
||
const knoten = huelle.firstChild;
|
||
document.body.appendChild(knoten);
|
||
|
||
const dialog = new bootstrap.Modal(knoten);
|
||
const waehlen = modus => {
|
||
dialog.hide();
|
||
automationLoeschen(id, modus);
|
||
};
|
||
knoten.querySelector("#btnAutoAbhaengen").addEventListener("click", () => waehlen("abhaengen"));
|
||
knoten.querySelector("#btnAutoMit").addEventListener("click", () => waehlen("mitloeschen"));
|
||
knoten.addEventListener("hidden.bs.modal", () => knoten.remove());
|
||
dialog.show();
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => refreshAutomations(null));
|