Project

General

Profile

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

haketilo / html / options_main.js @ 544c6df3

1
/**
2
 * Hachette HTML options page main script
3
 *
4
 * Copyright (C) 2021 Wojtek Kosior
5
 * Redistribution terms are gathered in the `copyright' file.
6
 */
7

    
8
/*
9
 * IMPORTS_START
10
 * IMPORT get_remote_storage
11
 * IMPORT TYPE_PREFIX
12
 * IMPORT TYPE_NAME
13
 * IMPORT list_prefixes
14
 * IMPORT nice_name
15
 * IMPORT parse_json_with_schema
16
 * IMPORT get_template
17
 * IMPORT by_id
18
 * IMPORT matchers
19
 * IMPORT get_import_frame
20
 * IMPORTS_END
21
 */
22

    
23
var storage;
24

    
25
const item_li_template = get_template("item_li");
26
const bag_component_li_template = get_template("bag_component_li");
27
const chbx_component_li_template = get_template("chbx_component_li");
28
const radio_component_li_template = get_template("radio_component_li");
29
/* Make sure they are later cloned without id. */
30
item_li_template.removeAttribute("id");
31
bag_component_li_template.removeAttribute("id");
32
chbx_component_li_template.removeAttribute("id");
33
radio_component_li_template.removeAttribute("id");
34

    
35
function list_set_scrollbar(list_elem)
36
{
37
    const op = ((list_elem.children.length === 1 &&
38
		 list_elem.children[0].classList.contains("hide")) ||
39
		list_elem.children.length < 1) ? "remove" : "add";
40
    list_elem.parentElement.parentElement.classList[op]("always_scrollbar");
41
}
42

    
43
function item_li_id(prefix, item)
44
{
45
    return `li_${prefix}_${item}`;
46
}
47

    
48
/* Insert into list of bags/pages/scripts/repos */
49
function add_li(prefix, item, at_the_end=false)
50
{
51
    let ul = ul_by_prefix[prefix];
52
    let li = item_li_template.cloneNode(true);
53
    li.id = item_li_id(prefix, item);
54

    
55
    let span = li.firstElementChild;
56
    span.textContent = item;
57

    
58
    let edit_button = span.nextElementSibling;
59
    edit_button.addEventListener("click", () => edit_item(prefix, item));
60

    
61
    let remove_button = edit_button.nextElementSibling;
62
    remove_button.addEventListener("click",
63
				   () => storage.remove(prefix, item));
64

    
65
    let export_button = remove_button.nextElementSibling;
66
    export_button.addEventListener("click",
67
				   () => export_item(prefix, item));
68
    if (prefix === TYPE_PREFIX.REPO)
69
	export_button.remove();
70

    
71
    if (!at_the_end) {
72
	for (let element of ul.ul.children) {
73
	    if (element.id < li.id || element.id.startsWith("work_"))
74
		continue;
75

    
76
	    ul.ul.insertBefore(li, element);
77
	    break;
78
	}
79
    }
80
    if (!li.parentElement)
81
	ul.ul.appendChild(li);
82

    
83
    list_set_scrollbar(ul.ul);
84
}
85

    
86
const chbx_components_ul = by_id("chbx_components_ul");
87
const radio_components_ul = by_id("radio_components_ul");
88

    
89
function chbx_li_id(prefix, item)
90
{
91
    return `cli_${prefix}_${item}`;
92
}
93

    
94
function radio_li_id(prefix, item)
95
{
96
    return `rli_${prefix}_${item}`;
97
}
98

    
99
//TODO: refactor the 2 functions below
100

    
101
function add_chbx_li(prefix, name)
102
{
103
    if (![TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(prefix))
104
	return;
105

    
106
    let li = chbx_component_li_template.cloneNode(true);
107
    li.id = chbx_li_id(prefix, name);
108
    li.setAttribute("data-prefix", prefix);
109
    li.setAttribute("data-name", name);
110

    
111
    let chbx = li.firstElementChild.firstElementChild;
112
    let span = chbx.nextElementSibling;
113

    
114
    span.textContent = nice_name(prefix, name);
115

    
116
    chbx_components_ul.appendChild(li);
117
    list_set_scrollbar(chbx_components_ul);
118
}
119

    
120
var radio_component_none_li = by_id("radio_component_none_li");
121

    
122
function add_radio_li(prefix, name)
123
{
124
    if (![TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(prefix))
125
	return;
126

    
127
    let li = radio_component_li_template.cloneNode(true);
128
    li.id = radio_li_id(prefix, name);
129
    li.setAttribute("data-prefix", prefix);
130
    li.setAttribute("data-name", name);
131

    
132
    let radio = li.firstElementChild.firstElementChild;
133
    let span = radio.nextElementSibling;
134

    
135
    span.textContent = nice_name(prefix, name);
136

    
137
    radio_component_none_li.before(li);
138
    list_set_scrollbar(radio_components_ul);
139
}
140

    
141
/* Used to reset edited repo. */
142
function reset_work_repo_li(ul, item, _)
143
{
144
    ul.work_name_input.value = maybe_string(item);
145
}
146

    
147
/* Used to get repo data for saving */
148
function work_repo_li_data(ul)
149
{
150
    return [ul.work_name_input.value, {}];
151
}
152

    
153
const page_payload_span = by_id("page_payload");
154

    
155
function set_page_components(components)
156
{
157
    if (components === undefined) {
158
	page_payload_span.setAttribute("data-payload", "no");
159
	page_payload_span.textContent = "(None)";
160
    } else {
161
	page_payload_span.setAttribute("data-payload", "yes");
162
	let [prefix, name] = components;
163
	page_payload_span.setAttribute("data-prefix", prefix);
164
	page_payload_span.setAttribute("data-name", name);
165
	page_payload_span.textContent = nice_name(prefix, name);
166
    }
167
}
168

    
169
const page_allow_chbx = by_id("page_allow_chbx");
170

    
171
/* Used to reset edited page. */
172
function reset_work_page_li(ul, item, settings)
173
{
174
    ul.work_name_input.value = maybe_string(item);
175
    settings = settings || {allow: false, components: undefined};
176
    page_allow_chbx.checked = !!settings.allow;
177

    
178
    set_page_components(settings.components);
179
}
180

    
181
function work_page_li_components()
182
{
183
    if (page_payload_span.getAttribute("data-payload") === "no")
184
	return undefined;
185

    
186
    let prefix = page_payload_span.getAttribute("data-prefix");
187
    let name = page_payload_span.getAttribute("data-name");
188
    return [prefix, name];
189
}
190

    
191
/* Used to get edited page data for saving. */
192
function work_page_li_data(ul)
193
{
194
    let url = ul.work_name_input.value;
195
    let settings = {
196
	components : work_page_li_components(),
197
	allow : !!page_allow_chbx.checked
198
    };
199

    
200
    return [url, settings];
201
}
202

    
203
const empty_bag_component_li = by_id("empty_bag_component_li");
204
var bag_components_ul = by_id("bag_components_ul");
205

    
206
function remove_bag_component_entry(entry)
207
{
208
    const list = entry.parentElement;
209
    entry.remove();
210
    list_set_scrollbar(list);
211
}
212

    
213
/* Used to construct and update components list of edited bag. */
214
function add_bag_components(components)
215
{
216
    for (let component of components) {
217
	let [prefix, name] = component;
218
	let li = bag_component_li_template.cloneNode(true);
219
	li.setAttribute("data-prefix", prefix);
220
	li.setAttribute("data-name", name);
221

    
222
	let span = li.firstElementChild;
223
	span.textContent = nice_name(prefix, name);
224
	let remove_but = span.nextElementSibling;
225
	remove_but.addEventListener("click",
226
				    () => remove_bag_component_entry(li));
227
	bag_components_ul.appendChild(li);
228
    }
229

    
230
    bag_components_ul.appendChild(empty_bag_component_li);
231
    list_set_scrollbar(bag_components_ul);
232
}
233

    
234
/* Used to reset edited bag. */
235
function reset_work_bag_li(ul, item, components)
236
{
237
    components = components || [];
238

    
239
    ul.work_name_input.value = maybe_string(item);
240
    let old_components_ul = bag_components_ul;
241
    bag_components_ul = old_components_ul.cloneNode(false);
242

    
243
    old_components_ul.replaceWith(bag_components_ul);
244

    
245
    add_bag_components(components);
246
}
247

    
248
/* Used to get edited bag data for saving. */
249
function work_bag_li_data(ul)
250
{
251
    let component_li = bag_components_ul.firstElementChild;
252

    
253
    let components = [];
254

    
255
    /* Last list element is empty li with id set. */
256
    while (component_li.id === '') {
257
	components.push([component_li.getAttribute("data-prefix"),
258
			 component_li.getAttribute("data-name")]);
259
	component_li = component_li.nextElementSibling;
260
    }
261

    
262
    return [ul.work_name_input.value, components];
263
}
264

    
265
const script_url_input = by_id("script_url_field");
266
const script_sha256_input = by_id("script_sha256_field");
267
const script_contents_field = by_id("script_contents_field");
268

    
269
function maybe_string(maybe_defined)
270
{
271
    return maybe_defined === undefined ? "" : maybe_defined + "";
272
}
273

    
274
/* Used to reset edited script. */
275
function reset_work_script_li(ul, name, data)
276
{
277
    ul.work_name_input.value = maybe_string(name);
278
    if (data === undefined)
279
	data = {};
280
    script_url_input.value = maybe_string(data.url);
281
    script_sha256_input.value = maybe_string(data.hash);
282
    script_contents_field.value = maybe_string(data.text);
283
}
284

    
285
/* Used to get edited script data for saving. */
286
function work_script_li_data(ul)
287
{
288
    return [ul.work_name_input.value, {
289
	url : script_url_input.value,
290
	hash : script_sha256_input.value,
291
	text : script_contents_field.value
292
    }];
293
}
294

    
295
function cancel_work(prefix)
296
{
297
    let ul = ul_by_prefix[prefix];
298

    
299
    if (ul.state === UL_STATE.IDLE)
300
	return;
301

    
302
    if (ul.state === UL_STATE.EDITING_ENTRY) {
303
	add_li(prefix, ul.edited_item);
304
    }
305

    
306
    ul.work_li.classList.add("hide");
307
    ul.ul.append(ul.work_li);
308
    list_set_scrollbar(ul.ul);
309
    ul.state = UL_STATE.IDLE;
310
}
311

    
312
function save_work(prefix)
313
{
314
    let ul = ul_by_prefix[prefix];
315

    
316
    if (ul.state === UL_STATE.IDLE)
317
	return;
318

    
319
    let [item, data] = ul.get_work_li_data(ul);
320

    
321
    /* Here we fire promises and return without waiting. */
322

    
323
    if (ul.state === UL_STATE.EDITING_ENTRY)
324
	storage.replace(prefix, ul.edited_item, item, data);
325
    if (ul.state === UL_STATE.ADDING_ENTRY)
326
	storage.set(prefix, item, data);
327

    
328
    cancel_work(prefix);
329
}
330

    
331
function edit_item(prefix, item)
332
{
333
    cancel_work(prefix);
334

    
335
    let ul = ul_by_prefix[prefix];
336
    let li = by_id(item_li_id(prefix, item));
337

    
338
    if (li === null) {
339
	add_new_item(prefix, item);
340
	return;
341
    }
342

    
343
    ul.reset_work_li(ul, item, storage.get(prefix, item));
344
    ul.ul.insertBefore(ul.work_li, li);
345
    ul.ul.removeChild(li);
346
    ul.work_li.classList.remove("hide");
347
    list_set_scrollbar(ul.ul);
348

    
349
    ul.state = UL_STATE.EDITING_ENTRY;
350
    ul.edited_item = item;
351
}
352

    
353
const file_downloader = by_id("file_downloader");
354

    
355
function recursively_export_item(prefix, name, added_items, items_data)
356
{
357
    let key = prefix + name;
358

    
359
    if (added_items.has(key))
360
	return;
361

    
362
    let data = storage.get(prefix, name);
363
    if (data === undefined) {
364
	console.log(`${TYPE_NAME[prefix]} '${name}' for export not found`);
365
	return;
366
    }
367

    
368
    if (prefix !== TYPE_PREFIX.SCRIPT) {
369
	let components = prefix === TYPE_PREFIX.BAG ?
370
	    data : [data.components];
371

    
372
	for (let [comp_prefix, comp_name] of components) {
373
	    recursively_export_item(comp_prefix, comp_name,
374
				    added_items, items_data);
375
	}
376
    }
377

    
378
    items_data.push({[key]: data});
379
    added_items.add(key);
380
}
381

    
382
function export_item(prefix, name)
383
{
384
    let added_items = new Set();
385
    let items_data = [];
386
    recursively_export_item(prefix, name, added_items, items_data);
387
    let file = new Blob([JSON.stringify(items_data)],
388
			{type: "application/json"});
389
    let url = URL.createObjectURL(file);
390
    file_downloader.setAttribute("href", url);
391
    file_downloader.setAttribute("download", prefix + name + ".json");
392
    file_downloader.click();
393
    file_downloader.removeAttribute("href");
394
    URL.revokeObjectURL(url);
395
}
396

    
397
function add_new_item(prefix, name)
398
{
399
    cancel_work(prefix);
400

    
401
    let ul = ul_by_prefix[prefix];
402
    ul.reset_work_li(ul);
403
    ul.work_li.classList.remove("hide");
404
    ul.ul.appendChild(ul.work_li);
405
    list_set_scrollbar(ul.ul);
406

    
407
    if (name !== undefined)
408
	ul.work_name_input.value = name;
409
    ul.state = UL_STATE.ADDING_ENTRY;
410
}
411

    
412
const chbx_components_window = by_id("chbx_components_window");
413

    
414
function bag_components()
415
{
416
    chbx_components_window.classList.remove("hide");
417
    radio_components_window.classList.add("hide");
418

    
419
    for (let li of chbx_components_ul.children) {
420
	let chbx = li.firstElementChild.firstElementChild;
421
	chbx.checked = false;
422
    }
423
}
424

    
425
function commit_bag_components()
426
{
427
    let selected = [];
428

    
429
    for (let li of chbx_components_ul.children) {
430
	let chbx = li.firstElementChild.firstElementChild;
431
	if (!chbx.checked)
432
	    continue;
433

    
434
	selected.push([li.getAttribute("data-prefix"),
435
		       li.getAttribute("data-name")]);
436
    }
437

    
438
    add_bag_components(selected);
439
    cancel_components();
440
}
441

    
442
const radio_components_window = by_id("radio_components_window");
443
var radio_component_none_input = by_id("radio_component_none_input");
444

    
445
function page_components()
446
{
447
    radio_components_window.classList.remove("hide");
448
    chbx_components_window.classList.add("hide");
449

    
450
    radio_component_none_input.checked = true;
451

    
452
    let components = work_page_li_components();
453
    if (components === undefined)
454
	return;
455

    
456
    let [prefix, item] = components;
457
    let li = by_id(radio_li_id(prefix, item));
458

    
459
    if (li === null)
460
	radio_component_none_input.checked = false;
461
    else
462
	li.firstElementChild.firstElementChild.checked = true;
463
}
464

    
465
function commit_page_components()
466
{
467
    let components = null;
468

    
469
    for (let li of radio_components_ul.children) {
470
	let radio = li.firstElementChild.firstElementChild;
471
	if (!radio.checked)
472
	    continue;
473

    
474
	components = [li.getAttribute("data-prefix"),
475
		      li.getAttribute("data-name")];
476

    
477
	if (radio.id === "radio_component_none_input")
478
	    components = undefined;
479

    
480
	break;
481
    }
482

    
483
    if (components !== null)
484
	set_page_components(components);
485
    cancel_components();
486
}
487

    
488
function cancel_components()
489
{
490
    chbx_components_window.classList.add("hide");
491
    radio_components_window.classList.add("hide");
492
}
493

    
494
const UL_STATE = {
495
    EDITING_ENTRY : 0,
496
    ADDING_ENTRY : 1,
497
    IDLE : 2
498
};
499

    
500
const ul_by_prefix = {
501
    [TYPE_PREFIX.REPO] : {
502
	ul : by_id("repos_ul"),
503
	work_li : by_id("work_repo_li"),
504
	work_name_input : by_id("repo_url_field"),
505
	reset_work_li : reset_work_repo_li,
506
	get_work_li_data : work_repo_li_data,
507
	state : UL_STATE.IDLE,
508
	edited_item : undefined,
509
    },
510
    [TYPE_PREFIX.PAGE] : {
511
	ul : by_id("pages_ul"),
512
	work_li : by_id("work_page_li"),
513
	work_name_input : by_id("page_url_field"),
514
	reset_work_li : reset_work_page_li,
515
	get_work_li_data : work_page_li_data,
516
	select_components : page_components,
517
	commit_components : commit_page_components,
518
	state : UL_STATE.IDLE,
519
	edited_item : undefined,
520
    },
521
    [TYPE_PREFIX.BAG] : {
522
	ul : by_id("bags_ul"),
523
	work_li : by_id("work_bag_li"),
524
	work_name_input : by_id("bag_name_field"),
525
	reset_work_li : reset_work_bag_li,
526
	get_work_li_data : work_bag_li_data,
527
	select_components : bag_components,
528
	commit_components : commit_bag_components,
529
	state : UL_STATE.IDLE,
530
	edited_item : undefined,
531
    },
532
    [TYPE_PREFIX.SCRIPT] : {
533
	ul : by_id("scripts_ul"),
534
	work_li : by_id("work_script_li"),
535
	work_name_input : by_id("script_name_field"),
536
	reset_work_li : reset_work_script_li,
537
	get_work_li_data : work_script_li_data,
538
	state : UL_STATE.IDLE,
539
	edited_item : undefined,
540
    }
541
}
542

    
543
/*
544
 * Newer browsers could utilise `text' method of File objects.
545
 * Older ones require FileReader.
546
 */
547

    
548
function _read_file(file, resolve, reject)
549
{
550
    let reader = new FileReader();
551

    
552
    reader.onload = () => resolve(reader.result);
553
    reader.onerror = () => reject(reader.error);
554
    reader.readAsText(file);
555
}
556

    
557
function read_file(file)
558
{
559
    return new Promise((resolve, reject) =>
560
		       _read_file(file, resolve, reject));
561
}
562

    
563
const url_regex = /^[a-z0-9]+:\/\/[^/]+\.[^/]{2,}(\/[^?#]*)?$/;
564
const empty_regex = /^$/;
565

    
566
const settings_schema = [
567
    [{}, "matchentry", "minentries", 1,
568
     new RegExp(`^${TYPE_PREFIX.SCRIPT}`), {
569
	 /* script data */
570
	 "url":    ["optional", url_regex, "or", empty_regex],
571
	 "sha256": ["optional", matchers.sha256, "or", empty_regex],
572
	 "text":   ["optional", "string"]
573
     },
574
     new RegExp(`^${TYPE_PREFIX.BAG}`), [
575
	 "optional",
576
	 [matchers.component, "repeat"],
577
	 "default", undefined
578
     ],
579
     new RegExp(`^${TYPE_PREFIX.PAGE}`), {
580
	 /* page data */
581
	 "components": ["optional", matchers.component]
582
     }], "repeat"
583
];
584

    
585
const import_window = by_id("import_window");
586
let import_frame;
587

    
588
async function import_from_file(event)
589
{
590
    let files = event.target.files;
591
    if (files.length < 1)
592
	return;
593

    
594
    import_window.classList.remove("hide");
595
    import_frame.show_loading();
596

    
597
    try {
598
	const file = await read_file(files[0]);
599
	var result = parse_json_with_schema(settings_schema, file);
600
    } catch(e) {
601
	import_frame.show_error("Bad file :(", "" + e);
602
	return;
603
    }
604

    
605
    import_frame.show_selection(result);
606
}
607

    
608
const file_opener_form = by_id("file_opener_form");
609

    
610
function hide_import_window()
611
{
612
    import_window.classList.add("hide");
613

    
614
    /*
615
     * Reset file <input>. Without this, a second attempt to import the same
616
     * file would result in "change" event not happening on <input> element.
617
     */
618
    file_opener_form.reset();
619
}
620

    
621
async function initialize_import_facility()
622
{
623
    let import_but = by_id("import_but");
624
    let file_opener = by_id("file_opener");
625

    
626
    import_but.addEventListener("click", () => file_opener.click());
627
    file_opener.addEventListener("change", import_from_file);
628

    
629
    import_frame = await get_import_frame();
630
    import_frame.onclose = hide_import_window;
631
}
632

    
633
/*
634
 * If url has a target appended, e.g.
635
 * chrome-extension://hnhmbnpohhlmhehionjgongbnfdnabdl/html/options.html#smyhax
636
 * that target will be split into prefix and item name (e.g. "s" and "myhax")
637
 * and editing of that respective item will be started.
638
 *
639
 * We don't need to worry about the state of the page (e.g. some editing being
640
 * in progress) in jump_to_item() - this function is called at the beginning,
641
 * together with callbacks being assigned to buttons, so it is safe to assume
642
 * lists are initialized with items and page is in its virgin state with regard
643
 * to everything else.
644
 */
645
function jump_to_item(url_with_item)
646
{
647
    const [dummy1, base_url, dummy2, target] =
648
	  /^([^#]*)(#(.*))?$/i.exec(url_with_item);
649
    if (target === undefined)
650
	return;
651

    
652
    const prefix = target.substring(0, 1);
653

    
654
    if (!list_prefixes.includes(prefix)) {
655
	history.replaceState(null, "", base_url);
656
	return;
657
    }
658

    
659
    by_id(`show_${TYPE_NAME[prefix]}s`).checked = true;
660
    edit_item(prefix, decodeURIComponent(target.substring(1)));
661
}
662

    
663
async function main()
664
{
665
    storage = await get_remote_storage();
666

    
667
    for (let prefix of list_prefixes) {
668
	for (let item of storage.get_all_names(prefix).sort()) {
669
	    add_li(prefix, item, true);
670
	    add_chbx_li(prefix, item);
671
	    add_radio_li(prefix, item);
672
	}
673

    
674
	let name = TYPE_NAME[prefix];
675

    
676
	let add_but = by_id(`add_${name}_but`);
677
	let discard_but = by_id(`discard_${name}_but`);
678
	let save_but = by_id(`save_${name}_but`);
679

    
680
	add_but.addEventListener("click", () => add_new_item(prefix));
681
	discard_but.addEventListener("click", () => cancel_work(prefix));
682
	save_but.addEventListener("click", () => save_work(prefix));
683

    
684
	if ([TYPE_PREFIX.REPO, TYPE_PREFIX.SCRIPT].includes(prefix))
685
	    continue;
686

    
687
	let ul = ul_by_prefix[prefix];
688

    
689
	let commit_components_but = by_id(`commit_${name}_components_but`);
690
	let cancel_components_but = by_id(`cancel_${name}_components_but`);
691
	let select_components_but = by_id(`select_${name}_components_but`);
692

    
693
	commit_components_but
694
	    .addEventListener("click", ul.commit_components);
695
	select_components_but
696
	    .addEventListener("click", ul.select_components);
697
	cancel_components_but.addEventListener("click", cancel_components);
698
    }
699

    
700
    jump_to_item(document.URL);
701

    
702
    storage.add_change_listener(handle_change);
703

    
704
    await initialize_import_facility();
705
}
706

    
707
function handle_change(change)
708
{
709
    if (change.old_val === undefined) {
710
	add_li(change.prefix, change.item);
711
	add_chbx_li(change.prefix, change.item);
712
	add_radio_li(change.prefix, change.item);
713

    
714
	return;
715
    }
716

    
717
    if (change.new_val !== undefined)
718
	return;
719

    
720
    let ul = ul_by_prefix[change.prefix];
721
    if (ul.state === UL_STATE.EDITING_ENTRY &&
722
	ul.edited_item === change.item) {
723
	ul.state = UL_STATE.ADDING_ENTRY;
724
	return;
725
    }
726

    
727
    let uls_creators = [[ul.ul, item_li_id]];
728

    
729
    if ([TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(change.prefix)) {
730
	uls_creators.push([chbx_components_ul, chbx_li_id]);
731
	uls_creators.push([radio_components_ul, radio_li_id]);
732
    }
733

    
734
    for (let [components_ul, id_creator] of uls_creators) {
735
	let li = by_id(id_creator(change.prefix, change.item));
736
	components_ul.removeChild(li);
737
	list_set_scrollbar(components_ul);
738
    }
739
}
740

    
741
main();
(8-8/9)