import {
capitalize,
pageReloadIn,
showPanelByName,
stringToBoolean,
} from './core.mjs';
import {
send,
sendAction,
listenAppConnected,
} from './connection.mjs';
import { validateFormsReportValidity, validateFormsPasswords, resetCustomValidity } from './validate.mjs';
import { notifyError } from './notify.mjs';
import {
countChangedElements,
isChangedElement,
isGroupElement,
isIgnoredElement,
setChangedElement,
resetChangedElement,
resetSettingsGroup,
} from './settings/utils.mjs';
/**
* generic value type to set to or get from an element. usually an editable type, like input or select
* @typedef { string | number | boolean | 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
*/
/**
* @typedef { HTMLInputElement | HTMLSelectElement } InputOrSelect
*/
/**
* @typedef {{element: InputOrSelect, key: string, value: ElementValue}} GroupElementInfo
*/
// 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.
/**
* @param {Element} target
* @returns {GroupElementInfo[]}
*/
function groupElementInfo(target) {
/** @type {GroupElementInfo[]} */
const out = [];
findInputOrSelect(target).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
* @param {string[]} pending
*/
function setGroupPending(elem, pending) {
elem.dataset["settingsGroupPending"] =
([...new Set(pending)]).join(" ");
}
/**
* @param {HTMLElement} elem
* @returns {string[]}
*/
function getGroupPending(elem) {
const raw = elem.dataset["settingsGroupPending"] || "";
if (!raw.length) {
return [];
}
return raw.split(" ");
}
/**
* @param {HTMLElement} group
*/
export function groupSettingsAdd(group) {
const index = group.children.length - 1;
const last = group.children[index];
const before = countChangedElements(group);
const pending = getGroupPending(group);
pending.push(`set:${index}`);
setGroupPending(group, pending);
let once = true;
for (let elem of findInputOrSelect(last)) {
if (once && elem.required) {
elem.focus();
elem.reportValidity();
once = false;
}
if (elem.required
&& elem.checkValidity()
&& (getDataForElement(elem) !== null))
{
setChangedElement(elem);
}
}
Settings.countFor(
countChangedElements(group) - before);
Settings.stylizeSave();
}
/**
* 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;
if (!(group instanceof HTMLElement)) {
return;
}
groupSettingsAdd(group);
}
/**
* @param {HTMLElement} group
*/
function delGroupPending(group) {
const top = group.childElementCount - 1;
if (top < 0) {
return;
}
let pending = getGroupPending(group);
const set = pending.indexOf(`set:${top}`);
if (set >= 0) {
pending.splice(set, 1);
} else {
pending.push(`del:${top}`);
}
setGroupPending(group, pending);
}
// TODO: 'current' state is maintaned through element data. track it externally?
/**
* @param {HTMLElement} group
* @param {HTMLElement} target
*/
export function groupSettingsDel(group, target) {
const elems = Array.from(group.children);
const shiftFrom = elems.indexOf(target);
const before = countChangedElements(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);
}
}
}
}
delGroupPending(group);
target.remove();
Settings.countFor(
countChangedElements(group) - before);
Settings.stylizeSave();
}
/**
* 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) {
event.preventDefault();
event.stopImmediatePropagation();
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const group = event.currentTarget;
if (!(group instanceof HTMLElement)) {
return;
}
groupSettingsDel(group, target);
}
// '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 target = event.target;
if (!(target instanceof HTMLElement)) {
return false;
}
const max = target.dataset["settingsMax"];
const val = 1 + target.children.length;
if ((max !== undefined) && (max !== "0") && (parseInt(max, 10) < val)) {
alert(`Max number of ${target.id} has been reached (${val} out of ${max})`);
return false;
}
return true;
}
/**
* @param {HTMLElement} elem
* @param {EventListener} listener
*/
export function groupSettingsOnAddElem(elem, listener) {
elem.addEventListener("settings-group-add",
(event) => {
event.stopPropagation();
if (!groupSettingsCheckMax(event)) {
return;
}
listener(event);
onGroupSettingsEventAdd(event);
});
}
/**
* @param {string} id
* @param {EventListener} listener
*/
export function groupSettingsOnAdd(id, listener) {
const elem = document.getElementById(id);
if (elem) {
groupSettingsOnAddElem(elem, listener);
}
}
/**
* @param {Event} event
*/
export function onGroupSettingsDel(event) {
let target = /** @type {HTMLElement | null} */(event.target);
if (!(target instanceof HTMLElement)) {
return;
}
let parent = target.parentElement;
if (!(parent instanceof HTMLElement)) {
return;
}
while (parent && !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 onGroupSettingsAddClick(event) {
const elem = event.target;
if (!(elem instanceof HTMLElement)) {
return;
}
const name = elem.dataset["settingsGroup"];
if (!name) {
return;
}
document.getElementById(name)
?.dispatchEvent(new CustomEvent("settings-group-add"));
}
/**
* @param {HTMLElement} container
* @param {string[]} keys
* @returns {string[]} - key# for each node that needs removal
*/
function groupSettingsCleanup(container, keys) {
/** @type {string[]} */
const out = [];
for (let elem of container.getElementsByClassName("settings-group")) {
if (!(elem instanceof HTMLElement)) {
continue;
}
const schema = elem.dataset["settingsSchemaDel"]
?? elem.dataset["settingsSchema"]
?? "";
if (!schema) {
continue;
}
const prefix = "del:";
getGroupPending(elem)
.filter((x) => x.startsWith(prefix))
.map((x) => x.slice(prefix.length))
.forEach((index) => {
const elem_keys = schema
.split(" ")
.map((x) => `${x}${index}`);
if (!elem_keys.length) {
return;
}
elem_keys.forEach((key) => {
if (!keys.includes(key)) {
out.push(key);
}
});
});
}
return [...new Set(out)];
}
/**
* @param {string | number |boolean} value
* @returns {DataValue}
*/
function maybeAdjustDataValue(value) {
if (typeof value === "boolean") {
return value ? 1 : 0;
}
if (typeof value === "string" && (value.length > 0)) {
const number = Number(value);
if (!Number.isNaN(number)) {
return number;
}
}
if (typeof value === "number" && isNaN(value)) {
return "nan";
}
return value;
}
/**
* 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, or everything available
* @typedef {{cleanup?: boolean, assumeChanged?: boolean}} GetDataOptions
*/
/**
* kvs for the device settings storage
* @typedef {string | number} DataValue
* @typedef {{[k: string]: DataValue}} SetRequest
*/
/**
* usually, settings request is sent as a single object
* @typedef {{set: SetRequest, del: string[]}} DataRequest
*/
/**
* populates two sets of data, ones that had been changed and ones that stayed the same
* @param {HTMLFormElement[]} forms
* @param {GetDataOptions} options
*/
export function getData(forms, {cleanup = true, assumeChanged = false} = {}) {
/** @type {{[k: string]: DataValue}} */
const data = {};
/** @type {string[]} */
const changed_data = [];
/** @type {{[k: string]: number}} */
const group_counter = {};
/** @type DataRequest */
const out = {
set: {
},
del: [
]
};
// TODO: 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
/**
* @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;
}
/**
* @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,