aboutsummaryrefslogtreecommitdiff
/**
 * This file is part of Haketilo.
 *
 * Function: Settings page logic.
 *
 * Copyright (C) 2021 Wojtek Kosior
 * Redistribution terms are gathered in the `copyright' file.
 */

/*
 * 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();