Project

General

Profile

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

haketilo / html / text_entry_list.js @ 1f9ccef9

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
	/* Make exception for localhost while disallowing http://. */
247
	if (/^http:\/\/(127\.0\.0\.1|localhost)([:/].*)?$/.test(repo_url))
248
	    error_msg = null;
249

    
250
	if (error_msg) {
251
	    dialog.error(dialog_ctx, error_msg);
252
	    throw error_msg;
253
	}
254

    
255
	return repo_url.replace(/\/*$/, "/");
256
    }
257

    
258
    async function remove_repo(repo_url) {
259
	dialog.loader(dialog_ctx, `Removing repository '${repo_url}'...`);
260

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

    
270
	    dialog.close(dialog_ctx);
271
	}
272
    }
273

    
274
    async function create_repo(repo_url) {
275
	repo_url = validate_normalize(repo_url);
276

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

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

    
286
	    dialog.close(dialog_ctx);
287
	}
288
    }
289

    
290
    async function replace_repo(old_repo_url, new_repo_url) {
291
	if (old_repo_url === new_repo_url)
292
	    return;
293

    
294
	new_repo_url = validate_normalize(new_repo_url);
295

    
296
	dialog.loader(dialog_ctx, "Replacing repository...");
297

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

    
306
	    dialog.close(dialog_ctx);
307
	}
308
    }
309

    
310
    function onchange(change) {
311
	if (change.new_val)
312
	    list.add(change.key);
313
	else
314
	    list.remove(change.key);
315
    }
316

    
317
    dialog.loader(dialog_ctx, "Loading repositories...");
318
    const [tracking, items] = await haketilodb.track.repo(onchange);
319
    dialog.close(dialog_ctx);
320

    
321
    list = new TextEntryList(dialog_ctx, () => haketilodb.untrack(tracking),
322
			     remove_repo, create_repo, replace_repo);
323

    
324
    items.forEach(item => list.add(item.url));
325

    
326
    return list;
327
}
328
#EXPORT repo_list
329

    
330
async function blocking_allowing_lists(dialog_ctx) {
331
    function validate_normalize(url_pattern) {
332
	try {
333
	    return validate_normalize_url_pattern(url_pattern);
334
	} catch(e) {
335
	    dialog.error(dialog_ctx, e);
336
	    throw e;
337
	}
338
    }
339

    
340
    async function default_allowing_on(url_pattern, allow) {
341
	dialog.loader(dialog_ctx,
342
		      `Setting default scripts blocking policy on '${url_pattern}'...`);
343

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

    
353
	    dialog.close(dialog_ctx);
354
	}
355
    }
356

    
357
    async function set_allowing_on(url_pattern, allow) {
358
	url_pattern = validate_normalize(url_pattern);
359

    
360
	const [action, action_cap] = allow ?
361
	      ["allowing", "Allowing"] : ["blocking", "Blocking"];
362
	dialog.loader(dialog_ctx, `${action_cap} scripts on '${url_pattern}'...`);
363

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

    
372
	    dialog.close(dialog_ctx);
373
	}
374
    }
375

    
376
    async function replace_allowing_on(old_pattern, new_pattern, allow) {
377
	new_pattern = validate_normalize(new_pattern);
378
	if (old_pattern === new_pattern)
379
	    return;
380

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

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

    
392
	    dialog.close(dialog_ctx);
393
	}
394
    }
395

    
396
    let blocking_list, allowing_list;
397

    
398
    function onchange(change) {
399
	if (change.new_val) {
400
	    if (change.new_val.allow)
401
		var [to_add, to_remove] = [allowing_list, blocking_list];
402
	    else
403
		var [to_add, to_remove] = [blocking_list, allowing_list];
404

    
405
	    to_add.add(change.key);
406
	    to_remove.remove(change.key);
407
	} else {
408
	    blocking_list.remove(change.key);
409
	    allowing_list.remove(change.key);
410
	}
411
    }
412

    
413
    dialog.loader(dialog_ctx, "Loading script blocking settings...");
414
    const [tracking, items] = await haketilodb.track.blocking(onchange);
415
    dialog.close(dialog_ctx);
416

    
417
    let untrack_called = 0;
418
    function untrack() {
419
	if (++untrack_called === 2)
420
	    haketilodb.untrack(tracking);
421
    }
422

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

    
433
    for (const item of items) {
434
	if (item.allow)
435
	    allowing_list.add(item.pattern);
436
	else
437
	    blocking_list.add(item.pattern);
438
    }
439

    
440
    return lists;
441
}
442
#EXPORT blocking_allowing_lists
(28-28/28)