Project

General

Profile

Download (12 KB) Statistics
| Branch: | Tag: | Revision:

haketilo / html / text_entry_list.js @ 92fc67cf

1
/**
2
 * This file is part of Haketilo.
3
 *
4
 * Function: Driving a list of editable entries. Used to make UI for management
5
 *           of repo URLs and script allowing/blocking rules.
6
 *
7
 * Copyright (C) 2022 Wojtek Kosior
8
 *
9
 * This program is free software: you can redistribute it and/or modify
10
 * it under the terms of the GNU General Public License as published by
11
 * the Free Software Foundation, either version 3 of the License, or
12
 * (at your option) any later version.
13
 *
14
 * This program is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
 * GNU General Public License for more details.
18
 *
19
 * As additional permission under GNU GPL version 3 section 7, you
20
 * may distribute forms of that code without the copy of the GNU
21
 * GPL normally required by section 4, provided you include this
22
 * license notice and, in case of non-source distribution, a URL
23
 * through which recipients can access the Corresponding Source.
24
 * If you modify file(s) with this exception, you may extend this
25
 * exception to your version of the file(s), but you are not
26
 * obligated to do so. If you do not wish to do so, delete this
27
 * exception statement from your version.
28
 *
29
 * As a special exception to the GPL, any HTML file which merely
30
 * makes function calls to this code, and for that purpose
31
 * includes it by reference shall be deemed a separate work for
32
 * copyright law purposes. If you modify this code, you may extend
33
 * this exception to your version of the code, but you are not
34
 * obligated to do so. If you do not wish to do so, delete this
35
 * exception statement from your version.
36
 *
37
 * You should have received a copy of the GNU General Public License
38
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
39
 *
40
 * I, Wojtek Kosior, thereby promise not to sue for violation of this file's
41
 * license. Although I request that you do not make use of this code in a
42
 * proprietary program, I am not going to enforce this in court.
43
 */
44

    
45
#IMPORT html/dialog.js
46
#IMPORT common/indexeddb.js AS haketilodb
47

    
48
#FROM html/DOM_helpers.js IMPORT clone_template
49
#FROM common/patterns.js  IMPORT validate_normalize_url_pattern
50

    
51
const coll = new Intl.Collator();
52

    
53
function Entry(text, list, entry_idx) {
54
    Object.assign(this, clone_template("text_entry"));
55

    
56
    const editable = () => list.active_entry === this;
57
    this.exists = () => text !== null;
58

    
59
    /*
60
     * Called in the constructor when creating a completely new entry and as a
61
     * result of clicking on an existing entry in the list.
62
     */
63
    this.make_editable = () => {
64
	if (editable())
65
	    return;
66

    
67
	if (list.active_entry !== null)
68
	    list.active_entry.make_noneditable();
69

    
70
	list.active_entry = this;
71

    
72
	this.editable_view.classList.remove("hide");
73
	this.noneditable_view.classList.add("hide");
74

    
75
	this.input.value = text || "";
76
    }
77

    
78
    /*
79
     * Called when 'Cancel' is clicked, when another entry becomes editable and
80
     * when an entry ends being modified.
81
     */
82
    this.make_noneditable = () => {
83
	if (!editable())
84
	    return;
85

    
86
	list.active_entry = null;
87

    
88
	if (!this.exists()) {
89
	    this.main_div.remove();
90
	    return;
91
	}
92

    
93
	this.editable_view.classList.add("hide");
94
	this.noneditable_view.classList.remove("hide");
95
    }
96

    
97
    /*
98
     * The *_cb() calls are allowed to throw if an error occurs. It is expected
99
     * *_cb() will show some dialog that will block other clicks until they
100
     * returns/throw.
101
     */
102

    
103
    const add_clicked = async () => {
104
	if (!editable() || this.exists())
105
	    return;
106

    
107
	await list.create_cb(this.input.value);
108
	this.make_noneditable();
109
    }
110

    
111
    /*
112
     * Changing entry's text is not handled here, instead we wait for subsequent
113
     * item removal and creation requests from the outside.
114
     */
115
    const save_clicked = async () => {
116
	if (!editable() || !this.exists())
117
	    return;
118

    
119
	await list.replace_cb(text, this.input.value);
120
	this.make_noneditable();
121
    }
122

    
123
    const enter_hit = () => add_clicked().then(save_clicked);
124

    
125
    /*
126
     * Removing entry from the list is not handled here, instead we wait for
127
     * subsequent item removal requests from the outside.
128
     */
129
    const remove_clicked = async () => {
130
	if (editable() || !this.exists())
131
	    return;
132

    
133
	await list.remove_cb(text);
134
    }
135

    
136
    /*
137
     * Called from the outside after the entry got removed from the database. It
138
     * is assumed entry_exists() is true when this is called.
139
     */
140
    this.remove = () => {
141
	if (editable()) {
142
	    text = null;
143
	    this.save_but.classList.add("hide");
144
	    this.add_but.classList.remove("hide");
145
	} else {
146
	    this.main_div.remove();
147
	}
148
    }
149

    
150
    if (this.exists()) {
151
	this.text.innerText = text;
152
    } else {
153
	this.save_but.classList.add("hide");
154
	this.add_but.classList.remove("hide");
155
	this.make_editable();
156
    }
157

    
158
    for (const [node, cb] of [
159
	[this.save_but,         save_clicked],
160
	[this.add_but,          add_clicked],
161
	[this.remove_but,       remove_clicked],
162
	[this.cancel_but,       this.make_noneditable],
163
	[this.noneditable_view, this.make_editable],
164
    ])
165
	node.addEventListener("click", list.dialog_ctx.when_hidden(cb));
166

    
167
    const enter_cb = e => (e.key === 'Enter') && enter_hit();
168
    this.input.addEventListener("keypress",
169
				list.dialog_ctx.when_hidden(enter_cb));
170

    
171
    if (entry_idx > 0) {
172
	const prev_text = list.shown_texts[entry_idx - 1];
173
	list.entries_by_text.get(prev_text).main_div.after(this.main_div);
174
    } else {
175
	if (!editable() && list.active_entry && !list.active_entry.exists())
176
	    list.active_entry.main_div.after(this.main_div);
177
	else
178
	    list.list_div.prepend(this.main_div);
179
    }
180
}
181

    
182
function TextEntryList(dialog_ctx, destroy_cb,
183
		       remove_cb, create_cb, replace_cb) {
184
    Object.assign(this, {dialog_ctx, remove_cb, create_cb, replace_cb});
185
    Object.assign(this, clone_template("text_entry_list"));
186
    this.ul = document.createElement("ul");
187
    this.shown_texts = [];
188
    this.entries_by_text = new Map();
189
    this.active_entry = null;
190

    
191
    const find_entry_idx = text => {
192
	let left = 0, right = this.shown_texts.length;
193

    
194
	while (left < right) {
195
	    const mid = (left + right) >> 1;
196
	    if (coll.compare(text, this.shown_texts[mid]) > 0)
197
		left = mid + 1;
198
	    else /* <= 0 */
199
		right = mid;
200
	}
201

    
202
	return left;
203
    }
204

    
205
    this.remove = text => {
206
	if (!this.entries_by_text.has(text))
207
	    return;
208

    
209
	this.shown_texts.splice(find_entry_idx(text), 1);
210
	this.entries_by_text.get(text).remove();
211
	this.entries_by_text.delete(text)
212
    }
213

    
214
    this.add = text => {
215
	if (this.entries_by_text.has(text))
216
	    return;
217

    
218
	const idx = find_entry_idx(text);
219
	this.entries_by_text.set(text, new Entry(text, this, idx));
220
	this.shown_texts.splice(idx, 0, text);
221
    }
222

    
223
    this.destroy = () => {
224
	this.main_div.remove();
225
	destroy_cb();
226
    }
227

    
228
    const add_new = () => new Entry(null, this, 0);
229

    
230
    this.new_but.addEventListener("click", dialog_ctx.when_hidden(add_new));
231
}
232

    
233
async function repo_list(dialog_ctx) {
234
    let list;
235

    
236
    function validate_normalize(repo_url) {
237
	let error_msg;
238

    
239
	/* In the future we might also try making a test connection. */
240
	if (!/^https:\/\/[^/.]+\.[^/.]+/.test(repo_url))
241
	    error_msg = "Provided URL is not valid.";
242

    
243
	if (!/^https:\/\//.test(repo_url))
244
	    error_msg = "Repository URLs shoud use https:// schema.";
245

    
246
	if (error_msg) {
247
	    dialog.error(dialog_ctx, error_msg);
248
	    throw error_msg;
249
	}
250

    
251
	return repo_url.replace(/\/*$/, "/");
252
    }
253

    
254
    async function remove_repo(repo_url) {
255
	dialog.loader(dialog_ctx, `Removing repository '${repo_url}'...`);
256

    
257
	try {
258
	    await haketilodb.del_repo(repo_url);
259
	    var removing_ok = true;
260
	} finally {
261
	    if (!removing_ok) {
262
		dialog.error(dialog_ctx,
263
			     `Failed to remove repository '${repo_url}' :(`);
264
	    }
265

    
266
	    dialog.close(dialog_ctx);
267
	}
268
    }
269

    
270
    async function create_repo(repo_url) {
271
	repo_url = validate_normalize(repo_url);
272

    
273
	dialog.loader(dialog_ctx, `Adding repository '${repo_url}'...`);
274

    
275
	try {
276
	    await haketilodb.set_repo(repo_url);
277
	    var adding_ok = true;
278
	} finally {
279
	    if (!adding_ok)
280
		dialog.error(dialog_ctx, `Failed to add repository '${repo_url}' :(`);
281

    
282
	    dialog.close(dialog_ctx);
283
	}
284
    }
285

    
286
    async function replace_repo(old_repo_url, new_repo_url) {
287
	if (old_repo_url === new_repo_url)
288
	    return;
289

    
290
	new_repo_url = validate_normalize(new_repo_url);
291

    
292
	dialog.loader(dialog_ctx, "Replacing repository...");
293

    
294
	try {
295
	    await haketilodb.set_repo(new_repo_url);
296
	    await haketilodb.del_repo(old_repo_url);
297
	    var replacing_ok = true;
298
	} finally {
299
	    if (!replacing_ok)
300
		dialog.error(dialog_ctx, "Failed to replace repository :(");
301

    
302
	    dialog.close(dialog_ctx);
303
	}
304
    }
305

    
306
    function onchange(change) {
307
	if (change.new_val)
308
	    list.add(change.key);
309
	else
310
	    list.remove(change.key);
311
    }
312

    
313
    dialog.loader(dialog_ctx, "Loading repositories...");
314
    const [tracking, items] = await haketilodb.track.repo(onchange);
315
    dialog.close(dialog_ctx);
316

    
317
    list = new TextEntryList(dialog_ctx, () => haketilodb.untrack(tracking),
318
			     remove_repo, create_repo, replace_repo);
319

    
320
    items.forEach(item => list.add(item.url));
321

    
322
    return list;
323
}
324
#EXPORT repo_list
325

    
326
async function blocking_allowing_lists(dialog_ctx) {
327
    function validate_normalize(url_pattern) {
328
	try {
329
	    return validate_normalize_url_pattern(url_pattern);
330
	} catch(e) {
331
	    dialog.error(dialog_ctx, e);
332
	    throw e;
333
	}
334
    }
335

    
336
    async function default_allowing_on(url_pattern, allow) {
337
	dialog.loader(dialog_ctx,
338
		      `Setting default scripts blocking policy on '${url_pattern}'...`);
339

    
340
	try {
341
	    await haketilodb.set_default_allowing(url_pattern);
342
	    var default_allowing_ok = true;
343
	} finally {
344
	    if (!default_allowing_ok) {
345
		dialog.error(dialog_ctx,
346
			     `Failed to remove rule for '${url_pattern}' :(`);
347
	    }
348

    
349
	    dialog.close(dialog_ctx);
350
	}
351
    }
352

    
353
    async function set_allowing_on(url_pattern, allow) {
354
	url_pattern = validate_normalize(url_pattern);
355

    
356
	const [action, action_cap] = allow ?
357
	      ["allowing", "Allowing"] : ["blocking", "Blocking"];
358
	dialog.loader(dialog_ctx, `${action_cap} scripts on '${url_pattern}'...`);
359

    
360
	try {
361
	    await haketilodb.set_allowed(url_pattern, allow);
362
	    var set_allowing_ok = true;
363
	} finally {
364
	    if (!set_allowing_ok)
365
		dialog.error(dialog_ctx,
366
			     `Failed to write ${action} rule for '${url_pattern}' :(`);
367

    
368
	    dialog.close(dialog_ctx);
369
	}
370
    }
371

    
372
    async function replace_allowing_on(old_pattern, new_pattern, allow) {
373
	new_pattern = validate_normalize(new_pattern);
374
	if (old_pattern === new_pattern)
375
	    return;
376

    
377
	const action = allow ? "allowing" : "blocking";
378
	dialog.loader(dialog_ctx, `Rewriting script ${action} rule...`);
379

    
380
	try {
381
	    await haketilodb.set_allowed(new_pattern, allow);
382
	    await haketilodb.set_default_allowing(old_pattern);
383
	    var replace_allowing_ok = true;
384
	} finally {
385
	    if (!replace_allowing_ok)
386
		dialog.error(dialog_ctx, `Failed to rewrite ${action} rule :(`);
387

    
388
	    dialog.close(dialog_ctx);
389
	}
390
    }
391

    
392
    let blocking_list, allowing_list;
393

    
394
    function onchange(change) {
395
	if (change.new_val) {
396
	    if (change.new_val.allow)
397
		var [to_add, to_remove] = [allowing_list, blocking_list];
398
	    else
399
		var [to_add, to_remove] = [blocking_list, allowing_list];
400

    
401
	    to_add.add(change.key);
402
	    to_remove.remove(change.key);
403
	} else {
404
	    blocking_list.remove(change.key);
405
	    allowing_list.remove(change.key);
406
	}
407
    }
408

    
409
    dialog.loader(dialog_ctx, "Loading script blocking settings...");
410
    const [tracking, items] = await haketilodb.track.blocking(onchange);
411
    dialog.close(dialog_ctx);
412

    
413
    let untrack_called = 0;
414
    function untrack() {
415
	if (++untrack_called === 2)
416
	    haketilodb.untrack(tracking);
417
    }
418

    
419
    const lists = [];
420
    for (const allow of [false, true]) {
421
	lists[allow + 0] =
422
	    new TextEntryList(dialog_ctx, untrack,
423
			      pattern => default_allowing_on(pattern),
424
			      pattern => set_allowing_on(pattern, allow),
425
			      (p1, p2) => replace_allowing_on(p1, p2, allow));
426
    }
427
    [blocking_list, allowing_list] = lists;
428

    
429
    for (const item of items) {
430
	if (item.allow)
431
	    allowing_list.add(item.pattern);
432
	else
433
	    blocking_list.add(item.pattern);
434
    }
435

    
436
    return lists;
437
}
438
#EXPORT blocking_allowing_lists
(26-26/26)