import { notifyError } from './errors.mjs'; import { pageReloadIn } from './core.mjs'; import { send, sendAction, configUrl } from './connection.mjs'; import { validateForms } from './validate.mjs'; // ?) /** * @param {HTMLSelectElement} select */ function stringifySelectedValues(select) { if (select.multiple) { return Array.from(select.selectedOptions) .map(option => option.value) .join(","); } else if (select.selectedIndex >= 0) { return select.options[select.selectedIndex].value; } return select.dataset["original"]; } /** * @param {HTMLElement} elem */ export function isChangedElement(elem) { return stringToBoolean(elem.dataset["changed"] ?? ""); } /** * @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) { elem.dataset["settingsGroupPending"] = ""; } // TODO: note that we also include kv schema as 'data-settings-schema' on the container. // produce a 'set' and compare instead of just matching length? /** * @param {string[]} source * @param {string[]} schema * @returns {{[k: string]: string}} */ export function fromSchema(source, schema) { if (schema.length !== source.length) { throw `Schema mismatch! Expected length ${schema.length} vs. ${source.length}`; } /** @type {{[k: string]: string}} */ let target = {}; schema.forEach((key, index) => { target[key] = source[index]; }); return target; } // 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.tagName !== "SELECT") && (elem.tagName !== "INPUT")) { continue; } if (isIgnoredElement(elem)) { continue; } const name = elem.dataset.settingsRealName || elem.name; if (name === undefined) { continue; } 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) || !options.changed) && !elem_indexed) { changed_data.push(group_element ? group_name : name); } data[group_element ? group_name : name] = value; } } } // Finally, filter out only fields that *must* be assigned. const resulting_data = { set: { }, del: [ ] }; for (const name in data) { if (!options.changed || (changed_data.indexOf(name) >= 0)) { resulting_data.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 (options.cleanup) { for (let elem of document.getElementsByClassName("settings-group")) { for (let pair of getGroupPending(elem)) { const [action, index] = pair.split(":"); if (action === "del") { const keysRaw = elem.dataset["settingsSchema"] || elem.dataset["settingsTarget"]; const keys = !keysRaw ? [] : keysRaw.split(" "); keys.forEach((key) => { resulting_data.del.push(`${key}${index}`); }); } } } } return resulting_data; } // 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 /** @typedef {boolean | number | string | null} ElementValue */ /** * @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 !isNaN(elem.valueAsNumber) ? elem.valueAsNumber : null; } } 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} */ function getOriginalForElement(elem) { const original = elem.dataset["original"]; if (original === undefined) { return null; } if (elem instanceof HTMLInputElement) { switch (elem.type) { case "radio": case "text": case "password": case "hidden": return original; case "checkbox": return stringToBoolean(original); case "number": case "range": return parseInt(original); } } else if (elem instanceof HTMLSelectElement) { 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 {HTMLElement} elem * @returns {string[]} */ function settingsTargets(elem) { let targets = elem.dataset["settingsTarget"]; if (!targets) { return []; } return targets.split(" "); } /** * @param {string} value * @returns {boolean} */ function stringToBoolean(value) { return [ "1", "y", "yes", "true", "on", ].includes(value.toLowerCase()); } /** * @param {boolean} value * @returns {string} */ function booleanToString(value) { return value ? "true" : "false"; } // 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 | undefined} action */ countFor(count, action) { this.counters.changed += count; if (typeof action === "string") { switch (action) { case "reboot": case "reload": case "reconnect": this.counters[action] += count; break; } } } /** * @param {string | undefined} action */ increment(action) { this.countFor(1, action); } /** * @param {string | undefined} action */ decrement(action) { 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; } } /** * Handle plain kv pairs when they are already on the page, and don't need special template handlers * Notice that uses a custom data attribute data-key=..., instead of name=... * @param {string} key * @param {ElementValue} value */ export function initGenericKeyValueElement(key, value) { for (const span of document.querySelectorAll(`span[data-key='${key}']`)) { setSpanValue(span, value); } const inputs = []; for (const elem of document.querySelectorAll(`[name='${key}'`)) { if (elem instanceof HTMLInputElement) { setInputValue(elem, value); inputs.push(elem); } else if (elem instanceof HTMLSelectElement) { setSelectValue(elem, value); inputs.push(elem); } } setOriginalsFromValues(inputs); } const Settings = new SettingsBase(); /** * @param {Event} event */ export function onElementChange(event) { const target = event.target; if (!(target instanceof HTMLElement)) { return; } if (!isInputOrSelect(target)) { return; } const action = target.dataset["action"]; if ("none" === action) { return; } const different = getOriginalForElement(target) !== getDataForElement(target); const changed = isChangedElement(target); if (different) { if (!changed) { ++Settings.counters.changed; if (action in Settings.counters) { ++Settings.counters[action]; } } setChangedElement(event.target); greenifySave(); } else { if (changed) { --Settings.counters.changed; if (action in Settings.counters) { --Settings.counters[action]; } } resetChangedElement(target); greyoutSave(); } } /** * @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") { initGenericKeyValueElement(key, value); } } function greyoutSave() { const elems = document.querySelectorAll(".button-save"); for (let elem of elems) { if (elem instanceof HTMLElement) { elem.style.removeProperty("--save-background"); } } } function greenifySave() { const elems = document.querySelectorAll(".button-save"); for (let elem of elems) { if (elem instanceof HTMLElement) { elem.style.setProperty("--save-background", "rgb(0, 192, 0)"); } } } function resetOriginals() { setOriginalsFromValues([]); 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(); greyoutSave(); } function waitForSaved(){ if (!Settings.saved) { setTimeout(waitForSaved, 1000); } else { afterSaved(); } } /** @param {any} settings */ export function applySettings(settings) { send(JSON.stringify({settings})); } export function applySettingsFromAllForms() { const elems = /** @type {NodeListOf} */(document.querySelectorAll("form.form-settings")); const forms = Array.from(elems); if (validateForms(forms)) { applySettings(getData(forms)); Settings.resetChanged(); waitForSaved(); } return false; } /** @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 { var data = JSON.parse(event.target.result); sendAction("restore", data); } catch (e) { notifyError(null, null, 0, 0, e); } }; reader.readAsText(inputFile); } /** @returns {boolean} */ export function pendingChanges() { return Settings.counters.changed > 0; } /** @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", (event) => { event.preventDefault(); applySettingsFromAllForms(); }); document.querySelector(".button-settings-backup") ?.addEventListener("click", (event) => { event.preventDefault(); const config = configUrl(); if (!config) { return; } const elem = document.getElementById("downloader"); if (elem instanceof HTMLAnchorElement) { elem.href = config.href; elem.click(); } }); document.querySelector(".button-settings-restore") ?.addEventListener("click", () => { document.getElementById("uploader")?.click(); }); document.querySelector(".button-settings-factory") ?.addEventListener("click", resetToFactoryDefaults); document.querySelectorAll(".button-add-settings-group") .forEach((elem) => { elem.addEventListener("click", groupSettingsAdd); }); // 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(); }