From 5acb2499c1df14d6275b1ad9e139f02d1280cb9c Mon Sep 17 00:00:00 2001 From: Wojtek Kosior Date: Thu, 13 Jan 2022 10:15:12 +0100 Subject: facilitate managing repository URLs in a list; minor other changes --- html/text_entry_list.js | 321 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 html/text_entry_list.js (limited to 'html/text_entry_list.js') diff --git a/html/text_entry_list.js b/html/text_entry_list.js new file mode 100644 index 0000000..4af19fd --- /dev/null +++ b/html/text_entry_list.js @@ -0,0 +1,321 @@ +/** + * This file is part of Haketilo. + * + * Function: Driving a list of editable entries. Used to make UI for management + * of repo URLs and script allowing/blocking rules. + * + * Copyright (C) 2022 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 . + * + * I, Wojtek Kosior, thereby promise not to sue for violation of this file's + * license. Although I request that you do not make use of this code in a + * proprietary program, I am not going to enforce this in court. + */ + +#IMPORT html/dialog.js +#IMPORT common/indexeddb.js AS haketilodb + +#FROM html/DOM_helpers.js IMPORT clone_template +#FROM common/patterns.js IMPORT deconstruct_url, patterns_doc_url + +const coll = new Intl.Collator(); + +function Entry(text, list, entry_idx) { + Object.assign(this, clone_template("text_entry")); + + const editable = () => list.active_entry === this; + this.exists = () => text !== null; + + /* + * Called in the constructor when creating a completely new entry and as a + * result of clicking on an existing entry in the list. + */ + this.make_editable = () => { + if (editable()) + return; + + if (list.active_entry !== null) + list.active_entry.make_noneditable(); + + list.active_entry = this; + + this.editable_view.classList.remove("hide"); + this.noneditable_view.classList.add("hide"); + + this.input.value = text || ""; + } + + /* + * Called when 'Cancel' is clicked, when another entry becomes editable and + * when an entry ends being modified. + */ + this.make_noneditable = () => { + if (!editable()) + return; + + list.active_entry = null; + + if (!this.exists()) { + this.main_div.remove(); + return; + } + + this.editable_view.classList.add("hide"); + this.noneditable_view.classList.remove("hide"); + } + + /* + * The *_cb() calls are allowed to throw if an error occurs. It is expected + * *_cb() will show some dialog that will block other clicks until they + * returns/throw. + */ + + const add_clicked = async () => { + if (!editable() || this.exists()) + return; + + await list.create_cb(this.input.value); + this.make_noneditable(); + } + + /* + * Changing entry's text is not handled here, instead we wait for subsequent + * item removal and creation requests from the outside. + */ + const save_clicked = async () => { + if (!editable() || !this.exists()) + return; + + await list.replace_cb(text, this.input.value); + this.make_noneditable(); + } + + const enter_hit = () => add_clicked().then(save_clicked); + + /* + * Removing entry from the list is not handled here, instead we wait for + * subsequent item removal requests from the outside. + */ + const remove_clicked = async () => { + if (editable() || !this.exists()) + return; + + await list.remove_cb(text); + } + + /* + * Called from the outside after the entry got removed from the database. It + * is assumed entry_exists() is true when this is called. + */ + this.remove = () => { + if (editable()) { + text = null; + this.save_but.classList.add("hide"); + this.add_but.classList.remove("hide"); + } else { + this.main_div.remove(); + } + } + + if (this.exists()) { + this.text.innerText = text; + } else { + this.save_but.classList.add("hide"); + this.add_but.classList.remove("hide"); + this.make_editable(); + } + + for (const [node, cb] of [ + [this.save_but, save_clicked], + [this.add_but, add_clicked], + [this.remove_but, remove_clicked], + [this.cancel_but, this.make_noneditable], + [this.noneditable_view, this.make_editable], + ]) + dialog.onevent(list.dialog_ctx, node, "click", cb); + + dialog.onevent(list.dialog_ctx, this.input, "keypress", + e => (e.key === 'Enter') && enter_hit()); + + if (entry_idx > 0) { + const prev_text = list.shown_texts[entry_idx - 1]; + list.entries_by_text.get(prev_text).main_div.after(this.main_div); + } else { + if (!editable() && list.active_entry && !list.active_entry.exists()) + list.active_entry.main_div.after(this.main_div); + else + list.list_div.prepend(this.main_div); + } +} + +function TextEntryList(dialog_ctx, destroy_cb, + remove_cb, create_cb, replace_cb) { + Object.assign(this, {dialog_ctx, remove_cb, create_cb, replace_cb}); + Object.assign(this, clone_template("text_entry_list")); + this.ul = document.createElement("ul"); + this.shown_texts = []; + this.entries_by_text = new Map(); + this.active_entry = null; + + const find_entry_idx = text => { + let left = 0, right = this.shown_texts.length; + + while (left < right) { + const mid = (left + right) >> 1; + if (coll.compare(text, this.shown_texts[mid]) > 0) + left = mid + 1; + else /* <= 0 */ + right = mid; + } + + return left; + } + + this.remove = text => { + if (!this.entries_by_text.has(text)) + return; + + this.shown_texts.splice(find_entry_idx(text), 1); + this.entries_by_text.get(text).remove(); + this.entries_by_text.delete(text) + } + + this.add = text => { + if (this.entries_by_text.has(text)) + return; + + const idx = find_entry_idx(text); + this.entries_by_text.set(text, new Entry(text, this, idx)); + this.shown_texts.splice(idx, 0, text); + } + + this.destroy = () => { + this.main_div.remove(); + destroy_cb(); + } + + const add_new = () => new Entry(null, this, 0); + + dialog.onevent(dialog_ctx, this.new_but, "click", add_new); +} + +async function repo_list(dialog_ctx) { + let list; + + function validate_normalize_repo_url(repo_url) { + let error_msg; + + /* In the future we might also try making a test connection. */ + if (!/^https:\/\/[^/.]+\.[^/.]+/.test(repo_url)) + error_msg = "Provided URL is not valid."; + + if (!/^https:\/\//.test(repo_url)) + error_msg = "Repository URLs shoud use https:// schema."; + + if (error_msg) { + dialog.error(dialog_ctx, error_msg); + throw error_msg; + } + + return repo_url.replace(/\/*$/, "/"); + } + + async function remove_repo(repo_url) { + dialog.loader(dialog_ctx, "Removing repository..."); + + try { + await haketilodb.del_repo(repo_url); + var removing_ok = true; + } finally { + if (!removing_ok) + dialog.error(dialog_ctx, "Failed to remove repository :("); + + dialog.close(dialog_ctx); + } + } + + async function create_repo(repo_url) { + repo_url = validate_normalize_repo_url(repo_url); + + dialog.loader(dialog_ctx, "Adding repository..."); + + try { + await haketilodb.set_repo(repo_url); + var adding_ok = true; + } finally { + if (!adding_ok) + dialog.error(dialog_ctx, "Failed to add repository :("); + + dialog.close(dialog_ctx); + } + } + + async function replace_repo(old_repo_url, new_repo_url) { + if (old_repo_url === new_repo_url) + return; + + new_repo_url = validate_normalize_repo_url(new_repo_url); + + dialog.loader(dialog_ctx, "Replacing repository..."); + + try { + await haketilodb.set_repo(new_repo_url); + await haketilodb.del_repo(old_repo_url); + var replacing_ok = true; + } finally { + if (!replacing_ok) + dialog.error(dialog_ctx, "Failed to replace repository :("); + + dialog.close(dialog_ctx); + } + } + + function onchange(change) { + if (change.new_val) + list.add(change.key); + else + list.remove(change.key); + } + + dialog.loader(dialog_ctx, "Loading repositories..."); + const [tracking, items] = await haketilodb.track.repos(onchange); + dialog.close(dialog_ctx); + + list = new TextEntryList(dialog_ctx, () => haketilodb.untrack(tracking), + remove_repo, create_repo, replace_repo); + + items.forEach(item => list.add(item.url)); + + return list; +} +#EXPORT repo_list -- cgit v1.2.3