/** * This file is part of Haketilo. * * Function: Settings page logic. * * Copyright (C) 2021 Wojtek Kosior * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * As additional permission under GNU GPL version 3 section 7, you * may distribute forms of that code without the copy of the GNU * GPL normally required by section 4, provided you include this * license notice and, in case of non-source distribution, a URL * through which recipients can access the Corresponding Source. * If you modify file(s) with this exception, you may extend this * exception to your version of the file(s), but you are not * obligated to do so. If you do not wish to do so, delete this * exception statement from your version. * * As a special exception to the GPL, any HTML file which merely * makes function calls to this code, and for that purpose * includes it by reference shall be deemed a separate work for * copyright law purposes. If you modify this code, you may extend * this exception to your version of the code, but you are not * obligated to do so. If you do not wish to do so, delete this * exception statement from your version. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * I, Wojtek Kosior, thereby promise not to sue for violation of this file's * license. Although I request that you do not make use this code in a * proprietary program, I am not going to enforce this in court. */ /* * IMPORTS_START * IMPORT get_remote_storage * IMPORT TYPE_PREFIX * IMPORT TYPE_NAME * IMPORT list_prefixes * IMPORT nice_name * IMPORT parse_json_with_schema * IMPORT get_template * IMPORT by_id * IMPORT matchers * IMPORT get_import_frame * IMPORT init_default_policy_dialog * IMPORTS_END */ var storage; const item_li_template = get_template("item_li"); const bag_component_li_template = get_template("bag_component_li"); const chbx_component_li_template = get_template("chbx_component_li"); const radio_component_li_template = get_template("radio_component_li"); /* Make sure they are later cloned without id. */ item_li_template.removeAttribute("id"); bag_component_li_template.removeAttribute("id"); chbx_component_li_template.removeAttribute("id"); radio_component_li_template.removeAttribute("id"); function list_set_scrollbar(list_elem) { const op = ((list_elem.children.length === 1 && list_elem.children[0].classList.contains("hide")) || list_elem.children.length < 1) ? "remove" : "add"; while (!list_elem.classList.contains("table_wrapper")) list_elem = list_elem.parentElement; list_elem.classList[op]("always_scrollbar"); } function item_li_id(prefix, item) { return `li_${prefix}_${item}`; } /* Insert into list of bags/pages/scripts/repos */ function add_li(prefix, item, at_the_end=false) { let ul = ul_by_prefix[prefix]; let li = item_li_template.cloneNode(true); li.id = item_li_id(prefix, item); let span = li.firstElementChild; span.textContent = item; let edit_button = span.nextElementSibling; edit_button.addEventListener("click", () => edit_item(prefix, item)); let remove_button = edit_button.nextElementSibling; remove_button.addEventListener("click", () => storage.remove(prefix, item)); let export_button = remove_button.nextElementSibling; export_button.addEventListener("click", () => export_item(prefix, item)); if (prefix === TYPE_PREFIX.REPO) export_button.remove(); if (!at_the_end) { for (let element of ul.ul.children) { if (element.id < li.id || element.id.startsWith("work_")) continue; ul.ul.insertBefore(li, element); break; } } if (!li.parentElement) { if (ul.work_li !== ul.ul.lastElementChild) ul.ul.appendChild(li); else ul.work_li.before(li); } list_set_scrollbar(ul.ul); } const chbx_components_ul = by_id("chbx_components_ul"); const radio_components_ul = by_id("radio_components_ul"); function chbx_li_id(prefix, item) { return `cli_${prefix}_${item}`; } function radio_li_id(prefix, item) { return `rli_${prefix}_${item}`; } //TODO: refactor the 2 functions below function add_chbx_li(prefix, name) { if (![TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(prefix)) return; let li = chbx_component_li_template.cloneNode(true); li.id = chbx_li_id(prefix, name); li.setAttribute("data-prefix", prefix); li.setAttribute("data-name", name); let chbx = li.firstElementChild.firstElementChild; let span = chbx.nextElementSibling; span.textContent = nice_name(prefix, name); chbx_components_ul.appendChild(li); list_set_scrollbar(chbx_components_ul); } var radio_component_none_li = by_id("radio_component_none_li"); function add_radio_li(prefix, name) { if (![TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(prefix)) return; let li = radio_component_li_template.cloneNode(true); li.id = radio_li_id(prefix, name); li.setAttribute("data-prefix", prefix); li.setAttribute("data-name", name); let radio = li.firstElementChild.firstElementChild; let span = radio.nextElementSibling; span.textContent = nice_name(prefix, name); radio_component_none_li.before(li); list_set_scrollbar(radio_components_ul); } /* Used to reset edited repo. */ function reset_work_repo_li(ul, item, _) { ul.work_name_input.value = maybe_string(item); } /* Used to get repo data for saving */ function work_repo_li_data(ul) { return [ul.work_name_input.value, {}]; } const allow_native_scripts_container = by_id("allow_native_scripts_container"); const page_payload_span = by_id("page_payload"); function set_page_components(components) { if (components === undefined) { page_payload_span.setAttribute("data-payload", "no"); page_payload_span.textContent = "(None)"; allow_native_scripts_container.classList.remove("form_disabled"); } else { page_payload_span.setAttribute("data-payload", "yes"); let [prefix, name] = components; page_payload_span.setAttribute("data-prefix", prefix); page_payload_span.setAttribute("data-name", name); page_payload_span.textContent = nice_name(prefix, name); allow_native_scripts_container.classList.add("form_disabled"); } } const page_allow_chbx = by_id("page_allow_chbx"); /* Used to reset edited page. */ function reset_work_page_li(ul, item, settings) { ul.work_name_input.value = maybe_string(item); settings = settings || {allow: false, components: undefined}; page_allow_chbx.checked = !!settings.allow; set_page_components(settings.components); } function work_page_li_components() { if (page_payload_span.getAttribute("data-payload") === "no") return undefined; let prefix = page_payload_span.getAttribute("data-prefix"); let name = page_payload_span.getAttribute("data-name"); return [prefix, name]; } /* Used to get edited page data for saving. */ function work_page_li_data(ul) { let url = ul.work_name_input.value; let settings = { components : work_page_li_components(), allow : !!page_allow_chbx.checked }; return [url, settings]; } const empty_bag_component_li = by_id("empty_bag_component_li"); var bag_components_ul = by_id("bag_components_ul"); function remove_bag_component_entry(entry) { const list = entry.parentElement; entry.remove(); list_set_scrollbar(list); } /* Used to construct and update components list of edited bag. */ function add_bag_components(components) { for (let component of components) { let [prefix, name] = component; let li = bag_component_li_template.cloneNode(true); li.setAttribute("data-prefix", prefix); li.setAttribute("data-name", name); let span = li.firstElementChild; span.textContent = nice_name(prefix, name); let remove_but = span.nextElementSibling; remove_but.addEventListener("click", () => remove_bag_component_entry(li)); bag_components_ul.appendChild(li); } bag_components_ul.appendChild(empty_bag_component_li); list_set_scrollbar(bag_components_ul); } /* Used to reset edited bag. */ function reset_work_bag_li(ul, item, components) { components = components || []; ul.work_name_input.value = maybe_string(item); let old_components_ul = bag_components_ul; bag_components_ul = old_components_ul.cloneNode(false); old_components_ul.replaceWith(bag_components_ul); add_bag_components(components); } /* Used to get edited bag data for saving. */ function work_bag_li_data(ul) { let component_li = bag_components_ul.firstElementChild; let components = []; /* Last list element is empty li with id set. */ while (component_li.id === '') { components.push([component_li.getAttribute("data-prefix"), component_li.getAttribute("data-name")]); component_li = component_li.nextElementSibling; } return [ul.work_name_input.value, components]; } const script_url_input = by_id("script_url_field"); const script_sha256_input = by_id("script_sha256_field"); const script_contents_field = by_id("script_contents_field"); function maybe_string(maybe_defined) { return maybe_defined === undefined ? "" : maybe_defined + ""; } /* Used to reset edited script. */ function reset_work_script_li(ul, name, data) { ul.work_name_input.value = maybe_string(name); if (data === undefined) data = {}; script_url_input.value = maybe_string(data.url); script_sha256_input.value = maybe_string(data.hash); script_contents_field.value = maybe_string(data.text); } /* Used to get edited script data for saving. */ function work_script_li_data(ul) { return [ul.work_name_input.value, { url : script_url_input.value, hash : script_sha256_input.value, text : script_contents_field.value }]; } function cancel_work(prefix) { let ul = ul_by_prefix[prefix]; if (ul.state === UL_STATE.IDLE) return; if (ul.state === UL_STATE.EDITING_ENTRY) { add_li(prefix, ul.edited_item); } ul.work_li.classList.add("hide"); ul.ul.append(ul.work_li); list_set_scrollbar(ul.ul); ul.state = UL_STATE.IDLE; } function save_work(prefix) { let ul = ul_by_prefix[prefix]; if (ul.state === UL_STATE.IDLE) return; let [item, data] = ul.get_work_li_data(ul); /* Here we fire promises and return without waiting. */ if (ul.state === UL_STATE.EDITING_ENTRY) storage.replace(prefix, ul.edited_item, item, data); if (ul.state === UL_STATE.ADDING_ENTRY) storage.set(prefix, item, data); cancel_work(prefix); } function edit_item(prefix, item) { cancel_work(prefix); let ul = ul_by_prefix[prefix]; let li = by_id(item_li_id(prefix, item)); if (li === null) { add_new_item(prefix, item); return; } ul.reset_work_li(ul, item, storage.get(prefix, item)); ul.ul.insertBefore(ul.work_li, li); ul.ul.removeChild(li); ul.work_li.classList.remove("hide"); list_set_scrollbar(ul.ul); ul.state = UL_STATE.EDITING_ENTRY; ul.edited_item = item; } const file_downloader = by_id("file_downloader"); function recursively_export_item(prefix, name, added_items, items_data) { let key = prefix + name; if (added_items.has(key)) return; let data = storage.get(prefix, name); if (data === undefined) { console.log(`${TYPE_NAME[prefix]} '${name}' for export not found`); return; } if (prefix !== TYPE_PREFIX.SCRIPT) { let components = prefix === TYPE_PREFIX.BAG ? data : [data.components]; for (let [comp_prefix, comp_name] of components) { recursively_export_item(comp_prefix, comp_name, added_items, items_data); } } items_data.push({[key]: data}); added_items.add(key); } function export_item(prefix, name) { let added_items = new Set(); let items_data = []; recursively_export_item(prefix, name, added_items, items_data); let file = new Blob([JSON.stringify(items_data)], {type: "application/json"}); let url = URL.createObjectURL(file); file_downloader.setAttribute("href", url); file_downloader.setAttribute("download", prefix + name + ".json"); file_downloader.click(); file_downloader.removeAttribute("href"); URL.revokeObjectURL(url); } function add_new_item(prefix, name) { cancel_work(prefix); let ul = ul_by_prefix[prefix]; ul.reset_work_li(ul); ul.work_li.classList.remove("hide"); ul.ul.appendChild(ul.work_li); list_set_scrollbar(ul.ul); if (name !== undefined) ul.work_name_input.value = name; ul.state = UL_STATE.ADDING_ENTRY; } const chbx_components_window = by_id("chbx_components_window"); function bag_components() { chbx_components_window.classList.remove("hide"); radio_components_window.classList.add("hide"); for (let li of chbx_components_ul.children) { let chbx = li.firstElementChild.firstElementChild; chbx.checked = false; } } function commit_bag_components() { let selected = []; for (let li of chbx_components_ul.children) { let chbx = li.firstElementChild.firstElementChild; if (!chbx.checked) continue; selected.push([li.getAttribute("data-prefix"), li.getAttribute("data-name")]); } add_bag_components(selected); cancel_components(); } const radio_components_window = by_id("radio_components_window"); var radio_component_none_input = by_id("radio_component_none_input"); function page_components() { radio_components_window.classList.remove("hide"); chbx_components_window.classList.add("hide"); radio_component_none_input.checked = true; let components = work_page_li_components(); if (components === undefined) return; let [prefix, item] = components; let li = by_id(radio_li_id(prefix, item)); if (li === null) radio_component_none_input.checked = false; else li.firstElementChild.firstElementChild.checked = true; } function commit_page_components() { let components = null; for (let li of radio_components_ul.children) { let radio = li.firstElementChild.firstElementChild; if (!radio.checked) continue; components = [li.getAttribute("data-prefix"), li.getAttribute("data-name")]; if (radio.id === "radio_component_none_input") components = undefined; break; } if (components !== null) set_page_components(components); cancel_components(); } function cancel_components() { chbx_components_window.classList.add("hide"); radio_components_window.classList.add("hide"); } const UL_STATE = { EDITING_ENTRY : 0, ADDING_ENTRY : 1, IDLE : 2 }; const ul_by_prefix = { [TYPE_PREFIX.REPO] : { ul : by_id("repos_ul"), work_li : by_id("work_repo_li"), work_name_input : by_id("repo_url_field"), reset_work_li : reset_work_repo_li, get_work_li_data : work_repo_li_data, state : UL_STATE.IDLE, edited_item : undefined, }, [TYPE_PREFIX.PAGE] : { ul : by_id("pages_ul"), work_li : by_id("work_page_li"), work_name_input : by_id("page_url_field"), reset_work_li : reset_work_page_li, get_work_li_data : work_page_li_data, select_components : page_components, commit_components : commit_page_components, state : UL_STATE.IDLE, edited_item : undefined, }, [TYPE_PREFIX.BAG] : { ul : by_id("bags_ul"), work_li : by_id("work_bag_li"), work_name_input : by_id("bag_name_field"), reset_work_li : reset_work_bag_li, get_work_li_data : work_bag_li_data, select_components : bag_components, commit_components : commit_bag_components, state : UL_STATE.IDLE, edited_item : undefined, }, [TYPE_PREFIX.SCRIPT] : { ul : by_id("scripts_ul"), work_li : by_id("work_script_li"), work_name_input : by_id("script_name_field"), reset_work_li : reset_work_script_li, get_work_li_data : work_script_li_data, state : UL_STATE.IDLE, edited_item : undefined, } } /* * Newer browsers could utilise `text' method of File objects. * Older ones require FileReader. */ function _read_file(file, resolve, reject) { let reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error); reader.readAsText(file); } function read_file(file) { return new Promise((resolve, reject) => _read_file(file, resolve, reject)); } const url_regex = /^[a-z0-9]+:\/\/[^/]+\.[^/]{2,}(\/[^?#]*)?$/; const empty_regex = /^$/; const settings_schema = [ [{}, "matchentry", "minentries", 1, new RegExp(`^${TYPE_PREFIX.SCRIPT}`), { /* script data */ "url": ["optional", url_regex, "or", empty_regex], "sha256": ["optional", matchers.sha256, "or", empty_regex], "text": ["optional", "string"] }, new RegExp(`^${TYPE_PREFIX.BAG}`), [ "optional", [matchers.component, "repeat"], "default", undefined ], new RegExp(`^${TYPE_PREFIX.PAGE}`), { /* page data */ "components": ["optional", matchers.component] }], "repeat" ]; const import_window = by_id("import_window"); let import_frame; async function import_from_file(event) { let files = event.target.files; if (files.length < 1) return; import_window.classList.remove("hide"); import_frame.show_loading(); try { const file = await read_file(files[0]); var result = parse_json_with_schema(settings_schema, file); } catch(e) { import_frame.show_error("Bad file :(", "" + e); return; } import_frame.show_selection(result); } const file_opener_form = by_id("file_opener_form"); function hide_import_window() { import_window.classList.add("hide"); /* * Reset file <input>. Without this, a second attempt to import the same * file would result in "change" event not happening on <input> element. */ file_opener_form.reset(); } async function initialize_import_facility() { let import_but = by_id("import_but"); let file_opener = by_id("file_opener"); import_but.addEventListener("click", () => file_opener.click()); file_opener.addEventListener("change", import_from_file); import_frame = await get_import_frame(); import_frame.onclose = hide_import_window; import_frame.style_table("has_bottom_line", "always_scrollbar", "has_upper_line", "tight_table"); } /* * If url has a target appended, e.g. * chrome-extension://hnhmbnpohhlmhehionjgongbnfdnabdl/html/options.html#smyhax * that target will be split into prefix and item name (e.g. "s" and "myhax") * and editing of that respective item will be started. * * We don't need to worry about the state of the page (e.g. some editing being * in progress) in jump_to_item() - this function is called at the beginning, * together with callbacks being assigned to buttons, so it is safe to assume * lists are initialized with items and page is in its virgin state with regard * to everything else. */ function jump_to_item(url_with_item) { const [dummy1, base_url, dummy2, target] = /^([^#]*)(#(.*))?$/i.exec(url_with_item); if (target === undefined) return; const prefix = target.substring(0, 1); if (!list_prefixes.includes(prefix)) { history.replaceState(null, "", base_url); return; } by_id(`show_${TYPE_NAME[prefix]}s`).checked = true; edit_item(prefix, decodeURIComponent(target.substring(1))); } async function main() { init_default_policy_dialog(); storage = await get_remote_storage(); for (let prefix of list_prefixes) { for (let item of storage.get_all_names(prefix).sort()) { add_li(prefix, item, true); add_chbx_li(prefix, item); add_radio_li(prefix, item); } let name = TYPE_NAME[prefix]; let add_but = by_id(`add_${name}_but`); let discard_but = by_id(`discard_${name}_but`); let save_but = by_id(`save_${name}_but`); add_but.addEventListener("click", () => add_new_item(prefix)); discard_but.addEventListener("click", () => cancel_work(prefix)); save_but.addEventListener("click", () => save_work(prefix)); if ([TYPE_PREFIX.REPO, TYPE_PREFIX.SCRIPT].includes(prefix)) continue; let ul = ul_by_prefix[prefix]; let commit_components_but = by_id(`commit_${name}_components_but`); let cancel_components_but = by_id(`cancel_${name}_components_but`); let select_components_but = by_id(`select_${name}_components_but`); commit_components_but .addEventListener("click", ul.commit_components); select_components_but .addEventListener("click", ul.select_components); cancel_components_but.addEventListener("click", cancel_components); } jump_to_item(document.URL); storage.add_change_listener(handle_change); await initialize_import_facility(); } function handle_change(change) { if (change.old_val === undefined) { add_li(change.prefix, change.item); add_chbx_li(change.prefix, change.item); add_radio_li(change.prefix, change.item); return; } if (change.new_val !== undefined) return; let ul = ul_by_prefix[change.prefix]; if (ul.state === UL_STATE.EDITING_ENTRY && ul.edited_item === change.item) { ul.state = UL_STATE.ADDING_ENTRY; return; } let uls_creators = [[ul.ul, item_li_id]]; if ([TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(change.prefix)) { uls_creators.push([chbx_components_ul, chbx_li_id]); uls_creators.push([radio_components_ul, radio_li_id]); } for (let [components_ul, id_creator] of uls_creators) { let li = by_id(id_creator(change.prefix, change.item)); components_ul.removeChild(li); list_set_scrollbar(components_ul); } } main();