bubblefoil / p4u-worklogger

// ==UserScript==
// @name         p4u-worklogger
// @description  JIRA work log in UU
// @version      2.10.1
// @namespace    https://uuos9.plus4u.net/
// @homepage     https://github.com/bubblefoil/p4u-worklogger
// @author       bubblefoil
// @license      MIT
// @require      https://code.jquery.com/jquery-3.2.1.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @connect      jira.unicorn.com
// @match        https://uuos9.plus4u.net/uu-specialistwtmg01-main/*
// @match        https://uuapp.plus4u.net/uu-specialistwtm-maing01/*
// @run-at       document-idle
// ==/UserScript==

//Test issue - FBLI-7870
const jiraComUrl = 'https://jira.unicorn.com';
const jiraRestApiPath = 'rest/api/2';
const jiraIssueKeyPattern = /([A-Z]+-\d+)/;
const jiraIssueProjectPattern = /([A-Z]+)-\d+/;

class PageCheck {

    static isWorkLogFormPage() {
        //Check that the work log page is loaded by querying for some expected elements
        if (document.title !== 'Working Time Management') {
            console.log("Judging by the page title, this does not seem to be the Working Time Management app. Exiting extension script.");
            return false;
        }
        return true;
    }
}

if (!PageCheck.isWorkLogFormPage()) {
    // noinspection JSAnnotator
    return;
} else {
    // language=CSS
    // noinspection JSUnresolvedFunction
    GM_addStyle(`
    /* Widen the month selection button envelope by overriding its fixed width. */
    .uu-specialistwtm-worker-monthly-detail-top-change-month-dropdown {
        min-width: 330px;
    }
    /* Copied from .uu-specialistwtm-worker-monthly-detail-top-back-icon without its padding */
    .wtm-month-switch-button-icon {
        color: #616161;
        font-size: 20px;
        height: fit-content;
    }
    `);

    // Polyfill some syntactic sugar
    Promise.of = Promise.resolve;
}

const jiraIssueLoaderAnimation = `
<style>
    .progress-spinner {
        width: 16px;
        height: 16px;
        -webkit-animation: spin 2s linear infinite; /* Safari */
        animation: spin 1.5s linear infinite;
    }

    /* Safari */
    @-webkit-keyframes spin {
        0% {
            -webkit-transform: rotate(0deg);
        }
        100% {
            -webkit-transform: rotate(360deg);
        }
    }

    @keyframes spin {
        0% {
            transform: rotate(0deg);
        }
        100% {
            transform: rotate(360deg);
        }
    }
</style>
<svg class="progress-spinner" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 75.76 75.76">
    <defs>
        <style>.cls-2 { fill: #2684ff; } .cls-3 { fill: url(#linear-gradient); } .cls-4 { fill: url(#linear-gradient-2); }</style>
        <linearGradient id="linear-gradient" x1="34.64" y1="15.35" x2="19" y2="30.99" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#0052cc"/><stop offset="1" stop-color="#2684ff"/></linearGradient>
        <linearGradient id="linear-gradient-2" x1="38.78" y1="60.28" x2="54.39" y2="44.67" xlink:href="#linear-gradient"/>
    </defs>
    <title>Connecting to Jira...</title>
    <g id="Layer_2">
        <g id="Blue">
            <path class="cls-2" d="M72.4,35.76,39.8,3.16,36.64,0h0L12.1,24.54h0L.88,35.76A3,3,0,0,0,.88,40L23.3,62.42,36.64,75.76,61.18,51.22l.38-.38L72.4,40A3,3,0,0,0,72.4,35.76ZM36.64,49.08l-11.2-11.2,11.2-11.2,11.2,11.2Z"/>
            <path class="cls-3" d="M36.64,26.68A18.86,18.86,0,0,1,36.56.09L12.05,24.59,25.39,37.93,36.64,26.68Z"/>
            <path class="cls-4" d="M47.87,37.85,36.64,49.08a18.86,18.86,0,0,1,0,26.68h0L61.21,51.19Z"/>
        </g>
    </g>
</svg>
`;

/**
 * Branches off the flow into a supplied function, typically to perform side-effects.
 * Returns the input value.
 *
 * @param {function(*): *} f The dead-end functions, may be impure.
 * @return {function(*): (Promise<*> | Promise<void>)} Resolved Promise of the original input
 */
const tee = (f) => (x) => {
    f(x);
    return Promise.of(x);
};

/**
 * Returns wrapped fn which, when called, delegates the call to fn
 * if and only if pred returns true at the time of invocation.
 * Wrapping function passes args to Both pred and fn.
 *
 * @param pred {Function} predicate
 * @param fn {Function} function to be called conditionally if pred(...args) == true
 * @return {Function}
 */
function when(pred, fn) {
    return function conditionalFn(...args) {
        if (pred(...args)) {
            return fn(...args);
        }
    }
}

/**
 * Returns a function that dispatches calls to one of given functions, based on the first matching predicate.
 * Takes pairs of predicate, function (alternating), tests predicates one by one
 * and when a predicate returns true (strict match), calls following function and returns its result.
 * Each predicate and fn gets arguments passed to the returned dispatching function.
 *
 * Basically a functional if-else.
 *
 * @param pred {Function} Predicate
 * @param fn {Function} Function to call if pred matches.
 * @param more More pred/fn pairs.
 * @return {function(...[*]=)} Dispatching function
 */
function condp(pred, fn, ...more) {
    if (more.length % 2 !== 0) {
        throw new Error('Invalid number of functions. Expected even number of functions, predicate/function pairs.')
    }
    const nonFn = [pred, fn, ...more].find(f => typeof f !== "function");
    if (nonFn) {
        throw new TypeError('Invalid argument. Expected even number of functions, predicate/function pairs, but got this: ' + nonFn);
    }
    return function predMatchingFn(...args) {
        const fns = [pred, fn, ...more];
        for (let i = 0; i < fns.length; i += 2) {
            if (fns[i](...args)) {
                return fns[i + 1](...args);
            }
        }
    };
}

/**
 * Returns pred giving negated result.
 */
function not(pred) {
    return function notPred(...args) {
        return !pred(...args);
    }
}

/**
 * Returns pred giving positive result if all given preds return true.
 */
function and(...preds) {
    return function everyPred(...args) {
        return preds.every(p => {
            if (typeof p !== "function") {
                throw new TypeError('All arguments of function [and] must be functions, not this thing:' + p);
            }
            return p(...args);
        });
    };
}

/**
 * Pure form of attribute assignment.
 * Adds an attribute to an object and returns the updated object.
 *
 * @param o Target object
 * @param k Attribute name
 * @param v Attribute value
 * @return {Object} o with o.k = v
 */
const assoc = (o, k, v) => {
    o[k] = v;
    return o;
};

/**
 * Enhances the work log table.
 */
class LogTableDecorator {

    /**
     * Finds the JIRA issue references in the work descriptions in the work log table
     * and replaces them with links.
     */
    static findAndLinkifyJiraIssues() {
        const logTableNodes = document.querySelectorAll('#table-tsitems td.htsItemStyle div.hts_object');
        const hasTextNodes = (p) => Array.from(p.childNodes).find(n => n.nodeType === 3);
        Array.from(logTableNodes)
            .filter(hasTextNodes)
            .forEach(node => this.replaceIssueByLink(node));
    }

    static replaceIssueByLink(element) {
        const issueKeyPatternGlobal = new RegExp(jiraIssueKeyPattern, "g");
        element.innerHTML = element.innerHTML
            .replace(issueKeyPatternGlobal, `<a href="${jiraComUrl + '/browse'}/$1" target="_blank">$1</a>`);
    }
}

const wtmMessage = {
    cs: {
        'wtm.table.day-range.label': 'ČAS MEZI DNY:',
        'wtm.month.prev.title': 'Předchozí měsíc',
        'wtm.month.next.title': 'Následující měsíc',
    },
    en: {
        'wtm.table.day-range.label': 'TIME BETWEEN DAYS:',
        'wtm.month.prev.title': 'Previous month',
        'wtm.month.next.title': 'Next month',
    }
};

const _t = function (messageCode) {
    if (!messageCode) {
        console.warn('Invalid I18N message code: ', messageCode);
        return '?';
    }
    const getBundle = function () {
        const language = WtmWorktableModel.language();
        if (wtmMessage.hasOwnProperty(language)) {
            return wtmMessage[language];
        }
        return wtmMessage.cs
    };
    const bundle = getBundle();
    if (!bundle.hasOwnProperty(messageCode)) {
        console.warn(`)I18N message "${messageCode}" is not defined for "${WtmWorktableModel.language()}`);
        return messageCode;
    }
    return bundle[messageCode];
};

class WtmDateTime {

    /**
     * Returns parsed date as an array of fields: [day, month, year]. Months are counted from 1.
     * @param {string} selectedDate
     * @return {number[]}
     */
    static parseDate(selectedDate) {
        const dateParts = selectedDate.split(/[.\/]/);
        const dateFields = dateParts.length === 3 && dateParts.map(Number) || [NaN, NaN, NaN];
        if (WtmWorktableModel.language() === 'cs') {
            return dateFields;
        } else {
            const [month, day, year] = dateFields;
            return [day, month, year];
        }
    }

    static parseDateTime(selectedDate, selectedTime) {
        const [day, month, year] = this.parseDate(selectedDate);
        const [hour, minute] = selectedTime.split(':').map(Number);
        return new Date(year, month - 1, day, hour, minute, 0, 0);
    }

    static padToDoubleDigit(num) {
        const norm = Math.floor(Math.abs(num));
        return (norm < 10 ? '0' : '') + norm;
    }

    static addHours(d, h) {
        return this.addMinutes(d, h * 60);
    }

    static addMinutes(d, m) {
        return new Date(d.getTime() + (m * 60 * 1000))
    }
}

/**
 * Access methods to the WTM time table view.
 */
class WtmWorktableModel {

    static language() {
        return document.getElementsByClassName("uu5-bricks-language-selector-code-text")[0].textContent;
    }

    static newItemButton() {
        return document.querySelector('button.uu-specialistwtm-create-timesheet-item-button');
    }

    static monthlyDetailTopTimeColumn() {
        return document.querySelector('.uu5-common-div .uu-specialistwtm-worker-monthly-detail-top-time-column');
    }

    static timeTable() {
        return document.querySelector('table.uu5-bricks-table-table');
    }

    /**
     * Reads the day of month from a time table row.
     * @param {HTMLTableRowElement} tableRow
     * @return {number|NaN} Day of month, 0 - 31, or NaN.
     */
    static getDay(tableRow) {
        const dateCellText = tableRow.cells[1].innerText;
        const dateFields = WtmDateTime.parseDate(dateCellText);
        return dateFields[0];
    }

    static isModalDialogOpened() {
        const modal = document.querySelector('div.uu5-bricks-page-modal');
        const modalStyle = modal && window.getComputedStyle(modal);
        return modalStyle && modalStyle.visibility === 'visible';
    }

    /**
     * Reads logged working time in minutes from a time table row.
     * @param {HTMLTableRowElement} tableRow
     * @return {number|NaN} Minutes of work, or NaN.
     */
    static getTimeInMinutes(tableRow) {
        const dateCellText = tableRow.cells[2].innerText;
        const match = dateCellText.match(/(\d\d)[:](\d\d)/);
        return match && 60 * Number(match[1]) + Number(match[2]) || NaN;
    }

    /**
     * Filters table rows by given range of days of month.
     * @param {number} dayFrom
     * @param {number} dayTo
     * @return {Promise<HTMLTableRowElement[]>}
     */
    static rowsBetweenDays(dayFrom, dayTo) {
        return new Promise(resolve => {
            const timeTable = WtmWorktableModel.timeTable();
            const firstDay = Math.min(dayFrom, dayTo);
            const lastDay = Math.max(dayFrom, dayTo);
            const rowsInRange = [].filter.call(timeTable.rows, (row, idx) => {
                return idx > 0 && firstDay <= WtmWorktableModel.getDay(row) && WtmWorktableModel.getDay(row) <= lastDay;
            });
            resolve(rowsInRange);
        })
    }

    /**
     *
     * @param dayFrom
     * @param dayTo
     * @return {Promise<number>} Sum of time in selected day range in minutes.
     */
    static minutesBetween(dayFrom, dayTo) {
        return this
            .rowsBetweenDays(dayFrom, dayTo)
            .then((rows) => new Promise(resolve => {
                const minutesTotal = rows
                    .map((row) => WtmWorktableModel.getTimeInMinutes(row))
                    .reduce((acc, minutes) => acc + minutes, 0);
                resolve(minutesTotal);
            }));
    }
}

/**
 * Takes care of Time table extension view.
 */
class WtmWorktableView {

    constructor() {
    }

    static worktableSumViewShow() {
        if (document.getElementById('wtt-time-range-form')) {
            console.log('WTM Extension: Work table already enhanced.');
            WtmWorktableView.updateSum();
            return;
        }
        console.log('WTM Extension: enhancing work table');
        const today = new Date();
        const dayOfWeek = (today.getDay() + 6) % 7;
        const lastMonday = Math.max(today.getDate() - dayOfWeek, 1);
        const nextSunday = Math.min(lastMonday + 6, 31);
        WtmWorktableModel.monthlyDetailTopTimeColumn()
            .insertAdjacentHTML(
                'beforeend',
                `
                <div id="wtt-time-range-form" class="uu-specialistwtm-worker-monthly-detail-top-time-column" style="z-index: 10">
                    <!--Move the span to front, because this div is covered by some uu component's width % and the content is hard to select by mouse-->
                    <span class="uu5-bricks-span uu5-bricks-lsi-item uu5-bricks-lsi uu-specialistwtm-worker-monthly-detail-top-total-time-label" style="width: max-content; min-width: 8em;">${_t('wtm.table.day-range.label')}</span>
                    <input class="uu5-bricks-text uu5-common-text uu-specialistwtm-worker-monthly-detail-table-form-date" type="number" id="wtt-day-from" value="${lastMonday}" min="1" max="31" style="width: 4em; margin: 0.25em">
                    <input class="uu5-bricks-text uu5-common-text uu-specialistwtm-worker-monthly-detail-table-form-date" type="number" id="wtt-day-to" value="${nextSunday}" min="1" max="31" style="width: 4em; margin: 0.25em">
                    <span id="wtt-time-in-range-sum" class="uu5-bricks-span uu-specialistwtm-worker-monthly-detail-top-total-time">${WtmWorktableView.formatToHours(0)}</span>
                </div>`
            );
        WtmWorktableView.getDayFromInput().onchange = () => WtmWorktableView.updateSum();
        WtmWorktableView.getDayFromInput().onclick = () => WtmWorktableView.updateSum();
        WtmWorktableView.getDayToInput().onchange = () => WtmWorktableView.updateSum();
        WtmWorktableView.getDayToInput().onclick = () => WtmWorktableView.updateSum();
        WtmWorktableView.updateSum().catch((e) => console.warn(e));
    }

    static getDayToInput() {
        return document.getElementById('wtt-day-to');
    }

    static getDayFromInput() {
        return document.getElementById('wtt-day-from');
    }

    static async updateSum() {
        const dFrom = Number(WtmWorktableView.getDayFromInput().value);
        const dTo = Number(WtmWorktableView.getDayToInput().value);
        document.getElementById('wtt-time-in-range-sum').innerText = '-h';
        const minutesInRange = await WtmWorktableModel.minutesBetween(dFrom, dTo);
        document.getElementById('wtt-time-in-range-sum').innerText = WtmWorktableView.formatToHours(minutesInRange);
    }

    static formatToHours(minutes) {
        return ` ${Number(Math.round(minutes / 60 * 100) / 100).toLocaleString(WtmWorktableModel.language())}h`;
    }
}

class WtmDialog {

    static descArea() {
        return document.getElementsByTagName("textarea")[0];
    }

    static _getNamedInput(elementName) {
        return document.getElementsByName(elementName)[0]
            .lastChild
            .firstChild
            .firstChild;
    }

    static datePicker() {
        return WtmDialog._getNamedInput("date")
    }

    static timeFrom() {
        return WtmDialog._getNamedInput("timeFrom");
    }

    static timeTo() {
        return WtmDialog._getNamedInput("timeTo");
    }

    static artifactField() {
        return WtmDialog._getNamedInput("subject");
    }

    static categoryField() {
        return WtmDialog._getNamedInput("category");
    }

    static dateFrom() {
        return WtmDateTime.parseDateTime(this.datePicker().value, this.timeFrom().value);
    }

    static dateTo() {
        return WtmDateTime.parseDateTime(this.datePicker().value, this.timeTo().value);
    }

    static getDurationSeconds() {
        const dateFrom = WtmDialog.dateFrom();
        const dateTo = WtmDialog.dateTo();
        if (isNaN(dateFrom.getTime()) || isNaN(dateTo.getTime())) {
            return 0;
        }
        const durationMillis = dateTo - dateFrom;
        return durationMillis > 0 ? durationMillis / 1000 : 0;
    }

    /** Returns the OK button. It is an &lt;a&gt; element containing a structure of spans. */
    static buttonNextItem() {
        return WtmDialog.highRateNode().parentElement
            .lastChild
            .firstChild
            .firstChild
            .firstChild;
    }

    /** Returns the 'Next item' button. It is an &lt;a&gt; element containing a structure of spans, or null in case of work log update. */
    static  buttonOk() {
        return WtmDialog.highRateNode().parentElement
            .lastChild
            .lastChild
            .firstChild
            .firstChild;
    }

    static addKeyboardShortcutMnemonics() {
        WtmDialog.buttonOk().title = "Ctrl + Enter";
        WtmDialog.buttonNextItem().title = "Ctrl + Shift + Enter";
    }

    /**
     *
     * @param {HTMLElement} element
     * @param {string} key
     */
    static addMnemonic(element, key) {
        if (element && key && key.length === 1 && element.innerText.indexOf(key) > 0) {
            element.innerHTML = element.innerText.replace(key, `<u>${key}</u>`);
            element.accessKey = key;
        }
    }

    static highRateNode() {
        return document.getElementsByName("highRate")[0];
    }
}

/**
 * Wrap log functions to make them more functional.
 * Cannot be wrapped in a class because FF does not support fields and standard function syntax is horribly bloated.
 */
const log = tee(console.log);
const error = tee(console.error);
const warn = tee(console.warn);
const info = tee(console.info);
const debug = tee(console.debug);
const trace = tee(console.trace);

/**
 * Removes leading and trailing slash '/' character.
 * @param s
 * @return {string}
 */
const stripSlashes = (s) => s
    .replace(/^\//, '')
    .replace(/\/?$/, '');

const addRequestParameter = (parameter) => (value) => (url) => {
    const separator = url.includes('?') ? '&' : '?';
    return `${url}${separator}${parameter}=${value}`;
};

/**
 * @param {...string|string[]} resource path
 * @return {function(*=): Promise<string | Error>}
 */
const getResourceUrl = (...resource) => (domain) => new Promise((resolve, reject) => {
        if (domain && typeof domain === 'string') {
            resolve(`${stripSlashes(domain)}/${resource.flat().map(stripSlashes).join('/')}`);
        } else {
            reject(new TypeError('Invalid url domain :' + domain));
        }
    }
);

/**
 * @param {...string} resourcePath path
 * @return {function(*=): Promise<string | Error>}
 */
const jiraRestApiResource = (...resourcePath) =>
    getResourceUrl([jiraRestApiPath].concat(resourcePath).flat(2));

/**
 * @param {...string} issue JIRA issue key
 * @return {function(string): Promise<string|Error>} Resource url provider which takes domain as the argument.
 */
const jiraRestApiIssueUrl = (issue) => jiraRestApiResource('issue', issue);


/**
 * @param {...string} issue JIRA issue key
 * @return {function(string): Promise<string|Error>} Resource url provider which takes domain as the argument.
 */
const jiraBrowseIssueUrl = (issue) => getResourceUrl('browse', issue);

class InvalidResponse {
    /**
     * @param {HttpResponse} response
     */
    constructor(response) {
        this._url = response.finalUrl;
        this._response = response;
    }

    get response() {
        return this._response;
    }

    get url() {
        return this._url;
    }
}

class InvalidProjectError {

    constructor(projectKey) {
        this._projectKey = projectKey;
    }

    get projectKey() {
        return this._projectKey;
    }
}

class ProjectLoadingError {

    constructor(projectKey) {
        this._projectKey = projectKey;
    }

    get projectKey() {
        return this._projectKey;
    }
}

/**
 * Constructs a base of an HTTP request for json data and GET method.
 *
 * @param {string} url
 * @return {Request} Pre-filled request object for GM_xmlhttpRequest
 */
const getJsonRequest = (url) => ({
    method: 'GET',
    headers: {'Accept': 'application/json'},
    url: url,
    onreadystatechange: function (res) {
        console.debug(`Processing request: [${url}], state: ${res.readyState}`);
    }
});

/**
 * JIRA API connector and utils.
 */
class Jira4U {

    constructor() {
    }

    static tryParseIssue(desc) {
        console.log(`Parsing description: ${desc}`);
        if (typeof desc !== "string") {
            return new WorkDescription();
        }
        return WorkDescription.parse(desc);
    }

    /**
     * @param {string} issueKey
     * @return {Promise<string|TypeError>}
     */
    static getProjectCode(issueKey) {
        if (typeof issueKey !== 'string' || !jiraIssueProjectPattern.test(issueKey)) {
            return Promise.reject(new TypeError(`Invalid argument issueKey: "${issueKey}", expected an issue key`));
        }
        return Promise.of(issueKey.match(jiraIssueProjectPattern)[1]);
    }

    /**
     * @param {Function} onprogress
     * @return {function(Object): Object}
     */
    static setOnProgressCallback(onprogress) {
        return (request) => Object.assign(request, {onreadystatechange: onprogress});
    }
    /**
     * Triggers the actual http request.
     *
     * @typedef HttpResponse
     * @property {number} status
     * @property {string} responseText
     * @property {string} responseHeaders
     * @property {string} finalUrl
     *
     * @param {Request} requestParams
     * @return {Promise<HttpResponse>} A Promise which resolves with the http response.
     */
    static request(requestParams) {
        return new Promise((resolve, reject) => {
            // noinspection JSUnresolvedFunction
            GM_xmlhttpRequest(
                Object.assign(requestParams, {
                    onload: resolve,
                    onerror: reject
                }))
        });
    }

    /**
     * Checks whether http response status is OK (200).
     *
     * @param {HttpResponse} response Http response
     * @return {Promise<HttpResponse|InvalidResponse>}
     */
    static validateStatusOk(response) {
        return (response.status === 200)
            ? Promise.of(response)
            : Promise.reject(new InvalidResponse(response));
    }

    /**
     * @param {HttpResponse} response
     * @return {object}
     */
    static parseResponse(response) {
        return JSON.parse(response.responseText);
    }

    /**
     * Resolve url of the JIRA in which requested project exists.
     * If the project is found at both eu and com domains, com is preferred.
     *
     * @param {string} projectCode Just the project prefix of a JIRA issue.
     * @return {Promise<string|InvalidProjectError|ProjectLoadingError>} JIRA url
     */
    static async getJiraUrlForProject(projectCode) {
        return jiraComUrl;
    }

    /**
     * Fetches raw JIRA issue data.
     * @param {string} key JIRA issue key string
     * @param {?Function} onprogress Optional loading progress callback
     * @return {Promise<HttpResponse|InvalidResponse>}
     */
    static fetchIssue(key, onprogress = () => undefined) {
        return Jira4U.getProjectCode(key)
            .then(Jira4U.getJiraUrlForProject)
            .then(tee(url => debug('project code url: ' + url)))
            .then(jiraRestApiIssueUrl(key))
            .then(tee(url => log(`Resolved JIRA issue url: [${url}]`)))
            .then(getJsonRequest)
            .then(Jira4U.setOnProgressCallback(onprogress))
            .then(Jira4U.request);
    }

    /**
     * Returns a Promise which loads and parses JIRA issue to an object.
     * @param {string} key JIRA issue key string
     * @param {?Function} onprogress Optional loading progress callback
     * @return {Promise<JiraIssue|InvalidResponse|string|InvalidProjectError|ProjectLoadingError>}
     */
    static loadIssue(key, onprogress = () => undefined) {
        return Jira4U.fetchIssue(key)
            .then(tee(_ => log(`Loading of issue ${key} completed.`)))
            //Getting into the onload function does not actually mean the status was OK
            .then(Jira4U.validateStatusOk)
            .then(tee(_ => log(`Issue ${key} loaded successfully.`)))
            .then(Jira4U.parseResponse)
    }

    /**
     * @param {string} workInfo.key JIRA issue key string.
     * @param {Date} workInfo.started The date/time the work on the issue started.
     * @param {number} workInfo.duration in seconds.
     * @param {string} workInfo.comment The work log comment.
     * @param {Function} [workInfo.onReadyStateChange] Callback to be invoked when the request state changes.
     * @return {Promise}
     */
    static logWork(workInfo) {
        console.log(`Sending a work log request. Issue=${workInfo.key}, Time spent=${workInfo.duration}minutes, Comment="${workInfo.comment}"`);
        return Jira4U.getProjectCode(workInfo.key)
            .then(Jira4U.getJiraUrlForProject)
            .then(jiraRestApiResource('issue', workInfo.key, 'worklog'))
            .then(url => (
                {
                    method: 'POST',
                    headers: {
                        "Content-Type": "application/json",
                        //Disable the cross-site request check on the JIRA side
                        "X-Atlassian-Token": "nocheck",
                        //Previous header does not work for requests from a web browser
                        "User-Agent": "xx"
                    },
                    data: JSON.stringify({
                        timeSpentSeconds: workInfo.duration,
                        started: Jira4U._toIsoString(workInfo.started),
                        comment: workInfo.comment,
                    }),
                    url: url,
                    onreadystatechange: workInfo.onReadyStateChange
                })
            )
            .then(Jira4U.request);
    }

    /**
     * Converts a date to a proper ISO formatted string, which contains milliseconds and the zone offset suffix.
     * No other date formats are recognized by JIRA.
     * @param {Date} date Valid Date object to be formatted.
     * @returns {string}
     */
    static _toIsoString(date) {
        const offset = -date.getTimezoneOffset();
        const offsetSign = offset >= 0 ? '+' : '-';
        const pad = WtmDateTime.padToDoubleDigit;
        return date.getFullYear()
            + '-' + pad(date.getMonth() + 1)
            + '-' + pad(date.getDate())
            + 'T' + pad(date.getHours())
            + ':' + pad(date.getMinutes())
            + ':' + pad(date.getSeconds())
            + '.' + String(date.getUTCMilliseconds()).padStart(3, "0").substr(0, 3)
            + offsetSign + pad(offset / 60) + pad(offset % 60);
    }

}

/**
 * JIRA issue visualisation functions.
 *
 * Most of this object is stateless functions
 * with the exception of currently visualised JIRA issue,
 * because it is repeatedly accessed when user changes
 * the working time to update the worklog bar.
 */
class IssueVisual {

    constructor() {
        IssueVisual.init();
    }

    /**
     * Install JIRA issue GUI into the worklog dialog.
     * Checks whether the GUI has already been added before
     * making any DOM changes, so repeated calls have no effect.
     */
    static init() {
        if (document.getElementById('jira-toolbar-envelope')) {
            console.log("JIRA toolbar was already added to form.");
            return;
        }
        IssueVisual.addToForm();
    }

    /**
     * Adds jira issue GUI to the time sheet form.
     */
    static addToForm() {
        console.log("Adding JIRA toolbar into form");

        const transition = "-webkit-transition: width 0.25s; transition-delay: 0.5s;";
        const trackerStyle = "width: 100%; border-collapse: collapse; height: 0.75em; margin-top: 0.4em;";

        //.uu5-forms-label uu5-forms-input-m
        const jiraBarNode = document.createElement('DIV');
        jiraBarNode.id = 'jira-toolbar-envelope';
        jiraBarNode.innerHTML = (`
        <div>
            <div>
                <span id="parsedJiraIssue" class="uu5-forms-input-m"></span>
            </div>
        </div>
        <div>
            <div>
                <table id="jiraWorkTrackerOriginal" style="${trackerStyle}">
                  <tbody>
                    <tr>
                      <td class="workTracker wtl" id="jiraOrigEstimate" title="Původní odhad:" style="background-color: #89AFD7; padding: 0; ${transition} width: 0;"></td>
                      <td class="workTracker wt" style="background-color: #eeeeee; padding: 0; ${transition} width: 100%"></td>
                    </tr>
                  </tbody>
                </table>
                <table id="jiraWorkTrackerLogged" style="${trackerStyle}">
                  <tbody>
                    <tr>
                      <td class="workTracker wtl" id="jiraWorkLogged" title="Vykázáno:" style="background-color: #51a825; padding: 0; ${transition} width: 0;"></td>
                      <td class="workTracker wtn" id="jiraWorkLogging" title="Nový výkaz" style="background-color: #51A82580; padding: 0; /*${transition}*/ width: 0"></td>
                      <td class="workTracker wtr" id="jiraRemainEstimate" title="Zbývající odhad:" style="background-color: #ec8e00; padding: 0; ${transition} width: 0;"></td>
                      <td class="workTracker pad" id="jiraWorkPad" title="Zbývá" style="background-color: #eeeeee; padding: 0; width: 100%"></td>
                    </tr>
                  </tbody>
                </table>
            </div>
            <div>
                <span id="parsedJiraIssue"></span>
            </div>
        </div>
        <div style="margin-top: 10px">
            <button id="jiraLogWorkButton" class="uu5-bricks-button-m uu6-bricks-button-filled" type="button" style="border: none" disabled>
                <span class="uu5-bricks-span uu5-bricks-lsi-item uu5-bricks-lsi">Vykázat na <u>J</u>IRA issue</span>
            </button>
            <span id="jira-issue-work-log-request-progress" style="margin-left: 8px;"></span>
        </div>
        `);
        IssueVisual.insertAfter(jiraBarNode, WtmDialog.highRateNode());
    }


    static insertAfter(newNode, referenceNode) {
        referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
    }

    /**
     * Display a loaded JIRA issue in the form as a link
     * and update the time tracker.
     *
     * @param {JiraIssue} issue The JIRA issue object as fetched from JIRA rest API
     */
    static async showIssue(issue) {
        const domain = issue.self.slice(0, issue.self.indexOf(jiraRestApiPath));
        const url = await jiraBrowseIssueUrl(issue.key)(domain);
        //Some projects use this field for a project code, like NBS or FBLI
        const projectCode = P4uWorklogger.mapToHumanJiraIssue(issue).projectCode;
        const projectCodeHtml = projectCode && `<div>Project Code: ${projectCode}</div>` || '';
        IssueVisual.$showInIssueSummary(IssueVisual.linkHtml(url, `${issue.key} - ${issue.fields.summary}`) + projectCodeHtml);
        IssueVisual.trackWorkOf(issue);
        return issue;
    }

    /**
     * Displays some fancy animation at the place of the JIRA issue summary.
     */
    static showIssueLoadingProgress() {
        IssueVisual.$showInIssueSummary(jiraIssueLoaderAnimation);
        IssueVisual.resetWorkTracker();
    }

    /**
     * Sets the currently displayed JIRA issue to null and resets all the visualisation.
     */
    static resetIssue() {
        IssueVisual.resetWorkTracker();
    }

    /**
     * Display a visual work log time tracker of the current JIRA issue in the form.
     */
    static updateWorkTracker() {
        if (IssueVisual._issue) {
            const issue = IssueVisual._issue;
            const orig = issue.fields.timetracking.originalEstimateSeconds || 0;
            const remain = issue.fields.timetracking.remainingEstimateSeconds || 0;
            const logged = issue.fields.timetracking.timeSpentSeconds || 0;
            const added = WtmDialog.getDurationSeconds();
            const totalMax = Math.max(orig, logged + Math.max(added, remain));
            const newRemain = Math.max(remain - added, 0);
            const percentOfTotal = (x) => totalMax > 0 ? x / totalMax * 100 : 0;
            const setWidth = (id, w) => {
                document.getElementById(id).style.width = `${Math.round(w)}%`;
            };
            const setTitle = (id, t) => {
                const e = document.getElementById(id);
                e.title = e.title.split(':')[0] + ': ' + t || "0h";
                e.alt = e.title;
            };
            const toHours = (seconds) => (seconds / 3600).toFixed(2) + 'h';

            setWidth('jiraOrigEstimate', percentOfTotal(orig));
            setTitle('jiraOrigEstimate', issue.fields.timetracking.originalEstimate);
            setWidth('jiraWorkLogged', percentOfTotal(logged));
            setTitle('jiraWorkLogged', issue.fields.timetracking.timeSpent);
            setWidth('jiraWorkLogging', percentOfTotal(added));
            setTitle('jiraWorkLogging', toHours(added));
            setWidth('jiraRemainEstimate', percentOfTotal(newRemain));
            setTitle('jiraRemainEstimate', toHours(newRemain));
            const remainCell = document.getElementById('jiraRemainEstimate');
            remainCell.style.display = (newRemain === 0) ? "none" : null;//Chrome renders zero width as 1px
        }
        else
            IssueVisual.resetWorkTracker();
    }

    /**
     * Sets currently displayed JIRA issue for the time tracker bar.
     * This is the only state held by IssueVisual class.
     *
     * @typedef JiraIssue
     * @property {string} key The key of the JIRA issue, e.g. XYZ-1234
     * @property {string} rawJiraIssue.fields.project.key The project key of the JIRA issue, e.g. XYZ
     * @property {string} fields.summary The JIRA issue summary, i.e. the title of the ticket.
     * @property {string} self JIRA link to this issue.
     * @property {?number} fields.timetracking.originalEstimateSeconds
     * @property {?number} fields.timetracking.remainingEstimateSeconds
     * @property {?number} fields.timetracking.timeSpentSeconds
     * @property {?number} fields.timetracking.originalEstimate
     * @property {?number} fields.timetracking.remainingEstimate
     * @property {?number} fields.timetracking.timeSpent
     * @property {string} rawJiraIssue.fields.issuetype.name Type of the issue, e.g. "Bug"
     * @property {?string} fields.customfield_13908 NBS Project code
     * @property {?string} fields.customfield_10174 FBL Project code
     * @property {?string} fields.customfield_12271 FBL System
     *
     * @param {?JiraIssue} issue
     */
    static trackWorkOf(issue = null) {
        IssueVisual._issue = issue;
        IssueVisual.updateWorkTracker();
    }

    /**
     * No JIRA issue is displayed, no work time is tracked.
     */
    static resetWorkTracker() {
        IssueVisual._issue = null;
        ['jiraOrigEstimate', 'jiraWorkLogged', 'jiraWorkLogging', 'jiraRemainEstimate']
            .forEach(id => document.getElementById(id).style.width = `0%`);
    }

    /**
     * Displays default content when no issue has been loaded.
     */
    static showIssueDefault() {
        IssueVisual.$showInIssueSummary(`<span>Zadejte kód JIRA Issue na začátek Popisu činnosti.</span>`);
        IssueVisual.resetIssue();
    }

    /**
     * Handles JIRA issue loading errors.
     * Updates GUI accordingly.
     *
     * @param {string} key JIRA issue
     * @param {InvalidResponse|InvalidProjectError|ProjectLoadingError|Error} error
     */
    static issueLoadingFailed(key, error) {
        IssueVisual.resetIssue();

        function tryGetProjectUrl() {
            return Jira4U.getProjectCode(key)
                .then(Jira4U.getJiraUrlForProject)
                .then(jiraBrowseIssueUrl(key));
        }

        function getErrorMessages(responseErr) {
            if (/content-type:\sapplication\/json/.test(responseErr.responseHeaders)) {
                let error = JSON.parse(responseErr.responseText);
                return Promise.of(error.errorMessages ? ' Chyba: ' + error.errorMessages.join(', ') : '');
            }
            return Promise.reject(responseErr);
        }

        if (error instanceof InvalidResponse) {
            const status = error.response.status;
            if (status === 401 || status === 403) {
                const renderJiraLink = url => IssueVisual.elem('SPAN',
                    [
                        document.createTextNode('JIRA autentizace selhala. '),
                        IssueVisual.nodeFromHtml(IssueVisual.linkHtml(url, 'Přihlaste se do JIRA')),
                        document.createTextNode(' a '),
                        IssueVisual.clickableSpan(P4uWorklogger.loadAndShowIssueFromDescription, 'zkuste to znovu')
                    ]);
                tryGetProjectUrl()
                    .then(renderJiraLink)
                    .then(IssueVisual.$showInIssueSummary)
                    .catch(_ => {
                        console.error('Failed to resolve url for issue ' + key);
                        IssueVisual.$showInIssueSummary(
                            `JIRA autentizace selhala.<br>
Přihlaste se do ${IssueVisual.linkHtml(jiraComUrl, 'jira.unicorn.com')}.`);
                    });
            } else if (status === 404) {
                getErrorMessages(error.response)
                    .then(msg => IssueVisual.$showInIssueSummary(`<span>Nepodařilo se načíst ${key}.${msg}.</span>`))
                    .catch(err => {
                        console.error(`Failed to load issue ${key}. Response: ${JSON.stringify(err, null, 2)}`);
                        IssueVisual.$showInIssueSummary(`<span>Nepodařilo se načíst ${key}. Chyba: 404</span>`);
                    });
            }
        } else if (error instanceof InvalidProjectError) {
            IssueVisual.$showInIssueSummary(`<span>Projekt ${error.projectKey} neexistje.</span>`);
        } else if (error instanceof ProjectLoadingError) {
            IssueVisual.$showInIssueSummary(`<span>Nepodařilo se načist projekt ${error.projectKey}.</span>`);
        } else {
            const showUnknownError = () => IssueVisual.$showInIssueSummary(`<span>Něco se přihodilo. Budete muset ${IssueVisual.linkHtml(jiraComUrl, 'vykázat do JIRA ručně.')}'</span>`);
            if (error.stack) {
                showUnknownError();
                throw error;
            }
            console.error('Unknown error: ' + error);
            showUnknownError();
        }
    }

    static linkHtml(href, label) {
        return `<a href="${href}" target="_blank">${label}</a>`;
    };

    static elem(name, children = []) {
        const element = document.createElement(name);
        children.forEach(child => element.appendChild(child));
        return element;
    };

    static nodeFromHtml(htmlContent) {
        const element = document.createElement('div');
        element.innerHTML = htmlContent;
        return element.firstChild;
    };

    static clickableSpan(callback, label) {
        const clickableSpan = document.createElement('SPAN');
        clickableSpan.innerText = label;
        clickableSpan.onclick = callback;
        clickableSpan.style.color = 'blue';
        clickableSpan.style.cursor = 'pointer';
        return clickableSpan;
    };

    static $jiraIssueSummary() {
        return $(document.getElementById("parsedJiraIssue"));
    }

    /**
     * Replaces content of the placeholder element for the JIRA issue summary.
     * Returns the element, wrapped as a jQuery object for easier content manipulation.
     *
     * @return {jQuery|HTMLElement}
     */
    static $showInIssueSummary(htmlContent) {
        const issueSummary = IssueVisual.$jiraIssueSummary();
        issueSummary.empty().append(htmlContent);
        return issueSummary;
    }

    /**
     * Display a sign next to the JIRA button
     * to show state of writing the work log record.
     *
     * @param {'loading'|'done'|'error'|'idle'} state Issue loading state.
     * @return {HTMLElement}
     */
    static showJiraIssueWorkLogRequestProgress(state) {
        const stateViews = {
            'loading': jiraIssueLoaderAnimation,
            'done': `✔`,
            'error': `❌`,
            'idle': ``
        };
        const logProgress = document.getElementById("jira-issue-work-log-request-progress");
        logProgress.innerHTML = stateViews[state] || stateViews['idle'];
        return logProgress;
    }

    /**
     * @return {HTMLElement} Log work button
     */
    static jiraLogWorkButton() {
        return document.getElementById('jiraLogWorkButton');
    }

}

/**
 * Container for a JIRA issue key + description.
 * It can construct itself by parsing the issue key from work description.
 */
class WorkDescription {

    constructor(issueKey = null, descriptionText = "") {
        this._issueKey = issueKey;
        this._descriptionText = descriptionText;
    }

    static parse(workDescriptionText) {
        if (typeof workDescriptionText === "string") {
            let segments = workDescriptionText.match(jiraIssueKeyPattern);
            if (segments != null) {
                let key = segments[1];
                return new WorkDescription(key, workDescriptionText.replace(key, "").trim());
            }
        }
        return new WorkDescription();
    }

    get issueKey() {
        return this._issueKey;
    }

    get descriptionText() {
        return this._descriptionText;
    }
}

/**
 * Wraps the rest of the script, mainly the steps that are executed when the document is loaded.
 */
class P4uWorklogger {

    constructor() {
        // Initialize the page decoration.
        this._previousDesctiptionValue = null;
        this._previousIssue = null;
    }

    workLogFormShow() {
        IssueVisual.init();
        this._previousDesctiptionValue = WtmDialog.descArea().value;
        this._previousIssue = Jira4U.tryParseIssue(this._previousDesctiptionValue);
        this.doTheMagic();
    }

    doTheMagic() {
        IssueVisual.showIssueDefault();

        WtmDialog.timeFrom().onblur = IssueVisual.updateWorkTracker;
        WtmDialog.timeTo().onblur = IssueVisual.updateWorkTracker;

        //Chrome fires DOM events for textContent changes even during typing, FF does not.
        //So we add input listener and paranoidly make sure we do not add it more than once.
        const descriptionChangeListener = ev => workLogger.checkWorkDescriptionChanged(ev.target.value);
        WtmDialog.descArea().removeEventListener('input', descriptionChangeListener);
        WtmDialog.descArea().addEventListener('input', descriptionChangeListener);
        //In case of a Work log update, there may already be some work description.
        P4uWorklogger.loadAndShowIssueFromDescription();

        const jiraLogWorkButton = IssueVisual.jiraLogWorkButton();
        jiraLogWorkButton.removeEventListener('click', P4uWorklogger.writeWorkLogToJira);
        jiraLogWorkButton.addEventListener('click', P4uWorklogger.writeWorkLogToJira);
        P4uWorklogger.registerKeyboardShortcuts();
    }

    static loadAndShowIssueFromDescription() {
        if (WtmDialog.descArea().value) {
            const wd = Jira4U.tryParseIssue(WtmDialog.descArea().value);
            P4uWorklogger.loadJiraIssue(wd);
        }
    }

    static loadIssueFromDescription() {
        if (WtmDialog.descArea().value) {
            const wd = Jira4U.tryParseIssue(WtmDialog.descArea().value);
            P4uWorklogger.loadJiraIssue(wd);
        }
    }

    static registerKeyboardShortcuts() {
        WtmDialog.addKeyboardShortcutMnemonics();

        const timeControlTitle = `Použijte šipky ⬆⬇ pro změnu času. Stisknutím 'T' zaměříte vstupní pole času.`;
        WtmDialog.timeFrom().addEventListener('keydown', P4uWorklogger.shiftTime);
        WtmDialog.timeFrom().title = timeControlTitle;
        WtmDialog.timeTo().addEventListener('keydown', P4uWorklogger.shiftTime);
        WtmDialog.timeTo().title = timeControlTitle;
    }

    /**
     * Tries to remember Subject and Category values previously filled for given jira issue.
     * @param rawJiraIssue {JiraIssue}
     */
    static async fillFormFromMemorizedValues(rawJiraIssue) {
        const subjectField = WtmDialog.artifactField();
        const categoryField = WtmDialog.categoryField();
        let jiraIssue = P4uWorklogger.mapToHumanJiraIssue(rawJiraIssue);
        let formValues = WorkloggerFormMemory.remember(jiraIssue);
        if (formValues.subject) {
            await P4uWorklogger.setInputValueWithEvent(subjectField, formValues.subject);
        }
        if (formValues.category) {
            await P4uWorklogger.setInputValueWithEvent(categoryField, formValues.category);
        }
        // Setting Category value shows an autocomplete popup and steals focus.
        // Let the popup render, click the first item in the whisperer and return focus to the Description
        window.requestAnimationFrame(() => {
            const catPopup = document.querySelector('div.uu5-bricks-popover-body a');
            catPopup && catPopup.click();
            setTimeout(() => WtmDialog.descArea().focus(), 0);
            setTimeout(() => WtmDialog.descArea().click(), 20);
        });
    }
    /**
     * @typedef HumanJiraIssue
     * @property projectCode {?string} Optional custom field used by some projects just for the work logs.
     * @property system {string} FBL specific
     * @property type {string} Issue type, like 'Bug'
     * @property issueKeyPrefix {string} Just the project part of the issue key, typically same as jiraIssue.key
     *
     * @param rawJiraIssue {JiraIssue}
     * @return {HumanJiraIssue}
     */
    static mapToHumanJiraIssue(rawJiraIssue) {
        return {
            projectCode: rawJiraIssue.fields.customfield_10174?.value || rawJiraIssue.fields.customfield_13908?.value,
            system: rawJiraIssue.fields.customfield_12271?.value,
            type: rawJiraIssue.fields.issuetype.name,
            issueKeyPrefix: rawJiraIssue.fields.project.key,
        };
    }

    static getTimeAdjustmentDirection(ev) {
        if (ev.key === 'ArrowDown') {
            return -1;
        } else if (ev.key === 'ArrowUp') {
            return 1;
        } else {
            return 0;
        }
    }

    /**
     * Updates selected work log range time based on arrow up|down key press.
     * @param {Event} ev The keyboard event.
     */
    static shiftTime(ev) {
        const input = ev.target;
        if (input.nodeName !== 'INPUT') {
            console.warn('Cannot shift selected time, element is not an input: ', input);
            return;
        }
        const timeAdjustment = P4uWorklogger.getTimeAdjustmentDirection(ev);
        if (timeAdjustment === 0) {
            return;
        }
        ev.preventDefault();
        //If the value is empty, try the other period boundary. This allows just adding time in an empty input.
        const value = input.value || WtmDialog.timeFrom().value || WtmDialog.timeTo().value || '08:00';
        const selectionStart = input.selectionStart;
        const selectionEnd = input.selectionEnd;
        const selectionDirection = input.selectionDirection;
        const cursorPosition = selectionDirection === 'backward' ? selectionStart : selectionEnd;
        const newValue = P4uWorklogger.updateTime(timeAdjustment, cursorPosition, value);
        P4uWorklogger.setInputValueWithEvent(input, newValue)
            .then(() => {
                    //Following are reset when the value changes
                    input.selectionStart = selectionStart;
                    input.selectionEnd = selectionEnd;
                    input.selectionDirection = selectionDirection;
                }
            )
            .then(IssueVisual.updateWorkTracker);
    }

    /**
     * Sets input.value to text in a way that React reacts to the related input event.
     * https://stackoverflow.com/a/46012210/2471106
     * @param input
     * @param text
     * @return {Promise<boolean>}
     */
    static async setInputValueWithEvent(input, text) {
        const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
        nativeInputValueSetter.call(input, text);
        const ev2 = new Event('input', {bubbles: true});
        return input.dispatchEvent(ev2);
    }

    /**
     * Updates hours or minutes of time represented as HH:mm string.
     * @param {number} adjustmentDirection Positive or negative number. Should be -1 or 1.
     * @param {number} cursorPosition Index of the caret. Decides whether to change hours or minutes.
     * @param {string} timeInputValue Time string in the input box
     * @return {string} Shifted and formatted time
     */
    static updateTime(adjustmentDirection, cursorPosition, timeInputValue) {
        const timeRegExp = /(\d{1,2}):(\d{1,2})/;
        if (!timeRegExp.test(timeInputValue)) {
            console.debug(`Invalid time format, cannot adjust time "${timeInputValue}"`);
            return timeInputValue;
        }
        const dateTime = WtmDateTime.parseDateTime(WtmDialog.datePicker().value, timeInputValue);
        const pad = WtmDateTime.padToDoubleDigit;
        const formatTime = (date) => pad(date.getHours()) + ':' + pad(date.getMinutes());
        if (cursorPosition <= timeInputValue.indexOf(':')) {
            return formatTime(WtmDateTime.addHours(dateTime, adjustmentDirection));
        } else {
            return formatTime(WtmDateTime.addMinutes(dateTime, 15 * adjustmentDirection));
        }
    }

    static writeWorkLogToJira() {
        console.debug(new Date().toISOString(), 'Adding a work log item.');
        const wd = Jira4U.tryParseIssue(WtmDialog.descArea().value);
        if (!wd.issueKey) {
            return;
        }
        IssueVisual.jiraLogWorkButton().disabled = true;
        const durationSeconds = WtmDialog.getDurationSeconds();
        if (durationSeconds <= 0) {
            return 0;
        }
        const dateFrom = WtmDialog.dateFrom();
        console.log(`Logging ${durationSeconds} minutes of work on ${wd.issueKey}`);
        Jira4U.logWork({
            key: wd.issueKey,
            started: dateFrom,
            duration: durationSeconds,
            comment: wd.descriptionText,
            onReadyStateChange: function (state) {
                console.debug("Log work request state changed to: " + state.readyState);
                if (state === 1) {
                    IssueVisual.showJiraIssueWorkLogRequestProgress('loading');
                }
            }
        }).then(res => {
            console.info("Work was successfully logged to JIRA.", JSON.parse(res.responseText));
            IssueVisual.showJiraIssueWorkLogRequestProgress('done');
            //The buttons are probably refreshed. They loose their enhancements after adding a worklog.
            setTimeout(() => {
                P4uWorklogger.registerKeyboardShortcuts();
                IssueVisual.jiraLogWorkButton().disabled = false;
            }, 500);
        }, err => {
            console.warn("Failed to log work to JIRA. ", err);
            IssueVisual.showJiraIssueWorkLogRequestProgress('error');
            IssueVisual.jiraLogWorkButton().disabled = false;
        });
    }

    checkWorkDescriptionChanged(description) {
        if (this._previousDesctiptionValue !== description) {
            this._previousDesctiptionValue = description;
            this.workDescriptionChanged(description);
        } else console.debug("No description change")
    }

    /**
     * @param {string} description The new work description value
     */
    workDescriptionChanged(description) {
        const wd = Jira4U.tryParseIssue(description);
        if (this._previousIssue.issueKey === null || this._previousIssue.issueKey !== wd.issueKey) {
            this._previousIssue = wd;
            P4uWorklogger.loadJiraIssue(wd);
        }
    }

    static loadJiraIssue(wd) {
        IssueVisual.jiraLogWorkButton().disabled = true;
        IssueVisual.showJiraIssueWorkLogRequestProgress('idle');
        if (!wd.issueKey) {
            IssueVisual.showIssueDefault();
            return;
        }

        let key = wd.issueKey;
        console.debug("JIRA issue key recognized: ", key);
        const showLoadingProgress = progress => {
            console.info(`Loading jira issue ${key}, state: ${progress.readyState}`);
            if (progress.readyState === 1) {
                IssueVisual.showIssueLoadingProgress();
            }
        };

        Jira4U.loadIssue(key, showLoadingProgress)
            .then(tee(IssueVisual.showIssue))
            .then(tee(_ => IssueVisual.jiraLogWorkButton().disabled = false))
            .then(P4uWorklogger.fillFormFromMemorizedValues)
            .catch(responseErr => {
                console.log(`Failed to load issue ${key}. Error: ${JSON.stringify(responseErr, null, 2)}`);
                IssueVisual.issueLoadingFailed(key, responseErr);
            });
    }
}

class WorkloggerFormMemory {

    /**
     * @typedef TimesheetItem
     * @property {string} datetimeFrom "2020-03-30T17:01:00+02:00"
     * @property {string} datetimeTo "2020-03-30T18:01:00+02:00"
     * @property {string} subject "ues:UNI-BT:USYE.NECS~SWA04"
     * @property {string} category "USYE.NECS"
     * @property {string} description "NECS-1234 Pretending to work"
     *
     * @param timesheetItem {TimesheetItem}
     */
    static memorize(timesheetItem) {
        console.debug('Remember form data for autocomplete');

        /**
         * @param {HumanJiraIssue} jiraIssue
         */
        function storeFormDataForProjectCode(jiraIssue) {
            const {subject = "", category = ""} = timesheetItem;
            if (jiraIssue.projectCode) {
                console.debug('Saving form data for project code ', jiraIssue.projectCode);
                GM_setValue(jiraIssue.projectCode, JSON.stringify({subject, category}));
                console.debug(`Form data for project code ${jiraIssue.projectCode} saved.`);
            } else if (jiraIssue.issueKeyPrefix) {
                console.debug('Saving form data for project key ', jiraIssue.issueKeyPrefix);
                GM_setValue(jiraIssue.issueKeyPrefix, JSON.stringify({subject, category}));
                console.debug(`Form data for project key ${jiraIssue.issueKeyPrefix} saved.`);
            }
        }

        if (timesheetItem.description && (timesheetItem.subject || timesheetItem.category)) {
            const workDescription = Jira4U.tryParseIssue(timesheetItem.description);
            if (workDescription.issueKey) {
                console.debug(`Loading JIRA issue '${workDescription.issueKey}' to get project code for form data memorization`);
                Jira4U.loadIssue(workDescription.issueKey)
                    .then(P4uWorklogger.mapToHumanJiraIssue)
                    .then(storeFormDataForProjectCode)
                    .catch(error);
            }
        } else {
            console.debug('No form data to memorize for autocomplete');
        }
    }

    /**
     * @typedef FormValues
     * @property {string} subject "ues:UNI-BT:USYE.NECS~SWA04"
     * @property {string} category "USYE.NECS"
     *
     * @param {HumanJiraIssue} jiraIssue
     * @return {FormValues}
     */
    static remember(jiraIssue) {
        console.log('Loading form data for issue ', jiraIssue);
        const value = GM_getValue(jiraIssue.projectCode) || GM_getValue(jiraIssue.issueKeyPrefix) || `{}`;
        return JSON.parse(value);
    }
}

/**
 * Keyboard shortcuts registration and handling.
 */
class WtmShortcuts {
    /**
     * Registers keyboard shortcuts available throughout WTM.
     * Element titles need to be set in DomObserver because they require the element to exist while this is installed when script loads.
     */
    static install() {
        if (WtmShortcuts.install.done) {
            return;
        }
        WtmShortcuts.install.done = true;
        // New work item - N
        document.addEventListener("keypress",
            condp(
                and(WtmShortcuts.keyCodePred('KeyN'), not(WtmWorktableModel.isModalDialogOpened))
                , WtmShortcuts.clickElement(WtmWorktableModel.newItemButton),
                /* TODO this requires filtering regular typing events (or events from input target elements)
                and(WtmShortcuts.keyCodePred('KeyT'), WtmWorktableModel.isModalDialogOpened)
                , WtmShortcuts.doWithElement(el => el.focus(), WtmDialog.timeFrom),
                and(WtmShortcuts.keyCodePred('KeyD'), WtmWorktableModel.isModalDialogOpened)
                , WtmShortcuts.doWithElement(el => el.focus(), WtmDialog.datePicker),*/
                and(ev => ev.ctrlKey, ev => ev.shiftKey, WtmShortcuts.keyCodePred('Enter'), WtmWorktableModel.isModalDialogOpened)
                , WtmShortcuts.clickElement(WtmDialog.buttonNextItem),
                and(ev => ev.ctrlKey, WtmShortcuts.keyCodePred('Enter'), WtmWorktableModel.isModalDialogOpened)
                , WtmShortcuts.clickElement(WtmDialog.buttonOk)));
    }

    static doWithElement(elementFn, targetElement) {
        return function elementAction() {
            let target = (typeof targetElement === "function") ? targetElement() : targetElement;
            elementFn(target);
        }
    }

    static clickElement(targetElement) {
        const clicker = element => {
            if (element && typeof element.click === "function") {
                element.click();
            } else {
                console.warn('Cannot activate element by shortcut. Element:', element)
            }
        };
        return WtmShortcuts.doWithElement(clicker, targetElement);
    }

    static keyCodePred(code) {
        return function checkIsKey(ev) {
            return ev.code === code;
        };
    }

}

/**
 * Adds month selection buttons.
 */
class MonthSelector {
    static getMonthSelectorContainer() {
        return document.querySelector('.uu-specialistwtm-worker-monthly-detail-top-change-month-dropdown');
    }

    static getMonthSelector() {
        return this.getMonthSelectorContainer().firstElementChild;
    }
    static getMonthSelectorButton() {
        return this.getMonthSelector().querySelector('button');
    }

    static getSelectedMonthValue() {
        return this.getMonthSelectorButton().querySelector('.uu-specialistwtm-worker-monthly-detail-top-month-dropdown-value').innerText;
    }

    install() {
        if (MonthSelector.getMonthSelectorContainer().querySelector('span.wtm-month-switch-button-icon')) {
            return;
        }
        const createArrow = (direction) => {
            const arrow = document.createElement('SPAN');
            arrow.classList.add('wtm-month-switch-button-icon', 'uu5-bricks-button', 'uu5-bricks-button-inverted', 'mdi', 'mdi-chevron-' + direction);
            return arrow;
        };

        /**
         * Creates the month switching callback, which is called after the dropdown menu is shown.
         * The menu is a div containing an UL element. This list is searched for the current month by the displayed text.
         * Index of the selected list item is updated and the neighbor item is clicked.
         *
         * @param selectedMonthText
         * @param monthIndexFn {Function} Returns new month index
         * @return {function(*): Function}
         */
        const createMonthSelector = function (selectedMonthText, monthIndexFn) {
            //This returned fn may recursively call itself to repeat the drop-down click if the menu was not rendered yet.
            return function selectMonth(attempts = 1) {
                const dropDown = MonthSelector.getMonthDropDown();
                if (!dropDown) {
                    if (attempts > 3) {
                        console.warn('Month drop-down menu does not exist. Attempt:', attempts);
                        return false;
                    }
                    MonthSelector.getMonthSelectorButton().click();
                    //Repeat opening the dropdown and wait for rendering.
                    window.requestAnimationFrame(() => selectMonth(++attempts));
                    return false;
                }
                const selectedMonthIndex = Array
                    .from(dropDown.children)
                    .findIndex(li => li.innerText.trim() === selectedMonthText);
                if (selectedMonthIndex < 0) {
                    console.debug('Cannot find selected month:', selectedMonthText);
                    return false;//May leave the menu opened? It may actually be desirable as a fallback scenario.
                }
                const newMonthIndex =
                    Math.max(0,
                        Math.min(dropDown.children.length - 1,
                            monthIndexFn(selectedMonthIndex)))
                    || selectedMonthIndex;
                dropDown.children[newMonthIndex].firstChild.click();//LI contains an A element
                return true;
            }
        };

        const createArrowClickHandler = (monthIdxUpdateFn) => (event) => {
            console.trace('WTM Extension', 'Click:', event);
            //Show the months dropdown
            MonthSelector.getMonthSelectorButton().click();
            //Allow browser to render the menu, then click desired month.
            // Using requestAnimationFrame here always caused the menu to be visible before script clicks an item in it.
            setTimeout(createMonthSelector(MonthSelector.getSelectedMonthValue(), monthIdxUpdateFn), 0);
        };

        const arrowLeft = createArrow('left');
        arrowLeft.onclick = createArrowClickHandler(i => i + 1);//Months are in the reversed order
        arrowLeft.title = _t('wtm.month.prev.title');

        const arrowRight = createArrow('right');
        arrowRight.onclick = createArrowClickHandler(i => i - 1);
        arrowRight.title = _t('wtm.month.next.title');

        const monthSelector = MonthSelector.getMonthSelector();
        monthSelector.insertBefore(arrowLeft, monthSelector.firstChild);
        monthSelector.appendChild(arrowRight);
    }

    static getMonthDropDown() {
        return MonthSelector.getMonthSelectorContainer().querySelector('ul.uu5-bricks-dropdown-menu-list');
    }
}

const workLogger = new P4uWorklogger();
const monthSelector = new MonthSelector();

class WtmDomObserver {

    constructor() {
        this.observeOptions = {
            attributes: false,
            characterData: false,
            childList: true,
            subtree: true,
            attributeOldValue: false,
            characterDataOldValue: false,
        };
        this.mutationObserver = null;
        this.pageReadyMutationOberver = null;
    }

    observe() {
        const hasAddedNodes = (mutation) => mutation.addedNodes.length > 0;
        const isWorkDescription = (mutation) => mutation.target.type === 'textarea' && mutation.target.name === 'description';
        const isWorkLogForm = (mutation) => affectsNodesWithClass(mutation, 'uu5-bricks-modal-body', 'uu-specialistwtm-create-timesheet-item-modal-container');
        const isWorkTable = (mutation) => affectsNodesWithClass(mutation, 'uu-specialistwtm-worker-monthly-detail-container', 'uu-specialistwtm-worker-monthly-detail-table');

        const affectsNodesWithClass = (mutation, targetNodeClass, childNodeClass) => {
            if (!mutation.target.classList.contains(targetNodeClass)) {
                return false;
            }
            for (const childNode of mutation.target.childNodes) {
                if (childNode.classList.contains(childNodeClass)) {
                    return true;
                }
            }
            return false;
        };

        this.mutationObserver = new MutationObserver(function (mutations) {
            mutations
            // .filter(hasAddedNodes)
                .forEach((mutation) => {
                    // console.log(mutation); //I expect to use this functionality frequently
                    if (isWorkDescription(mutation)) {
                        workLogger.checkWorkDescriptionChanged(mutation.target.textContent);
                    }
                    if (isWorkLogForm(mutation)) {
                        workLogger.workLogFormShow();
                    } else if (mutation.target.classList.contains('uu-specialistwtm-create-timesheet-item-buttons-save')) {
                        console.debug('Buttons changed, re-applying extension.');
                        P4uWorklogger.registerKeyboardShortcuts();
                    }
                    if (isWorkTable(mutation)) {
                        WtmWorktableView.worktableSumViewShow();
                    }

                    if (MonthSelector.getMonthSelectorContainer()) {
                        monthSelector.install();
                    }

                    if (WtmWorktableModel.newItemButton()) {
                        WtmWorktableModel.newItemButton().title = '(n)';
                    }
                });
        });

        //During page loading, there are tons of mutations. This observer is active until the main page is added, then it disconnects and activates the actual observer.
        this.pageReadyMutationOberver = new MutationObserver(function (mutations) {
            const isMainPageAddition = (mutation) => hasAddedNodes(mutation) && mutation.type === 'childList' && mutation.target.matches('div.uu5-common-div.uu5-bricks-page-system-layer.plus4u5-app-page-system-layer-wrapper');
            if (mutations.some(isMainPageAddition)) {
                swapObservers();
            }
        });

        let swapObservers = () => {
            if (this.pageReadyMutationOberver) {
                this.pageReadyMutationOberver.disconnect();
            }
            this.mutationObserver.observe(document.body, this.observeOptions);
        };
        this.pageReadyMutationOberver.observe(document.body, this.observeOptions);
    }
}

/**
 * Intercepts XMLHttpRequests and invokes a registered callback.
 * Currently supports just one callback, registering more would probably
 * nest the XMLHttpRequest.open and XMLHttpRequest.send proxy functions.
 */
class RequestListener {

    static onDataSent(urlFilter, callback) {
        // Start with interception of fn XMLHttpRequest.open, which is provided request URL
        (function (open) {
            window.XMLHttpRequest.prototype.open = function (method, url, ...args) {
                // Store the URL on he XMLHttpRequest.send fn because send() gets the request body right after open() is called.
                window.XMLHttpRequest.prototype.send.lastOpenedUrl = url;
                open.apply(this, [method, url, ...args]);
            };
        })(window.XMLHttpRequest.prototype.open);

        // Next, intercept the XMLHttpRequest.send fn, check that the last opened URL is the one to intercept and invoke callback
        (function (send) {
            window.XMLHttpRequest.prototype.send = function (data) {
                const lastOpenedUrl = window.XMLHttpRequest.prototype.send.lastOpenedUrl;
                if (data && lastOpenedUrl && urlFilter(lastOpenedUrl)) {
                    callback(lastOpenedUrl, data)
                }
                send.call(this, data);
            };
        })(window.XMLHttpRequest.prototype.send);
    }

    static isUrlPathName(urlEnding) {
        return function requestEventUrlPathnameFilter(url) {
            try {
                return new URL(url).pathname.endsWith(urlEnding);
            } catch (e) {
                console.warn(`failed to compare URL ending "${urlEnding} with URL ${url}`);
                return false;
            }
        };
    }
}

RequestListener.onDataSent(
    RequestListener.isUrlPathName('/createTimesheetItem'),
    (url, data) => {
        WorkloggerFormMemory.memorize(JSON.parse(data));
    }
);

const brickObserver = new WtmDomObserver();
brickObserver.observe();
WtmShortcuts.install();