"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.} match * @property {function.} execute * @property {('mutation'|'node'|undefined)} expects */ /** * @type Stage * @todo this can now also contain a dict */ let currentStage; const settings = browser.storage.sync; /** * @type ("delay"|"nodep"|"abort"|"") */ let disruption = ""; /** * * @param {string} prop * @param {function.} existCallback * @param {function} noExistCallback */ const ensureSettingsProp = (prop, existCallback, noExistCallback) => { return settings.get(prop).then(foundKeys => { console.debug("storage returned", foundKeys); if (prop in foundKeys){ return existCallback(foundKeys[prop]); } else { return 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"]; const genericExecute = (stage, o) => { const success = stage.execute(o); Promise.resolve(success).then(result => { if (result === true) { console.info(currentStage.name, "executed successfully"); } else { console.error(currentStage.name, "failed"); } nextStage(); }, failReason => { console.error(currentStage.name, "failed: ", failReason); }); } /** * @param {Node} n */ function processSingleAddedNode(n) { if (currentStage.match(n)) { console.info(currentStage.name, "matched: ", n); genericExecute(currentStage, n); } else { console.debug(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.error("this Stage is invalid, aborting", currentStage); } } for (const mutation of mutationList) { if ('expects' in currentStage) { if (currentStage.expects == 'mutation') { if (currentStage.match(mutation)) { console.info(currentStage.name, "matched: ", mutation); genericExecute(currentStage, mutation); } else { console.debug(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))) { // skip comment nodes and text nodes without logging // log all the others console.debug("skipping node", n); } }); } else { console.error("could not dispatch mutation", mutation); } } } let hasConfiguredBankDetails, hasConfiguredPersonalData; let observer = new MutationObserver(processMutations); function nextStage() { if (stages.length > 0) { currentStage = stages.shift(); } else { console.info("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.debug("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; } } /** @type Stage */ const activateAppellationDropdown = { name: "activateAppellationDropdown", match: node => node.classList.contains("persoenlicheangaben") && hasConfiguredPersonalData, execute: node => { return ensureSettingsProp("addr__appellation", () => { const button = node.querySelector('.test-name-anrede.db-web-select button'); button.dispatchEvent(_clickEv()); return true; }, () => 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.debug("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.debug("filling", configKey_Selector[k], "with", v); setTimeout(() => { fillTextInput(node, configKey_Selector[k], v) }, delay); delay += 100; } else { console.warn("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'); // "I didn't travel" comes without this selection and goes straight to bank payout. if (xfrRadio instanceof Element) xfrRadio.dispatchEvent(new Event('change')); settings.get(bankDetailConfigKeys).then(results => { console.debug("storage returned", 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; } } }) return 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;