Project

General

Profile

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

haketilo / html / options_main.js @ 792fbe18

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 by_id
17
 * IMPORT matchers
18
 * IMPORT get_import_frame
19
 * IMPORTS_END
20
 */
21

    
22
var storage;
23

    
24
const item_li_template = by_id("item_li_template");
25
const bag_component_li_template = by_id("bag_component_li_template");
26
const chbx_component_li_template = by_id("chbx_component_li_template");
27
const radio_component_li_template = by_id("radio_component_li_template");
28
/* Make sure they are later cloned without id. */
29
item_li_template.removeAttribute("id");
30
bag_component_li_template.removeAttribute("id");
31
chbx_component_li_template.removeAttribute("id");
32
radio_component_li_template.removeAttribute("id");
33

    
34
function item_li_id(prefix, item)
35
{
36
    return `li_${prefix}_${item}`;
37
}
38

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

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

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

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

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

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

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

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

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

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

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

    
88
//TODO: refactor the 2 functions below
89

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

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

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

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

    
105
    chbx_components_ul.appendChild(li);
106
}
107

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

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

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

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

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

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

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

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

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

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

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

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

    
165
    set_page_components(settings.components);
166
}
167

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

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

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

    
187
    return [url, settings];
188
}
189

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

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

    
209
    bag_components_ul.appendChild(empty_bag_component_li);
210
}
211

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

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

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

    
224
    add_bag_components(components);
225
}
226

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

    
233
    let components = [];
234

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
306
    cancel_work(prefix);
307
}
308

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
426
    radio_component_none_input.checked = true;
427

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

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

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

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

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

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

    
455
	break;
456
    }
457

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
580
    import_frame.show_selection(result);
581
}
582

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

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

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

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

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

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

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

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

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

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

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

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

    
649
	let name = TYPE_NAME[prefix];
650

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

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

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

    
662
	let ul = ul_by_prefix[prefix];
663

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

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

    
675
    jump_to_item(document.URL);
676

    
677
    storage.add_change_listener(handle_change);
678

    
679
    await initialize_import_facility();
680
}
681

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

    
689
	return;
690
    }
691

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

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

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

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

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

    
715
main();
(8-8/8)