bc100-autofill/content_script.js
2024-05-03 23:21:42 +02:00

493 lines
17 KiB
JavaScript

"use strict";
// Put all the javascript code here, that you want to execute after page load.
/**
* @typedef ("delay"|"nodep"|"abort"|"") DisruptionType
*/
/**
*
* @typedef {Object} Stage
* @property {string} name
* @property {function.<Node|MutationRecord>} match
* @property {function.<Node|MutationRecord>} execute
* @property {('mutation'|'node'|undefined)} expects
*/
/**
* @type Stage
* @todo this can now also contain a dict<DisruptionType, Stage>
*/
let currentStage;
const settings = browser.storage.sync;
/**
* @type ("delay"|"nodep"|"abort"|"")
*/
let disruption = "";
/**
*
* @param {string} prop
* @param {function.<string>} existCallback
* @param {function} noExistCallback
*/
const ensureSettingsProp = (prop, existCallback, noExistCallback) => {
settings.get(prop).then(foundKeys => {
console.log("storage returned", foundKeys);
return (prop in foundKeys ? existCallback(foundKeys[prop]) : noExistCallback());
});
}
const _clickEv = () => { return new Event('click', { bubbles: true }) };
/** @param {string} s */
const $ = s => document.querySelector(s);
/**
* @param {Element} n
* @param {string} s
*/
const $$ = (n, s) => n.querySelector(s);
const pressKey = (...keys) => {
keys.forEach(k => {
let d = document.dispatchEvent;
d(new KeyboardEvent('keydown', { bubbles: true, key: k }));
d(new KeyboardEvent('keyup', { bubbles: true, key: k }));
})
}
let stages;
const personalDataConfigKeys = [
"addr__appellation", "addr__title", "addr__firstName", "addr__surName",
"addr__email", "addr__street", "addr__postcode", "addr__placename"];
const bankDetailConfigKeys = ["pymt__iban", "pymt__bic"];
/**
* @param {Node} n
*/
function processSingleAddedNode(n) {
if (currentStage.match(n)) {
console.log(currentStage.name, "matched: ", n);
console.log(currentStage.name, currentStage.execute(n) ? "executed" : "execution failed");
nextStage();
} else {
console.log(currentStage.name, "did not match: ", n);
}
}
/**
*
* @param {MutationRecord[]} mutationList
* @param {MutationObserver} observer
*/
function processMutations(mutationList, observer) {
if (currentStage === undefined) {
currentStage = stages.shift();
}
if (!("name" in currentStage)){
// stages always have a `name`, so this must be the disruption type split
if (disruption in currentStage){
stages.unshift(...currentStage[disruption]);
currentStage = stages.shift();
} else if (disruption == "") {
// no default action - skip to personal data stuff
currentStage = stages.shift();
//TODO sniff action and provide field jumps instead
} else {
console.log("this Stage is invalid, aborting", currentStage);
}
}
for (const mutation of mutationList) {
if ('expects' in currentStage) {
if (currentStage.expects == 'mutation') {
if (currentStage.match(mutation)) {
console.log(currentStage.name, "matched: ", mutation);
console.log(currentStage.name, currentStage.execute(mutation) ? "executed" : "execution failed");
nextStage();
} else {
console.log(currentStage.name, "did not match: ", mutation);
}
} else if (currentStage.expects == 'node' && mutation.type === "childList") {
mutation.addedNodes.forEach(processSingleAddedNode);
}
} else if (mutation.type === "childList") {
//last resort
mutation.addedNodes.forEach(n => {
if (n.nodeType === Node.ELEMENT_NODE) {
processSingleAddedNode(n);
} else if (!([Node.COMMENT_NODE, Node.TEXT_NODE].includes(n.nodeType))) {
console.log("skipping node", n);
}
});
} else {
console.log("err: could not dispatch mutation", mutation);
}
}
}
let hasConfiguredBankDetails, hasConfiguredPersonalData;
let observer = new MutationObserver(processMutations);
function nextStage() {
if (stages.length > 0) {
currentStage = stages.shift();
} else {
console.log("no more stages - disconnecting observer");
observer.disconnect();
}
}
const addObserver = () => {
settings.get(
['enable', 'defaultAction'].concat(personalDataConfigKeys, bankDetailConfigKeys)
).then(userSettings => {
disruption = userSettings.defaultAction || "";
hasConfiguredPersonalData = Object.keys(userSettings).filter(k => personalDataConfigKeys.includes(k)).length > 0;
hasConfiguredBankDetails = Object.keys(userSettings).filter(k => bankDetailConfigKeys.includes(k)).length > 0;
if (!!userSettings.enable) {
observer.observe(document.body, {
childList: true, subtree: true
})
}
})
};
addObserver();
function fillTextInput(parentNode, selector, value) {
const node = $$(parentNode, selector);
node.value = value;
// node.focus();
// node.dispatchEvent(new FocusEvent("focus"));
// node.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
node.dispatchEvent(new Event("input", { bubbles: true, inputType: "insertFromPaste", data: value }));
// node.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
}
/**
*
* @param {MutationRecord} mut
* @param {string} testName
* @param {"open"|"close"} openOrClose
* @returns boolean
*/
const matchDropdown = (mut, testName, openOrClose) => {
let nodes;
if (openOrClose == "open") {
nodes = mut.addedNodes;
} else if (openOrClose == "close") {
nodes = mut.removedNodes;
} else {
throw new Error(`"${openOrClose}" is not a valid value for openOrClose`);
}
return mut.target.parentNode.parentNode.classList.contains(testName) &&
Array.from(nodes).some(
n => n.nodeType === Node.ELEMENT_NODE &&
n.classList.contains("db-web-dropdown-outer-container"))
}
/**@param {MutationRecord} mut */
const getDropdownList = (mut) => {
const dd = Array.from(mut.addedNodes).filter(e => e instanceof Element && e.querySelector("ul") !== null);
return dd.at(0).querySelector("ul");
}
/**@param {MutationRecord} mut */
const getDropdownCloseButton = (mut) => mut.target.parentElement.parentElement.querySelector("button");
const startClaim = {
name: "startClaim",
match: node => node.classList.contains("main-layout"),
execute: node => {
const startenButton = node.querySelector('button.test-antrag-starten-button');
if (startenButton instanceof HTMLButtonElement) {
startenButton.dispatchEvent(_clickEv());
return true;
}
return false;
}
}
const fillData = {
name: "fillData",
match: node => node.classList.contains("fahrgastrechte-bahn-card-auswahl"),
execute: node => {
settings.get(["bcnum", "bday"]).then(cfg => {
for (const [k, v] of Object.entries(cfg)) {
if (k == "bcnum" && !!v) {
fillTextInput(node, '#fahrgastrechte-bahn-card-auswahl-nummer--db-web-text-input', v);
}
if (k == "bday" && !!v) {
fillTextInput(node, "#fahrgastrechte-bahn-card-auswahl-geburts-datum--db-web-text-input", v);
}
}
});
return true;
}
}
const clickContinue = {
name: "clickContinue",
match: () => $('#fahrgastrechte-bahn-card-auswahl-geburts-datum--db-web-text-input').value !== "",
execute: e => {
const continueButton = $('.fahrgastrechte-bahn-card-auswahl button.fahrgastrechte-continue-button');
if (continueButton instanceof Element) {
continueButton.dispatchEvent(_clickEv());
return true;
}
return false;
}
}
//TODO construction zone
const sniffDisruption = {
name: "sniffDisruption",
match: node => {
//delay -> .verspaetungs-auswahl.fahrgastrechte-page__section.test-current-section
//nodep -> .fahrplan...., .antrags-typ-auswahl:children("input[name=radioAntragsTyp]"):id(#antragstyp-nicht-angetreten):has-siblings(.db-web-radio-button-container__radio-button-icon--checked)
//abort -> .fahrplan...., ...#antragstyp-abgebrochen, An welchem Bahnhof? -> .fahrgastrechte-page__section:has-child(.fahrgastrechte-editable.fahrgastrechte-bahnhof.abbruchbahnhof)
// Weitere Kosten? (.kosten-abfrage.f-p__s.t-c-s:has_child(.db-web-radio-button-container.kosten-abfrage__radio))
},
execute: () => true
}
const iWasDelayed = {
name: "iWasDelayed",
match: node => node.classList.contains("antrags-typ-auswahl"),
execute: node => {
const delay = $$(node, 'input#antragstyp-verspaetung');
if (delay instanceof HTMLInputElement) {
delay.dispatchEvent(new Event('change'));
return true;
}
return false;
}
}
const iDidntTravel = {
name: "iDidntTravel",
match: node => node.classList.contains("antrags-typ-auswahl"),
execute: node => {
const noTravel = $$(node, "input#antragstyp-nicht-angetreten");
if (noTravel instanceof HTMLInputElement){
noTravel.dispatchEvent(new Event("change"));
return true;
}
return false;
}
}
const iWentBack = {
name: "iWentBack",
match: node => node.classList.contains("antrags-typ-auswahl"),
execute: node => {
const wentBack = $$(node, "input#antragstyp-abgebrochen");
if (wentBack instanceof HTMLInputElement){
wentBack.dispatchEvent(new Event("change"));
return true;
}
return false;
}
}
const focusTurnaroundStationInput = {
name: "focusTurnaroundStationInput",
expects: "node",
match: node => node instanceof Element && Array.from(node.children).some(c => c.classList.contains("abbruchbahnhof")),
execute: node => {
$$(node, ".abbruchbahnhof .fahrgastrechte-bahnhof__halt-search input").focus();
return true;
}
}
const moreThan60Minutes = {
name: "moreThan60Minutes",
match: node => node.classList.contains("verspaetungs-auswahl"),
execute: node => {
return node.querySelector('#verspaetungstyp-mehr-als-stunde').dispatchEvent(new Event('change'));
}
}
const continueToForm = {
name: "continueToForm",
match: node => node.classList.contains("verspaetung-bestaetigung"),
execute: node => {
$$(node, 'button.fahrgastrechte-continue-button').dispatchEvent(_clickEv());
return true;
}
}
const focusDepartureInput = {
name: "focusDepartureInput",
match: node => node.classList.contains("fahrplan"),
execute: node => {
const depInput = $$(node, '.fahrplan__start .fahrplan__haltestelle input');
const obs = new IntersectionObserver((entries, intObserver) => {
if (!(entries.some(e => e.isIntersecting))) return false;
console.log("observer fired:", entries);
depInput.focus();
intObserver.disconnect();
}, { threshold: 1 });
obs.observe(depInput);
return true;
}
}
const jumpToTimeInput = {
name: "jumpToTimeInput",
match: node => node.classList.contains("ankunft-zeit"),
execute: node => {
$$(node, '#fahrgastrechte-ankunft-uhrzeit--db-web-text-input').focus();
return true;
}
}
const activateAppellationDropdown = {
name: "activateAppellationDropdown",
match: node => node.classList.contains("persoenlicheangaben") && hasConfiguredPersonalData,
execute: node => {
return ensureSettingsProp("addr__appellation", () => {
const selectList = node.querySelector('.test-name-anrede.db-web-select');
selectList.querySelector('button').dispatchEvent(_clickEv());
}, () => true);
}
}
/**@type Stage */
const enterAppellationAndActivateTitleDropdown = {
name: "enterAppellationAndActivateTitleDropdown",
expects: "mutation",
match: mut => matchDropdown(mut, "test-name-anrede", "open"),
/**@param {MutationRecord} mut */
execute: mut => {
ensureSettingsProp("addr__appellation", v => {
getDropdownList(mut).querySelector(`[data-value=${v}]`).dispatchEvent(_clickEv());
$('.test-name-titel.db-web-select button').dispatchEvent(_clickEv());
}, () => {
getDropdownCloseButton(mut).dispatchEvent(_clickEv());
$('.test-name-titel.db-web-select button').dispatchEvent(_clickEv());
});
return true;
}
}
/**@type Stage */
const enterTitleAndActivateCountryDropdown = {
name: "enterTitle",
expects: "mutation",
/**@param {MutationRecord} mut */
match: mut => matchDropdown(mut, "test-name-titel", "open"),
/**@param {MutationRecord} mut */
execute: mut => {
ensureSettingsProp("addr__title", v => {
const selectList = getDropdownList(mut);
selectList.querySelector(`[data-value=${v}]`).dispatchEvent(_clickEv());
$(".test-adresse-land.db-web-select button").dispatchEvent(_clickEv());
}, () => {
getDropdownCloseButton(mut).dispatchEvent(_clickEv());
$(".test-adresse-land.db-web-select button").dispatchEvent(_clickEv());
}
);
return true;
}
}
/** @type Stage */
const enterCountry = {
name: "enterCountry",
expects: "mutation",
/** @param {MutationRecord} mut */
match: mut => matchDropdown(mut, "test-adresse-land", "open"),
execute: () => {
ensureSettingsProp("addr__country", v => {
const selectList = $(".test-adresse-land ul");
selectList.querySelector(`[data-value=${v}]`).dispatchEvent(_clickEv());
}, () => $(".test-adresse-land.db-web-select button").dispatchEvent(_clickEv()));
return true;
}
}
/**
* @type Stage
*/
const enterTextPersonalData = {
name: "enterTextPersonalData",
expects: 'mutation',
/** @param {MutationRecord} mut */
match: mut => matchDropdown(mut, "test-name-titel", "close"),
execute: () => {
let node = document;
let delay = 100;
settings.get(personalDataConfigKeys).then(foundKeys => {
console.log("storage returned", foundKeys);
const configKey_Selector = {
"addr__firstName": ".test-name-vorname input",
"addr__surName": ".test-name-nachname input",
"addr__email": ".persoenlicheangaben__email input",
"addr__street": ".test-adresse-strasse input",
"addr__postcode": ".test-adresse-plz input",
"addr__placename": ".test-adresse-ort input"
}
for (const [k, v] of Object.entries(foundKeys)) {
if (k in configKey_Selector) {
//TODO WIP this only works on some fields
console.log("filling", configKey_Selector[k], "with", v);
setTimeout(() => {
fillTextInput(node, configKey_Selector[k], v)
}, delay);
delay += 100;
} else {
console.log("no selector found for config key", k);
}
}
setTimeout(() => {
const continueBtn = $(".fahrgastrechte-editable__buttons button.fahrgastrechte-continue-button");
continueBtn.focus();
continueBtn.dispatchEvent(_clickEv());
}, delay);
});
return true;
},
}
/** @type Stage */
const continueToPayout = {
name: "continueToPayout",
expects: "mutation",
match: () => Object.is(
document.activeElement,
$(".fahrgastrechte-editable__buttons button.fahrgastrechte-continue-button")
),
execute: () => document.activeElement.dispatchEvent(_clickEv()),
}
/** @type Stage */
const enterPaymentDetails = {
name: "enterPaymentDetails",
match: node => node.querySelector(".entschaedigung"),
execute: node => {
if (!hasConfiguredBankDetails) return true;
const xfrRadio = node.querySelector('#ueberweisung');
xfrRadio.dispatchEvent(new Event('change'));
settings.get(bankDetailConfigKeys).then(results => {
console.log(results);
for (const [k, v] of Object.entries(results)) {
switch (k) {
case "pymt__iban":
fillTextInput(node, '.test-entschaedigung-iban input', v);
break;
case "pymt__bic":
fillTextInput(node, '.test-entschaedigung-bic input', v);
break;
}
}
})
true
},
}
const defaultStages = [
startClaim, fillData, clickContinue,
{"delay": [iWasDelayed, moreThan60Minutes, continueToForm,
focusDepartureInput, jumpToTimeInput], "nodep": [iDidntTravel, focusDepartureInput], "abort": [iWentBack, focusDepartureInput, focusTurnaroundStationInput]},
activateAppellationDropdown, enterAppellationAndActivateTitleDropdown,
enterTitleAndActivateCountryDropdown, enterCountry,
enterTextPersonalData, /* continueToPayout, */ enterPaymentDetails
];
/** @type Stage[] */
stages = defaultStages;