Project

General

Profile

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

haketilo / html / options_main.js @ 826b4fd8

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 item_li_id(prefix, item)
36
{
37
    return `li_${prefix}_${item}`;
38
}
39

    
40
/* Insert into list of bags/pages/scripts/repos */
41
function add_li(prefix, item, at_the_end=false)
42
{
43
    let ul = ul_by_prefix[prefix];
44
    let li = item_li_template.cloneNode(true);
45
    li.id = item_li_id(prefix, item);
46

    
47
    let span = li.firstElementChild;
48
    span.textContent = item;
49

    
50
    let edit_button = span.nextElementSibling;
51
    edit_button.addEventListener("click", () => edit_item(prefix, item));
52

    
53
    let remove_button = edit_button.nextElementSibling;
54
    remove_button.addEventListener("click",
55
				   () => storage.remove(prefix, item));
56

    
57
    let export_button = remove_button.nextElementSibling;
58
    export_button.addEventListener("click",
59
				   () => export_item(prefix, item));
60
    if (prefix === TYPE_PREFIX.REPO)
61
	export_button.remove();
62

    
63
    if (!at_the_end) {
64
	for (let element of ul.ul.children) {
65
	    if (element.id < li.id || element.id.startsWith("work_"))
66
		continue;
67

    
68
	    ul.ul.insertBefore(li, element);
69
	    return;
70
	}
71
    }
72

    
73
    ul.ul.appendChild(li);
74
}
75

    
76
const chbx_components_ul = by_id("chbx_components_ul");
77
const radio_components_ul = by_id("radio_components_ul");
78

    
79
function chbx_li_id(prefix, item)
80
{
81
    return `cli_${prefix}_${item}`;
82
}
83

    
84
function radio_li_id(prefix, item)
85
{
86
    return `rli_${prefix}_${item}`;
87
}
88

    
89
//TODO: refactor the 2 functions below
90

    
91
function add_chbx_li(prefix, name)
92
{
93
    if (![TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(prefix))
94
	return;
95

    
96
    let li = chbx_component_li_template.cloneNode(true);
97
    li.id = chbx_li_id(prefix, name);
98
    li.setAttribute("data-prefix", prefix);
99
    li.setAttribute("data-name", name);
100

    
101
    let chbx = li.firstElementChild;
102
    let span = chbx.nextElementSibling;
103

    
104
    span.textContent = nice_name(prefix, name);
105

    
106
    chbx_components_ul.appendChild(li);
107
}
108

    
109
var radio_component_none_li = by_id("radio_component_none_li");
110

    
111
function add_radio_li(prefix, name)
112
{
113
    if (![TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(prefix))
114
	return;
115

    
116
    let li = radio_component_li_template.cloneNode(true);
117
    li.id = radio_li_id(prefix, name);
118
    li.setAttribute("data-prefix", prefix);
119
    li.setAttribute("data-name", name);
120

    
121
    let radio = li.firstElementChild;
122
    let span = radio.nextElementSibling;
123

    
124
    span.textContent = nice_name(prefix, name);
125

    
126
    radio_components_ul.insertBefore(li, radio_component_none_li);
127
}
128

    
129
/* Used to reset edited repo. */
130
function reset_work_repo_li(ul, item, _)
131
{
132
    ul.work_name_input.value = maybe_string(item);
133
}
134

    
135
/* Used to get repo data for saving */
136
function work_repo_li_data(ul)
137
{
138
    return [ul.work_name_input.value, {}];
139
}
140

    
141
const page_payload_span = by_id("page_payload");
142

    
143
function set_page_components(components)
144
{
145
    if (components === undefined) {
146
	page_payload_span.setAttribute("data-payload", "no");
147
	page_payload_span.textContent = "(None)";
148
    } else {
149
	page_payload_span.setAttribute("data-payload", "yes");
150
	let [prefix, name] = components;
151
	page_payload_span.setAttribute("data-prefix", prefix);
152
	page_payload_span.setAttribute("data-name", name);
153
	page_payload_span.textContent = nice_name(prefix, name);
154
    }
155
}
156

    
157
const page_allow_chbx = by_id("page_allow_chbx");
158

    
159
/* Used to reset edited page. */
160
function reset_work_page_li(ul, item, settings)
161
{
162
    ul.work_name_input.value = maybe_string(item);
163
    settings = settings || {allow: false, components: undefined};
164
    page_allow_chbx.checked = !!settings.allow;
165

    
166
    set_page_components(settings.components);
167
}
168

    
169
function work_page_li_components()
170
{
171
    if (page_payload_span.getAttribute("data-payload") === "no")
172
	return undefined;
173

    
174
    let prefix = page_payload_span.getAttribute("data-prefix");
175
    let name = page_payload_span.getAttribute("data-name");
176
    return [prefix, name];
177
}
178

    
179
/* Used to get edited page data for saving. */
180
function work_page_li_data(ul)
181
{
182
    let url = ul.work_name_input.value;
183
    let settings = {
184
	components : work_page_li_components(),
185
	allow : !!page_allow_chbx.checked
186
    };
187

    
188
    return [url, settings];
189
}
190

    
191
const empty_bag_component_li = by_id("empty_bag_component_li");
192
var bag_components_ul = by_id("bag_components_ul");
193

    
194
/* Used to construct and update components list of edited bag. */
195
function add_bag_components(components)
196
{
197
    for (let component of components) {
198
	let [prefix, name] = component;
199
	let li = bag_component_li_template.cloneNode(true);
200
	li.setAttribute("data-prefix", prefix);
201
	li.setAttribute("data-name", name);
202
	let span = li.firstElementChild;
203
	span.textContent = nice_name(prefix, name);
204
	let remove_but = span.nextElementSibling;
205
	remove_but.addEventListener("click", () =>
206
				    bag_components_ul.removeChild(li));
207
	bag_components_ul.appendChild(li);
208
    }
209

    
210
    bag_components_ul.appendChild(empty_bag_component_li);
211
}
212

    
213
/* Used to reset edited bag. */
214
function reset_work_bag_li(ul, item, components)
215
{
216
    components = components || [];
217

    
218
    ul.work_name_input.value = maybe_string(item);
219
    let old_components_ul = bag_components_ul;
220
    bag_components_ul = old_components_ul.cloneNode(false);
221

    
222
    ul.work_li.insertBefore(bag_components_ul, old_components_ul);
223
    ul.work_li.removeChild(old_components_ul);
224

    
225
    add_bag_components(components);
226
}
227

    
228
/* Used to get edited bag data for saving. */
229
function work_bag_li_data(ul)
230
{
231
    let components_ul = ul.work_name_input.nextElementSibling;
232
    let component_li = components_ul.firstElementChild;
233

    
234
    let components = [];
235

    
236
    /* Last list element is empty li with id set. */
237
    while (component_li.id === '') {
238
	components.push([component_li.getAttribute("data-prefix"),
239
			 component_li.getAttribute("data-name")]);
240
	component_li = component_li.nextElementSibling;
241
    }
242

    
243
    return [ul.work_name_input.value, components];
244
}
245

    
246
const script_url_input = by_id("script_url_field");
247
const script_sha256_input = by_id("script_sha256_field");
248
const script_contents_field = by_id("script_contents_field");
249

    
250
function maybe_string(maybe_defined)
251
{
252
    return maybe_defined === undefined ? "" : maybe_defined + "";
253
}
254

    
255
/* Used to reset edited script. */
256
function reset_work_script_li(ul, name, data)
257
{
258
    ul.work_name_input.value = maybe_string(name);
259
    if (data === undefined)
260
	data = {};
261
    script_url_input.value = maybe_string(data.url);
262
    script_sha256_input.value = maybe_string(data.hash);
263
    script_contents_field.value = maybe_string(data.text);
264
}
265

    
266
/* Used to get edited script data for saving. */
267
function work_script_li_data(ul)
268
{
269
    return [ul.work_name_input.value, {
270
	url : script_url_input.value,
271
	hash : script_sha256_input.value,
272
	text : script_contents_field.value
273
    }];
274
}
275

    
276
function cancel_work(prefix)
277
{
278
    let ul = ul_by_prefix[prefix];
279

    
280
    if (ul.state === UL_STATE.IDLE)
281
	return;
282

    
283
    if (ul.state === UL_STATE.EDITING_ENTRY) {
284
	add_li(prefix, ul.edited_item);
285
    }
286

    
287
    ul.work_li.classList.add("hide");
288
    ul.state = UL_STATE.IDLE;
289
}
290

    
291
function save_work(prefix)
292
{
293
    let ul = ul_by_prefix[prefix];
294

    
295
    if (ul.state === UL_STATE.IDLE)
296
	return;
297

    
298
    let [item, data] = ul.get_work_li_data(ul);
299

    
300
    /* Here we fire promises and return without waiting. */
301

    
302
    if (ul.state === UL_STATE.EDITING_ENTRY)
303
	storage.replace(prefix, ul.edited_item, item, data);
304
    if (ul.state === UL_STATE.ADDING_ENTRY)
305
	storage.set(prefix, item, data);
306

    
307
    cancel_work(prefix);
308
}
309

    
310
function edit_item(prefix, item)
311
{
312
    cancel_work(prefix);
313

    
314
    let ul = ul_by_prefix[prefix];
315
    let li = by_id(item_li_id(prefix, item));
316

    
317
    if (li === null) {
318
	add_new_item(prefix, item);
319
	return;
320
    }
321

    
322
    ul.reset_work_li(ul, item, storage.get(prefix, item));
323
    ul.ul.insertBefore(ul.work_li, li);
324
    ul.ul.removeChild(li);
325
    ul.work_li.classList.remove("hide");
326

    
327
    ul.state = UL_STATE.EDITING_ENTRY;
328
    ul.edited_item = item;
329
}
330

    
331
const file_downloader = by_id("file_downloader");
332

    
333
function recursively_export_item(prefix, name, added_items, items_data)
334
{
335
    let key = prefix + name;
336

    
337
    if (added_items.has(key))
338
	return;
339

    
340
    let data = storage.get(prefix, name);
341
    if (data === undefined) {
342
	console.log(`${TYPE_NAME[prefix]} '${name}' for export not found`);
343
	return;
344
    }
345

    
346
    if (prefix !== TYPE_PREFIX.SCRIPT) {
347
	let components = prefix === TYPE_PREFIX.BAG ?
348
	    data : [data.components];
349

    
350
	for (let [comp_prefix, comp_name] of components) {
351
	    recursively_export_item(comp_prefix, comp_name,
352
				    added_items, items_data);
353
	}
354
    }
355

    
356
    items_data.push({[key]: data});
357
    added_items.add(key);
358
}
359

    
360
function export_item(prefix, name)
361
{
362
    let added_items = new Set();
363
    let items_data = [];
364
    recursively_export_item(prefix, name, added_items, items_data);
365
    let file = new Blob([JSON.stringify(items_data)],
366
			{type: "application/json"});
367
    let url = URL.createObjectURL(file);
368
    file_downloader.setAttribute("href", url);
369
    file_downloader.setAttribute("download", prefix + name + ".json");
370
    file_downloader.click();
371
    file_downloader.removeAttribute("href");
372
    URL.revokeObjectURL(url);
373
}
374

    
375
function add_new_item(prefix, name)
376
{
377
    cancel_work(prefix);
378

    
379
    let ul = ul_by_prefix[prefix];
380
    ul.reset_work_li(ul);
381
    ul.work_li.classList.remove("hide");
382
    ul.ul.appendChild(ul.work_li);
383

    
384
    if (name !== undefined)
385
	ul.work_name_input.value = name;
386
    ul.state = UL_STATE.ADDING_ENTRY;
387
}
388

    
389
const chbx_components_window = by_id("chbx_components_window");
390

    
391
function bag_components()
392
{
393
    chbx_components_window.classList.remove("hide");
394
    radio_components_window.classList.add("hide");
395

    
396
    for (let li of chbx_components_ul.children) {
397
	let chbx = li.firstElementChild;
398
	chbx.checked = false;
399
    }
400
}
401

    
402
function commit_bag_components()
403
{
404
    let selected = [];
405

    
406
    for (let li of chbx_components_ul.children) {
407
	let chbx = li.firstElementChild;
408
	if (!chbx.checked)
409
	    continue;
410

    
411
	selected.push([li.getAttribute("data-prefix"),
412
		       li.getAttribute("data-name")]);
413
    }
414

    
415
    add_bag_components(selected);
416
    cancel_components();
417
}
418

    
419
const radio_components_window = by_id("radio_components_window");
420
var radio_component_none_input = by_id("radio_component_none_input");
421

    
422
function page_components()
423
{
424
    radio_components_window.classList.remove("hide");
425
    chbx_components_window.classList.add("hide");
426

    
427
    radio_component_none_input.checked = true;
428

    
429
    let components = work_page_li_components();
430
    if (components === undefined)
431
	return;
432

    
433
    let [prefix, item] = components;
434
    let li = by_id(radio_li_id(prefix, item));
435
    if (li === null)
436
	radio_component_none_input.checked = false;
437
    else
438
	li.firstElementChild.checked = true;
439
}
440

    
441
function commit_page_components()
442
{
443
    let components = null;
444

    
445
    for (let li of radio_components_ul.children) {
446
	let radio = li.firstElementChild;
447
	if (!radio.checked)
448
	    continue;
449

    
450
	components = [li.getAttribute("data-prefix"),
451
		      li.getAttribute("data-name")];
452

    
453
	if (radio.id === "radio_component_none_input")
454
	    components = undefined;
455

    
456
	break;
457
    }
458

    
459
    if (components !== null)
460
	set_page_components(components);
461
    cancel_components();
462
}
463

    
464
function cancel_components()
465
{
466
    chbx_components_window.classList.add("hide");
467
    radio_components_window.classList.add("hide");
468
}
469

    
470
const UL_STATE = {
471
    EDITING_ENTRY : 0,
472
    ADDING_ENTRY : 1,
473
    IDLE : 2
474
};
475

    
476
const ul_by_prefix = {
477
    [TYPE_PREFIX.REPO] : {
478
	ul : by_id("repos_ul"),
479
	work_li : by_id("work_repo_li"),
480
	work_name_input : by_id("repo_url_field"),
481
	reset_work_li : reset_work_repo_li,
482
	get_work_li_data : work_repo_li_data,
483
	state : UL_STATE.IDLE,
484
	edited_item : undefined,
485
    },
486
    [TYPE_PREFIX.PAGE] : {
487
	ul : by_id("pages_ul"),
488
	work_li : by_id("work_page_li"),
489
	work_name_input : by_id("page_url_field"),
490
	reset_work_li : reset_work_page_li,
491
	get_work_li_data : work_page_li_data,
492
	select_components : page_components,
493
	commit_components : commit_page_components,
494
	state : UL_STATE.IDLE,
495
	edited_item : undefined,
496
    },
497
    [TYPE_PREFIX.BAG] : {
498
	ul : by_id("bags_ul"),
499
	work_li : by_id("work_bag_li"),
500
	work_name_input : by_id("bag_name_field"),
501
	reset_work_li : reset_work_bag_li,
502
	get_work_li_data : work_bag_li_data,
503
	select_components : bag_components,
504
	commit_components : commit_bag_components,
505
	state : UL_STATE.IDLE,
506
	edited_item : undefined,
507
    },
508
    [TYPE_PREFIX.SCRIPT] : {
509
	ul : by_id("scripts_ul"),
510
	work_li : by_id("work_script_li"),
511
	work_name_input : by_id("script_name_field"),
512
	reset_work_li : reset_work_script_li,
513
	get_work_li_data : work_script_li_data,
514
	state : UL_STATE.IDLE,
515
	edited_item : undefined,
516
    }
517
}
518

    
519
/*
520
 * Newer browsers could utilise `text' method of File objects.
521
 * Older ones require FileReader.
522
 */
523

    
524
function _read_file(file, resolve, reject)
525
{
526
    let reader = new FileReader();
527

    
528
    reader.onload = () => resolve(reader.result);
529
    reader.onerror = () => reject(reader.error);
530
    reader.readAsText(file);
531
}
532

    
533
function read_file(file)
534
{
535
    return new Promise((resolve, reject) =>
536
		       _read_file(file, resolve, reject));
537
}
538

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

    
542
const settings_schema = [
543
    [{}, "matchentry", "minentries", 1,
544
     new RegExp(`^${TYPE_PREFIX.SCRIPT}`), {
545
	 /* script data */
546
	 "url":    ["optional", url_regex, "or", empty_regex],
547
	 "sha256": ["optional", matchers.sha256, "or", empty_regex],
548
	 "text":   ["optional", "string"]
549
     },
550
     new RegExp(`^${TYPE_PREFIX.BAG}`), [
551
	 "optional",
552
	 [matchers.component, "repeat"],
553
	 "default", undefined
554
     ],
555
     new RegExp(`^${TYPE_PREFIX.PAGE}`), {
556
	 /* page data */
557
	 "components": ["optional", matchers.component]
558
     }], "repeat"
559
];
560

    
561
const import_window = by_id("import_window");
562
let import_frame;
563

    
564
async function import_from_file(event)
565
{
566
    let files = event.target.files;
567
    if (files.length < 1)
568
	return;
569

    
570
    import_window.classList.remove("hide");
571
    import_frame.show_loading();
572

    
573
    try {
574
	const file = await read_file(files[0]);
575
	var result = parse_json_with_schema(settings_schema, file);
576
    } catch(e) {
577
	import_frame.show_error("Bad file :(", "" + e);
578
	return;
579
    }
580

    
581
    import_frame.show_selection(result);
582
}
583

    
584
const file_opener_form = by_id("file_opener_form");
585

    
586
function hide_import_window()
587
{
588
    import_window.classList.add("hide");
589

    
590
    /*
591
     * Reset file <input>. Without this, a second attempt to import the same
592
     * file would result in "change" event not happening on <input> element.
593
     */
594
    file_opener_form.reset();
595
}
596

    
597
async function initialize_import_facility()
598
{
599
    let import_but = by_id("import_but");
600
    let file_opener = by_id("file_opener");
601

    
602
    import_but.addEventListener("click", () => file_opener.click());
603
    file_opener.addEventListener("change", import_from_file);
604

    
605
    import_frame = await get_import_frame();
606
    import_frame.onclose = hide_import_window;
607
}
608

    
609
/*
610
 * If url has a target appended, e.g.
611
 * chrome-extension://hnhmbnpohhlmhehionjgongbnfdnabdl/html/options.html#smyhax
612
 * that target will be split into prefix and item name (e.g. "s" and "myhax")
613
 * and editing of that respective item will be started.
614
 *
615
 * We don't need to worry about the state of the page (e.g. some editing being
616
 * in progress) in jump_to_item() - this function is called at the beginning,
617
 * together with callbacks being assigned to buttons, so it is safe to assume
618
 * lists are initialized with items and page is in its virgin state with regard
619
 * to everything else.
620
 */
621
function jump_to_item(url_with_item)
622
{
623
    const [dummy1, base_url, dummy2, target] =
624
	  /^([^#]*)(#(.*))?$/i.exec(url_with_item);
625
    if (target === undefined)
626
	return;
627

    
628
    const prefix = target.substring(0, 1);
629

    
630
    if (!list_prefixes.includes(prefix)) {
631
	history.replaceState(null, "", base_url);
632
	return;
633
    }
634

    
635
    by_id(`show_${TYPE_NAME[prefix]}s`).checked = true;
636
    edit_item(prefix, decodeURIComponent(target.substring(1)));
637
}
638

    
639
async function main()
640
{
641
    storage = await get_remote_storage();
642

    
643
    for (let prefix of list_prefixes) {
644
	for (let item of storage.get_all_names(prefix).sort()) {
645
	    add_li(prefix, item, true);
646
	    add_chbx_li(prefix, item);
647
	    add_radio_li(prefix, item);
648
	}
649

    
650
	let name = TYPE_NAME[prefix];
651

    
652
	let add_but = by_id(`add_${name}_but`);
653
	let discard_but = by_id(`discard_${name}_but`);
654
	let save_but = by_id(`save_${name}_but`);
655

    
656
	add_but.addEventListener("click", () => add_new_item(prefix));
657
	discard_but.addEventListener("click", () => cancel_work(prefix));
658
	save_but.addEventListener("click", () => save_work(prefix));
659

    
660
	if ([TYPE_PREFIX.REPO, TYPE_PREFIX.SCRIPT].includes(prefix))
661
	    continue;
662

    
663
	let ul = ul_by_prefix[prefix];
664

    
665
	let commit_components_but = by_id(`commit_${name}_components_but`);
666
	let cancel_components_but = by_id(`cancel_${name}_components_but`);
667
	let select_components_but = by_id(`select_${name}_components_but`);
668

    
669
	commit_components_but
670
	    .addEventListener("click", ul.commit_components);
671
	select_components_but
672
	    .addEventListener("click", ul.select_components);
673
	cancel_components_but.addEventListener("click", cancel_components);
674
    }
675

    
676
    jump_to_item(document.URL);
677

    
678
    storage.add_change_listener(handle_change);
679

    
680
    await initialize_import_facility();
681
}
682

    
683
function handle_change(change)
684
{
685
    if (change.old_val === undefined) {
686
	add_li(change.prefix, change.item);
687
	add_chbx_li(change.prefix, change.item);
688
	add_radio_li(change.prefix, change.item);
689

    
690
	return;
691
    }
692

    
693
    if (change.new_val !== undefined)
694
	return;
695

    
696
    let ul = ul_by_prefix[change.prefix];
697
    if (ul.state === UL_STATE.EDITING_ENTRY &&
698
	ul.edited_item === change.item) {
699
	ul.state = UL_STATE.ADDING_ENTRY;
700
	return;
701
    }
702

    
703
    let uls_creators = [[ul.ul, item_li_id]];
704

    
705
    if ([TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(change.prefix)) {
706
	uls_creators.push([chbx_components_ul, chbx_li_id]);
707
	uls_creators.push([radio_components_ul, radio_li_id]);
708
    }
709

    
710
    for (let [components_ul, id_creator] of uls_creators) {
711
	let li = by_id(id_creator(change.prefix, change.item));
712
	components_ul.removeChild(li);
713
    }
714
}
715

    
716
main();
(8-8/8)