- app/ui_text/nl.json and app/uitext.py: every page word in Dutch, English fallback per key; the page picks by browser language. Home's sentences move into ui_text `home_words`, so /status?lang=nl builds them in Dutch. - test_ui_text.py: EN/NL key and placeholder parity, STYLE limits, jargon lists for both languages, no inline details, untranslated Dutch caught. Shared rules moved unchanged to tools/style_rules.py. - tools/ux13_screenshots.sh/.py: DOCS screenshots from the demo fixtures in headless Chromium; docs/img/ux13/. Battery modes (P3): no screenshot. - "Other time windows": a list editor (ui.periods) that builds static_profile and names bad times, empty prices, overlaps and gaps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
852 lines
46 KiB
JavaScript
852 lines
46 KiB
JavaScript
"use strict";
|
||
/* UX-13 P2: Settings, the Installer view. It draws from /config/model's
|
||
`layout` (app/uilayout.py through app/uipage.py) and `ui` (the page model
|
||
from app/uimodel.py), so this file names no option key: keys, labels,
|
||
ranges and choices all arrive as data. It posts the page model back; the
|
||
add-on turns it into options and runs its own start checks, and nothing
|
||
they refuse is saved. Every word is from app/ui_text or the add-on, and
|
||
data is set as text, never as HTML.
|
||
P4: the Setup tab's wizard is the same page model, drawn one step at a
|
||
time from each field's `wizard` step, and saved through the same routes. */
|
||
(function () {
|
||
const ROOT = window.UX13.words; // English or Dutch (app.js)
|
||
const W = ROOT.settings;
|
||
const { h, ask, toast, maint, lang } = window.UX13;
|
||
const $ = (s) => document.querySelector(s);
|
||
const ws = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), W);
|
||
const clone = (x) => (x === undefined ? undefined : JSON.parse(JSON.stringify(x)));
|
||
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||
|
||
document.querySelectorAll("[data-s]").forEach((e) => { e.textContent = ws(e.dataset.s) || ""; });
|
||
document.querySelectorAll("[data-s-ph]").forEach((e) => { e.placeholder = ws(e.dataset.sPh) || ""; });
|
||
document.querySelectorAll("[data-s-label]").forEach((e) => { e.setAttribute("aria-label", ws(e.dataset.sLabel) || ""); });
|
||
$("#savebar").setAttribute("aria-label", W.savebar.label);
|
||
|
||
let M = null; // the last /config/model
|
||
let V = null; // the page model being edited
|
||
let ORIG = null; // the page model as stored
|
||
let FP = ""; // the stored blob's fingerprint (compare-and-swap)
|
||
let MSG = []; // [{field, card, tier, text}] from the add-on
|
||
let DEVICE = "";
|
||
let LIST = null; // the inverter list field
|
||
let FILLED = {}; // field key -> the preset field that fills it
|
||
let view = null; // "settings" or "setup": the tab drawing V
|
||
let STEPS = {}; // wizard step -> refusals that block it
|
||
let uid = 0;
|
||
const ROWS = [];
|
||
const OPEN = new Set();
|
||
const UNLOCKED = new Set();
|
||
|
||
/* ---------------- the page model ---------------- */
|
||
const sub = (f) => f.key.slice(LIST.key.length + 1);
|
||
function valueIn(model, f, card) {
|
||
if (card == null) return model[f.key];
|
||
const c = (model[LIST.key] || [])[card];
|
||
return c ? c[sub(f)] : undefined;
|
||
}
|
||
const get = (f, card) => valueIn(V, f, card);
|
||
function visible(f) {
|
||
return (f.show || []).every(([name, op, want]) => {
|
||
const have = V[name];
|
||
if (op === "==") return same(have, want);
|
||
if (op === "!=") return !same(have, want);
|
||
if (op === "in") return want.some((x) => same(x, have));
|
||
if (have == null || have === "" || isNaN(+have)) return false;
|
||
return op === ">" ? +have > want : +have < want;
|
||
});
|
||
}
|
||
function put(f, card, v, redraw) {
|
||
if (card == null) V[f.key] = v;
|
||
else V[LIST.key][card][sub(f)] = v;
|
||
if (f.mirror) {
|
||
const m = findField(f.mirror);
|
||
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
|
||
}
|
||
if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value;
|
||
if (redraw || f.fills) redrawView(); else paint();
|
||
queueValidate();
|
||
}
|
||
function findField(key) {
|
||
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
|
||
if (f.key === key) return f;
|
||
for (const s of f.fields || []) if (s.key === key) return s;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* ---------------- numbers: stored units <-> shown units ---------------- */
|
||
const decimals = (x) => { const s = String(x); return s.includes(".") ? s.split(".")[1].length : 0; };
|
||
function shown(f, v) {
|
||
if (v == null || v === "") return "";
|
||
return String(+(Number(v) / (f.scale || 1)).toFixed(6));
|
||
}
|
||
function stored(f, x) {
|
||
const st = f.step || (f.type === "integer" ? 1 : 0);
|
||
let v = x * (f.scale || 1);
|
||
if (st) v = Math.round(v / st) * st;
|
||
if (f.min != null) v = Math.max(f.min, v);
|
||
if (f.max != null) v = Math.min(f.max, v);
|
||
return f.type === "integer" ? Math.round(v) : +v.toFixed(Math.min(10, decimals(st) + 2));
|
||
}
|
||
|
||
/* ---------------- controls ---------------- */
|
||
// A choice the page can only keep stays offered while it is the stored value.
|
||
function choicesOf(f, card) {
|
||
const cur = get(f, card), was = valueIn(ORIG, f, card);
|
||
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur) || same(c[0], was));
|
||
if (cur != null && cur !== "" && !out.some((c) => same(c[0], cur))) out.push([cur, String(cur)]);
|
||
return out;
|
||
}
|
||
function toggle(f, card, lid) {
|
||
const on = !!get(f, card);
|
||
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(on), "aria-labelledby": lid });
|
||
const txt = h("span", { class: "swtext", "aria-hidden": "true" }, on ? W.on : W.off);
|
||
b.addEventListener("click", () => {
|
||
const n = b.getAttribute("aria-checked") !== "true";
|
||
b.setAttribute("aria-checked", String(n)); txt.textContent = n ? W.on : W.off;
|
||
put(f, card, n);
|
||
});
|
||
return h("span", { class: "swrap" }, b, txt);
|
||
}
|
||
function radios(f, card, lid, cards) {
|
||
const cur = get(f, card);
|
||
const g = h("div", { class: cards ? "choices" : "seg", role: "radiogroup", "aria-labelledby": lid });
|
||
const items = choicesOf(f, card);
|
||
const buttons = items.map(([v, label], i) => {
|
||
const on = same(v, cur);
|
||
const hint = cards && f.hints ? f.hints[v] : "";
|
||
const b = h("button", { type: "button", role: "radio", class: cards ? "choice" : null, "aria-checked": String(on),
|
||
tabindex: on || (!items.some((c) => same(c[0], cur)) && i === 0) ? "0" : "-1" },
|
||
cards ? [h("span", { class: "rad", "aria-hidden": "true" }), h("span", {}, h("div", {}, label), hint ? h("div", { class: "tiny muted" }, hint) : null)] : label);
|
||
b.addEventListener("click", () => put(f, card, v, true));
|
||
b.addEventListener("keydown", (e) => {
|
||
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
|
||
if (!d) return;
|
||
e.preventDefault();
|
||
const j = (i + d + items.length) % items.length;
|
||
put(f, card, items[j][0], true);
|
||
const again = document.querySelector(`[data-rk="${CSS.escape(g.dataset.rk)}"] [role=radio]:nth-child(${j + 1})`);
|
||
if (again) again.focus();
|
||
});
|
||
return b;
|
||
});
|
||
g.dataset.rk = f.key + "|" + card;
|
||
g.append(...buttons);
|
||
return g;
|
||
}
|
||
function select(f, card, lid) {
|
||
const cur = get(f, card);
|
||
const s = h("select", { class: "sel", "aria-labelledby": lid });
|
||
for (const [v, label] of choicesOf(f, card)) s.append(h("option", { value: JSON.stringify(v), selected: same(v, cur) }, label));
|
||
for (const [v, label] of f.later || []) s.append(h("option", { value: JSON.stringify(v), disabled: true }, label));
|
||
s.addEventListener("change", () => put(f, card, JSON.parse(s.value), true));
|
||
const preset = f.presets && f.presets[cur];
|
||
return preset ? h("div", { class: "row" }, s, h("span", { class: "chip" }, W.region_year.replace("{year}", preset.year))) : s;
|
||
}
|
||
function slider(f, card, lid) {
|
||
if (f.min == null || f.max == null) return number(f, card, lid);
|
||
const sc = f.scale || 1;
|
||
const st = f.step || (f.type === "integer" ? 1 : 0.01);
|
||
const v = get(f, card);
|
||
const r = h("input", { type: "range", min: f.min, max: f.max, step: st, value: v == null ? f.min : v, "aria-labelledby": lid });
|
||
const n = h("input", { class: "num", type: "number", inputmode: "decimal", min: f.min / sc, max: f.max / sc, step: st / sc, value: shown(f, v), "aria-labelledby": lid });
|
||
r.addEventListener("input", () => { const x = stored(f, +r.value / sc); n.value = shown(f, x); put(f, card, x); });
|
||
n.addEventListener("change", () => {
|
||
if (n.value === "" || isNaN(+n.value)) { n.value = shown(f, get(f, card)); return; }
|
||
const x = stored(f, +n.value); r.value = x; n.value = shown(f, x); put(f, card, x);
|
||
});
|
||
return h("div", { class: "slider" }, r, n, f.unit ? h("span", { class: "unit" }, f.unit) : null);
|
||
}
|
||
function number(f, card, lid) {
|
||
const sc = f.scale || 1;
|
||
const by = FILLED[f.key];
|
||
const preset = by && by.presets[V[by.key]];
|
||
const n = h("input", { class: "num", type: "number", inputmode: "decimal", step: f.step ? f.step / sc : "any",
|
||
min: f.min == null ? null : f.min / sc, max: f.max == null ? null : f.max / sc,
|
||
value: shown(f, get(f, card)), disabled: !!preset, "aria-labelledby": lid });
|
||
n.addEventListener("input", () => {
|
||
if (n.value === "") { if (f.optional) put(f, card, null); return; }
|
||
if (!isNaN(+n.value)) put(f, card, stored(f, +n.value));
|
||
});
|
||
n.addEventListener("change", () => { n.value = shown(f, get(f, card)); });
|
||
return h("div", {}, h("div", { class: "row" }, n, f.unit ? h("span", { class: "unit" }, f.unit) : null),
|
||
preset ? h("div", { class: "tiny muted" }, W.preset.description) : null);
|
||
}
|
||
function text(f, card, lid) {
|
||
const v = get(f, card);
|
||
const t = h("input", { class: "txt", type: "text", value: v == null ? "" : v, autocapitalize: "none", spellcheck: "false", "aria-labelledby": lid });
|
||
t.addEventListener("input", () => put(f, card, t.value));
|
||
return t;
|
||
}
|
||
function locked(f, card, lid) {
|
||
const box = h("div", {});
|
||
const id = f.key + "#" + card;
|
||
const open = () => { UNLOCKED.add(id); box.replaceChildren(slider(f, card, lid)); };
|
||
if (UNLOCKED.has(id)) { open(); return box; }
|
||
const v = get(f, card);
|
||
box.append(h("div", { class: "row" },
|
||
h("strong", {}, v == null ? W.not_set : shown(f, v) + (f.unit ? " " + f.unit : "")),
|
||
h("span", { class: "chip" }, W.locked),
|
||
h("button", { type: "button", class: "btn link", "aria-label": W.change + ": " + f.label, onclick: open }, W.change)));
|
||
return box;
|
||
}
|
||
|
||
/* entity picker: search, the matching kind first, the live reading */
|
||
const ENT = {};
|
||
function entities(kind) {
|
||
const now = Date.now();
|
||
const e = ENT[kind];
|
||
if (e && now - e.at < 30000) return e.p;
|
||
const p = fetch("ha/entities?kind=" + encodeURIComponent(kind || ""), { cache: "no-store" })
|
||
.then((r) => (r.ok ? r.json() : { available: false, entities: [] }))
|
||
.catch(() => ({ available: false, entities: [] }));
|
||
ENT[kind] = { at: now, p };
|
||
return p;
|
||
}
|
||
const reading = (e) => (e && e.state != null && e.state !== "" ? String(e.state) + (e.unit ? " " + e.unit : "") : "–");
|
||
function picker(f, lid, value, pick) {
|
||
let data = null, all = false;
|
||
const btn = h("button", { type: "button", class: "ent-btn", "aria-expanded": "false", "aria-haspopup": "listbox", "aria-labelledby": lid });
|
||
const pop = h("div", { class: "ent-pop", hidden: true });
|
||
const q = h("input", { type: "search", placeholder: W.picker.search.description, "aria-label": W.picker.search.description, autocomplete: "off", spellcheck: "false" });
|
||
const note = h("div", { class: "tiny muted ent-note" });
|
||
const list = h("div", { class: "ent-list", role: "listbox", "aria-labelledby": lid });
|
||
const allBox = h("label", { class: "tiny muted ent-all" },
|
||
h("input", { type: "checkbox", onchange: (e) => { all = e.target.checked; fill(); } }), W.picker.all);
|
||
function drawBtn() {
|
||
const id = value();
|
||
const e = data && data.entities.find((x) => x.entity_id === id);
|
||
btn.replaceChildren();
|
||
if (id) btn.append(h("span", { class: "grow" }, h("div", {}, e ? e.name : id), h("div", { class: "key" }, id)),
|
||
data && data.available ? h("span", { class: "chip " + (e ? "now" : "rs") }, e ? reading(e) : W.picker.missing) : null);
|
||
else btn.append(h("span", { class: "grow muted" }, W.picker.choose));
|
||
btn.append(h("span", { class: "muted", "aria-hidden": "true" }, "▾"));
|
||
}
|
||
function opt(id, name, val) {
|
||
return h("button", { type: "button", class: "ent-opt", role: "option", "aria-selected": String(id === value()),
|
||
onclick: () => { pick(id); close(); drawBtn(); btn.focus(); } },
|
||
h("span", { class: "grow" }, h("div", {}, name), h("div", { class: "key" }, id)), val ? h("span", { class: "v" }, val) : null);
|
||
}
|
||
function fill() {
|
||
const typed = q.value.trim();
|
||
const t = typed.toLowerCase();
|
||
const rows = (data ? data.entities : []).filter((e) => (all || e.match) && (e.entity_id + " " + e.name).toLowerCase().includes(t)).slice(0, 200);
|
||
list.replaceChildren(...rows.map((e) => opt(e.entity_id, e.name, reading(e))));
|
||
if (typed.includes(".") && !rows.some((e) => e.entity_id === typed)) list.append(opt(typed, W.picker.use, ""));
|
||
if (!list.children.length) list.append(h("div", { class: "small muted", style: "padding:10px" }, W.picker.none.description));
|
||
note.textContent = data && data.text ? data.text : "";
|
||
note.hidden = !note.textContent;
|
||
}
|
||
function close() { pop.hidden = true; btn.setAttribute("aria-expanded", "false"); }
|
||
btn.addEventListener("click", () => {
|
||
if (!pop.hidden) { close(); return; }
|
||
pop.hidden = false; btn.setAttribute("aria-expanded", "true"); q.value = ""; fill();
|
||
setTimeout(() => q.focus(), 0);
|
||
});
|
||
q.addEventListener("input", fill);
|
||
pop.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") { e.preventDefault(); close(); btn.focus(); return; }
|
||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||
e.preventDefault();
|
||
const opts = [...list.querySelectorAll(".ent-opt")];
|
||
const i = opts.indexOf(document.activeElement);
|
||
const j = e.key === "ArrowDown" ? Math.min(opts.length - 1, i + 1) : i - 1;
|
||
if (j < 0) q.focus(); else if (opts[j]) opts[j].focus();
|
||
});
|
||
pop.append(q, note, list, allBox);
|
||
drawBtn();
|
||
entities(f.kind).then((d) => { data = d; drawBtn(); if (!pop.hidden) fill(); });
|
||
return h("div", { class: "ent" }, btn, pop,
|
||
f.optional ? h("button", { type: "button", class: "btn link tiny", onclick: () => { pick(""); drawBtn(); } }, W.picker.clear) : null);
|
||
}
|
||
function entity(f, card, lid) {
|
||
return picker(f, lid, () => get(f, card) || "", (v) => put(f, card, v));
|
||
}
|
||
function entityList(f, card, lid) {
|
||
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
|
||
const box = h("div", { class: "elist" });
|
||
arr().forEach((_id, i) => {
|
||
box.append(h("div", { class: "erow" },
|
||
h("div", { class: "tiny muted" }, W.phase + " " + (i + 1)),
|
||
picker(f, lid, () => arr()[i] || "", (v) => { const a = arr().slice(); a[i] = v; put(f, card, a); }),
|
||
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = arr().slice(); a.splice(i, 1); put(f, card, a, true); } }, W.remove)));
|
||
});
|
||
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([""]), true) }, "+ " + W.add_row));
|
||
return box;
|
||
}
|
||
function solarList(f, card, lid) {
|
||
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
|
||
const box = h("div", { class: "elist" });
|
||
arr().forEach((row, i) => {
|
||
const flip = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(!!row.reversed), "aria-label": W.reversed });
|
||
flip.addEventListener("click", () => { const a = clone(arr()); a[i].reversed = !a[i].reversed; put(f, card, a, true); });
|
||
box.append(h("div", { class: "erow" },
|
||
picker(f, lid, () => arr()[i].entity || "", (v) => { const a = clone(arr()); a[i].entity = v; put(f, card, a); }),
|
||
h("div", { class: "row" }, h("span", { class: "swrap" }, flip, h("span", { class: "swtext" }, W.reversed)),
|
||
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove))));
|
||
});
|
||
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([{ entity: "", reversed: false }]), true) }, "+ " + W.add_solar));
|
||
return box;
|
||
}
|
||
// P5: time windows, each a start, an end, its days and a price. The add-on
|
||
// builds the tariff from the rows and names the first overlap or gap.
|
||
function periodList(f, card) {
|
||
const P = W.periods;
|
||
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
|
||
const box = h("div", { class: "elist plist" });
|
||
if (!Array.isArray(get(f, card))) return h("div", { class: "msg info" }, W.derive.custom_profile.description);
|
||
arr().forEach((row, i) => {
|
||
const set = (k, v) => { const a = clone(arr()); a[i][k] = v; put(f, card, a); };
|
||
const say = (what) => P.window + " " + (i + 1) + ", " + what;
|
||
const time = (k) => {
|
||
const e = h("input", { class: "txt time", type: "text", inputmode: "numeric", maxlength: "5", placeholder: "00:00",
|
||
value: row[k] == null ? "" : row[k], autocomplete: "off", spellcheck: "false", "aria-label": say(P[k]) });
|
||
e.addEventListener("input", () => set(k, e.value.trim()));
|
||
return e;
|
||
};
|
||
const days = h("select", { class: "sel", "aria-label": say(P.days) });
|
||
for (const [v, label] of f.days || []) days.append(h("option", { value: v, selected: v === row.days }, label));
|
||
days.addEventListener("change", () => set("days", days.value));
|
||
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: "0.01",
|
||
value: row.price == null ? "" : row.price, "aria-label": say(P.price) });
|
||
price.addEventListener("input", () => set("price", price.value === "" || isNaN(+price.value) ? null : +price.value));
|
||
const cell = (label, ...kids) => h("label", { class: "pf" }, h("span", { class: "tiny muted" }, label), ...kids);
|
||
box.append(h("div", { class: "erow prow" },
|
||
h("div", { class: "row spread" }, h("strong", { class: "small" }, P.window + " " + (i + 1)),
|
||
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove)),
|
||
h("div", { class: "row" }, cell(P.from, time("from")), cell(P.to, time("to")),
|
||
cell(P.price, h("span", { class: "row" }, price, f.unit ? h("span", { class: "unit" }, f.unit) : null))),
|
||
cell(P.days, days)));
|
||
});
|
||
box.append(h("button", { type: "button", class: "btn", onclick: () => {
|
||
const a = arr(), last = a[a.length - 1];
|
||
put(f, card, clone(a).concat([{ from: last ? last.to : "00:00", to: "24:00", days: last ? last.days : "mon-sun", price: null }]), true);
|
||
} }, "+ " + P.add));
|
||
return box;
|
||
}
|
||
const CONTROLS = { toggle, segmented: (f, c, l) => radios(f, c, l, false), choice: (f, c, l) => radios(f, c, l, true),
|
||
select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList,
|
||
period_list: periodList };
|
||
|
||
/* ---------------- rows, cards and groups ---------------- */
|
||
function keyText(f, card) {
|
||
if (view === "setup") return ""; // the wizard shows no keys
|
||
if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f);
|
||
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
|
||
}
|
||
function fieldRow(f, card, gid) {
|
||
if (f.control === "inverter_list") return cardList(f, gid);
|
||
const lid = "sf" + (++uid);
|
||
const keys = keyText(f, card);
|
||
const msg = h("div", { class: "msgs" });
|
||
const make = CONTROLS[f.control] || text;
|
||
const row = h("div", { class: "f" + (f.control === "toggle" ? " inline" : ""), "data-key": f.key, "data-card": card },
|
||
h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && !["slider", "number", "locked", "period_list"].includes(f.control) ? " (" + f.unit + ")" : ""),
|
||
keys ? h("div", { class: "key" }, keys) : null),
|
||
h("div", { class: "ctl" }, make(f, card, lid)),
|
||
f.help ? h("div", { class: "help" }, f.help) : null, msg);
|
||
ROWS.push({ f, card, el: row, msg, gid, search: (f.label + " " + keys + " " + f.key + " " + (f.help || "")).toLowerCase() });
|
||
return row;
|
||
}
|
||
function blankCard(f, first) {
|
||
const out = {};
|
||
for (const s of f.fields) {
|
||
const k = sub(s);
|
||
out[k] = s.control === "slider" && first && first[k] != null ? first[k] : s.type === "boolean" ? false : s.control === "number" ? null : "";
|
||
}
|
||
return out;
|
||
}
|
||
function cardList(f, gid) {
|
||
const wrap = h("div", { class: "invs", "data-key": f.key });
|
||
const msg = h("div", { class: "msgs" });
|
||
const cards = V[f.key];
|
||
if (!Array.isArray(cards)) {
|
||
wrap.append(h("div", { class: "msg info" }, W.raw_roster.description), msg);
|
||
} else {
|
||
cards.forEach((c, i) => {
|
||
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
|
||
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), view === "setup" ? null : h("span", { class: "key" }, f.key + "[" + i + "]"),
|
||
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
|
||
V[f.key].splice(i, 1); V[f.count] = V[f.key].length;
|
||
// Back to one card, the stored shape returns: a flat inverter stays flat.
|
||
if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);
|
||
put(f, null, V[f.key], true);
|
||
} }, W.remove) : null));
|
||
for (const s of f.fields) box.append(fieldRow(s, i, gid));
|
||
wrap.append(box);
|
||
});
|
||
wrap.append(msg, h("button", { type: "button", class: "btn", onclick: () => {
|
||
V[f.key].push(blankCard(f, V[f.key][0])); Object.assign(V, f.grow); V[f.count] = V[f.key].length;
|
||
put(f, null, V[f.key], true);
|
||
} }, "+ " + W.add_inverter));
|
||
}
|
||
ROWS.push({ f, card: null, el: wrap, msg, gid, list: true, search: (f.label + " " + f.key + " " + (f.help || "")).toLowerCase() });
|
||
return wrap;
|
||
}
|
||
const chev = () => h("span", { class: "chev", "aria-hidden": "true" }, "›");
|
||
function render() {
|
||
const host = $("#groups");
|
||
const y = window.scrollY;
|
||
host.replaceChildren();
|
||
ROWS.length = 0;
|
||
for (const g of M.layout.groups) {
|
||
const d = h("details", { class: "grp", "data-g": g.id },
|
||
h("summary", {}, h("span", { class: "grow" }, h("strong", {}, g.name), h("span", { class: "small muted gdesc" }, g.description)),
|
||
h("span", { class: "gchips" }, h("span", { class: "gbadge" }), ...g.applies.map((a) => h("span", { class: "chip applies" }, a))), chev()));
|
||
d.open = OPEN.has(g.id);
|
||
d.addEventListener("toggle", () => { if (!$("#set-search").value) (d.open ? OPEN.add(g.id) : OPEN.delete(g.id)); });
|
||
const body = h("div", { class: "body" });
|
||
if (g.support_only) body.append(h("div", { class: "msg warn", role: "note" }, W.support.description));
|
||
for (const p of g.parts) {
|
||
const pid = g.id + "." + p.id;
|
||
let box;
|
||
if (p.collapsed) {
|
||
box = h("details", { class: "sub part" }, h("summary", {}, p.name));
|
||
box.open = OPEN.has(pid);
|
||
box.addEventListener("toggle", () => { if (!$("#set-search").value) (box.open ? OPEN.add(pid) : OPEN.delete(pid)); });
|
||
} else {
|
||
box = h("div", { class: "part" }, p.name ? h("div", { class: "subhead" }, p.name) : null);
|
||
}
|
||
if (p.description) box.append(h("p", { class: "small muted", style: "margin:4px 0" }, p.description));
|
||
for (const f of p.fields) box.append(fieldRow(f, null, g.id));
|
||
body.append(box);
|
||
}
|
||
if (g.id === "maintenance") {
|
||
body.append(h("div", { class: "part" }, h("div", { class: "subhead" }, W.cycle.name),
|
||
h("p", { class: "small muted", style: "margin:4px 0" }, W.cycle.description),
|
||
h("div", { class: "row" },
|
||
h("button", { type: "button", class: "btn", onclick: () => maint("maint_start") }, ROOT.home.control.maint_start),
|
||
h("button", { type: "button", class: "btn danger", onclick: () => maint("maint_abort") }, ROOT.home.control.maint_abort))));
|
||
}
|
||
d.append(body);
|
||
host.append(d);
|
||
}
|
||
paint();
|
||
window.scrollTo(0, y);
|
||
}
|
||
|
||
/* ---------------- messages, visibility, search, badges ---------------- */
|
||
const ICON = { bad: "×", warn: "!", info: "i" };
|
||
const RANK = { bad: 0, warn: 1, info: 2 };
|
||
function mine(r, m) {
|
||
if (m.field !== r.f.key) return false;
|
||
if (r.list || r.card == null) return true;
|
||
return m.card == null ? r.card === 0 : m.card === r.card;
|
||
}
|
||
function paint() {
|
||
const q = view === "setup" ? "" : $("#set-search").value.trim().toLowerCase();
|
||
const placed = new Set();
|
||
for (const r of ROWS) {
|
||
r.visible = visible(r.f);
|
||
const msgs = MSG.filter((m, i) => { const hit = mine(r, m); if (hit && r.visible) placed.add(i); return hit; })
|
||
.sort((a, z) => RANK[a.tier] - RANK[z.tier]);
|
||
r.tiers = new Set(msgs.map((m) => m.tier));
|
||
r.msg.replaceChildren(...msgs.map((m) => h("div", { class: "msg " + m.tier },
|
||
h("span", { class: "mi", "aria-hidden": "true" }, ICON[m.tier] || "i"),
|
||
h("span", {}, h("span", { class: "sr" }, W.tier[m.tier] + ": "), m.text))));
|
||
r.el.classList.toggle("has-bad", r.tiers.has("bad"));
|
||
// A card list counts as one change when cards were added or removed;
|
||
// an edit inside a card counts on its own row.
|
||
r.changed = r.list ? !same((get(r.f) || []).length, (valueIn(ORIG, r.f) || []).length)
|
||
: !same(get(r.f, r.card), valueIn(ORIG, r.f, r.card));
|
||
r.el.classList.toggle("changed", r.changed && !r.list);
|
||
}
|
||
// Search: a row shows when it is visible and matches. A card list shows
|
||
// while any of its rows does.
|
||
for (const r of ROWS) if (!r.list) r.el.hidden = !(r.visible && (!q || r.search.includes(q)));
|
||
for (const r of ROWS) if (r.list) r.el.hidden = !!q && !r.search.includes(q) && !ROWS.some((s) => !s.list && s.card != null && !s.el.hidden);
|
||
let any = false;
|
||
document.querySelectorAll("#groups details.grp").forEach((d) => {
|
||
const rows = ROWS.filter((r) => r.gid === d.dataset.g);
|
||
d.querySelectorAll(".part").forEach((p) => {
|
||
const inside = rows.filter((r) => p.contains(r.el));
|
||
if (inside.length) p.hidden = inside.every((r) => r.el.hidden);
|
||
});
|
||
const hit = rows.some((r) => !r.el.hidden);
|
||
any = any || hit;
|
||
d.hidden = !!q && !hit;
|
||
if (q && hit) { d.open = true; d.querySelectorAll("details.sub").forEach((s) => { s.open = true; }); }
|
||
const shownRows = rows.filter((r) => r.visible && !r.list);
|
||
const bad = shownRows.filter((r) => r.tiers.has("bad")).length + rows.filter((r) => r.list && r.tiers.has("bad")).length;
|
||
const warn = shownRows.filter((r) => !r.tiers.has("bad") && r.tiers.has("warn")).length;
|
||
const changed = rows.filter((r) => r.changed).length;
|
||
d.querySelector(".gbadge").replaceChildren(...[
|
||
bad ? h("span", { class: "chip bad" }, bad + " " + W.to_fix) : null,
|
||
warn ? h("span", { class: "chip rs" }, warn + " " + W.to_check) : null,
|
||
changed ? h("span", { class: "chip changed" }, changed + " " + W.changed) : null].filter(Boolean));
|
||
});
|
||
$("#set-nomatch").hidden = !q || any;
|
||
// A sentence shown beside any field is not repeated here.
|
||
const shownText = new Set(MSG.filter((m, i) => placed.has(i)).map((m) => m.text));
|
||
const other = MSG.filter((m, i) => !shownText.has(m.text) && MSG.findIndex((x) => x.text === m.text) === i);
|
||
if (DEVICE) other.push({ tier: "info", text: DEVICE });
|
||
$("#set-other").hidden = !other.length;
|
||
$("#set-other-list").replaceChildren(...other.map((m) => h("li", { class: "c-" + ({ bad: "bad", warn: "warn" }[m.tier] || "info") },
|
||
h("span", { class: "sr" }, W.tier[m.tier] + ": "), (ICON[m.tier] || "i") + " " + m.text)));
|
||
savebar();
|
||
}
|
||
function dirtyCount() {
|
||
const seen = new Set();
|
||
for (const r of ROWS) if (r.changed) seen.add(r.f.key + "#" + r.card);
|
||
return seen.size;
|
||
}
|
||
function savebar() {
|
||
const n = V ? dirtyCount() : 0;
|
||
$("#savebar").hidden = !(view === "settings" && n);
|
||
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
|
||
}
|
||
$("#set-search").addEventListener("input", () => { if (V) paint(); });
|
||
|
||
/* ---------------- talking to the add-on ---------------- */
|
||
let vtimer = null, vseq = 0;
|
||
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
|
||
// `extra` asks for more, e.g. the wizard's live checks. Returns the answer,
|
||
// or null when it failed or a newer check overtook it.
|
||
async function validate(extra) {
|
||
const seq = ++vseq;
|
||
try {
|
||
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(Object.assign({ model: V, lang }, extra || {})) });
|
||
const b = await r.json();
|
||
if (seq !== vseq) return null;
|
||
MSG = b.messages || [];
|
||
DEVICE = b.device_note || "";
|
||
STEPS = b.steps || {};
|
||
paint();
|
||
return b;
|
||
} catch (e) { return null; /* the next change tries again */ }
|
||
}
|
||
function banner(level, title, sub, button, onClick) {
|
||
const host = $("#set-banner");
|
||
if (!title) { host.replaceChildren(); return; }
|
||
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
|
||
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
|
||
}
|
||
function restart() {
|
||
ask(W.dialog, async () => {
|
||
try {
|
||
await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ confirm: "restart" }) });
|
||
} catch (e) { /* the add-on stops answering while it restarts */ }
|
||
toast(W.toast.restarting.description);
|
||
});
|
||
}
|
||
async function load(after) {
|
||
let m;
|
||
try {
|
||
const r = await fetch("config/model?lang=" + lang, { cache: "no-store" });
|
||
if (!r.ok) throw new Error("model " + r.status);
|
||
m = await r.json();
|
||
if (!m.layout || !m.ui) throw new Error("no layout");
|
||
} catch (e) {
|
||
$("#groups").replaceChildren();
|
||
$("#set-search").hidden = true;
|
||
banner("bad", W.load_failed.name, W.load_failed.description);
|
||
return;
|
||
}
|
||
M = m; V = clone(m.ui); ORIG = clone(m.ui); FP = m.fingerprint; MSG = []; DEVICE = "";
|
||
UNLOCKED.clear();
|
||
$("#set-search").hidden = false;
|
||
FILLED = {};
|
||
LIST = null;
|
||
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
|
||
if (f.fills) FILLED[f.fills] = f;
|
||
if (f.control === "inverter_list") LIST = f;
|
||
}
|
||
if (!OPEN.size && M.layout.groups.length) OPEN.add(M.layout.groups[0].id);
|
||
if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart);
|
||
else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart);
|
||
else banner();
|
||
if (view === "setup") drawStep(); else render();
|
||
validate();
|
||
}
|
||
function firstBad() {
|
||
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible);
|
||
if (!r) { if (!$("#set-other").hidden) $("#set-other").scrollIntoView({ block: "center" }); return; }
|
||
for (let p = r.el.parentElement; p; p = p.parentElement) if (p.tagName === "DETAILS") p.open = true;
|
||
r.el.scrollIntoView({ block: "center" });
|
||
const input = r.el.querySelector("input,select,button");
|
||
if (input) input.focus({ preventScroll: true });
|
||
}
|
||
// The one save path, for the Installer view and the wizard. After a stale
|
||
// refusal the page holds the new token, so a second press saves. `base` is
|
||
// the model the page opened: only what changed since then is written, onto
|
||
// what is stored now (UX-15 F2).
|
||
async function postSave(extra) {
|
||
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP, lang }, extra || {})) });
|
||
const b = await r.json().catch(() => ({}));
|
||
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
|
||
return { r, b };
|
||
}
|
||
// The settings another surface changed (not this page's own edits).
|
||
function staleText(b) {
|
||
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
|
||
return W.stale.description + (names.length ? " " + names.join(", ") + "." : "");
|
||
}
|
||
async function save() {
|
||
const btn = $("#sb-save");
|
||
btn.disabled = true; btn.textContent = W.savebar.saving;
|
||
try {
|
||
clearTimeout(vtimer);
|
||
await validate();
|
||
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
|
||
const { r, b } = await postSave();
|
||
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
|
||
if (r.status === 409 && b.kind === "stale") {
|
||
banner("bad", W.stale.name, staleText(b));
|
||
return;
|
||
}
|
||
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; }
|
||
toast(W.toast.failed);
|
||
} catch (e) { toast(W.toast.failed); }
|
||
finally { btn.disabled = false; btn.textContent = W.savebar.save; }
|
||
}
|
||
$("#sb-save").addEventListener("click", save);
|
||
// Discard reloads what is stored: after a stale save ORIG is no longer it.
|
||
$("#sb-discard").addEventListener("click", async () => {
|
||
await load(); toast(W.toast.discarded);
|
||
});
|
||
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
|
||
|
||
/* ---------------- the setup wizard (P4) ---------------- */
|
||
const WS = W.setup;
|
||
const WZ = { step: 1, kettle: null, shadowOn: true, done: "", checked: null };
|
||
function redrawView() { if (view === "setup") drawStep(); else render(); }
|
||
function allFields() {
|
||
const out = [];
|
||
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) out.push(f);
|
||
return out;
|
||
}
|
||
const say = (text, vars) => Object.keys(vars || {}).reduce((t, k) => t.replace("{" + k + "}", vars[k]), text);
|
||
function watts(w, signed) {
|
||
const n = String(Math.round(Math.abs(w))).replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||
return (signed && w < 0 ? "-" : "") + n + " W";
|
||
}
|
||
function line(tier, text) {
|
||
const icon = { ok: "✓", bad: "×", warn: "!", info: "i" }[tier] || "i";
|
||
return h("li", {}, h("span", { class: "sev " + tier, "aria-hidden": "true" }, icon),
|
||
h("span", {}, h("span", { class: "sr" }, (W.tier[tier] || "") + ": "), text));
|
||
}
|
||
|
||
// Step 1's kettle test. The table is app/uilayout.py's KETTLE; this is
|
||
// uipage.kettle() (test_setup.py grades that one) on the page.
|
||
function kettleSource() { const K = M.setup.kettle; return K.sources[V[K.source]] || null; }
|
||
function kettleSig(src) { return JSON.stringify([V[M.setup.kettle.source], src.read.map(([k]) => V[k] || "")]); }
|
||
function kettleOutcome(src, before, after) {
|
||
const d = after - before;
|
||
if (Math.abs(d) < M.setup.kettle.min_w) return { say: "none", sets: null, d };
|
||
const o = src[d > 0 ? "up" : "down"];
|
||
return { say: o.say, sets: o.sets, d };
|
||
}
|
||
function kettleOk() {
|
||
const src = kettleSource();
|
||
return !src || !!(WZ.kettle && WZ.kettle.sig === kettleSig(src) && WZ.kettle.ok);
|
||
}
|
||
async function readMeter(src) {
|
||
let total = 0;
|
||
for (const [key, sign] of src.read) {
|
||
if (!V[key]) return null;
|
||
const r = await fetch("ha/entities?entity=" + encodeURIComponent(V[key]), { cache: "no-store" })
|
||
.then((x) => (x.ok ? x.json() : null)).catch(() => null);
|
||
if (!r || r.watts == null) return null;
|
||
total += sign * r.watts;
|
||
}
|
||
return total;
|
||
}
|
||
function kettleBox() {
|
||
const src = kettleSource();
|
||
const box = h("div", { class: "f kettle" }, h("div", { class: "lbl" }, WS.kettle.name));
|
||
if (!src) { box.append(h("div", { class: "help" }, WS.kettle.self.description)); return box; }
|
||
const sig = kettleSig(src);
|
||
if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again
|
||
const k = WZ.kettle || { sig };
|
||
box.append(h("div", { class: "help" }, WS.kettle.description));
|
||
// A skip after the reading went down still says it went down (UX-15).
|
||
const said = k.say === "skipped" && k.wrong ? "skipped_down" : k.say;
|
||
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn", skipped_down: "warn" }[said] || "bad";
|
||
const words = said ? WS.kettle[said] : k.before != null ? WS.kettle.before : null;
|
||
if (words) box.append(h("div", { class: "msg " + (k.say ? tone : "info"), role: "status" },
|
||
say(words.description, { w: k.before != null ? watts(k.before, true) : "", d: k.d != null ? watts(k.d) : "" })));
|
||
const busy = (b) => { b.disabled = true; };
|
||
const read = h("button", { type: "button", class: "btn" }, k.before != null && !k.ok ? WS.kettle.again : WS.kettle.read);
|
||
read.addEventListener("click", async () => {
|
||
busy(read);
|
||
const sig = kettleSig(src); // the entity may have changed since drawing
|
||
if (k.sig !== sig) { k.before = null; k.ok = false; k.sig = sig; }
|
||
const w = await readMeter(src);
|
||
if (w == null) WZ.kettle = Object.assign({}, k, { sig, say: "unread" });
|
||
else if (k.before == null || k.ok) WZ.kettle = { sig, before: w, wrong: !k.ok && !!k.wrong };
|
||
else {
|
||
const o = kettleOutcome(src, k.before, w);
|
||
// `wrong`: the reading went the way no option can fix. A skip after
|
||
// that keeps saying so on the last step (review R2).
|
||
const wrong = o.say === "none" ? !!k.wrong : o.sets == null;
|
||
WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null, wrong };
|
||
if (o.sets) { Object.assign(V, o.sets); queueValidate(); }
|
||
}
|
||
drawStep();
|
||
});
|
||
const row = h("div", { class: "row" }, read);
|
||
if (!k.ok) row.append(h("button", { type: "button", class: "btn link", onclick: () => {
|
||
WZ.kettle = { sig: kettleSig(src), say: "skipped", ok: true, skipped: true, wrong: !!k.wrong }; drawStep();
|
||
} }, WS.kettle.skip));
|
||
box.append(row);
|
||
return box;
|
||
}
|
||
|
||
// Step 6: the shadow switch, the checks, and Save and restart.
|
||
// The shadow switch goes only on the model the wizard posts, never into V,
|
||
// which the Installer view saves too (review R3).
|
||
function shadowValue() {
|
||
const s = M.setup.shadow;
|
||
return WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off);
|
||
}
|
||
function posted() { return Object.assign({}, V, { [M.setup.shadow.field]: shadowValue() }); }
|
||
function shadowRow() {
|
||
const lid = "wzs";
|
||
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(WZ.shadowOn), "aria-labelledby": lid });
|
||
b.addEventListener("click", () => { WZ.shadowOn = !WZ.shadowOn; drawStep(); });
|
||
return h("div", { class: "f inline" },
|
||
h("div", {}, h("div", { class: "lbl", id: lid }, WS.shadow.name), h("div", { class: "help" }, WS.shadow.description)),
|
||
h("div", { class: "ctl" }, b));
|
||
}
|
||
async function runChecks() {
|
||
const list = $("#wz-checks");
|
||
if (list) list.replaceChildren(line("info", WS.check.running.description));
|
||
clearTimeout(vtimer);
|
||
const b = await validate({ setup: true, model: posted() });
|
||
WZ.checked = b;
|
||
if (!list || !list.isConnected) return b;
|
||
if (!b) { list.replaceChildren(line("bad", WS.check.failed.description)); return b; }
|
||
const items = [], seen = new Set();
|
||
const add = (tier, text) => { if (!seen.has(text)) { seen.add(text); items.push([tier, text]); } };
|
||
const bad = (b.messages || []).filter((m) => m.tier === "bad");
|
||
if (!bad.length) add("ok", WS.check.pass.description);
|
||
for (const m of bad) add("bad", m.text);
|
||
for (const m of b.setup || []) add(m.tier, m.text);
|
||
if (b.device_note) add("info", b.device_note);
|
||
const src = kettleSource();
|
||
if (!src) add("info", WS.kettle.self.description);
|
||
else if (WZ.kettle && WZ.kettle.skipped) add("warn", (WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped).description);
|
||
else if (kettleOk()) add("ok", WS.kettle.tested.description);
|
||
const rank = { bad: 0, warn: 1, ok: 2, info: 3 };
|
||
items.sort((a, z) => rank[a[0]] - rank[z[0]]);
|
||
list.replaceChildren(...items.map(([t, x]) => line(t, x)));
|
||
WZ.blocked = items.some(([t]) => t === "bad");
|
||
return b;
|
||
}
|
||
function finalBox() {
|
||
const again = h("button", { type: "button", class: "btn", onclick: runChecks }, WS.check.again);
|
||
setTimeout(runChecks, 0);
|
||
return [h("div", { class: "subhead" }, WS.check.name),
|
||
h("ul", { class: "plain checklist small", id: "wz-checks", "aria-live": "polite" }), h("div", { class: "row" }, again),
|
||
shadowRow()];
|
||
}
|
||
function wzBanner(level, title, sub, button, onClick) {
|
||
const host = $("#wz-banner");
|
||
if (!title) { host.replaceChildren(); return; }
|
||
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
|
||
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
|
||
}
|
||
async function finish() {
|
||
const b = await runChecks();
|
||
if (!b) { toast(WS.check.failed.description); return; }
|
||
if (WZ.blocked) { toast(WS.check.blocked.description); return; }
|
||
const shadow = shadowValue() === M.setup.shadow.on;
|
||
// In setup-only mode nothing runs yet, so no inverter is set to 0 W first.
|
||
const dlg = M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live) : (shadow ? WS.dialog : WS.dialog_live);
|
||
ask(dlg, async () => {
|
||
wzBanner();
|
||
let res;
|
||
try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; }
|
||
const { r, b: body } = res;
|
||
if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; }
|
||
if (r.status === 422) {
|
||
MSG = body.messages || []; STEPS = body.steps || {};
|
||
const first = Object.keys(STEPS).map(Number).sort((a, z) => a - z).find((n) => STEPS[n] > 0);
|
||
if (first) WZ.step = first;
|
||
drawStep(); toast(W.toast.blocked.description); firstBad();
|
||
return;
|
||
}
|
||
if (!r.ok) { toast(W.toast.failed); return; }
|
||
let restarted = true;
|
||
try {
|
||
const rr = await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ confirm: "restart" }) });
|
||
restarted = rr.ok;
|
||
} catch (e) { /* the add-on stops answering while it restarts */ }
|
||
WZ.done = restarted ? (shadow ? "done" : "done_live") : "restart_failed";
|
||
drawStep();
|
||
if (!restarted) await load("saved"); // still running: read what is stored
|
||
});
|
||
}
|
||
function drawDone() {
|
||
const w = WS[WZ.done];
|
||
$("#wz-count").textContent = "";
|
||
$("#wz-steps").replaceChildren();
|
||
$("#wz-desc").textContent = "";
|
||
$("#wz-nav").hidden = true;
|
||
ROWS.length = 0;
|
||
if (WZ.done === "restart_failed") wzBanner("warn", w.name, w.description, W.restart, restart);
|
||
else wzBanner("ok", w.name, w.description);
|
||
$("#wz-body").replaceChildren(h("a", { class: "btn primary", href: "#home" }, WS.home));
|
||
}
|
||
function drawStep() {
|
||
if (!M) return;
|
||
if (WZ.done) { drawDone(); return; }
|
||
const S = M.setup, n = WZ.step, words = WS.steps[n];
|
||
$("#wz-nav").hidden = false;
|
||
$("#wz-title").textContent = words.name;
|
||
$("#wz-count").textContent = say(WS.step, { n, total: S.steps });
|
||
$("#wz-steps").replaceChildren(...Array.from({ length: S.steps }, (_x, i) =>
|
||
h("i", { class: i + 1 < n ? "done" : i + 1 === n ? "cur" : null })));
|
||
$("#wz-desc").textContent = words.description;
|
||
ROWS.length = 0;
|
||
const body = [];
|
||
if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description));
|
||
for (const f of allFields()) if (f.wizard === n && f.key !== S.shadow.field) body.push(fieldRow(f, null, "wz"));
|
||
if (n === 1) body.push(kettleBox());
|
||
if (n === 5) body.push(h("p", { class: "small muted" }, WS.modes_later.description));
|
||
if (n === S.final) body.push(...finalBox());
|
||
$("#wz-body").replaceChildren(...body);
|
||
$("#wz-back").textContent = n === 1 ? WS.not_now : WS.back;
|
||
$("#wz-next").textContent = n === S.final ? WS.finish : S.optional.includes(n) ? WS.skip : WS.next;
|
||
paint();
|
||
}
|
||
$("#wz-back").addEventListener("click", () => {
|
||
if (WZ.step === 1) { location.hash = "#home"; return; }
|
||
WZ.step -= 1; drawStep(); window.scrollTo(0, 0);
|
||
});
|
||
$("#wz-next").addEventListener("click", async () => {
|
||
if (!M) return;
|
||
const btn = $("#wz-next");
|
||
btn.disabled = true;
|
||
try {
|
||
const n = WZ.step;
|
||
if (n === M.setup.final) { await finish(); return; }
|
||
clearTimeout(vtimer);
|
||
const b = await validate();
|
||
if (!b) { toast(W.toast.failed); return; }
|
||
if (n === 1 && !kettleOk()) { toast(WS.kettle.needed.description); return; }
|
||
if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); firstBad(); return; }
|
||
WZ.step = n + 1; drawStep(); window.scrollTo(0, 0);
|
||
} finally { btn.disabled = false; }
|
||
});
|
||
|
||
document.addEventListener("ux13:tab", (e) => {
|
||
view = e.detail === "settings" || e.detail === "setup" ? e.detail : null;
|
||
if (view && !M) load(); else if (view) { redrawView(); validate(); } else savebar();
|
||
});
|
||
// app.js chose the first tab before this file ran.
|
||
const sel = document.querySelector(".tab[aria-selected=true]");
|
||
if (sel && (sel.dataset.hash === "settings" || sel.dataset.hash === "setup")) { view = sel.dataset.hash; load(); }
|
||
// First run: opened with no route, on a site the add-on says is new
|
||
// (uipage.setup_needed), the page goes to Setup by itself.
|
||
else if (window.UX13.first) {
|
||
load().then(() => { if (M && M.setup_needed && location.hash === "#home") location.hash = "#setup"; });
|
||
}
|
||
})();
|