mirror of
https://github.com/xoseperez/espurna.git
synced 2026-03-05 16:04:19 +01:00
1222 lines
32 KiB
JavaScript
1222 lines
32 KiB
JavaScript
import { notifyError } from './errors.mjs';
|
|
import { pageReloadIn } from './core.mjs';
|
|
import { send, sendAction, configUrl } from './connection.mjs';
|
|
import { validateForms } from './validate.mjs';
|
|
|
|
// <select data-original="..."> is read / saved as:
|
|
// - multiple=false -> value string of the selected option
|
|
// - multiple=true -> comma-separated values of all selected options
|
|
//
|
|
// If selectedIndex is -1, it means we never selected anything
|
|
// (TODO: could this actually happen with anything other than empty <select>?)
|
|
|
|
/**
|
|
* @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 <select>
|
|
|
|
/**
|
|
* @typedef {HTMLInputElement | HTMLSelectElement} InputOrSelect
|
|
* @typedef {{element: InputOrSelect, key: string, value: ElementValue}} GroupElementInfo
|
|
*
|
|
* @param {Element} target
|
|
* @returns {GroupElementInfo[]}
|
|
*/
|
|
function groupElementInfo(target) {
|
|
/** @type {GroupElementInfo[]} */
|
|
const out = [];
|
|
|
|
/** @type {NodeListOf<InputOrSelect>} */
|
|
const inputs = target.querySelectorAll("input,select");
|
|
|
|
inputs.forEach((elem) => {
|
|
const name = elem.dataset.settingsRealName || elem.name;
|
|
if (name === undefined) {
|
|
return;
|
|
}
|
|
|
|
const value = getOriginalForElement(elem) ?? getDataForElement(elem);
|
|
if (null === value) {
|
|
return;
|
|
}
|
|
|
|
out.push({
|
|
element: elem,
|
|
key: name,
|
|
value: value,
|
|
});
|
|
});
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} elem
|
|
* @returns {string[]}
|
|
*/
|
|
function getGroupPending(elem) {
|
|
const raw = elem.dataset["settingsGroupPending"] || "";
|
|
if (!raw.length) {
|
|
return [];
|
|
}
|
|
|
|
return raw.split(",");
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} elem
|
|
* @param {number} index
|
|
*/
|
|
function addGroupPending(elem, index) {
|
|
const pending = getGroupPending(elem);
|
|
pending.push(`set:${index}`);
|
|
elem.dataset["settingsGroupPending"] = pending.join(",");
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} elem
|
|
* @param {number} index
|
|
*/
|
|
function popGroupPending(elem, index) {
|
|
const pending = getGroupPending(elem);
|
|
|
|
const added = pending.indexOf(`set:${index}`);
|
|
if (added >= 0) {
|
|
pending.splice(added, 1);
|
|
} else {
|
|
pending.push(`del:${index}`);
|
|
}
|
|
|
|
elem.dataset["settingsGroupPending"] = pending.join(",");
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} elem
|
|
*/
|
|
function isGroupElement(elem) {
|
|
return elem.dataset["settingsGroupElement"] !== undefined;
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} elem
|
|
*/
|
|
function isIgnoredElement(elem) {
|
|
return elem.dataset["settingsIgnore"] !== undefined;
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} elem
|
|
*/
|
|
function isInputOrSelect(elem) {
|
|
return (elem instanceof HTMLInputElement) || (elem instanceof HTMLSelectElement);
|
|
}
|
|
|
|
/**
|
|
* to 'instantiate' a new element, we must explicitly set 'target' keys in kvs
|
|
* notice that the 'row' creation *should* be handled by the group-specific
|
|
* event listener, we already expect the dom element to exist at this point
|
|
* @param {Event} event
|
|
*/
|
|
function onGroupSettingsEventAdd(event) {
|
|
const group = event.target;
|
|
const index = group.children.length - 1;
|
|
const last = group.children[index];
|
|
addGroupPending(group, index);
|
|
|
|
for (const target of settingsTargets(group)) {
|
|
/** @type {HTMLElement | null} */
|
|
const elem = last.querySelector(`[name='${target}']`);
|
|
if (elem) {
|
|
setChangedElement(elem);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* removing the element means we need to notify the kvs about the updated keys
|
|
* in case it's the last row, just remove those keys from the store
|
|
* in case we are in the middle, make sure to handle difference update
|
|
* in case change was 'ephemeral' (i.e. from the previous add that was not saved), do nothing
|
|
* @param {Event} event
|
|
*/
|
|
function onGroupSettingsEventDel(event) {
|
|
const group = event.currentTarget;
|
|
if (!(group instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const elems = Array.from(group.children);
|
|
const shiftFrom = elems.indexOf(group);
|
|
|
|
const info = elems.map(groupElementInfo);
|
|
for (let index = -1; index < info.length; ++index) {
|
|
const prev = (index > 0)
|
|
? info[index - 1]
|
|
: null;
|
|
const current = info[index];
|
|
|
|
if ((index > shiftFrom) && prev && (prev.length === current.length)) {
|
|
for (let inner = 0; inner < prev.length; ++inner) {
|
|
const [lhs, rhs] = [prev[inner], current[inner]];
|
|
if (lhs.value !== rhs.value) {
|
|
setChangedElement(rhs.element);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (elems.length) {
|
|
popGroupPending(group, elems.length - 1);
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
event.target.remove();
|
|
}
|
|
|
|
// 'settings-group' contain elements that represent kv list that is suffixed with an index in raw kvs
|
|
// 'button-add-settings-group' will trigger update on the specified 'data-settings-group' element id, which
|
|
// needs to have 'settings-group-add' event handler attached to it.
|
|
|
|
/**
|
|
* @param {Event} event
|
|
* @returns {boolean}
|
|
*/
|
|
function groupSettingsCheckMax(event) {
|
|
const max = event.target.dataset["settingsMax"];
|
|
const val = 1 + event.target.children.length;
|
|
|
|
if ((max !== undefined) && (parseInt(max, 10) < val)) {
|
|
alert(`Max number of ${event.target.id} has been reached (${val} out of ${max})`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param {string} elementId
|
|
* @param {EventListener} listener
|
|
*/
|
|
export function groupSettingsOnAdd(elementId, listener) {
|
|
document.getElementById(elementId)
|
|
.addEventListener("settings-group-add", (event) => {
|
|
event.stopPropagation();
|
|
if (!groupSettingsCheckMax(event)) {
|
|
return;
|
|
}
|
|
|
|
listener(event);
|
|
onGroupSettingsEventAdd(event);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {Event} event
|
|
*/
|
|
export function onGroupSettingsDel(event) {
|
|
let target = event.target;
|
|
let parent = target.parentElement;
|
|
|
|
while (!parent.classList.contains("settings-group")) {
|
|
target = parent;
|
|
parent = target.parentElement;
|
|
}
|
|
|
|
target.dispatchEvent(
|
|
new CustomEvent("settings-group-del", {bubbles: true}));
|
|
}
|
|
|
|
/**
|
|
* handle addition to the group using the 'add' button
|
|
* @param {Event} event
|
|
*/
|
|
function groupSettingsAdd(event) {
|
|
const prefix = "settingsGroupDetail";
|
|
const elem = event.target;
|
|
|
|
/** @type {{detail: {[k: string]: string}}} */
|
|
let eventInit = {detail: {}};
|
|
|
|
for (let [key, value] of Object.entries(elem.dataset)) {
|
|
if (!key.startsWith(prefix)) {
|
|
continue;
|
|
}
|
|
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
|
|
let eventKey = key.replace(prefix, "");
|
|
eventKey = eventKey[0].toLowerCase() + eventKey.slice(1);
|
|
|
|
eventInit.detail[eventKey] = value;
|
|
}
|
|
|
|
document.getElementById(name)
|
|
?.dispatchEvent(new CustomEvent("settings-group-add", eventInit));
|
|
}
|
|
|
|
export function getData(forms, options) {
|
|
// Populate two sets of data, ones that had been changed and ones that stayed the same
|
|
if (options === undefined) {
|
|
options = {};
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* besides gathering the data, func is expected to also provide
|
|
* - del: 'cleanup' keys, usually from setting groups that marked certain keys for deletion
|
|
* - set: kvs only for 'changed' keys
|
|
* @typedef {{cleanup?: boolean, changed?: boolean}} GetDataOptions
|
|
*/
|
|
|
|
/**
|
|
* kvs for the device settings storage
|
|
* @typedef {string | number} DataValue
|
|
* @typedef {{[k: string]: DataValue}} SetRequest
|
|
*/
|
|
|
|
/**
|
|
* specific 'key' string to remove from the device settings storage
|
|
* @typedef {string} DelRequest
|
|
*/
|
|
|
|
/**
|
|
* usually, settings request is sent as a single object
|
|
* @typedef {{set: SetRequest, del: DelRequest[]}} DataRequest
|
|
*/
|
|
|
|
/**
|
|
* populates two sets of data, ones that had been changed and ones that stayed the same
|
|
* @param {HTMLFormElement[]} forms
|
|
* @param {GetDataOptions} options
|
|
*/
|
|
const data = {};
|
|
const changed_data = [];
|
|
if (options.cleanup === undefined) {
|
|
options.cleanup = true;
|
|
}
|
|
|
|
if (options.changed === undefined) {
|
|
options.changed = true;
|
|
}
|
|
|
|
const group_counter = {};
|
|
|
|
// TODO: <input type="radio"> 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: <input type="radio"> 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, <select multiple=true> <option> values are treated as bitset (u32) indexes (i.e. individual bits that are set)
|
|
// For example 0b101 is translated to ["0", "2"], or 0b1111 is translated to ["0", "1", "2", "3"]
|
|
// Right now only `hbReport` uses such format, but it is not yet clear how such select element should behave when value is not an integer
|
|
|
|
/**
|
|
* @param {number} bitset
|
|
* @returns {string[]}
|
|
*/
|
|
function bitsetToSelectedValues(bitset) {
|
|
let values = [];
|
|
for (let index = 0; index < 31; ++index) {
|
|
if (bitset & (1 << index)) {
|
|
values.push(index.toString());
|
|
}
|
|
}
|
|
|
|
return values;
|
|
}
|
|
|
|
/**
|
|
* @param {string[]} values
|
|
* @returns {number}
|
|
*/
|
|
function bitsetFromSelectedValues(values) {
|
|
let result = 0;
|
|
for (let value of values) {
|
|
result |= 1 << parseInt(value);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLSelectElement} select
|
|
* @returns {number}
|
|
*/
|
|
function bitsetForSelectElement(select) {
|
|
let values = [];
|
|
for (let option of select.selectedOptions) {
|
|
values.push(option.value);
|
|
}
|
|
|
|
return bitsetFromSelectedValues(values);
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLInputElement} input
|
|
* @param {ElementValue} value
|
|
*/
|
|
export function setInputValue(input, value) {
|
|
switch (input.type) {
|
|
case "radio":
|
|
input.checked = (value === input.value);
|
|
break;
|
|
case "checkbox":
|
|
input.checked =
|
|
(typeof(value) === "boolean") ? value :
|
|
(typeof(value) === "string") ? stringToBoolean(value) :
|
|
(typeof(value) === "number") ? (value !== 0) : false;
|
|
break;
|
|
case "text":
|
|
case "password":
|
|
case "number":
|
|
if (value !== null) {
|
|
input.value = value.toString();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLSpanElement} span
|
|
* @param {ElementValue} value
|
|
*/
|
|
export function setSpanValue(span, value) {
|
|
if (Array.isArray(value)) {
|
|
value.forEach((text) => {
|
|
setSpanValue(span, text);
|
|
span.appendChild(document.createElement("br"));
|
|
});
|
|
} else {
|
|
if (typeof(value) === "string") {
|
|
value = span.dataset[`value${value.toUpperCase()}`] || value;
|
|
}
|
|
|
|
let content = "";
|
|
if (span.attributes.pre) {
|
|
content += span.attributes.pre.value;
|
|
}
|
|
content += value;
|
|
if (span.attributes.post) {
|
|
content += span.attributes.post.value;
|
|
}
|
|
span.textContent = content;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLSelectElement} select
|
|
* @param {ElementValue} value
|
|
*/
|
|
export function setSelectValue(select, value) {
|
|
switch (typeof value) {
|
|
case "string":
|
|
case "number":
|
|
break;
|
|
|
|
default:
|
|
return;
|
|
}
|
|
|
|
const values = (typeof value === "number" && select.multiple)
|
|
? bitsetToSelectedValues(value)
|
|
: [value.toString()];
|
|
|
|
Array.from(select.options)
|
|
.filter((option) => values.includes(option.value))
|
|
.forEach((option) => {
|
|
option.selected = true;
|
|
});
|
|
|
|
select.dataset["original"] = values.join(",");
|
|
}
|
|
|
|
/**
|
|
* @param {Document | HTMLElement} node
|
|
* @param {InputOrSelect[]} elems
|
|
*/
|
|
export function setOriginalsFromValuesForNode(node, elems) {
|
|
if (elems === undefined) {
|
|
elems = [.../** @type {NodeListOf<InputOrSelect>} */(node.querySelectorAll("input,select"))];
|
|
}
|
|
|
|
for (let elem of elems) {
|
|
if (elem instanceof HTMLInputElement) {
|
|
if (elem.type === "checkbox") {
|
|
elem.dataset["original"] = booleanToString(elem.checked);
|
|
} else {
|
|
elem.dataset["original"] = elem.value;
|
|
}
|
|
} else if (elem instanceof HTMLSelectElement) {
|
|
elem.dataset["original"] = stringifySelectedValues(elem);
|
|
}
|
|
|
|
resetChangedElement(elem);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {InputOrSelect[]} elems
|
|
*/
|
|
export function setOriginalsFromValues(elems) {
|
|
setOriginalsFromValuesForNode(document, elems);
|
|
}
|
|
|
|
/**
|
|
* automatically generate <select> options for know entities
|
|
* @typedef {{id: number, name: string}} EnumerableEntry
|
|
* @type {{[k: string]: EnumerableEntry[]}}
|
|
*/
|
|
const Enumerable = {};
|
|
|
|
// <select> initialization from simple {id: ..., name: ...} that map as <option> value=... and textContent
|
|
// To avoid depending on order of incoming messages, always store real value inside of dataset["original"] and provide a way to re-initialize every 'enumerable' <select> element on the page
|
|
//
|
|
// Notice that <select multiple> input and output format is u32 number, but the 'original' string is comma-separated <option> value=... attributes
|
|
|
|
/**
|
|
* @typedef {{id: number, name: string}} SelectValue
|
|
*
|
|
* @param {HTMLSelectElement} select
|
|
* @param {SelectValue[]} values
|
|
*/
|
|
export function initSelect(select, values) {
|
|
for (let value of values) {
|
|
let option = document.createElement("option");
|
|
option.setAttribute("value", value.id.toString());
|
|
option.textContent = value.name;
|
|
select.appendChild(option);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @callback EnumerableCallback
|
|
* @param {HTMLSelectElement} select
|
|
* @param {EnumerableEntry[]} enumerables
|
|
*/
|
|
|
|
/**
|
|
* @param {HTMLSelectElement} select
|
|
* @param {EnumerableCallback} callback
|
|
*/
|
|
export function initEnumerableSelect(select, callback) {
|
|
for (let className of select.classList) {
|
|
const prefix = "enumerable-";
|
|
if (className.startsWith(prefix)) {
|
|
const name = className.replace(prefix, "");
|
|
if ((Enumerable[name] !== undefined) && Enumerable[name].length) {
|
|
callback(select, Enumerable[name]);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
*/
|
|
function refreshEnumerableSelect(name) {
|
|
const selector = (name !== undefined)
|
|
? `select.enumerable.enumerable-${name}`
|
|
: "select.enumerable";
|
|
|
|
for (let select of document.querySelectorAll(selector)) {
|
|
if (!(select instanceof HTMLSelectElement)) {
|
|
break;
|
|
}
|
|
|
|
initEnumerableSelect(select, (_, enumerable) => {
|
|
while (select.childElementCount && select.firstElementChild) {
|
|
select.removeChild(select.firstElementChild);
|
|
}
|
|
|
|
initSelect(select, enumerable);
|
|
|
|
const original = select.dataset["original"];
|
|
if (original) {
|
|
setSelectValue(select, original);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @returns {EnumerableEntry[]}
|
|
*/
|
|
export function getEnumerables(name) {
|
|
return Enumerable[name];
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @param {EnumerableEntry[]} enumerables
|
|
*/
|
|
export function addEnumerables(name, enumerables) {
|
|
Enumerable[name] = enumerables;
|
|
refreshEnumerableSelect(name);
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @param {string} prettyName
|
|
* @param {number} count
|
|
*/
|
|
export function addSimpleEnumerables(name, prettyName, count) {
|
|
if (count) {
|
|
let enumerables = [];
|
|
for (let id = 0; id < count; ++id) {
|
|
enumerables.push({"id": id, "name": `${prettyName} #${id}`});
|
|
}
|
|
|
|
addEnumerables(name, enumerables);
|
|
}
|
|
}
|
|
|
|
// track <input> 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 <span> 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<HTMLFormElement>} */(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();
|
|
}
|