Project

General

Profile

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

haketilo / html / options_main.js @ 453ba039

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
	if (ul.work_li !== ul.ul.lastElementChild)
82
	    ul.ul.appendChild(li);
83
	else
84
	    ul.work_li.before(li);
85
    }
86

    
87
    list_set_scrollbar(ul.ul);
88
}
89

    
90
const chbx_components_ul = by_id("chbx_components_ul");
91
const radio_components_ul = by_id("radio_components_ul");
92

    
93
function chbx_li_id(prefix, item)
94
{
95
    return `cli_${prefix}_${item}`;
96
}
97

    
98
function radio_li_id(prefix, item)
99
{
100
    return `rli_${prefix}_${item}`;
101
}
102

    
103
//TODO: refactor the 2 functions below
104

    
105
function add_chbx_li(prefix, name)
106
{
107
    if (![TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(prefix))
108
	return;
109

    
110
    let li = chbx_component_li_template.cloneNode(true);
111
    li.id = chbx_li_id(prefix, name);
112
    li.setAttribute("data-prefix", prefix);
113
    li.setAttribute("data-name", name);
114

    
115
    let chbx = li.firstElementChild.firstElementChild;
116
    let span = chbx.nextElementSibling;
117

    
118
    span.textContent = nice_name(prefix, name);
119

    
120
    chbx_components_ul.appendChild(li);
121
    list_set_scrollbar(chbx_components_ul);
122
}
123

    
124
var radio_component_none_li = by_id("radio_component_none_li");
125

    
126
function add_radio_li(prefix, name)
127
{
128
    if (![TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(prefix))
129
	return;
130

    
131
    let li = radio_component_li_template.cloneNode(true);
132
    li.id = radio_li_id(prefix, name);
133
    li.setAttribute("data-prefix", prefix);
134
    li.setAttribute("data-name", name);
135

    
136
    let radio = li.firstElementChild.firstElementChild;
137
    let span = radio.nextElementSibling;
138

    
139
    span.textContent = nice_name(prefix, name);
140

    
141
    radio_component_none_li.before(li);
142
    list_set_scrollbar(radio_components_ul);
143
}
144

    
145
/* Used to reset edited repo. */
146
function reset_work_repo_li(ul, item, _)
147
{
148
    ul.work_name_input.value = maybe_string(item);
149
}
150

    
151
/* Used to get repo data for saving */
152
function work_repo_li_data(ul)
153
{
154
    return [ul.work_name_input.value, {}];
155
}
156

    
157
const page_payload_span = by_id("page_payload");
158

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

    
173
const page_allow_chbx = by_id("page_allow_chbx");
174

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

    
182
    set_page_components(settings.components);
183
}
184

    
185
function work_page_li_components()
186
{
187
    if (page_payload_span.getAttribute("data-payload") === "no")
188
	return undefined;
189

    
190
    let prefix = page_payload_span.getAttribute("data-prefix");
191
    let name = page_payload_span.getAttribute("data-name");
192
    return [prefix, name];
193
}
194

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

    
204
    return [url, settings];
205
}
206

    
207
const empty_bag_component_li = by_id("empty_bag_component_li");
208
var bag_components_ul = by_id("bag_components_ul");
209

    
210
function remove_bag_component_entry(entry)
211
{
212
    const list = entry.parentElement;
213
    entry.remove();
214
    list_set_scrollbar(list);
215
}
216

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

    
226
	let span = li.firstElementChild;
227
	span.textContent = nice_name(prefix, name);
228
	let remove_but = span.nextElementSibling;
229
	remove_but.addEventListener("click",
230
				    () => remove_bag_component_entry(li));
231
	bag_components_ul.appendChild(li);
232
    }
233

    
234
    bag_components_ul.appendChild(empty_bag_component_li);
235
    list_set_scrollbar(bag_components_ul);
236
}
237

    
238
/* Used to reset edited bag. */
239
function reset_work_bag_li(ul, item, components)
240
{
241
    components = components || [];
242

    
243
    ul.work_name_input.value = maybe_string(item);
244
    let old_components_ul = bag_components_ul;
245
    bag_components_ul = old_components_ul.cloneNode(false);
246

    
247
    old_components_ul.replaceWith(bag_components_ul);
248

    
249
    add_bag_components(components);
250
}
251

    
252
/* Used to get edited bag data for saving. */
253
function work_bag_li_data(ul)
254
{
255
    let component_li = bag_components_ul.firstElementChild;
256

    
257
    let components = [];
258

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

    
266
    return [ul.work_name_input.value, components];
267
}
268

    
269
const script_url_input = by_id("script_url_field");
270
const script_sha256_input = by_id("script_sha256_field");
271
const script_contents_field = by_id("script_contents_field");
272

    
273
function maybe_string(maybe_defined)
274
{
275
    return maybe_defined === undefined ? "" : maybe_defined + "";
276
}
277

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

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

    
299
function cancel_work(prefix)
300
{
301
    let ul = ul_by_prefix[prefix];
302

    
303
    if (ul.state === UL_STATE.IDLE)
304
	return;
305

    
306
    if (ul.state === UL_STATE.EDITING_ENTRY) {
307
	add_li(prefix, ul.edited_item);
308
    }
309

    
310
    ul.work_li.classList.add("hide");
311
    ul.ul.append(ul.work_li);
312
    list_set_scrollbar(ul.ul);
313
    ul.state = UL_STATE.IDLE;
314
}
315

    
316
function save_work(prefix)
317
{
318
    let ul = ul_by_prefix[prefix];
319

    
320
    if (ul.state === UL_STATE.IDLE)
321
	return;
322

    
323
    let [item, data] = ul.get_work_li_data(ul);
324

    
325
    /* Here we fire promises and return without waiting. */
326

    
327
    if (ul.state === UL_STATE.EDITING_ENTRY)
328
	storage.replace(prefix, ul.edited_item, item, data);
329
    if (ul.state === UL_STATE.ADDING_ENTRY)
330
	storage.set(prefix, item, data);
331

    
332
    cancel_work(prefix);
333
}
334

    
335
function edit_item(prefix, item)
336
{
337
    cancel_work(prefix);
338

    
339
    let ul = ul_by_prefix[prefix];
340
    let li = by_id(item_li_id(prefix, item));
341

    
342
    if (li === null) {
343
	add_new_item(prefix, item);
344
	return;
345
    }
346

    
347
    ul.reset_work_li(ul, item, storage.get(prefix, item));
348
    ul.ul.insertBefore(ul.work_li, li);
349
    ul.ul.removeChild(li);
350
    ul.work_li.classList.remove("hide");
351
    list_set_scrollbar(ul.ul);
352

    
353
    ul.state = UL_STATE.EDITING_ENTRY;
354
    ul.edited_item = item;
355
}
356

    
357
const file_downloader = by_id("file_downloader");
358

    
359
function recursively_export_item(prefix, name, added_items, items_data)
360
{
361
    let key = prefix + name;
362

    
363
    if (added_items.has(key))
364
	return;
365

    
366
    let data = storage.get(prefix, name);
367
    if (data === undefined) {
368
	console.log(`${TYPE_NAME[prefix]} '${name}' for export not found`);
369
	return;
370
    }
371

    
372
    if (prefix !== TYPE_PREFIX.SCRIPT) {
373
	let components = prefix === TYPE_PREFIX.BAG ?
374
	    data : [data.components];
375

    
376
	for (let [comp_prefix, comp_name] of components) {
377
	    recursively_export_item(comp_prefix, comp_name,
378
				    added_items, items_data);
379
	}
380
    }
381

    
382
    items_data.push({[key]: data});
383
    added_items.add(key);
384
}
385

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

    
401
function add_new_item(prefix, name)
402
{
403
    cancel_work(prefix);
404

    
405
    let ul = ul_by_prefix[prefix];
406
    ul.reset_work_li(ul);
407
    ul.work_li.classList.remove("hide");
408
    ul.ul.appendChild(ul.work_li);
409
    list_set_scrollbar(ul.ul);
410

    
411
    if (name !== undefined)
412
	ul.work_name_input.value = name;
413
    ul.state = UL_STATE.ADDING_ENTRY;
414
}
415

    
416
const chbx_components_window = by_id("chbx_components_window");
417

    
418
function bag_components()
419
{
420
    chbx_components_window.classList.remove("hide");
421
    radio_components_window.classList.add("hide");
422

    
423
    for (let li of chbx_components_ul.children) {
424
	let chbx = li.firstElementChild.firstElementChild;
425
	chbx.checked = false;
426
    }
427
}
428

    
429
function commit_bag_components()
430
{
431
    let selected = [];
432

    
433
    for (let li of chbx_components_ul.children) {
434
	let chbx = li.firstElementChild.firstElementChild;
435
	if (!chbx.checked)
436
	    continue;
437

    
438
	selected.push([li.getAttribute("data-prefix"),
439
		       li.getAttribute("data-name")]);
440
    }
441

    
442
    add_bag_components(selected);
443
    cancel_components();
444
}
445

    
446
const radio_components_window = by_id("radio_components_window");
447
var radio_component_none_input = by_id("radio_component_none_input");
448

    
449
function page_components()
450
{
451
    radio_components_window.classList.remove("hide");
452
    chbx_components_window.classList.add("hide");
453

    
454
    radio_component_none_input.checked = true;
455

    
456
    let components = work_page_li_components();
457
    if (components === undefined)
458
	return;
459

    
460
    let [prefix, item] = components;
461
    let li = by_id(radio_li_id(prefix, item));
462

    
463
    if (li === null)
464
	radio_component_none_input.checked = false;
465
    else
466
	li.firstElementChild.firstElementChild.checked = true;
467
}
468

    
469
function commit_page_components()
470
{
471
    let components = null;
472

    
473
    for (let li of radio_components_ul.children) {
474
	let radio = li.firstElementChild.firstElementChild;
475
	if (!radio.checked)
476
	    continue;
477

    
478
	components = [li.getAttribute("data-prefix"),
479
		      li.getAttribute("data-name")];
480

    
481
	if (radio.id === "radio_component_none_input")
482
	    components = undefined;
483

    
484
	break;
485
    }
486

    
487
    if (components !== null)
488
	set_page_components(components);
489
    cancel_components();
490
}
491

    
492
function cancel_components()
493
{
494
    chbx_components_window.classList.add("hide");
495
    radio_components_window.classList.add("hide");
496
}
497

    
498
const UL_STATE = {
499
    EDITING_ENTRY : 0,
500
    ADDING_ENTRY : 1,
501
    IDLE : 2
502
};
503

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

    
547
/*
548
 * Newer browsers could utilise `text' method of File objects.
549
 * Older ones require FileReader.
550
 */
551

    
552
function _read_file(file, resolve, reject)
553
{
554
    let reader = new FileReader();
555

    
556
    reader.onload = () => resolve(reader.result);
557
    reader.onerror = () => reject(reader.error);
558
    reader.readAsText(file);
559
}
560

    
561
function read_file(file)
562
{
563
    return new Promise((resolve, reject) =>
564
		       _read_file(file, resolve, reject));
565
}
566

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

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

    
589
const import_window = by_id("import_window");
590
let import_frame;
591

    
592
async function import_from_file(event)
593
{
594
    let files = event.target.files;
595
    if (files.length < 1)
596
	return;
597

    
598
    import_window.classList.remove("hide");
599
    import_frame.show_loading();
600

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

    
609
    import_frame.show_selection(result);
610
}
611

    
612
const file_opener_form = by_id("file_opener_form");
613

    
614
function hide_import_window()
615
{
616
    import_window.classList.add("hide");
617

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

    
625
async function initialize_import_facility()
626
{
627
    let import_but = by_id("import_but");
628
    let file_opener = by_id("file_opener");
629

    
630
    import_but.addEventListener("click", () => file_opener.click());
631
    file_opener.addEventListener("change", import_from_file);
632

    
633
    import_frame = await get_import_frame();
634
    import_frame.onclose = hide_import_window;
635
}
636

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

    
656
    const prefix = target.substring(0, 1);
657

    
658
    if (!list_prefixes.includes(prefix)) {
659
	history.replaceState(null, "", base_url);
660
	return;
661
    }
662

    
663
    by_id(`show_${TYPE_NAME[prefix]}s`).checked = true;
664
    edit_item(prefix, decodeURIComponent(target.substring(1)));
665
}
666

    
667
async function main()
668
{
669
    storage = await get_remote_storage();
670

    
671
    for (let prefix of list_prefixes) {
672
	for (let item of storage.get_all_names(prefix).sort()) {
673
	    add_li(prefix, item, true);
674
	    add_chbx_li(prefix, item);
675
	    add_radio_li(prefix, item);
676
	}
677

    
678
	let name = TYPE_NAME[prefix];
679

    
680
	let add_but = by_id(`add_${name}_but`);
681
	let discard_but = by_id(`discard_${name}_but`);
682
	let save_but = by_id(`save_${name}_but`);
683

    
684
	add_but.addEventListener("click", () => add_new_item(prefix));
685
	discard_but.addEventListener("click", () => cancel_work(prefix));
686
	save_but.addEventListener("click", () => save_work(prefix));
687

    
688
	if ([TYPE_PREFIX.REPO, TYPE_PREFIX.SCRIPT].includes(prefix))
689
	    continue;
690

    
691
	let ul = ul_by_prefix[prefix];
692

    
693
	let commit_components_but = by_id(`commit_${name}_components_but`);
694
	let cancel_components_but = by_id(`cancel_${name}_components_but`);
695
	let select_components_but = by_id(`select_${name}_components_but`);
696

    
697
	commit_components_but
698
	    .addEventListener("click", ul.commit_components);
699
	select_components_but
700
	    .addEventListener("click", ul.select_components);
701
	cancel_components_but.addEventListener("click", cancel_components);
702
    }
703

    
704
    jump_to_item(document.URL);
705

    
706
    storage.add_change_listener(handle_change);
707

    
708
    await initialize_import_facility();
709
}
710

    
711
function handle_change(change)
712
{
713
    if (change.old_val === undefined) {
714
	add_li(change.prefix, change.item);
715
	add_chbx_li(change.prefix, change.item);
716
	add_radio_li(change.prefix, change.item);
717

    
718
	return;
719
    }
720

    
721
    if (change.new_val !== undefined)
722
	return;
723

    
724
    let ul = ul_by_prefix[change.prefix];
725
    if (ul.state === UL_STATE.EDITING_ENTRY &&
726
	ul.edited_item === change.item) {
727
	ul.state = UL_STATE.ADDING_ENTRY;
728
	return;
729
    }
730

    
731
    let uls_creators = [[ul.ul, item_li_id]];
732

    
733
    if ([TYPE_PREFIX.BAG, TYPE_PREFIX.SCRIPT].includes(change.prefix)) {
734
	uls_creators.push([chbx_components_ul, chbx_li_id]);
735
	uls_creators.push([radio_components_ul, radio_li_id]);
736
    }
737

    
738
    for (let [components_ul, id_creator] of uls_creators) {
739
	let li = by_id(id_creator(change.prefix, change.item));
740
	components_ul.removeChild(li);
741
	list_set_scrollbar(components_ul);
742
    }
743
}
744

    
745
main();
(9-9/11)