aboutsummaryrefslogtreecommitdiff
path: root/html/text_entry_list.js
diff options
context:
space:
mode:
Diffstat (limited to 'html/text_entry_list.js')
-rw-r--r--html/text_entry_list.js321
1 files changed, 321 insertions, 0 deletions
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 <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 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