import { notifyError } from './errors.mjs'; import { count, pageReloadIn, showPanelByName, } from './core.mjs'; import { send, sendAction, listenAppConnected, } from './connection.mjs'; import { validateForms } from './validate.mjs'; /** * @param {HTMLElement} elem */ export function isChangedElement(elem) { return stringToBoolean(elem.dataset["changed"] ?? ""); } /** * @param {Element} node */ export function getElements(node) { return /** @type {Array} */( Array.from(node.querySelectorAll( "input[data-changed],select[data-changed]"))); } /** * @param {Element} node */ export function countChangedElements(node) { return count(getElements(node), isChangedElement); } /** * @param {HTMLElement} elem */ export function setChangedElement(elem) { elem.dataset["changed"] = "true"; } /** * @param {HTMLElement} elem */ export function resetChangedElement(elem) { elem.dataset["changed"] = "false"; } /** * @param {HTMLElement} elem */ function resetGroupPending(elem) { delete elem.dataset["settingsGroupPending"]; } // Right now, group additions happen from: // - WebSocket, likely to happen exactly once per connection through processData handler(s). Specific keys trigger functions that append into the container element. // - User input. Same functions are triggered, but with an additional event for the container element that causes most recent element to be marked as changed. // Removal only happens from user input by triggering 'settings-group-del' from the target element. // // TODO: distinguish 'current' state to avoid sending keys when adding and immediatly removing the latest node? // TODO: previous implementation relied on defaultValue and / or jquery $(...).val(), but this does not really work where 'line' only has can be found as both individual elements and as a `RadioNodeList` view. // matching will extract the specific radio element, but will ignore the list b/c it has no tagName // TODO: actually use type="radio" in the WebUI to check whether this works for (let form of forms) { for (let elem of form.elements) { if (!(elem instanceof HTMLInputElement) && !(elem instanceof HTMLSelectElement)) { continue; } if (isIgnoredElement(elem)) { continue; } if (elem instanceof HTMLInputElement && elem.readOnly) { continue; } const name = elem.dataset["settingsRealName"] || elem.name; if (!name) { continue; } // explicitly attributed, but *could* be determined implicitly by `form.elements[elem.name]` or `FormData::getAll(elem.name)` returing a list // - https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements // - https://developer.mozilla.org/en-US/docs/Web/API/FormData/getAll // ts-lint would trigger false positive, though (at least with current version of lib-dom) // - https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1009 const group_element = isGroupElement(elem); const group_index = group_counter[name] || 0; const group_name = `${name}${group_index}`; if (group_element) { group_counter[name] = group_index + 1; } const value = getDataForElement(elem); if (null !== value) { const elem_indexed = changed_data.indexOf(name) >= 0; if ((isChangedElement(elem) || assumeChanged) && !elem_indexed) { changed_data.push(group_element ? group_name : name); } // whenever group is involved, make sure its name is used instead const data_name = group_element ? group_name : name; // fixing outgoing data, when it is necessary data[data_name] = maybeAdjustDataValue(value); } } } // Finally, filter out only fields that *must* be assigned. for (const name of Object.keys(data)) { if (assumeChanged || (changed_data.indexOf(name) >= 0)) { out.set[name] = data[name]; } } // Make sure to remove dynamic group entries from the kvs // Only group keys can be removed atm, so only process .settings-group if (cleanup) { for (let form of forms) { out.del.push(...groupSettingsCleanup(form, Object.keys(out.set))); } } return out; } // TODO: is a special beast, since the actual value is one of 'checked' elements with the same name=... attribute. // Right now, WebUI does not use this kind of input, but in case it does this needs a once-over that the actual input value is picked up correctly through all of changed / original comparisons. // Not all of available forms are used for settings: // - terminal input, which is implemented with an input field. it is attributed with `action="none"`, so settings handler never treats it as 'changed' // - initial setup. it is shown programatically, but is still available from the global list of forms /** * generic value type to set to or get from an element. usually an editable type, like input or select * @typedef {boolean | number | string | null} ElementValue */ /** * generic value to be set to an element. usually cannot be edited after setting, expected to be updated from the device side * @typedef {ElementValue | ElementValue[]} DisplayValue */ /** * @param {InputOrSelect} elem * @returns {ElementValue} */ export function getDataForElement(elem) { if (elem instanceof HTMLInputElement) { switch (elem.type) { case "checkbox": return elem.checked; case "radio": if (elem.checked) { return elem.value; } return null; case "text": case "password": case "hidden": return elem.value; case "number": case "range": return elem.valueAsNumber; } } else if (elem instanceof HTMLSelectElement) { if (elem.multiple) { return bitsetForSelectElement(elem); } else if (elem.selectedIndex >= 0) { const option = elem.options[elem.selectedIndex]; if (option.disabled) { return null; } return option.value; } } return null; } /** * @param {InputOrSelect} elem * @returns {ElementValue} */ export function getOriginalForElement(elem) { const original = elem.dataset["original"]; if (elem instanceof HTMLInputElement) { switch (elem.type) { case "radio": case "text": case "password": case "hidden": return original ?? ""; case "checkbox": return (original !== undefined) ? stringToBoolean(original) : false; case "number": case "range": return (original !== undefined) ? parseInt(original) : NaN; } } else if (elem instanceof HTMLSelectElement) { if (original === undefined) { return ""; } else if (elem.multiple) { return bitsetFromSelectedValues(original.split(",")); } else { return original; } } return null; } function resetSettingsGroup() { const elems = document.getElementsByClassName("settings-group"); for (let elem of elems) { if (!(elem instanceof HTMLElement)) { continue; } resetChangedElement(elem); resetGroupPending(elem); } } /** * @param {string} value * @returns {boolean} */ function stringToBoolean(value) { return [ "1", "y", "yes", "true", "on", ].includes(value.toLowerCase()); } /** * @param {HTMLSelectElement} select * @returns {string[]} */ function selectedValues(select) { if (select.multiple) { return Array.from(select.selectedOptions) .map(option => option.value); } else if (select.selectedIndex >= 0) { return [select.options[select.selectedIndex].value]; } return []; } // When receiving / returning data, options for know entities * @typedef {{id: number, name: string}} EnumerableEntry */ /** @type {{[k: string]: EnumerableEntry[]}} */ const Enumerable = {}; // element on the page // // Notice that values, count total number of changes and their side-effects / needed actions class SettingsBase { constructor() { this.counters = { changed: 0, reboot: 0, reconnect: 0, reload: 0, }; this.saved = false; } /** * @param {number} count * @param {string?} action */ countFor(count, action = null) { this.counters.changed += count; if (typeof action === "string") { switch (action) { case "reboot": case "reload": case "reconnect": this.counters[action] += count; break; } } } /** * @param {string?} action */ increment(action = null) { this.countFor(1, action); } /** * @param {string?} action */ decrement(action = null) { this.countFor(-1, action); } resetChanged() { this.counters.changed = 0; } resetCounters() { this.counters.changed = 0; this.counters.reboot = 0; this.counters.reconnect = 0; this.counters.reload = 0; } stylizeSave() { const changed = this.counters.changed !== 0; document.querySelectorAll(".button-save") .forEach((elem) => { if (!(elem instanceof HTMLElement)) { return; } if (changed) { elem.style.setProperty("--save-background", "rgb(0, 192, 0)"); } else { elem.style.removeProperty("--save-background"); } }); } } /** * read-only kv pairs. currently, this is span with a data-key=$key * @param {Document | Element} node * @param {string} key * @param {DisplayValue} value */ export function setSpanValueByKey(node, key, value) { for (const span of node.querySelectorAll(`span[data-key='${key}']`)) { if (!(span instanceof HTMLSpanElement)) { continue; } setSpanValue(span, value); } } /** * @param {InputOrSelect} elem * @param {ElementValue} value */ function setInputOrSelect(elem, value) { if (elem instanceof HTMLInputElement) { setInputValue(elem, value); } else if (elem instanceof HTMLSelectElement) { setSelectValue(elem, value); } } /** * handle plain kv pairs when they are already on the page, and don't need special template handlers * @param {Document | Element} node * @param {string} key * @param {ElementValue} value */ export function setInputOrSelectValueByKey(node, key, value) { const inputs = []; for (const elem of node.querySelectorAll(`[name='${key}'`)) { if ((elem instanceof HTMLInputElement) || (elem instanceof HTMLSelectElement)) { if (isGroupElement(elem)) { continue; } setInputOrSelect(elem, value); inputs.push(elem); } } setOriginalsFromValues(inputs); } const Settings = new SettingsBase(); /** * @param {InputOrSelect} elem * @returns {boolean} */ function checkElementChanged(elem) { const lhs = getOriginalForElement(elem); const rhs = getDataForElement(elem); if (typeof lhs === "number" && typeof rhs === "number" && isNaN(lhs) && isNaN(rhs)) { return false; } return lhs !== rhs; } /** * @param {InputOrSelect} elem * @returns {boolean} */ export function checkAndSetElementChanged(elem) { const changed = isChangedElement(elem); if (checkElementChanged(elem)) { setChangedElement(elem); } else { resetChangedElement(elem); } return changed !== isChangedElement(elem); } /** * @param {Event} event */ export function onElementChange(event) { const target = event.target; if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLSelectElement)) { return; } if (target instanceof HTMLInputElement && target.readOnly) { return; } const action = target.dataset["action"]; if ("none" === action) { return; } if (!checkAndSetElementChanged(target)) { return; } if (isChangedElement(target)) { Settings.increment(action); } else { Settings.decrement(action); } if (target.required) { target.reportValidity(); } Settings.stylizeSave(); } /** * @typedef {function(string, any): void} KeyValueListener */ /** * @typedef {{[k: string]: KeyValueListener}} KeyValueListeners */ /** * @type {{[k: string]: KeyValueListener[]}} */ const __variable_listeners = {}; /** * @param {string} key * @param {KeyValueListener} func */ export function listenVariables(key, func) { if (__variable_listeners[key] === undefined) { __variable_listeners[key] = []; } __variable_listeners[key].push(func); } /** * @param {KeyValueListeners} listeners */ export function variableListeners(listeners) { for (const [key, listener] of Object.entries(listeners)) { listenVariables(key, listener); } } /** * @param {string} key * @param {any} value */ export function updateKeyValue(key, value) { const listeners = __variable_listeners[key]; if (listeners !== undefined) { for (let listener of listeners) { listener(key, value); } } if (typeof value === "object") { return; } setSpanValueByKey(document, key, value); setInputOrSelectValueByKey(document, key, value); } function resetOriginals() { setOriginalsFromValuesForNode(document.documentElement); resetSettingsGroup(); Settings.resetCounters(); Settings.saved = false; } function afterSaved() { let response = false; if (Settings.counters.reboot > 0) { response = window.confirm("You have to reboot the board for the changes to take effect, do you want to do it now?"); if (response) { sendAction("reboot"); } } else if (Settings.counters.reconnect > 0) { response = window.confirm("You have to reconnect to the WiFi for the changes to take effect, do you want to do it now?"); if (response) { sendAction("reconnect"); } } else if (Settings.counters.reload > 0) { response = window.confirm("You have to reload the page to see the latest changes, do you want to do it now?"); if (response) { pageReloadIn(0); } } resetOriginals(); Settings.stylizeSave(); } function waitForSaved(){ if (!Settings.saved) { setTimeout(waitForSaved, 1000); } else { afterSaved(); } } /** @param {any} settings */ export function applySettings(settings) { send(JSON.stringify({settings})); } /** @param {HTMLFormElement[]} forms */ export function applySettingsFromForms(forms) { applySettings(getData(forms)); Settings.resetChanged(); waitForSaved(); } /** @param {Event} event */ function applySettingsFromAllForms(event) { event.preventDefault(); const elems = /** @type {NodeListOf} */ (document.querySelectorAll("form.form-settings")); const forms = Array.from(elems); if (validateForms(forms)) { applySettingsFromForms(forms); } } /** @param {Event} event */ function resetToFactoryDefaults(event) { event.preventDefault(); if (window.confirm("Are you sure you want to erase all settings from the device?")) { sendAction("factory_reset"); } } /** @param {Event} event */ function handleSettingsFile(event) { event.preventDefault(); const target = event.target; if (!(target instanceof HTMLInputElement)) { return; } const inputFiles = target.files; if (!inputFiles || inputFiles.length === 0) { return false; } const inputFile = inputFiles[0]; target.value = ""; if (!window.confirm("Previous settings will be overwritten. Are you sure you want to restore from this file?")) { return false; } const reader = new FileReader(); reader.onload = function(event) { try { const data = event.target?.result; if (!data) { throw new Error(`${event.target} is missing data payload`); } if (data instanceof ArrayBuffer) { throw new Error("invalid payload type - ArrayBuffer"); } sendAction("restore", JSON.parse(data)); } catch (e) { notifyError(/** @type {Error} */(e)); } }; reader.readAsText(inputFile); } /** @returns {boolean} */ export function pendingChanges() { return Settings.counters.changed > 0; } /** @type {import("./question.mjs").QuestionWrapper} */ export function askSaveSettings(ask) { if (pendingChanges()) { return ask("There are pending changes to the settings, continue the operation without saving?"); } return true; } /** @returns {KeyValueListeners} */ function listeners() { return { "saved": (_, value) => { Settings.saved = value; }, }; } /** @param {{[k: string]: any}} kvs */ export function updateVariables(kvs) { Object.entries(kvs) .forEach(([key, value]) => { updateKeyValue(key, value); }); } export function init() { variableListeners(listeners()); document.getElementById("uploader") ?.addEventListener("change", handleSettingsFile); document.querySelector(".button-save") ?.addEventListener("click", applySettingsFromAllForms); const backup = document.querySelector(".button-settings-backup"); if (backup instanceof HTMLButtonElement) { listenAppConnected((urls) => { backup.dataset["url"] = urls.config.href; }); backup.addEventListener("click", (event) => { event.preventDefault(); const url = backup.dataset["url"]; if (!url) { alert("Not connected"); return; } const elem = document.getElementById("downloader"); if (elem instanceof HTMLAnchorElement) { elem.href = url; elem.click(); } }); } document.querySelector(".button-settings-restore") ?.addEventListener("click", () => { document.getElementById("uploader")?.click(); }); document.querySelector(".button-settings-factory") ?.addEventListener("click", resetToFactoryDefaults); document.querySelector(".button-settings-password") ?.addEventListener("click", () => { showPanelByName("password"); }); document.querySelectorAll(".button-add-settings-group") .forEach((elem) => { elem.addEventListener("click", onGroupSettingsAddClick); }); document.querySelectorAll("[data-enumerable]") .forEach((elem) => { if (!(elem instanceof HTMLElement)) { return; } listenEnumerable(elem); }); // No group handler should be registered after this point, since we depend on the order // of registration to trigger 'after-add' handler and update group attributes *after* // module function finishes modifying the container for (const group of document.querySelectorAll(".settings-group")) { group.addEventListener("settings-group-del", onGroupSettingsEventDel); group.addEventListener("change", onElementChange); } for (let elem of document.querySelectorAll("input,select")) { elem.addEventListener("change", onElementChange); } resetOriginals(); }