internal/LoadAndSave.js

/**
 * Load, save and apply options to HTML options page.
 *
 * @public
 * @module AutomaticSettings
 * @requires lodash/debounce
 * @requires ../data/MessageLevel
 * @requires AutomaticSettings/Trigger
 * @requires AutomaticSettings/HtmlModification
 */

// common modules
import debounce from "../../lodash/debounce.js";
import * as CommonMessages from "../../MessageHandler/CommonMessages.js";

// import internal modules
import * as Trigger from "./Trigger.js";
import * as HtmlMod from "./HtmlModification.js";
import * as OptionsModel from "./OptionsModel.js";

const DEFAULT_DEBOUNCE_TIME = 250; // 250 ms

// vars
let managedInfoIsShown = false;

let lastOptionsBeforeReset;

/**
 * Saves the specific settings that triggered this.
 *
 * @private
 * @function
 * @param  {Object} event
 * @returns {void}
 */
async function saveOption(event) {
    /** @var {HTMLElement} */
    let elOption = event.target;

    // radio options need special handling, use (closest) parent
    if (elOption.getAttribute("type") === "radio") {
        elOption = elOption.closest("[data-type=radiogroup]");
        elOption.selectedElement = event.target;
    }

    // do not save if managed
    if (elOption.hasAttribute("disabled")) {
        console.info(elOption, "is disabled, ignore sync setting");
        return;
    }

    let [option, optionValue] = HtmlMod.getIdAndOptionsFromElement(elOption);

    const saveTriggerValue = await Trigger.runSaveTrigger(option, optionValue, event);

    console.info("save option", elOption, option, optionValue, event, saveTriggerValue);

    try {
        const result = await Trigger.runOverrideSave(option, optionValue, saveTriggerValue, event);

        // destructure data, if it has been returned
        if (result.command === Trigger.CONTINUE_RESULT) {
            ( {option = option, optionValue = optionValue} = result.data );
        }

        // continue saving if no triggers executed or they want to save something
        if (result === Trigger.NO_TRIGGERS_EXECUTED ||
            result.command === Trigger.CONTINUE_RESULT) {
            browser.storage.sync.set({
                [option]: optionValue
            });
        }
    } catch (error) {
        console.error("could not save option", option, ": ", error);
        CommonMessages.showError("couldNotSaveOption", true);
    }
}

/**
 * Show info that some settings are managed.
 *
 * @private
 * @function
 * @returns {void}
 */
function showManagedInfo() {
    // prevent re-showings for multiple options
    if (managedInfoIsShown) {
        // already shown
        return;
    }

    CommonMessages.showInfo("someSettingsAreManaged", false);
    managedInfoIsShown = true;
}

/**
 * Get the name of the option from an element..
 *
 * @private
 * @function
 * @param {string} option
 * @returns {HTMLElement}
 */
function getElementFromOptionId(option) {
    return document.querySelector(`[name=${option}]`);
}

/**
 * Applies an option to the HTML element. This is the final step, before it goes
 * into the {@link HtmlMod} module.
 *
 * @private
 * @function
 * @param  {string} option string ob object ID
 * @param  {string|null} optionGroup optiom group, if it is used
 * @param  {HTMLElement} elOption where to apply feature
 * @param  {Object|undefined} optionValues object values
 * @returns {Promise}
 */
async function applyOption(option, optionGroup, elOption, optionValues) {
    let optionValue = OptionsModel.getOptionValueFromRequestResults(option, optionGroup, optionValues);

    const overwriteResult = await Trigger.runOverrideLoad(option, optionValue, elOption, optionValues);

    // loading manually handled if no triggers executed or they want to save something
    if (overwriteResult !== Trigger.NO_TRIGGERS_EXECUTED &&
        overwriteResult.command !== Trigger.CONTINUE_RESULT) {
        return Promise.resolve();
    }

    // destructre data, if it has been returned
    if (overwriteResult.command === Trigger.CONTINUE_RESULT) {
        ( {option = option, optionValue = optionValue, elOption = elOption} = overwriteResult.data );
    }

    return HtmlMod.applyOptionToElement(option, optionValue, elOption);
}

/**
 * Restores the managed options by administrators.
 *
 * They override users selection, so the user control is disabled.
 *
 * @private
 * @function
 * @param  {string} option name of the option
 * @param  {string|null|undefined} optionGroup name of the option group,
 *                                             undefined will automatically
 *                                             detect the element
 * @param  {HTMLElement|null} elOption optional element of the option, will
 *                                     be autodetected otherwise
 * @returns {Promise}
 */
function setManagedOption(option, optionGroup, elOption = getElementFromOptionId(option)) {
    if (optionGroup === undefined && elOption.hasAttribute("data-optiongroup")) {
        optionGroup = elOption.getAttribute("data-optiongroup");
    }

    let gettingOption;
    if (optionGroup == null) {
        gettingOption = browser.storage.managed.get(option);
    } else {
        gettingOption = browser.storage.managed.get(optionGroup);
    }

    return gettingOption.then((res) => {
        showManagedInfo();

        console.info("managed config found", res, elOption);

        // and disable control
        elOption.setAttribute("disabled", "");
        elOption.setAttribute("title", browser.i18n.getMessage("optionIsDisabledBecauseManaged"));
        // could also set readonly elOption.setAttribute("readonly", "") //TODO: test

        return applyOption(option, optionGroup, elOption, res);
    });
}

/**
 * Display option in option page.
 *
 * If the option is not saved already, it uses the default provided by the
 * function provided with {@link ./HtmlModification#setDefaultOptionProvider}.
 *
 * @private
 * @function
 * @param  {string} option name of the option
 * @param  {string|null|undefined} optionGroup name of the option group,
 *                                             undefined will automatically
 *                                             detect the element
 * @param  {HTMLElement|null} elOption optional element of the option, will
 *                                     be autodetected otherwise
 * @returns {Promise}
 */
function setSyncedOption(option, optionGroup, elOption = getElementFromOptionId(option)) {
    if (optionGroup === undefined && elOption.hasAttribute("data-optiongroup")) {
        optionGroup = elOption.getAttribute("data-optiongroup");
    }

    let gettingOption;
    if (optionGroup == null) {
        gettingOption = browser.storage.sync.get(option);
    } else {
        gettingOption = browser.storage.sync.get(optionGroup);
    }

    return gettingOption.then((res) => {
        console.info("sync config found", res, elOption);

        return applyOption(option, optionGroup, elOption, res);
    });
}

/**
 * Load option and set it to the given element.
 *
 * Optionally, you can already give it the option name.
 *
 * @package
 * @function
 * @param  {HTMLElement} elOption element of the option
 * @param  {string} [option] name of the option
 * @returns {Promise}
 */
export function loadOption(elOption, option) {
    option = option ? option : HtmlMod.getOptionIdFromElement(elOption);

    let optionGroup = null;
    if ("optiongroup" in elOption.dataset) {
        optionGroup = elOption.dataset.optiongroup;
    }

    // try to get option ID from input element if needed
    if (!option && elOption.dataset.type === "radiogroup") {
        option = elOption.querySelector("input[type=radio]").getAttribute("name");
    }

    return setManagedOption(option, optionGroup, elOption).catch((error) => {
        /* only log warning as that is expected when no manifest file is found */
        console.warn("could not get managed options", error);

        // now set "real"/"usual" option
        return setSyncedOption(option, optionGroup, elOption);
    });
}

/**
 * Load option and set to element if you give it an option name.
 *
 * @package
 * @function
 * @param  {string} option name of the option
 * @param  {HTMLElement} [elOption] optional element of the option, will
 *                                be autodetected otherwise
 * @returns {Promise}
 */
export function loadOptionByName(option, elOption = getElementFromOptionId(option)) {
    return loadOption(elOption, option);
}

/**
 * Loads all options of the page.
 *
 * @private
 * @function
 * @returns {Promise}
 */
async function loadAllOptions() {
    // reset remembered options to prevent arkward errors when reloading the options
    OptionsModel.resetRememberedOptions();
    const allPromises = [];

    await Trigger.runBeforeLoadTrigger();

    // set each option
    document.querySelectorAll(".setting").forEach((currentElem, index) => {
        allPromises[index] = loadOption(currentElem);
    });

    // when everything is finished, apply live elements for values if needed
    const allOptionsLoaded = Promise.all(allPromises);

    return allOptionsLoaded.then(() => {
        // to apply options live
        return Trigger.runAfterLoadTrigger();
    });
}

/**
 * Resets all options.
 *
 * @private
 * @function
 * @param {Event} event
 * @returns {void}
 */
async function resetOptions(event) {
    console.info("reset options");

    // disable reset button (which triggered this) until process is finished
    event.target.setAttribute("disabled", "");

    // temporarily save old options
    await browser.storage.sync.get().then((options) => {
        lastOptionsBeforeReset = options;
    });

    // cleanup resetted cached option after message is hidden
    CommonMessages.setSuccessHook(null, () => {
        lastOptionsBeforeReset = null;
        console.info("reset options message hidden, undo vars cleaned");
    });

    // finally reset options
    browser.storage.sync.clear().then(() => loadAllOptions().then(
        () => CommonMessages.showSuccess("resettingOptionsWorked", true, {
            text: "messageUndoButton",
            action: () => {
                browser.storage.sync.set(lastOptionsBeforeReset).then(() => {
                    // re-load the options again
                    return loadAllOptions();
                }).catch((error) => {
                    console.error("Could not undo option resetting: ", error);
                    CommonMessages.showError("couldNotUndoAction");
                }).finally(() => {
                    CommonMessages.hideSuccess();
                });
            }
        })
    )).catch((error) => {
        console.error(error);
        CommonMessages.showError("resettingOptionsFailed", true);
    }).finally(() => {
        // re-enable button
        event.target.removeAttribute("disabled");
    });
}

/**
 * Initializes the options, loads them and sets everything up.
 *
 * @public
 * @function
 * @param {Object} [options]
 * @param {number} [options.debounceTime] {@link https://lodash.com/docs#debounce}
 * @returns {Promise}
 */
export function init(options = {
    debounceTime: DEFAULT_DEBOUNCE_TIME
}) {
    // check requirements
    OptionsModel.verifyItIsReady();

    const loadPromise = loadAllOptions().catch((error) => {
        console.error(error);
        CommonMessages.showError("couldNotLoadOptions", false);

        // re-throw error
        throw error;
    });

    // add event listeners for all options
    document.querySelectorAll(".save-on-input").forEach((currentElem) => {
        currentElem.addEventListener("input", saveOption);
    });
    document.querySelectorAll(".save-on-change").forEach((currentElem) => {
        currentElem.addEventListener("change", saveOption);
    });

    // debounced versions
    const saveOptionDebounced = debounce(saveOption, options.debounceTime);
    document.querySelectorAll(".save-on-input-debounce").forEach((currentElem) => {
        currentElem.addEventListener("input", saveOptionDebounced);
    });
    document.querySelectorAll(".save-on-change-debounce").forEach((currentElem) => {
        currentElem.addEventListener("change", saveOptionDebounced);
    });

    document.querySelectorAll(".trigger-on-update").forEach((currentElem) => {
        currentElem.addEventListener("input", Trigger.runHtmlEventTrigger);
    });
    document.querySelectorAll(".trigger-on-change").forEach((currentElem) => {
        currentElem.addEventListener("change", Trigger.runHtmlEventTrigger);
    });

    const resetButton = document.getElementById("resetButton");
    if (resetButton) {
        resetButton.addEventListener("click", resetOptions);
    }

    return loadPromise;
}