diff options
author | jahoti <jahoti@tilde.team> | 2021-08-17 00:00:00 +0000 |
---|---|---|
committer | jahoti <jahoti@tilde.team> | 2021-08-17 00:00:00 +0000 |
commit | 5b7c9edbbb46074436b819435feb80ebbd9ab4ad (patch) | |
tree | cfa7ef034d99dc1957ce3b146256d766a6a8426d | |
parent | 7796e55405e2c27f053122bdec25ffc06df92b4f (diff) | |
parent | 443bc095a72949adb4a007c9a19a43da7dd8843d (diff) | |
download | browser-extension-5b7c9edbbb46074436b819435feb80ebbd9ab4ad.tar.gz browser-extension-5b7c9edbbb46074436b819435feb80ebbd9ab4ad.zip |
Merge remote-tracking branch 'origin/master' into jahoti
-rw-r--r-- | background/main.js | 2 | ||||
-rw-r--r-- | background/page_actions_server.js | 6 | ||||
-rw-r--r-- | background/page_info_server.js | 74 | ||||
-rw-r--r-- | background/policy_injector.js | 149 | ||||
-rw-r--r-- | background/storage.js | 24 | ||||
-rwxr-xr-x | build.sh | 42 | ||||
-rw-r--r-- | common/connection_types.js | 3 | ||||
-rw-r--r-- | common/misc.js | 50 | ||||
-rw-r--r-- | common/observable.js | 36 | ||||
-rw-r--r-- | common/sanitize_JSON.js | 400 | ||||
-rw-r--r-- | common/settings_query.js (renamed from background/settings_query.js) | 33 | ||||
-rw-r--r-- | common/stored_types.js | 4 | ||||
-rw-r--r-- | content/activity_info_server.js | 32 | ||||
-rw-r--r-- | content/main.js | 3 | ||||
-rw-r--r-- | content/repo_query.js | 111 | ||||
-rw-r--r-- | copyright | 6 | ||||
-rw-r--r-- | default_settings.json | 3 | ||||
-rw-r--r-- | html/DOM_helpers.js | 41 | ||||
-rw-r--r-- | html/base.css | 44 | ||||
-rw-r--r-- | html/display-panel.html | 123 | ||||
-rw-r--r-- | html/display-panel.js | 392 | ||||
-rw-r--r-- | html/import_frame.html | 27 | ||||
-rw-r--r-- | html/import_frame.js | 163 | ||||
-rw-r--r-- | html/options.html | 47 | ||||
-rw-r--r-- | html/options_main.js | 176 | ||||
-rwxr-xr-x | process_html_file.sh | 33 | ||||
-rw-r--r-- | shell_utils.sh | 39 |
27 files changed, 1472 insertions, 591 deletions
diff --git a/background/main.js b/background/main.js index ffa814e..7c50fd5 100644 --- a/background/main.js +++ b/background/main.js @@ -12,7 +12,6 @@ * IMPORT start_storage_server * IMPORT start_page_actions_server * IMPORT start_policy_injector - * IMPORT start_page_info_server * IMPORT browser * IMPORTS_END */ @@ -20,7 +19,6 @@ start_storage_server(); start_page_actions_server(); start_policy_injector(); -start_page_info_server(); async function init_ext(install_details) { diff --git a/background/page_actions_server.js b/background/page_actions_server.js index a7a44c1..58a0073 100644 --- a/background/page_actions_server.js +++ b/background/page_actions_server.js @@ -13,18 +13,17 @@ * IMPORT browser * IMPORT listen_for_connection * IMPORT sha256 - * IMPORT get_query_best + * IMPORT query_best * IMPORT make_ajax_request * IMPORTS_END */ var storage; -var query_best; var handler; function send_actions(url, port) { - const [pattern, settings] = query_best(url); + const [pattern, settings] = query_best(storage, url); const repos = storage.get_all(TYPE_PREFIX.REPO); port.postMessage(["settings", [pattern, settings, repos]]); @@ -126,7 +125,6 @@ function new_connection(port) async function start_page_actions_server() { storage = await get_storage(); - query_best = await get_query_best(); listen_for_connection(CONNECTION_TYPE.PAGE_ACTIONS, new_connection); } diff --git a/background/page_info_server.js b/background/page_info_server.js deleted file mode 100644 index 6f02750..0000000 --- a/background/page_info_server.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * part of Hachette - * Serving of storage data corresponding to requested urls (server side). - * - * Copyright (C) 2021 Wojtek Kosior - * Redistribution terms are gathered in the `copyright' file. - */ - -/* - * IMPORTS_START - * IMPORT listen_for_connection - * IMPORT get_storage - * IMPORT get_query_all - * IMPORT TYPE_PREFIX - * IMPORT CONNECTION_TYPE - * IMPORT url_matches - * IMPORTS_END - */ - -var storage; -var query_all; - -function handle_change(connection_data, change) -{ - if (change.prefix !== TYPE_PREFIX.PAGE) - return; - - connection_data.port.postMessage(["change", change]); -} - -async function handle_subscription(connection_data, message) -{ - const [action, url] = message; - if (action === "unsubscribe") { - connection_data.subscribed.delete(url); - return; - } - - connection_data.subscribed.add(url); - connection_data.port.postMessage(["new_url", query_all(url)]); -} - -function new_connection(port) -{ - console.log("new page info connection!"); - - const connection_data = { - subscribed : new Set(), - port - }; - - let _handle_change = change => handle_change(connection_data, change); - - storage.add_change_listener(_handle_change); - - port.onMessage.addListener(m => handle_subscription(connection_data, m)); - port.onDisconnect.addListener( - () => storage.remove_change_listener(_handle_change) - ); -} - -async function start_page_info_server() -{ - storage = await get_storage(); - query_all = await get_query_all(); - - listen_for_connection(CONNECTION_TYPE.PAGE_INFO, new_connection); -} - -/* - * EXPORTS_START - * EXPORT start_page_info_server - * EXPORTS_END - */ diff --git a/background/policy_injector.js b/background/policy_injector.js index f58fb71..9725e99 100644 --- a/background/policy_injector.js +++ b/background/policy_injector.js @@ -16,36 +16,32 @@ * IMPORT gen_unique * IMPORT gen_nonce * IMPORT is_privileged_url + * IMPORT url_item * IMPORT url_extract_target * IMPORT sign_policy - * IMPORT get_query_best - * IMPORT parse_csp + * IMPORT query_best + * IMPORT sanitize_csp_header * IMPORTS_END */ var storage; -var query_best; - -const csp_header_names = { - "content-security-policy" : true, - "x-webkit-csp" : true, - "x-content-security-policy" : true -}; - -const unwanted_csp_directives = { - "report-to" : true, - "report-uri" : true, - "script-src" : true, - "script-src-elem" : true, - "prefetch-src": true -}; -const report_only = "content-security-policy-report-only"; +const csp_header_names = new Set([ + "content-security-policy", + "x-webkit-csp", + "x-content-security-policy" +]); + +/* TODO: variable no longer in use; remove if not needed */ +const unwanted_csp_directives = new Set([ + "report-to", + "report-uri", + "script-src", + "script-src-elem", + "prefetch-src" +]); -function not_csp_header(header) -{ - return !csp_header_names[header.name.toLowerCase()]; -} +const report_only = "content-security-policy-report-only"; function url_inject(details) { @@ -60,7 +56,7 @@ function url_inject(details) if (targets.policy) targets.target = ""; - let [pattern, settings] = query_best(targets.base_url); + let [pattern, settings] = query_best(storage, targets.base_url); /* Defaults */ if (!pattern) settings = {}; @@ -83,38 +79,6 @@ function url_inject(details) }; } -function process_csp_header(header, rule, block) -{ - const csp = parse_csp(header.value); - - /* No snitching */ - delete csp['report-to']; - delete csp['report-uri']; - - if (block) { - delete csp['script-src']; - delete csp['script-src-elem']; - csp['script-src-attr'] = ["'none'"]; - csp['prefetch-src'] = ["'none'"]; - } - - if ('script-src' in csp) - csp['script-src'].push(rule); - else - csp['script-src'] = [rule]; - - if ('script-src-elem' in csp) - csp['script-src-elem'].push(rule); - else - csp['script-src-elem'] = [rule]; - - const new_policy = Object.entries(csp).map( - i => i[0] + ' ' + i[1].join(' ') + ';' - ); - - return {name: header.name, value: new_policy.join('')} -} - function headers_inject(details) { const targets = url_extract_target(details.url); @@ -128,48 +92,44 @@ function headers_inject(details) const rule = `'nonce-${targets.policy.nonce}'`; const block = !targets.policy.allow; - - for (let header of details.responseHeaders) { - if (not_csp_header(header)) { - /* Retain all non-snitching headers */ - if (header.name.toLowerCase() !== report_only) { - headers.push(header); - - /* If these are the original CSP headers, use them instead */ - /* Test based on url_extract_target() in misc.js */ - if (is_mozilla && header.name === "x-orig-csp") { - let index = header.value.indexOf('%5B'); - if (index === -1) - continue; - - let sig = header.value.substring(0, index); - let data = header.value.substring(index); - if (sig !== sign_policy(data, 0)) - continue; - - /* Confirmed- it's the originals, smuggled in! */ - try { - data = JSON.parse(decodeURIComponent(data)); - } catch (e) { - /* This should not be reached - - it's our self-produced valid JSON. */ - console.log("Unexpected internal error - invalid JSON smuggled!", e); - } - - orig_csp_headers = csp_headers = null; - for (let header of data) - headers.push(process_csp_header(header, rule, block)); + + for (const header of details.responseHeaders) { + if (!csp_header_names.has(header)) { + /* Remove headers that only snitch on us */ + if (header.name.toLowerCase() === report_only && block) + continue; + headers.push(header); + + /* If these are the original CSP headers, use them instead */ + /* Test based on url_extract_target() in misc.js */ + if (is_mozilla && header.name === "x-orig-csp") { + let index = header.value.indexOf('%5B'); + if (index === -1) + continue; + + let sig = header.value.substring(0, index); + let data = header.value.substring(index); + if (sig !== sign_policy(data, 0)) + continue; + + /* Confirmed- it's the originals, smuggled in! */ + try { + data = JSON.parse(decodeURIComponent(data)); + } catch (e) { + /* This should not be reached - + it's our self-produced valid JSON. */ + console.log("Unexpected internal error - invalid JSON smuggled!", e); } - } - continue; + orig_csp_headers = csp_headers = null; + for (const header of data) + headers.push(sanitize_csp_header(header, rule, block)); + } + } else if (is_chrome || !orig_csp_headers) { + csp_headers.push(sanitize_csp_header(header, rule, block)); + if (is_mozilla) + orig_csp_headers.push(header); } - if (is_mozilla && !orig_csp_headers) - continue; - - csp_headers.push(process_csp_header(header, rule, block)); - if (is_mozilla) - orig_csp_headers.push(header); } if (orig_csp_headers) { @@ -202,7 +162,6 @@ function headers_inject(details) async function start_policy_injector() { storage = await get_storage(); - query_best = await get_query_best(); let extra_opts = ["blocking", "responseHeaders"]; if (is_chrome) diff --git a/background/storage.js b/background/storage.js index 682f933..c2160b0 100644 --- a/background/storage.js +++ b/background/storage.js @@ -16,6 +16,7 @@ * IMPORT make_once * IMPORT browser * IMPORT is_chrome + * IMPORT observables * IMPORTS_END */ @@ -98,7 +99,8 @@ async function list(prefix) for (let item of await get_list_var(name)) map.set(item, await get(prefix + item)); - return {map, prefix, name, listeners : new Set(), lock : make_lock()}; + return {map, prefix, name, observable: observables.make(), + lock: make_lock()}; } var list_by_prefix = {}; @@ -121,7 +123,7 @@ exports.add_change_listener = function (cb, prefixes=list_prefixes) prefixes = [prefixes]; for (let prefix of prefixes) - list_by_prefix[prefix].listeners.add(cb); + observables.subscribe(list_by_prefix[prefix].observable, cb); } exports.remove_change_listener = function (cb, prefixes=list_prefixes) @@ -130,13 +132,7 @@ exports.remove_change_listener = function (cb, prefixes=list_prefixes) prefixes = [prefixes]; for (let prefix of prefixes) - list_by_prefix[prefix].listeners.delete(cb); -} - -function broadcast_change(change, list) -{ - for (let listener_callback of list.listeners) - listener_callback(change); + observables.unsubscribe(list_by_prefix[prefix].observable, cb); } /* Prepare some hepler functions to get elements of a list */ @@ -198,7 +194,7 @@ async function _set_item(item, value, list) new_val : value }; - broadcast_change(change, list); + observables.broadcast(list.observable, change); return old_val; } @@ -236,7 +232,7 @@ async function _remove_item(item, list) new_val : undefined }; - broadcast_change(change, list); + observables.broadcast(list.observable, change); return old_val; } @@ -282,7 +278,7 @@ async function _replace_item(old_item, new_item, list, new_val=undefined) new_val : undefined }; - broadcast_change(change, list); + observables.broadcast(list.observable, change); list.map.set(new_item, new_val); @@ -290,7 +286,7 @@ async function _replace_item(old_item, new_item, list, new_val=undefined) change.old_val = undefined; change.new_val = new_val; - broadcast_change(change, list); + observables.broadcast(list.observable, change); return old_val; } @@ -375,7 +371,7 @@ exports.clear = async function () for (let [item, val] of list_entries_it(list)) { change.item = item; change.old_val = val; - broadcast_change(change, list); + observables.broadcast(list.observable, change); } list.map = new Map(); @@ -3,36 +3,7 @@ # Copyright (C) 2021 Wojtek Kosior # Redistribution terms are gathered in the `copyright' file. -ENDL=" -" - -errcho() { - echo "$@" >&2 -} - -map_set_instr() { - echo "$1__$2='$3'" -} - -map_set() { - eval "$(map_set_instr "$@")" -} - -map_get() { - eval "echo \"\$$1__$2\"" -} - -map_del_instr() { - echo "unset $1__$2" -} - -map_del() { - eval "$(map_del_instr "$@")" -} - -sanitize() { - echo "$1" | tr /.- _ -} +. ./shell_utils.sh handle_export_line() { if [ "x$1" = "xEXPORTS_START" ]; then @@ -259,11 +230,13 @@ s^_BGSCRIPTS_^$BGSCRIPTS^ s^_CONTENTSCRIPTS_^$CONTENTSCRIPTS^" \ < manifest.json > $BUILDDIR/manifest.json - sed "s^_POPUPSCRIPTS_^$POPUPSCRIPTS^" \ - < html/display-panel.html > $BUILDDIR/html/display-panel.html + ./process_html_file.sh html/display-panel.html | + sed "s^_POPUPSCRIPTS_^$POPUPSCRIPTS^" \ + > $BUILDDIR/html/display-panel.html - sed "s^_OPTIONSSCRIPTS_^$OPTIONSSCRIPTS^" \ - < html/options.html > $BUILDDIR/html/options.html + ./process_html_file.sh html/options.html | + sed "s^_OPTIONSSCRIPTS_^$OPTIONSSCRIPTS^" \ + > $BUILDDIR/html/options.html for FILE in $SCRIPTS; do FILEKEY=$(sanitize "$FILE") @@ -300,6 +273,7 @@ $(map_get EXPORTCODES $FILEKEY) fi cp -r copyright licenses/ $BUILDDIR + cp html/*.css $BUILDDIR/html mkdir $BUILDDIR/icons cp icons/*.png $BUILDDIR/icons } diff --git a/common/connection_types.js b/common/connection_types.js index 41bde75..88c6964 100644 --- a/common/connection_types.js +++ b/common/connection_types.js @@ -13,8 +13,7 @@ const CONNECTION_TYPE = { REMOTE_STORAGE : "0", PAGE_ACTIONS : "1", - PAGE_INFO : "2", - ACTIVITY_INFO : "3" + ACTIVITY_INFO : "2" }; /* diff --git a/common/misc.js b/common/misc.js index 0d8466e..3c7dc46 100644 --- a/common/misc.js +++ b/common/misc.js @@ -12,6 +12,7 @@ * IMPORT browser * IMPORT is_chrome * IMPORT TYPE_NAME + * IMPORT TYPE_PREFIX * IMPORTS_END */ @@ -173,6 +174,52 @@ function parse_csp(csp) { return directives; } +/* Make CSP headers do our bidding, not interfere */ +function sanitize_csp_header(header, rule, block) +{ + const csp = parse_csp(header.value); + + if (block) { + /* No snitching */ + delete csp['report-to']; + delete csp['report-uri']; + + delete csp['script-src']; + delete csp['script-src-elem']; + + csp['script-src-attr'] = ["'none'"]; + csp['prefetch-src'] = ["'none'"]; + } + + if ('script-src' in csp) + csp['script-src'].push(rule); + else + csp['script-src'] = [rule]; + + if ('script-src-elem' in csp) + csp['script-src-elem'].push(rule); + else + csp['script-src-elem'] = [rule]; + + const new_policy = Object.entries(csp).map( + i => `${i[0]} ${i[1].join(' ')};` + ); + + return {name: header.name, value: new_policy.join('')}; +} + +/* Regexes and objest to use as/in schemas for parse_json_with_schema(). */ +const nonempty_string_matcher = /.+/; + +const matchers = { + sha256: /^[0-9a-f]{64}$/, + nonempty_string: nonempty_string_matcher, + component: [ + new RegExp(`^[${TYPE_PREFIX.SCRIPT}${TYPE_PREFIX.BAG}]$`), + nonempty_string_matcher + ] +}; + /* * EXPORTS_START * EXPORT gen_nonce @@ -184,6 +231,7 @@ function parse_csp(csp) { * EXPORT nice_name * EXPORT open_in_settings * EXPORT is_privileged_url - * EXPORT parse_csp + * EXPORT sanitize_csp_header + * EXPORT matchers * EXPORTS_END */ diff --git a/common/observable.js b/common/observable.js new file mode 100644 index 0000000..1fb0b0a --- /dev/null +++ b/common/observable.js @@ -0,0 +1,36 @@ +/** + * part of Hachette + * Facilitate listening to events + * + * Copyright (C) 2021 Wojtek Kosior + * Redistribution terms are gathered in the `copyright' file. + */ + +function make() +{ + return new Set(); +} + +function subscribe(observable, cb) +{ + observable.add(cb); +} + +function unsubscribe(observable, cb) +{ + observable.delete(cb); +} + +function broadcast(observable, event) +{ + for (const callback of observable) + callback(event); +} + +const observables = {make, subscribe, unsubscribe, broadcast}; + +/* + * EXPORTS_START + * EXPORT observables + * EXPORTS_END + */ diff --git a/common/sanitize_JSON.js b/common/sanitize_JSON.js new file mode 100644 index 0000000..8b86d2d --- /dev/null +++ b/common/sanitize_JSON.js @@ -0,0 +1,400 @@ +/** + * part of Hachette + * Powerful, full-blown format enforcer for externally-obtained JSON + * + * Copyright (C) 2021 Wojtek Kosior + * Redistribution terms are gathered in the `copyright' file. + */ + +var error_path; +var invalid_schema; + +function parse_json_with_schema(schema, json_string) +{ + error_path = []; + invalid_schema = false; + + try { + return sanitize_unknown(schema, JSON.parse(json_string)); + } catch (e) { + throw `Invalid JSON${invalid_schema ? " schema" : ""}: ${e}.`; + } finally { + /* Allow garbage collection. */ + error_path = undefined; + } +} + +function error_message(cause) +{ + return `object${error_path.join("")} ${cause}`; +} + +function sanitize_unknown(schema, item) +{ + let error_msg = undefined; + let schema_options = []; + let has_default = false; + let _default = undefined; + + if (!Array.isArray(schema) || schema[1] === "matchentry" || + schema.length < 2 || !["ordefault", "or"].includes(schema[1])) + return sanitize_unknown_no_alternatives(schema, item); + + if ((schema.length & 1) !== 1) { + invalid_schema = true; + throw error_message("was not understood"); + } + + for (let i = 0; i < schema.length; i++) { + if ((i & 1) !== 1) { + schema_options.push(schema[i]); + continue; + } + + if (schema[i] === "or") + continue; + if (schema[i] === "ordefault" && schema.length === i + 2) { + has_default = true; + _default = schema[i + 1]; + break; + } + + invalid_schema = true; + throw error_message("was not understood"); + } + + for (const schema_option of schema_options) { + try { + return sanitize_unknown_no_alternatives(schema_option, item); + } catch (e) { + if (invalid_schema) + throw e; + + if (has_default) + continue; + + if (error_msg === undefined) + error_msg = e; + else + error_msg = `${error_msg}, or ${e}`; + } + } + + if (has_default) + return _default; + + throw error_msg; +} + +function sanitize_unknown_no_alternatives(schema, item) +{ + for (const [schema_check, item_check, sanitizer, type_name] of checks) { + if (schema_check(schema)) { + if (item_check(item)) + return sanitizer(schema, item); + throw error_message(`should be ${type_name} but is not`); + } + } + + invalid_schema = true; + throw error_message("was not understood"); +} + +function key_error_path_segment(key) +{ + return /^[a-zA-Z_][a-zA-Z_0-9]*$/.exec(key) ? + `.${key}` : `[${JSON.stringify(key)}]`; +} + +/* + * Generic object - one that can contain arbitrary keys (in addition to ones + * specified explicitly in the schema). + */ +function sanitize_genobj(schema, object) +{ + let max_matched_entries = Infinity; + let min_matched_entries = 0; + let matched_entries = 0; + const entry_schemas = []; + schema = [...schema]; + + if (schema[2] === "minentries") { + if (schema.length < 4) { + invalid_schema = true; + throw error_message("was not understood"); + } + + min_matched_entries = schema[3]; + schema.splice(2, 2); + } + + if (min_matched_entries < 0) { + invalid_schema = true; + throw error_message('specifies invalid "minentries" (should be a non-negative number)'); + } + + if (schema[2] === "maxentries") { + if (schema.length < 4) { + invalid_schema = true; + throw error_message("was not understood"); + } + + max_matched_entries = schema[3]; + schema.splice(2, 2); + } + + if (max_matched_entries < 0) { + invalid_schema = true; + throw error_message('specifies invalid "maxentries" (should be a non-negative number)'); + } + + while (schema.length > 2) { + let regex = /.+/; + + if (schema.length > 3) { + regex = schema[2]; + schema.splice(2, 1); + } + + if (typeof regex === "string") + regex = new RegExp(regex); + + entry_schemas.push([regex, schema[2]]); + schema.splice(2, 1); + } + + const result = sanitize_object(schema[0], object); + + for (const [key, entry] of Object.entries(object)) { + if (result.hasOwnProperty(key)) + continue; + + matched_entries += 1; + if (matched_entries > max_matched_entries) + throw error_message(`has more than ${max_matched_entries} matched entr${max_matched_entries === 1 ? "y" : "ies"}`); + + error_path.push(key_error_path_segment(key)); + + let match = false; + for (const [key_regex, entry_schema] of entry_schemas) { + if (!key_regex.exec(key)) + continue; + + match = true; + + sanitize_object_entry(result, key, entry_schema, object); + break; + } + + if (!match) { + const regex_list = entry_schemas.map(i => i[0]).join(", "); + throw error_message(`does not match any of key regexes: [${regex_list}]`); + } + + error_path.pop(); + } + + if (matched_entries < min_matched_entries) + throw error_message(`has less than ${min_matched_entries} matched entr${min_matched_entries === 1 ? "y" : "ies"}`); + + return result; +} + +function sanitize_array(schema, array) +{ + let min_length = 0; + let max_length = Infinity; + let repeat_length = 1; + let i = 0; + const result = []; + + schema = [...schema]; + if (schema[schema.length - 2] === "maxlen") { + max_length = schema[schema.length - 1]; + schema.splice(schema.length - 2); + } + + if (schema[schema.length - 2] === "minlen") { + min_length = schema[schema.length - 1]; + schema.splice(schema.length - 2); + } + + if (["repeat", "repeatfull"].includes(schema[schema.length - 2])) + repeat_length = schema.pop(); + if (repeat_length < 1) { + invalid_schema = true; + throw error_message('specifies invalid "${schema[schema.length - 2]}" (should be number greater than 1)'); + } + if (["repeat", "repeatfull"].includes(schema[schema.length - 1])) { + var repeat_directive = schema.pop(); + var repeat = schema.splice(schema.length - repeat_length); + } else if (schema.length !== array.length) { + throw error_message(`does not have exactly ${schema.length} items`); + } + + if (repeat_directive === "repeatfull" && + (array.length - schema.length) % repeat_length !== 0) + throw error_message(`does not contain a full number of item group repetitions`); + + if (array.length < min_length) + throw error_message(`has less than ${min_length} element${min_length === 1 ? "" : "s"}`); + + if (array.length > max_length) + throw error_message(`has more than ${max_length} element${max_length === 1 ? "" : "s"}`); + + for (const item of array) { + if (i >= schema.length) { + i = 0; + schema = repeat; + } + + error_path.push(`[${i}]`); + const sanitized = sanitize_unknown(schema[i], item); + if (sanitized !== discard) + result.push(sanitized); + error_path.pop(); + + i++; + } + + return result; +} + +function sanitize_regex(schema, string) +{ + if (schema.test(string)) + return string; + + throw error_message(`does not match regex ${schema}`); +} + +const string_spec_regex = /^string(:(.*))?$/; + +function sanitize_string(schema, string) +{ + const regex = string_spec_regex.exec(schema)[2]; + + if (regex === undefined) + return string; + + return sanitize_regex(new RegExp(regex), string); +} + +function sanitize_object(schema, object) +{ + const result = {}; + + for (let [key, entry_schema] of Object.entries(schema)) { + error_path.push(key_error_path_segment(key)); + sanitize_object_entry(result, key, entry_schema, object); + error_path.pop(); + } + + return result; +} + +function sanitize_object_entry(result, key, entry_schema, object) +{ + let optional = false; + let has_default = false; + let _default = undefined; + + if (Array.isArray(entry_schema) && entry_schema.length > 1) { + if (entry_schema[0] === "optional") { + optional = true; + entry_schema = [...entry_schema].splice(1); + + const idx_def = entry_schema.length - (entry_schema.length & 1) - 1; + if (entry_schema[idx_def] === "default") { + has_default = true; + _default = entry_schema[idx_def + 1]; + entry_schema.splice(idx_def); + } else if ((entry_schema.length & 1) !== 1) { + invalid_schema = true; + throw error_message("was not understood"); + } + + if (entry_schema.length < 2) + entry_schema = entry_schema[0]; + } + } + + let unsanitized_value = object[key]; + if (unsanitized_value === undefined) { + if (!optional) + throw error_message("is missing"); + + if (has_default) + result[key] = _default; + + return; + } + + const sanitized = sanitize_unknown(entry_schema, unsanitized_value); + if (sanitized !== discard) + result[key] = sanitized; +} + +function take_literal(schema, item) +{ + return item; +} + +/* + * This function is used like a symbol. Other parts of code do sth like + * `item === discard` to check if item was returned by this function. + */ +function discard(schema, item) +{ + return discard; +} + +/* + * The following are some helper functions to categorize various + * schema item specifiers (used in the array below). + */ + +function is_genobj_spec(item) +{ + return Array.isArray(item) && item[1] === "matchentry"; +} + +function is_regex(item) +{ + return typeof item === "object" && typeof item.test === "function"; +} + +function is_string_spec(item) +{ + return typeof item === "string" && string_spec_regex.test(item); +} + +function is_object(item) +{ + return typeof item === "object"; +} + +function eq(what) +{ + return i => i === what; +} + +/* Array and null checks must go before object check. */ +const checks = [ + [is_genobj_spec, is_object, sanitize_genobj, "an object"], + [Array.isArray, Array.isArray, sanitize_array, "an array"], + [eq(null), i => i === null, take_literal, "null"], + [is_regex, i => typeof i === "string", sanitize_regex, "a string"], + [is_string_spec, i => typeof i === "string", sanitize_string, "a string"], + [is_object, is_object, sanitize_object, "an object"], + [eq("number"), i => typeof i === "number", take_literal, "a number"], + [eq("boolean"), i => typeof i === "boolean", take_literal, "a boolean"], + [eq("anything"), i => true, take_literal, "dummy"], + [eq("discard"), i => true, discard, "dummy"] +]; + +/* + * EXPORTS_START + * EXPORT parse_json_with_schema + * EXPORTS_END + */ diff --git a/background/settings_query.js b/common/settings_query.js index d0d9511..e85ae63 100644 --- a/background/settings_query.js +++ b/common/settings_query.js @@ -7,23 +7,12 @@ /* * IMPORTS_START - * IMPORT make_once - * IMPORT get_storage * IMPORT TYPE_PREFIX * IMPORT for_each_possible_pattern * IMPORTS_END */ -var storage; - -async function init(fun) -{ - storage = await get_storage(); - - return fun; -} - -function check_pattern(pattern, multiple, matched) +function check_pattern(storage, pattern, multiple, matched) { const settings = storage.get(TYPE_PREFIX.PAGE, pattern); @@ -36,30 +25,28 @@ function check_pattern(pattern, multiple, matched) return false; } -function query(url, multiple) +function query(storage, url, multiple) { const matched = []; - for_each_possible_pattern(url, p => check_pattern(p, multiple, matched)); + const cb = p => check_pattern(storage, p, multiple, matched); + for_each_possible_pattern(url, cb); return multiple ? matched : (matched[0] || [undefined, undefined]); } -function query_best(url) +function query_best(storage, url) { - return query(url, false); + return query(storage, url, false); } -function query_all(url) +function query_all(storage, url) { - return query(url, true); + return query(storage, url, true); } -const get_query_best = make_once(() => init(query_best)); -const get_query_all = make_once(() => init(query_all)); - /* * EXPORTS_START - * EXPORT get_query_best - * EXPORT get_query_all + * EXPORT query_best + * EXPORT query_all * EXPORTS_END */ diff --git a/common/stored_types.js b/common/stored_types.js index 304842b..bfceba6 100644 --- a/common/stored_types.js +++ b/common/stored_types.js @@ -18,7 +18,9 @@ const TYPE_PREFIX = { PAGE : "p", BAG : "b", SCRIPT : "s", - VAR : "_" + VAR : "_", + /* Url prefix is not used in stored settings. */ + URL : "u" }; const TYPE_NAME = { diff --git a/content/activity_info_server.js b/content/activity_info_server.js index a1384e9..81a25fb 100644 --- a/content/activity_info_server.js +++ b/content/activity_info_server.js @@ -11,14 +11,20 @@ * IMPORTS_START * IMPORT listen_for_connection * IMPORT CONNECTION_TYPE - * IMPORT set_repo_query_repos - * IMPORT set_repo_query_callback + * IMPORT repo_query + * IMPORT subscribe_repo_query_results + * IMPORT unsubscribe_repo_query_results * IMPORTS_END */ var activities = []; var ports = new Set(); +function report_activity_oneshot(name, data, port) +{ + port.postMessage([name, data]); +} + function report_activity(name, data) { const activity = [name, data]; @@ -35,20 +41,23 @@ function report_script(script_data) function report_settings(settings) { - const [pattern, settings_data, repos] = settings; - set_repo_query_repos(repos); - report_activity("settings", settings); } -function report_repo_query_result(result) +function report_repo_query_action(update, port) { - report_activity("repo_query_result", result); + report_activity_oneshot("repo_query_action", update, port); } -function trigger_repo_query() +function trigger_repo_query(query_specifier) { - set_repo_query_callback(report_repo_query_result); + repo_query(...query_specifier); +} + +function handle_disconnect(port, report_action) +{ + ports.delete(port) + unsubscribe_repo_query_results(report_action); } function new_connection(port) @@ -60,13 +69,16 @@ function new_connection(port) for (const activity of activities) port.postMessage(activity); + const report_action = u => report_repo_query_action(u, port); + subscribe_repo_query_results(report_action); + /* * So far the only thing we expect to receive is repo query order. Once more * possibilities arrive, we will need to complicate this listener. */ port.onMessage.addListener(trigger_repo_query); - port.onDisconnect.addListener(() => ports.delete(port)); + port.onDisconnect.addListener(() => handle_disconnect(port, report_action)); } function start_activity_info_server() diff --git a/content/main.js b/content/main.js index 8ba751b..9ed557c 100644 --- a/content/main.js +++ b/content/main.js @@ -19,7 +19,6 @@ * IMPORT is_chrome * IMPORT is_mozilla * IMPORT start_activity_info_server - * IMPORT set_repo_query_url * IMPORTS_END */ @@ -129,6 +128,4 @@ if (!is_privileged_url(document.URL)) { } start_activity_info_server(); - - set_repo_query_url(document.URL); } diff --git a/content/repo_query.js b/content/repo_query.js index b8c8ed9..3708108 100644 --- a/content/repo_query.js +++ b/content/repo_query.js @@ -9,72 +9,105 @@ /* * IMPORTS_START * IMPORT make_ajax_request + * IMPORT observables + * IMPORT TYPE_PREFIX + * IMPORT parse_json_with_schema + * IMPORT matchers * IMPORTS_END */ -var query_started = false; +const paths = { + [TYPE_PREFIX.PAGE]: "/pattern", + [TYPE_PREFIX.BAG]: "/bag", + [TYPE_PREFIX.SCRIPT]: "/script", + [TYPE_PREFIX.URL]: "/query" +}; -var url = undefined; -var repos = undefined; -var callback = undefined; +const queried_items = new Map(); +const observable = observables.make(); -async function query(repo) +function repo_query(prefix, item, repo_urls) { - const [repo_url, data] = repo; + const key = prefix + item; - let response = "Query failed"; - const query_url = `${repo_url}/query?n=${encodeURIComponent(url)}`; + const results = queried_items.get(key) || {}; + queried_items.set(key, results); - try { - let xhttp = await make_ajax_request("GET", query_url); - if (xhttp.status === 200) - response = xhttp.responseText; - console.log(xhttp); - } catch (e) { - console.log(e); - } + for (const repo_url of repo_urls) + perform_query_against(key, repo_url, results); +} - callback([repo_url, response]); +const page_schema = { + pattern: matchers.nonempty_string, + payload: ["optional", matchers.component, "default", undefined] +}; +const bag_schema = { + name: matchers.nonempty_string, + components: ["optional", [matchers.component, "repeat"], "default", []] +}; +const script_schema = { + name: matchers.nonempty_string, + location: matchers.nonempty_string, + sha256: matchers.sha256, +}; +const search_result_schema = [page_schema, "repeat"]; + +const schemas = { + [TYPE_PREFIX.PAGE]: page_schema, + [TYPE_PREFIX.BAG]: bag_schema, + [TYPE_PREFIX.SCRIPT]: script_schema, + [TYPE_PREFIX.URL]: search_result_schema } -function start_query() +async function perform_query_against(key, repo_url, results) { - if (query_started || !url || !repos || !callback) + if (results[repo_url] !== undefined) return; - query_started = true; + const prefix = key[0]; + const item = key.substring(1); + const result = {state: "started"}; + results[repo_url] = result; - console.log(`about to query ${url} from ${repos}`); + const broadcast_msg = {prefix, item, results: {[repo_url]: result}}; + observables.broadcast(observable, broadcast_msg); - for (const repo of repos) - query(repo); -} + let state = "connection_error"; + const query_url = + `${repo_url}${paths[prefix]}?n=${encodeURIComponent(item)}`; -function set_repo_query_url(_url) -{ - url = _url; + try { + let xhttp = await make_ajax_request("GET", query_url); + if (xhttp.status === 200) { + state = "parse_error"; + result.response = + parse_json_with_schema(schemas[prefix], xhttp.responseText); + state = "completed"; + } + } catch (e) { + console.log(e); + } - start_query(); + result.state = state; + observables.broadcast(observable, broadcast_msg); } -function set_repo_query_repos(_repos) +function subscribe_repo_query_results(cb) { - repos = _repos; - - start_query(); + observables.subscribe(observable, cb); + for (const [key, results] of queried_items.entries()) + cb({prefix: key[0], item: key.substring(1), results}); } -function set_repo_query_callback(_callback) +function unsubscribe_repo_query_results(cb) { - callback = _callback; - - start_query(); + observables.unsubscribe(observable, cb); } /* * EXPORTS_START - * EXPORT set_repo_query_url - * EXPORT set_repo_query_repos - * EXPORT set_repo_query_callback + * EXPORT repo_query + * EXPORT subscribe_repo_query_results + * EXPORT unsubscribe_repo_query_results * EXPORTS_END */ @@ -6,7 +6,7 @@ Files: * Copyright: 2021 Wojtek Kosior <koszko@koszko.org> License: GPL-3+-javascript or Alicense-1.0 -Files: re-generate_icons.sh build.sh +Files: *.sh Copyright: 2021 Wojtek Kosior <koszko@koszko.org> 2021 jahoti <jahoti@tilde.team> License: CC0 @@ -20,11 +20,11 @@ Copyright: 2021 Wojtek Kosior <koszko@koszko.org> 2021 jahoti <jahoti@tilde.team> License: GPL-3+-javascript or Alicense-1.0 -Files: README.txt copyright +Files: *.html README.txt copyright Copyright: 2021 Wojtek Kosior <koszko@koszko.org> License: GPL-3+ or Alicense-1.0 or CC-BY-SA-4.0 -Files: *.html +Files: html/base.css Copyright: 2021 Wojtek Kosior <koszko@koszko.org> 2021 Nicholas Johnson <nicholasjohnson@posteo.org> License: GPL-3+ or Alicense-1.0 or CC-BY-SA-4.0 diff --git a/default_settings.json b/default_settings.json index 8656381..44fbca0 100644 --- a/default_settings.json +++ b/default_settings.json @@ -43,5 +43,8 @@ "phttps://www.worldcat.org/title/**": { "components": ["s", "worldcat (library holdings)"] } + }, + { + "rhttps://api-demo.hachette-hydrilla.org": {} } ] diff --git a/html/DOM_helpers.js b/html/DOM_helpers.js new file mode 100644 index 0000000..2bff966 --- /dev/null +++ b/html/DOM_helpers.js @@ -0,0 +1,41 @@ +/** + * Hachette operations on DOM elements + * + * Copyright (C) 2021 Wojtek Kosior + * Redistribution terms are gathered in the `copyright' file. + */ + +function by_id(id) +{ + return document.getElementById(id); +} + +function clone_template(template_id) +{ + const clone = document.getElementById(template_id).cloneNode(true); + const result_object = {}; + const to_process = [clone]; + + while (to_process.length > 0) { + const element = to_process.pop(); + const template_key = element.getAttribute("data-template"); + + if (template_key) + result_object[template_key] = element; + + element.removeAttribute("id"); + element.removeAttribute("template_key"); + + for (const child of element.children) + to_process.push(child); + } + + return result_object; +} + +/* + * EXPORTS_START + * EXPORT by_id + * EXPORT clone_template + * EXPORTS_END + */ diff --git a/html/base.css b/html/base.css new file mode 100644 index 0000000..2256833 --- /dev/null +++ b/html/base.css @@ -0,0 +1,44 @@ +/** + * Hachette base styles + * + * Copyright (C) 2021 Wojtek Kosior + * Copyright (C) 2021 Nicholas Johnson + * Redistribution terms are gathered in the `copyright' file. + */ + +input[type="checkbox"], input[type="radio"], .hide { + display: none; +} + +.show_next:not(:checked)+* { + display: none; +} + +.show_hide_next2:not(:checked)+* { + display: none; +} + +.show_hide_next2:checked+*+* { + display: none; +} + +button, .button { + background-color: #4CAF50; + border: none; + border-radius: 8px; + color: white; + text-align: center; + text-decoration: none; + display: inline-block; + padding: 6px 12px; + margin: 2px 0px; +} + +button.slimbutton, .button.slimbutton { + padding: 2px 4px; + margin: 0; +} + +button:hover, .button:hover { + box-shadow: 0 6px 8px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); +} diff --git a/html/display-panel.html b/html/display-panel.html index d8d7f5d..1b9c77b 100644 --- a/html/display-panel.html +++ b/html/display-panel.html @@ -7,35 +7,25 @@ <head> <meta charset="utf-8"/> <title>Hachette - page settings</title> + <link type="text/css" rel="stylesheet" href="base.css" /> <style> - input[type="radio"], input[type="checkbox"] { - display: none; - } - body { width: 300px; height: 300px; } - .show_next:not(:checked)+* { - display: none; + ul { + padding-inline-start: 15px; } - .show_hide_next2:not(:checked)+* { - display: none; + .bold { + font-weight: bold; } - .show_hide_next2:checked+*+* { - display: none; - } - - .hide { - display: none; - } - - #possible_patterns_chbx:not(:checked)+label span#triangle:first-child+span, - #possible_patterns_chbx:not(:checked)+label+*, - #possible_patterns_chbx:checked+label span#triangle:first-child { + .unroll_chbx:not(:checked)+*+label span.triangle:first-child+span.triangle, + .unroll_chbx:checked+*+label span.triangle:first-child, + .unroll_chbx:not(:checked)+*, + .unroll_chbx:not(:checked)+*+label+* { display: none; } @@ -51,31 +41,73 @@ <span></span> <button>View in settings</button> </li> + <li id="query_match_li_template" class="queried_pattern_match" data-template="li"> + <div> + <span>pattern:</span> + <span class="bold" data-template="pattern"></span> + <button data-template="btn">Install</button> + </div> + <div id="unrollable_component_template" data-template="unroll_container"> + <span data-template="component_label">payload:</span> + <input type="checkbox" class="unroll_chbx" data-template="chbx"></input> + <br data-template="br"/> + <label class="bold" data-template="lbl"> + <span data-template="triangle"> + <span class="triangle">⏵</span> + <span class="triangle">⏷</span> + </span> + <span data-template="component"></span> + </label> + <div data-template="unroll"></div> + </div> + </li> </div> - <h2 id="page_url_heading"></h2> - - <input id="show_privileged_notice_chbx" type="checkbox" class="show_next"></input> - <h3>Privileged page</h3> + <input id="show_install_view_chbx" type="checkbox" class="show_hide_next2"></input> + <div id="install_view"> + <IMPORT html/import_frame.html /> + <!-- + <div id="install_status"></div> + <label for="show_install_chbx" class="bold">Cancel install</label> + <button id="commit_install_but">Commit install</button> + --> + </div> + <div id="main_view"> + <h2 id="page_url_heading"></h2> - <input id="show_page_state_chbx" type="checkbox" class="show_next"></input> - <div> - <input id="possible_patterns_chbx" type="checkbox"></input> - <label for="possible_patterns_chbx"> - <h3> - <span id="triangle">⏵</span><span>⏷</span> - Possible patterns - </h3> - </label> - <ul id="possible_patterns"></ul> + <input id="show_privileged_notice_chbx" type="checkbox" class="show_next"></input> + <h3>Privileged page</h3> - <input id="connected_chbx" type="checkbox" class="show_hide_next2"></input> + <input id="show_page_state_chbx" type="checkbox" class="show_next"></input> <div> - <h3> - Matched pattern: <span id="pattern">...</span> + <input id="possible_patterns_chbx" type="checkbox" class="unroll_chbx"></input> + <span></span> + <label for="possible_patterns_chbx"> + <h3> + <span class="triangle">⏵</span> + <span class="triangle">⏷</span> + Possible patterns + </h3> + </label> + <ul id="possible_patterns"></ul> + + <input id="connected_chbx" type="checkbox" class="show_hide_next2"></input> + <div> + Matched pattern: <span id="pattern" class="bold">...</span> <button id="view_pattern" class="hide"> View in settings </button> + <br/> + Blocked: <span id="blocked" class="bold">...</span> + <br/> + Payload: <span id="payload" class="bold">...</span> + <button id="view_payload" class="hide"> + View in settings + </button> + <h3>Injected</h3> + <div id="container_for_injected"> + <span id="none_injected">None</span> + </div> <input id="query_started_chbx" type="checkbox" class="show_hide_next2"></input> <div id="container_for_repo_responses"> <h3>Queried from repositories</h3> @@ -83,24 +115,11 @@ <button id="query_pattern"> Search for matching patterns </button> - </h3> - <h3> - Blocked: <span id="blocked">...</span> - </h3> - <h3> - Payload: <span id="payload">...</span> - <button id="view_payload" class="hide"> - View in settings - </button> - </h3> - <h3>Injected</h3> - <div id="container_for_injected"> - <span id="none_injected">None</span> </div> + <h3>Trying to connect..<input id="loading_chbx" type="checkbox" class="show_next"></input><span>.</span></h3> </div> - <h3>Trying to connect..<input id="loading_chbx" type="checkbox" class="show_next"></input><span>.</span></h3> - </div> - <button id="settings_but" type="button" style="margin-top: 20px;">Settings</button>_POPUPSCRIPTS_ + <button id="settings_but" type="button" style="margin-top: 20px;">Settings</button> + </div>_POPUPSCRIPTS_ </body> </html> diff --git a/html/display-panel.js b/html/display-panel.js index 1693182..b4d9abb 100644 --- a/html/display-panel.js +++ b/html/display-panel.js @@ -10,6 +10,11 @@ * IMPORT browser * IMPORT is_chrome * IMPORT is_mozilla + *** Using remote storage here seems inefficient, we only resort to that + *** temporarily, before all storage access gets reworked. + * IMPORT get_remote_storage + * IMPORT get_import_frame + * IMPORT query_all * IMPORT CONNECTION_TYPE * IMPORT url_item * IMPORT is_privileged_url @@ -17,13 +22,13 @@ * IMPORT nice_name * IMPORT open_in_settings * IMPORT for_each_possible_pattern + * IMPORT by_id + * IMPORT clone_template * IMPORTS_END */ -function by_id(id) -{ - return document.getElementById(id); -} +let storage; +let tab_url; const tab_query = {currentWindow: true, active: true}; @@ -55,28 +60,19 @@ async function show_page_activity_info() return; } - const url = url_item(tab.url); - page_url_heading.textContent = url; - if (is_privileged_url(url)) { + tab_url = url_item(tab.url); + page_url_heading.textContent = tab_url; + if (is_privileged_url(tab_url)) { show_privileged_notice_chbx.checked = true; return; } - populate_possible_patterns_list(url); + populate_possible_patterns_list(tab_url); show_page_state_chbx.checked = true; try_to_connect(tab.id); } -function populate_possible_patterns_list(url) -{ - for_each_possible_pattern(url, add_pattern_to_list); - - const port = browser.runtime.connect({name: CONNECTION_TYPE.PAGE_INFO}); - port.onMessage.addListener(handle_page_info); - port.postMessage(["subscribe", url]); -} - const possible_patterns_ul = by_id("possible_patterns"); const pattern_li_template = by_id("pattern_li_template"); pattern_li_template.removeAttribute("id"); @@ -121,53 +117,55 @@ function set_pattern_li_button_text(li_id, text) by_id(li_id).firstElementChild.nextElementSibling.textContent = text; } -function handle_page_info(message) +function handle_page_change(change) { - const [type, data] = message; + const li_id = ensure_pattern_exists(change.item); + if (change.old_val === undefined) + set_pattern_li_button_text(li_id, "Edit in settings"); + if (change.new_val === undefined) + set_pattern_li_button_text(li_id, "Add setting"); +} - if (type === "change") { - const li_id = ensure_pattern_exists(data.item); - if (data.old_val === undefined) - set_pattern_li_button_text(li_id, "Edit in settings"); - if (data.new_val === undefined) - set_pattern_li_button_text(li_id, "Add setting"); - } +function populate_possible_patterns_list(url) +{ + for_each_possible_pattern(url, add_pattern_to_list); - if (type === "new_url") { - for (const li_id of known_patterns.values()) - set_pattern_li_button_text(li_id, "Add setting"); - for (const [pattern, settings] of data) { - set_pattern_li_button_text(ensure_pattern_exists(pattern), - "Edit in settings") - } + for (const [pattern, settings] of query_all(storage, url)) { + set_pattern_li_button_text(ensure_pattern_exists(pattern), + "Edit in settings"); } + + storage.add_change_listener(handle_page_change, [TYPE_PREFIX.PAGE]); } const connected_chbx = by_id("connected_chbx"); const query_pattern_but = by_id("query_pattern"); +var content_script_port; + function try_to_connect(tab_id) { /* This won't connect to iframes. We'll add support for them later */ const connect_info = {name: CONNECTION_TYPE.ACTIVITY_INFO, frameId: 0}; - const port = browser.tabs.connect(tab_id, connect_info); + content_script_port = browser.tabs.connect(tab_id, connect_info); - const button_cb = (e) => start_querying_repos(port); + const disconnect_cb = () => handle_disconnect(tab_id, start_querying_repos); + content_script_port.onDisconnect.addListener(disconnect_cb); + content_script_port.onMessage.addListener(handle_activity_report); - port.onDisconnect.addListener(port => handle_disconnect(tab_id, button_cb)); - port.onMessage.addListener(handle_activity_report); - - query_pattern_but.addEventListener("click", button_cb); + query_pattern_but.addEventListener("click", start_querying_repos); if (is_mozilla) - setTimeout(() => monitor_connecting(port, tab_id), 1000); + setTimeout(() => monitor_connecting(tab_id), 1000); } const query_started_chbx = by_id("query_started_chbx"); function start_querying_repos(port) { - port.postMessage("dummy (trigger repo querying)"); + const repo_urls = storage.get_all_names(TYPE_PREFIX.REPO); + if (content_script_port) + content_script_port.postMessage([TYPE_PREFIX.URL, tab_url, repo_urls]); query_started_chbx.checked = true; } @@ -176,6 +174,7 @@ const loading_chbx = by_id("loading_chbx"); function handle_disconnect(tab_id, button_cb) { query_pattern_but.removeEventListener("click", button_cb); + content_script_port = null; if (is_chrome && !browser.runtime.lastError) return; @@ -188,12 +187,16 @@ function handle_disconnect(tab_id, button_cb) setTimeout(() => try_to_connect(tab_id), 1000); } -function monitor_connecting(port, tab_id) +function monitor_connecting(tab_id) { if (connected_chbx.checked) return; - port.disconnect(); + if (content_script_port) + content_script_port.disconnect(); + else + return; + loading_chbx.checked = !loading_chbx.checked; try_to_connect(tab_id); } @@ -204,7 +207,8 @@ const blocked_span = by_id("blocked"); const payload_span = by_id("payload"); const view_payload_but = by_id("view_payload"); const container_for_injected = by_id("container_for_injected"); -const container_for_repo_responses = by_id("container_for_repo_responses"); + +const queried_items = new Map(); function handle_activity_report(message) { @@ -213,7 +217,7 @@ function handle_activity_report(message) const [type, data] = message; if (type === "settings") { - let [pattern, settings, repos] = data; + let [pattern, settings] = data; settings = settings || {}; blocked_span.textContent = settings.allow ? "no" : "yes"; @@ -247,20 +251,300 @@ function handle_activity_report(message) container_for_injected.appendChild(h4); container_for_injected.appendChild(pre); } - if (type === "repo_query_result") { - const [repo_url, response_text] = data; + if (type === "repo_query_action") { + query_started_chbx.checked = true; - const h4 = document.createElement("h4"); - const pre = document.createElement("pre"); - h4.textContent = repo_url; - pre.textContent = response_text; + const key = data.prefix + data.item; + const results = queried_items.get(key) || {}; + Object.assign(results, data.results); + queried_items.set(key, results); + + const action = data.prefix === TYPE_PREFIX.URL ? + show_query_result : record_fetched_install_dep; + + for (const [repo_url, result] of Object.entries(data.results)) + action(data.prefix, data.item, repo_url, result); + } +} + +const container_for_repo_responses = by_id("container_for_repo_responses"); + +const results_lists = new Map(); + +function create_results_list(url) +{ + const list_div = document.createElement("div"); + const list_head = document.createElement("h4"); + const list = document.createElement("ul"); + + list_head.textContent = url; + list_div.appendChild(list_head); + list_div.appendChild(list); + container_for_repo_responses.appendChild(list_div); + + const list_object = {list, by_repo: new Map()}; + + results_lists.set(url, list_object); + + return list_object; +} + +function create_result_item(list_object, repo_url, result) +{ + const result_li = document.createElement("li"); + const repo_url_span = document.createElement("span"); + const result_item = {result_li, appended: null}; + + repo_url_span.textContent = repo_url; + result_li.appendChild(repo_url_span); + + list_object.list.appendChild(result_li); + list_object.by_repo.set(repo_url, result_item); + + return result_item; +} + +function set_appended(result_item, element) +{ + if (result_item.appended) + result_item.appended.remove(); + result_item.appended = element; + result_item.result_li.appendChild(element); +} + +function show_message(result_item, text) +{ + const div = document.createElement("div"); + div.textContent = text; + set_appended(result_item, div); +} + +function showcb(text) +{ + return item => show_message(item, text); +} + +function unroll_chbx_first_checked(entry_object) +{ + if (!entry_object.chbx.checked) + return; + + entry_object.chbx.removeEventListener("change", entry_object.unroll_cb); + delete entry_object.unroll_cb; + + entry_object.unroll.textContent = "preview not implemented..."; +} + +const show_install_chbx = by_id("show_install_view_chbx"); + +let import_frame; +let install_target = null; + +function install_abort(error_state) +{ + import_frame.show_error(`Error: ${error_state}`); + install_target = null; +} + +/* + * Translate objects from the format in which they are sent by Hydrilla to the + * format in which they are stored in settings. + */ - container_for_repo_responses.appendChild(h4); - container_for_repo_responses.appendChild(pre); +function translate_script(script_object, repo_url) +{ + return { + [TYPE_PREFIX.SCRIPT + script_object.name]: { + hash: script_object.sha256, + url: `${repo_url}/content/${script_object.location}` + } + }; +} + +function translate_bag(bag_object) +{ + return { + [TYPE_PREFIX.BAG + bag_object.name]: bag_object.components + }; +} + +const format_translators = { + [TYPE_PREFIX.BAG]: translate_bag, + [TYPE_PREFIX.SCRIPT]: translate_script +}; + +function install_check_ready() +{ + if (install_target.to_fetch.size > 0) + return; + + const page_key = [TYPE_PREFIX.PAGE + install_target.pattern]; + const to_install = [{[page_key]: {components: install_target.payload}}]; + + for (const key of install_target.fetched) { + const old_object = + queried_items.get(key)[install_target.repo_url].response; + const new_object = + format_translators[key[0]](old_object, install_target.repo_url); + to_install.push(new_object); + } + + import_frame.show_selection(to_install); +} + +const possible_errors = ["connection_error", "parse_error"]; + +function fetch_install_deps(components) +{ + const needed = [...components]; + const processed = new Set(); + + while (needed.length > 0) { + const [prefix, item] = needed.pop(); + const key = prefix + item; + processed.add(key); + const results = queried_items.get(key); + let relevant_result = null; + + if (results) + relevant_result = results[install_target.repo_url]; + + if (!relevant_result) { + content_script_port.postMessage([prefix, item, + [install_target.repo_url]]); + install_target.to_fetch.add(key); + continue; + } + + if (possible_errors.includes(relevant_result.state)) { + install_abort(relevant_result.state); + return false; + } + + install_target.fetched.add(key); + + if (prefix !== TYPE_PREFIX.BAG) + continue; + + for (const dependency of relevant_result.response.components) { + if (processed.has(dependency.join(''))) + continue; + needed.push(dependency); + } } } +function record_fetched_install_dep(prefix, item, repo_url, result) +{ + const key = prefix + item; + + if (!install_target || repo_url !== install_target.repo_url || + !install_target.to_fetch.has(key)) + return; + + if (possible_errors.includes(result.state)) { + install_abort(result.state); + return; + } + + if (result.state !== "completed") + return; + + install_target.to_fetch.delete(key); + install_target.fetched.add(key); + + if (prefix === TYPE_PREFIX.BAG && + fetch_install_deps(result.response.components) === false) + return; + + install_check_ready(); +} + +function install_clicked(entry_object) +{ + show_install_chbx.checked = true; + import_frame.show_loading(); + + install_target = { + repo_url: entry_object.repo_url, + pattern: entry_object.match_object.pattern, + payload: entry_object.match_object.payload, + fetched: new Set(), + to_fetch: new Set() + }; + + fetch_install_deps([install_target.payload]); + + install_check_ready(); +} + +var max_query_result_id = 0; + +function show_query_successful_result(result_item, repo_url, result) +{ + const ul = document.createElement("ul"); + + set_appended(result_item, ul); + + for (const match of result) { + const entry_object = clone_template("query_match_li_template"); + + entry_object.pattern.textContent = match.pattern; + + ul.appendChild(entry_object.li); + + if (!match.payload) { + entry_object.payload.textContent = "(none)"; + for (const key of ["chbx", "br", "triangle", "unroll"]) + entry_object[key].remove(); + continue; + } + + entry_object.component.textContent = nice_name(...match.payload); + + const install_cb = () => install_clicked(entry_object); + entry_object.btn.addEventListener("click", install_cb); + + const chbx_id = `query_result_${max_query_result_id++}`; + entry_object.chbx.id = chbx_id; + entry_object.lbl.setAttribute("for", chbx_id); + + entry_object.unroll_cb = () => unroll_chbx_first_checked(entry_object); + entry_object.chbx.addEventListener("change", entry_object.unroll_cb); + + entry_object.component_object = match.payload; + entry_object.match_object = match; + entry_object.repo_url = repo_url; + } +} + +function show_query_result(url_prefix, url, repo_url, result) +{ + const results_list_object = results_lists.get(url) || + create_results_list(url); + const result_item = results_list_object.by_repo.get(repo_url) || + create_result_item(results_list_object, repo_url, result); + + const completed_cb = + item => show_query_successful_result(item, repo_url, result.response); + const possible_actions = { + completed: completed_cb, + started: showcb("loading..."), + connection_error: showcb("Error when querying repository."), + parse_error: showcb("Bad data format received.") + }; + possible_actions[result.state](result_item, repo_url); +} + by_id("settings_but") .addEventListener("click", (e) => browser.runtime.openOptionsPage()); -show_page_activity_info(); +async function main() +{ + storage = await get_remote_storage(); + import_frame = await get_import_frame(); + import_frame.onclose = () => show_install_chbx.checked = false; + show_page_activity_info(); +} + +main(); diff --git a/html/import_frame.html b/html/import_frame.html new file mode 100644 index 0000000..c86c3de --- /dev/null +++ b/html/import_frame.html @@ -0,0 +1,27 @@ +<div style="display: none;"> + <li id="import_li_template"> + <span></span> + <input type="checkbox" style="display: inline;" checked></input> + <span></span> + </li> +</div> +<h2> Settings import </h2> +<input id="import_loading_radio" type="radio" name="import_window_content" class="show_next"></input> +<span> Loading... </span> +<input id="import_failed_radio" type="radio" name="import_window_content" class="show_next"></input> +<div> + <span id="import_errormsg"></span> + <input id="import_errordetail_chbx" type="checkbox" class="show_next"></input> + <pre id="import_errordetail"></pre> + <button id="import_failok_but"> OK </button> +</div> +<input id="import_selection_radio" type="radio" name="import_window_content" class="show_next"></input> +<div> + <button id="check_all_import_but"> Check all </button> + <button id="uncheck_all_import_but"> Uncheck all </button> + <button id="uncheck_colliding_import_but"> Uncheck existing </button> + <ul id="import_ul"> + </ul> + <button id="commit_import_but"> OK </button> + <button id="cancel_import_but"> Cancel </button> +</div> diff --git a/html/import_frame.js b/html/import_frame.js new file mode 100644 index 0000000..4075433 --- /dev/null +++ b/html/import_frame.js @@ -0,0 +1,163 @@ +/** + * Hachette HTML import frame script + * + * Copyright (C) 2021 Wojtek Kosior + * Redistribution terms are gathered in the `copyright' file. + */ + +/* + * IMPORTS_START + * IMPORT get_remote_storage + * IMPORT by_id + * IMPORT nice_name + * IMPORT make_once + * IMPORTS_END + */ + +let storage; + +const import_li_template = by_id("import_li_template"); +import_li_template.removeAttribute("id"); + +function import_li_id(prefix, item) +{ + return `ili_${prefix}_${item}`; +} + +let import_ul = by_id("import_ul"); +let import_chbxs_colliding = undefined; +let settings_import_map = undefined; + +function add_import_li(prefix, name) +{ + let li = import_li_template.cloneNode(true); + let name_span = li.firstElementChild; + let chbx = name_span.nextElementSibling; + let warning_span = chbx.nextElementSibling; + + li.setAttribute("data-prefix", prefix); + li.setAttribute("data-name", name); + li.id = import_li_id(prefix, name); + name_span.textContent = nice_name(prefix, name); + + if (storage.get(prefix, name) !== undefined) { + import_chbxs_colliding.push(chbx); + warning_span.textContent = "(will overwrite existing setting!)"; + } + + import_ul.appendChild(li); +} + +function check_all_imports() +{ + for (let li of import_ul.children) + li.firstElementChild.nextElementSibling.checked = true; +} + +function uncheck_all_imports() +{ + for (let li of import_ul.children) + li.firstElementChild.nextElementSibling.checked = false; +} + +function uncheck_colliding_imports() +{ + for (let chbx of import_chbxs_colliding) + chbx.checked = false; +} + +function commit_import() +{ + for (let li of import_ul.children) { + let chbx = li.firstElementChild.nextElementSibling; + + if (!chbx.checked) + continue; + + let prefix = li.getAttribute("data-prefix"); + let name = li.getAttribute("data-name"); + let key = prefix + name; + let value = settings_import_map.get(key); + storage.set(prefix, name, value); + } + + deactivate(); +} + +const import_loading_radio = by_id("import_loading_radio"); + +function show_loading() +{ + import_loading_radio.checked = true; +} + +const import_failed_radio = by_id("import_failed_radio"); +const import_errormsg = by_id("import_errormsg"); +const import_errordetail_chbx = by_id("import_errordetail_chbx"); +const import_errordetail = by_id("import_errordetail"); + +function show_error(errormsg, errordetail) +{ + import_failed_radio.checked = true; + import_errormsg.textContent = errormsg; + import_errordetail_chbx.checked = errordetail; + import_errordetail.textContent = errordetail; +} + +const import_selection_radio = by_id("import_selection_radio"); + +function show_selection(settings) +{ + import_selection_radio.checked = true; + + let old_children = import_ul.children; + while (old_children[0] !== undefined) + import_ul.removeChild(old_children[0]); + + import_chbxs_colliding = []; + settings_import_map = new Map(); + + for (let setting of settings) { + let [key, value] = Object.entries(setting)[0]; + let prefix = key[0]; + let name = key.substring(1); + add_import_li(prefix, name); + settings_import_map.set(key, value); + } +} + +function deactivate() +{ + /* Let GC free some memory */ + import_chbxs_colliding = undefined; + settings_import_map = undefined; + + if (exports.onclose) + exports.onclose(); +} + +const exports = {show_loading, show_error, show_selection, deactivate}; + +async function init() +{ + storage = await get_remote_storage(); + + by_id("commit_import_but").addEventListener("click", commit_import); + by_id("check_all_import_but").addEventListener("click", check_all_imports); + by_id("uncheck_all_import_but") + .addEventListener("click", uncheck_all_imports); + by_id("uncheck_colliding_import_but") + .addEventListener("click", uncheck_colliding_imports); + by_id("cancel_import_but").addEventListener("click", deactivate); + by_id("import_failok_but").addEventListener("click", deactivate); + + return exports; +} + +const get_import_frame = make_once(init); + +/* + * EXPORTS_START + * EXPORT get_import_frame + * EXPORTS_END + */ diff --git a/html/options.html b/html/options.html index a10b919..2246f9a 100644 --- a/html/options.html +++ b/html/options.html @@ -8,11 +8,8 @@ <head> <meta charset="utf-8"/> <title>Hachette options</title> + <link type="text/css" rel="stylesheet" href="base.css" /> <style> - input[type="checkbox"], input[type="radio"], .hide, .popup.hide { - display: none; - } - /* pages list */ #page_components_ul { max-height: 80vh; @@ -78,23 +75,6 @@ input[type="radio"]:not(:checked)+.import_window_content { display: none; } - - /* buttons */ - button { - background-color: #4CAF50; - border: none; - border-radius: 8px; - color: white; - padding: 6px 12px; - text-align: center; - text-decoration: none; - display: inline-block; - margin: 2px 0px; - } - - button:hover { - box-shadow: 0 6px 8px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19); - } </style> </head> <body> @@ -118,11 +98,6 @@ <input type="radio" style="display: inline;" name="page_components"></input> <span></span> </li> - <li id="import_li_template"> - <span></span> - <input type="checkbox" style="display: inline;" checked></input> - <span></span> - </li> </div> <!-- Mind the show_*s ids below - their format is assumed in js code --> @@ -243,25 +218,7 @@ <div id="import_window" class="hide popup" position="absolute"> <div class="popup_frame"> - <h2> Settings import </h2> - <input id="import_loading_radio" type="radio" name="import_window_content"></input> - <span class="import_window_content"> Loading... </span> - <input id="import_failed_radio" type="radio" name="import_window_content"></input> - <div class="import_window_content"> - <span> Bad file :( </span> - <pre id="bad_file_errormsg"></pre> - <button id="import_failok_but"> OK </button> - </div> - <input id="import_selection_radio" type="radio" name="import_window_content"></input> - <div class="import_window_content"> - <button id="check_all_import_but"> Check all </button> - <button id="uncheck_all_import_but"> Uncheck all </button> - <button id="uncheck_colliding_import_but"> Uncheck existing </button> - <ul id="import_ul"> - </ul> - <button id="commit_import_but"> OK </button> - <button id="cancel_import_but"> Cancel </button> - </div> + <IMPORT html/import_frame.html /> </div> </div> diff --git a/html/options_main.js b/html/options_main.js index 026b9ba..830c860 100644 --- a/html/options_main.js +++ b/html/options_main.js @@ -12,26 +12,24 @@ * IMPORT TYPE_NAME * IMPORT list_prefixes * IMPORT nice_name + * IMPORT parse_json_with_schema + * IMPORT by_id + * IMPORT matchers + * IMPORT get_import_frame * IMPORTS_END */ var storage; -function by_id(id) -{ - return document.getElementById(id); -} const item_li_template = by_id("item_li_template"); const bag_component_li_template = by_id("bag_component_li_template"); const chbx_component_li_template = by_id("chbx_component_li_template"); const radio_component_li_template = by_id("radio_component_li_template"); -const import_li_template = by_id("import_li_template"); /* Make sure they are later cloned without id. */ item_li_template.removeAttribute("id"); bag_component_li_template.removeAttribute("id"); chbx_component_li_template.removeAttribute("id"); radio_component_li_template.removeAttribute("id"); -import_li_template.removeAttribute("id"); function item_li_id(prefix, item) { @@ -517,12 +515,6 @@ const ul_by_prefix = { } } -const import_window = by_id("import_window"); -const import_loading_radio = by_id("import_loading_radio"); -const import_failed_radio = by_id("import_failed_radio"); -const import_selection_radio = by_id("import_selection_radio"); -const bad_file_errormsg = by_id("bad_file_errormsg"); - /* * Newer browsers could utilise `text' method of File objects. * Older ones require FileReader. @@ -543,6 +535,31 @@ function read_file(file) _read_file(file, resolve, reject)); } +const url_regex = /^[a-z0-9]+:\/\/[^/]+\.[^/]{2,}(\/[^?#]*)?$/; +const empty_regex = /^$/; + +const settings_schema = [ + [{}, "matchentry", "minentries", 1, + new RegExp(`^${TYPE_PREFIX.SCRIPT}`), { + /* script data */ + "url": ["optional", url_regex, "or", empty_regex], + "sha256": ["optional", matchers.sha256, "or", empty_regex], + "text": ["optional", "string"] + }, + new RegExp(`^${TYPE_PREFIX.BAG}`), [ + "optional", + [matchers.component, "repeat"], + "default", undefined + ], + new RegExp(`^${TYPE_PREFIX.PAGE}`), { + /* page data */ + "components": ["optional", matchers.component] + }], "repeat" +]; + +const import_window = by_id("import_window"); +let import_frame; + async function import_from_file(event) { let files = event.target.files; @@ -550,98 +567,17 @@ async function import_from_file(event) return; import_window.classList.remove("hide"); - import_loading_radio.checked = true; - - let result = undefined; + import_frame.show_loading(); try { - result = JSON.parse(await read_file(files[0])); + const file = await read_file(files[0]); + var result = parse_json_with_schema(settings_schema, file); } catch(e) { - bad_file_errormsg.textContent = "" + e; - import_failed_radio.checked = true; - return; - } - - let errormsg = validate_settings(result); - if (errormsg !== false) { - bad_file_errormsg.textContent = errormsg; - import_failed_radio.checked = true; + import_frame.show_error("Bad file :(", "" + e); return; } - populate_import_list(result); - import_selection_radio.checked = true; -} - -function validate_settings(settings) -{ - // TODO - return false; -} - -function import_li_id(prefix, item) -{ - return `ili_${prefix}_${item}`; -} - -let import_ul = by_id("import_ul"); -let import_chbxs_colliding = undefined; -let settings_import_map = undefined; - -function populate_import_list(settings) -{ - let old_children = import_ul.children; - while (old_children[0] !== undefined) - import_ul.removeChild(old_children[0]); - - import_chbxs_colliding = []; - settings_import_map = new Map(); - - for (let setting of settings) { - let [key, value] = Object.entries(setting)[0]; - let prefix = key[0]; - let name = key.substring(1); - add_import_li(prefix, name); - settings_import_map.set(key, value); - } -} - -function add_import_li(prefix, name) -{ - let li = import_li_template.cloneNode(true); - let name_span = li.firstElementChild; - let chbx = name_span.nextElementSibling; - let warning_span = chbx.nextElementSibling; - - li.setAttribute("data-prefix", prefix); - li.setAttribute("data-name", name); - li.id = import_li_id(prefix, name); - name_span.textContent = nice_name(prefix, name); - - if (storage.get(prefix, name) !== undefined) { - import_chbxs_colliding.push(chbx); - warning_span.textContent = "(will overwrite existing setting!)"; - } - - import_ul.appendChild(li); -} - -function check_all_imports() -{ - for (let li of import_ul.children) - li.firstElementChild.nextElementSibling.checked = true; -} - -function uncheck_all_imports() -{ - for (let li of import_ul.children) - li.firstElementChild.nextElementSibling.checked = false; -} - -function uncheck_colliding_imports() -{ - for (let chbx of import_chbxs_colliding) - chbx.checked = false; + import_frame.show_selection(result); } const file_opener_form = by_id("file_opener_form"); @@ -649,9 +585,6 @@ const file_opener_form = by_id("file_opener_form"); function hide_import_window() { import_window.classList.add("hide"); - /* Let GC free some memory */ - import_chbxs_colliding = undefined; - settings_import_map = undefined; /* * Reset file <input>. Without this, a second attempt to import the same @@ -660,43 +593,16 @@ function hide_import_window() file_opener_form.reset(); } -function commit_import() -{ - for (let li of import_ul.children) { - let chbx = li.firstElementChild.nextElementSibling; - - if (!chbx.checked) - continue; - - let prefix = li.getAttribute("data-prefix"); - let name = li.getAttribute("data-name"); - let key = prefix + name; - let value = settings_import_map.get(key); - storage.set(prefix, name, value); - } - - hide_import_window(); -} - -function initialize_import_facility() +async function initialize_import_facility() { let import_but = by_id("import_but"); let file_opener = by_id("file_opener"); - let import_failok_but = by_id("import_failok_but"); - let check_all_import_but = by_id("check_all_import_but"); - let uncheck_all_import_but = by_id("uncheck_all_import_but"); - let uncheck_existing_import_but = by_id("uncheck_existing_import_but"); - let commit_import_but = by_id("commit_import_but"); - let cancel_import_but = by_id("cancel_import_but"); + import_but.addEventListener("click", () => file_opener.click()); file_opener.addEventListener("change", import_from_file); - import_failok_but.addEventListener("click", hide_import_window); - check_all_import_but.addEventListener("click", check_all_imports); - uncheck_all_import_but.addEventListener("click", uncheck_all_imports); - uncheck_colliding_import_but - .addEventListener("click", uncheck_colliding_imports); - commit_import_but.addEventListener("click", commit_import); - cancel_import_but.addEventListener("click", hide_import_window); + + import_frame = await get_import_frame(); + import_frame.onclose = hide_import_window; } /* @@ -768,9 +674,9 @@ async function main() jump_to_item(document.URL); - initialize_import_facility(); - storage.add_change_listener(handle_change); + + await initialize_import_facility(); } function handle_change(change) diff --git a/process_html_file.sh b/process_html_file.sh new file mode 100755 index 0000000..1ed0295 --- /dev/null +++ b/process_html_file.sh @@ -0,0 +1,33 @@ +#!/bin/sh + +# Copyright (C) 2021 Wojtek Kosior +# Redistribution terms are gathered in the `copyright' file. + +# Call like: +# ./process_html_file.sh html/options.html + +. ./shell_utils.sh + +FILE="$1" +FILEKEY=$(sanitize "$FILE") + +if [ "x$(map_get HTML_FILENAMES $FILEKEY)" = "xyes" ]; then + errcho "import loop on $FILE" + exit 1 +fi + +map_set_export HTML_FILENAMES $FILEKEY yes + +awk '\ +!/^[\t\r ]*<IMPORT[\t\r ]+([^\t\r ]+)[\t\r ]+\/>[\t\r ]*$/{ + print $0; +} +/^[\t\r ]*<IMPORT[\t\r ]+([^\t\r ]+)[\t\r ]+\/>[\t\r ]*$/{ + indent = substr($0, 1, index($0, "<") - 1); + command = "./process_html_file.sh " $2; + while (command | getline) { + print indent $0; + } + if (close(command) != 0) + exit 1; +}' < "$FILE" diff --git a/shell_utils.sh b/shell_utils.sh new file mode 100644 index 0000000..95e0d4e --- /dev/null +++ b/shell_utils.sh @@ -0,0 +1,39 @@ +# Copyright (C) 2021 Wojtek Kosior +# Redistribution terms are gathered in the `copyright' file. + +# This file is meant to be sourced in sh. + +ENDL=" +" + +errcho() { + echo "$@" >&2 +} + +map_set_instr() { + echo "$1__$2='$3'" +} + +map_set() { + eval "$(map_set_instr "$@")" +} + +map_set_export() { + eval "export $(map_set_instr "$@")" +} + +map_get() { + eval "echo \"\$$1__$2\"" +} + +map_del_instr() { + echo "unset $1__$2" +} + +map_del() { + eval "$(map_del_instr "$@")" +} + +sanitize() { + echo "$1" | tr /.- _ +} |