/** * 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 validate_normalize_url_pattern 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], ]) node.addEventListener("click", list.dialog_ctx.when_hidden(cb)); const enter_cb = e => (e.key === 'Enter') && enter_hit(); this.input.addEventListener("keypress", list.dialog_ctx.when_hidden(enter_cb)); 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); this.new_but.addEventListener("click", dialog_ctx.when_hidden(add_new)); } async function repo_list(dialog_ctx) { let list; function validate_normalize(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."; /* Make exception for localhost while disallowing http://. */ if (/^http:\/\/(127\.0\.0\.1|localhost)([:/].*)?$/.test(repo_url)) error_msg = null; 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 '${repo_url}'...`); try { await haketilodb.del_repo(repo_url); var removing_ok = true; } finally { if (!removing_ok) { dialog.error(dialog_ctx, `Failed to remove repository '${repo_url}' :(`); } dialog.close(dialog_ctx); } } async function create_repo(repo_url) { repo_url = validate_normalize(repo_url); dialog.loader(dialog_ctx, `Adding repository '${repo_url}'...`); try { await haketilodb.set_repo(repo_url); var adding_ok = true; } finally { if (!adding_ok) dialog.error(dialog_ctx, `Failed to add repository '${repo_url}' :(`); 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(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.repo(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 async function blocking_allowing_lists(dialog_ctx) { function validate_normalize(url_pattern) { try { return validate_normalize_url_pattern(url_pattern); } catch(e) { dialog.error(dialog_ctx, e); throw e; } } async function default_allowing_on(url_pattern, allow) { dialog.loader(dialog_ctx, `Setting default scripts blocking policy on '${url_pattern}'...`); try { await haketilodb.set_default_allowing(url_pattern); var default_allowing_ok = true; } finally { if (!default_allowing_ok) { dialog.error(dialog_ctx, `Failed to remove rule for '${url_pattern}' :(`); } dialog.close(dialog_ctx); } } async function set_allowing_on(url_pattern, allow) { url_pattern = validate_normalize(url_pattern); const [action, action_cap] = allow ? ["allowing", "Allowing"] : ["blocking", "Blocking"]; dialog.loader(dialog_ctx, `${action_cap} scripts on '${url_pattern}'...`); try { await haketilodb.set_allowed(url_pattern, allow); var set_allowing_ok = true; } finally { if (!set_allowing_ok) dialog.error(dialog_ctx, `Failed to write ${action} rule for '${url_pattern}' :(`); dialog.close(dialog_ctx); } } async function replace_allowing_on(old_pattern, new_pattern, allow) { new_pattern = validate_normalize(new_pattern); if (old_pattern === new_pattern) return; const action = allow ? "allowing" : "blocking"; dialog.loader(dialog_ctx, `Rewriting script ${action} rule...`); try { await haketilodb.set_allowed(new_pattern, allow); await haketilodb.set_default_allowing(old_pattern); var replace_allowing_ok = true; } finally { if (!replace_allowing_ok) dialog.error(dialog_ctx, `Failed to rewrite ${action} rule :(`); dialog.close(dialog_ctx); } } let blocking_list, allowing_list; function onchange(change) { if (change.new_val) { if (change.new_val.allow) var [to_add, to_remove] = [allowing_list, blocking_list]; else var [to_add, to_remove] = [blocking_list, allowing_list]; to_add.add(change.key); to_remove.remove(change.key); } else { blocking_list.remove(change.key); allowing_list.remove(change.key); } } dialog.loader(dialog_ctx, "Loading script blocking settings..."); const [tracking, items] = await haketilodb.track.blocking(onchange); dialog.close(dialog_ctx); let untrack_called = 0; function untrack() { if (++untrack_called === 2) haketilodb.untrack(tracking); } const lists = []; for (const allow of [false, true]) { lists[allow + 0] = new TextEntryList(dialog_ctx, untrack, pattern => default_allowing_on(pattern), pattern => set_allowing_on(pattern, allow), (p1, p2) => replace_allowing_on(p1, p2, allow)); } [blocking_list, allowing_list] = lists; for (const item of items) { if (item.allow) allowing_list.add(item.pattern); else blocking_list.add(item.pattern); } return lists; } #EXPORT blocking_allowing_lists