ds3v / Gitlab review extension-beta

// ==UserScript==
// @name         Gitlab review extension-beta
// @version      0.5.9
// @description  File approving for gitlab!
// @author       V. Vasin
// @include      /^https?:\/\/[^/]*gitlab.[^/]+\//
// @run-at       document-body
// @icon         https://about.gitlab.com/ico/favicon-192x192.png
// @updateURL    https://openuserjs.org/meta/ds3v/Gitlab_review_extension-beta.meta.js
// @require      https://www.gstatic.com/firebasejs/3.6.2/firebase.js
// @require      https://raw.githubusercontent.com/emn178/js-sha256/730f41e84b76d3525b5a9b505cea549ab0c2a287/build/sha256.min.js

// ==/UserScript==
(function() {
    'use strict';

    const STYLES =`
    .files .diff-file.approved .approve-btn {
    	background : #afa;
    	color: black;
    }
    .files.hide-approved .diff-file.approved,
    .files.hide-filtered .diff-file.filtered-file,
    .files.approve-script-applied.hide-approved .diff-file.approved,
    .files.approve-script-applied.hide-filtered .diff-file.filtered-file,
    .files.approve-script-applied .diff-file.filtered-file .diff-content,
    .files .diff-file.filtered-file .diff-content,
    .files .diff-file.filtered-file .approve-btn,
    .files .diff-file.approved .diff-content {
    	display: none;
    }
    .files .diff-file .diff-content .approve-btn {
        position: absolute;
        right : 5px;
        bottom: 5px;
        opacity: 0.2;
        background: #afa;
        color: #000;
        border: 1px solid #555;
        box-shadow: 0 0 3px white;
    }

    .files .diff-file .diff-content .approve-btn:hover {
        opacity: 0.9
    }
    .files .diff-file {
        position:relative;
    }
    .files.approve-script-applied .diff-file {
    	display: block;
    }
    #utilHolder #approvedCnt {
        color: green;
    }
    #utilHolder #filteredCnt {
        color: blue;
    }
    #utilHolder #filteredCnt:after,
    #utilHolder #filteredCnt:before {
        content : '/';
        color: gray;
        display: inline-block;
        width: 30px;
        text-align: center;
    }
    #utilHolder {
        position: fixed;
        top: 3px;
        left: calc(50% - 210px);
        z-index: 9999;
        background: #fff;
        border: 1px solid #eee;
        width: 420px;
        border-radius: 3px;
    }
    #utilHolder #progressCnt:before {
        content : 'Review progress:  '
    }
    #utilHolder #progressCnt {
        margin : 0 15px 0 0;
        color : red;
        font-weight: bold;
    }
    #utilHolder #progressCnt.half {
        color: #aa4500;
    }
    #utilHolder #progressCnt.done {
        color : green;
    }
    #utilHolder #setting-editor-button {
        float: left;
        margin: 5px 10px;
        font-size: 20px;
        cursor: pointer;
    }
    .hideFiltered, .hideApproved {
        margin-right:15px;
        color: black;
        cursor: pointer;
    }
    .hideApproved:before,
    .hideFiltered:before {
        margin-right: 5px;
    }
    .hideApproved.fa-eye-slash,
    .hideFiltered.fa-eye-slash {
        color: gray;
    }
    .settings-editor {
        position: fixed;
        left: calc(50% - 210px);
        top : 75px;
        z-index: 9999;
        background: #fff;
        border: 1px solid #eee;
        width: 420px;
        border-radius: 3px;
        padding : 15px;
        box-shadow: 0 0 5px black;
    }
    .settings-editor label {
       margin : 5px 0;
       display: block;
    }
    .settings-editor .repo-type {
       display: block;
    }
    .settings-editor .repo-type::after {
       margin : 5px;
       content: "Use public Firebase"
    }
    .settings-editor .buttons {
       text-align: center;
    }
    .settings-editor .buttons .btn {
       width: 100px;
       margin : 5px 10px;
    }
    .settings-editor .inner-settings {
        padding-left:20px;
    }
    `;

    function scrollTo(element) {
        if (localStorage.noscroll === "true") {
            return;
        }
        setTimeout(() =>  {
            const offset = element.offset();
            if (offset) {
                $('html, body').animate({scrollTop: offset.top - 155}, 300);
            }
        }, 100);
    }
    class Statistic {
        constructor(parent) {
            this.progress = $("<span id='progressCnt' title='Progress'>0%</span>").appendTo(parent);
            this.approved = $("<span id='approvedCnt' title='Approved'>0</span>").appendTo(parent);
            this.filtered = $("<span id='filteredCnt' title='Filtered'>0</span>").appendTo(parent);
            this.total    = $("<span id='totalCnt' title='Total'>0</span>").appendTo(parent);
        }
        update() {
            const files = $('.files .diff-file'),
                  total = files.length || 1,
                  approved = files.filter('.approved').length,
                  filtered = files.filter('.filtered-file:not(.approved)').length,
                  processed = filtered + approved;
            this.total.text(total);
            this.approved.text(approved);
            this.filtered.text(filtered);
            this.progress
                .text((processed * 100 / total).toFixed(1) + "%")
                .toggleClass("half", processed / total >= 0.5)
                .toggleClass("done", processed === total);
        }
    }

    class Filters {
        constructor(settings) {
            this.settings = settings;
            this.registeredFilters = [
                this.filterByPattern.bind(this),
                this.filterMoved.bind(this),
                this.filterByHighlightedDiff.bind(this),
                this.filterEmptyChanges.bind(this),
            ];
        }
        filter(changedFile) {
            const failedFilter = this.registeredFilters.find(f => !f.apply(changedFile));
            if (failedFilter) {
                console.log(failedFilter.name);
            }
            return failedFilter === undefined;
            //return this.filterByPattern(changedFile) && this.filterMoved(changedFile) && this.filterDiff(changedFile);
        }
        filterByPattern(changedFile) {
            return !this.settings.filters.some(p => changedFile.fileName.indexOf(p) > -1);
        }
        filterMoved(changedFile) {
            return changedFile.element.find(".nothing-here-block:contains('File moved')").length === 0;
        }
        filterByHighlightedDiff(changedFile) {
            return !this.settings.filterEmptyChanges || changedFile.changedLines
                .map(l => l.highlightedDiff)
                .some(diff => diff.length === 0 || diff.replace(/[\s;]+/g, "").length > 0);
        }
//        filterByContent(changedLine) {
//            const value = changedLine.content.trim();
//            return this.filterEmptyChanges(value) && this.filterImportChanges(value) && this.filterPackageChanges(value);
//        }
        filterEmptyChanges(value) {
            return !this.settings.filterEmptyChanges || changedFile.changedLines.some(l => l.content.length !== 0);
            //return !this.settings.filterEmptyChanges || value.length !== 0;
        }
        filterImportChanges(value) {
            return !this.settings.filterImportChanges || value.indexOf("import ") !== 0;
        }
        filterPackageChanges(value) {
            return !this.settings.filterPackageChanges || value.indexOf("package ") !== 0;
        }
    }
    class SettingsEditorButton {
         constructor(parent, settings) {
             const editor =  new SettingsEditor(settings);
             this.button = $("<span id='setting-editor-button' class='btn fa fa-cogs'/>")
                 .appendTo(parent)
                 .click(() => editor.show());
        }
    }
    class ReviewPanel {
        constructor(settings) {
            this.panel = $("<div id='utilHolder'/>");
            this.settingsButton = new SettingsEditorButton(this.panel, settings);
            this.statistic = new Statistic(this.panel);
            this.controls = new ReviewPanelControls(this.panel);
        }
        update() {
            this.statistic.update();
            this.controls.update();
        }
        inject() {
            this.panel.appendTo('body');
            this.update();
        }
    }
    class ReviewPanelControls {
        constructor(parent) {
            const holder = $("<div/>").appendTo(parent);
            this.hideFilteredBtn = $("<label class='hideFiltered fa fa-eye'>&nbsp;Filtered</label>")
                .appendTo(holder)
                .click(() => this.toggleFiltered());
            this.hideApprovedBtn = $("<label class='hideApproved fa fa-eye'>&nbsp;Approved</label>")
                .appendTo(holder)
                .click(() => this.toggleApproved());
        }
        toggleFiltered() {
            localStorage.hideFiltered = localStorage.hideFiltered !== "true";
            this.update();
        }

        toggleApproved() {
            localStorage.hideApproved = localStorage.hideApproved !== "true";
            this.update();
        }
        update() {
            const hideFiltered = localStorage.hideFiltered === undefined ? true : localStorage.hideFiltered === "true";
            const hideApproved = localStorage.hideApproved === "true";
            localStorage.hideFiltered = hideFiltered;
            localStorage.hideApproved = hideApproved;
            this.hideFilteredBtn.toggleClass("fa-eye-slash", hideFiltered);
            this.hideApprovedBtn.toggleClass("fa-eye-slash", hideApproved);
            $('.files')
                .toggleClass("hide-filtered", hideFiltered)
                .toggleClass("hide-approved", hideApproved);
        }
    }

    class MergeRequestInjector {
        constructor(userInfo, filters, reviewPanel, approveStateStorage, unhideStateStorage) {
            this.reviewPanel = reviewPanel;
            this.filters = filters;
            this.approveStateStorage = approveStateStorage;
            this.unhideStateStorage = unhideStateStorage;
            this.userInfo = userInfo;
        }
        inject() {
            this.injectStyles();
            this.injectActionsToFileList();
            this.reviewPanel.inject();
        }
        injectActionsToFileList() {
            $('.files .diff-file').each((i, element) => {
                this.injectActionsToFile(new ChangedFile($(element)));
    	    });
            $('.files').addClass("approve-script-applied");
        }
        injectActionsToFile(file) {
            const approveButton = new ApproveButton(this.approveStateStorage, this.userInfo, file);
            approveButton.onClick(() => this.reviewPanel.update());
            file.addAction(approveButton.element);
            file.addActionToDiffContent(new ContentApproveButton(approveButton).element);
            if (!this.filters.filter(file)) {
                const unhideButton = new UnhideFilteredButton(this.unhideStateStorage, file);
                unhideButton.onClick(() => this.reviewPanel.update());
                file.addAction(unhideButton.element);
            }
        }
        injectStyles() {
    	    $('<style>')
                .html(STYLES)
                .appendTo("head");
        }
    }

    class ContentApproveButton {
        constructor(approveButton) {
            this.element = $("<span class='btn fa approve-btn fa-check'/>")
                .click(() => approveButton.toggleApprove());
        }
    }

    class ApproveButton {
        constructor(stateStorage, userInfo, file) {
            this.element = $("<span>");
            this.avatar = $("<img class='avatar avatar-inline s24'>").appendTo(this.element).hide();
            this.button = $("<span class='btn fa approve-btn fa-check'/>")
                .appendTo(this.element)
                .click(this.toggleApprove.bind(this));
            this.file = file;
            this.approved = false;
            this.userInfo = userInfo;
            this.approveUserInfo = userInfo.plain();
            this.stateStorage = stateStorage;
            this.storeId = file.fileId;
            this.listener = () => {};
            this.init();
        }
        init() {
            this.stateStorage.watch(this.storeId, state => {
                this.approved = !!state && state.checksum === this.file.checksum;
                this.approveUserInfo = state && state.user || this.userInfo;
                this.update();
            });
        }
        toggleApprove() {
            if (!this.userInfo.isAuthorized()) {
                return;
            }
            this.approved = !this.approved;
            if (this.approved) {
                this.approveUserInfo = this.userInfo.plain();
                this.stateStorage.set(this.storeId, {
                    checksum : this.file.checksum,
                    user : this.approveUserInfo
                });
                this.avatar.attr("src", this.approveUserInfo.avatar);
                this.file.scrollToNext();
            } else {
                this.stateStorage.set(this.storeId, null);
            }
            this.update();
        }
        onClick(listener) {
           this.listener = listener;
        }
        update() {
            this.file.toggleClass("approved", this.approved);
            this.avatar
                .attr("title", "Approved by " + this.approveUserInfo.name)
                .attr("src", this.approveUserInfo.avatar)
                .toggle(this.approved);
            this.button.toggleClass("active", this.approved);
            this.listener();
        }
    }
    class UserInfo {
        constructor() {
            const link = $(".profile-link");
            const avatarUrl = $(".header-user-avatar").eq(0).attr("src");
            this.name = link.data("user");
            this.url = link.attr("href");
            this.avatar = avatarUrl && (avatarUrl.indexOf(location.host) == -1 ? avatarUrl : avatarUrl.replace(/[^:]+:\/\/[^/]+/, "")) || "";
        }
        isAuthorized() {
            return !!this.name;
        }
        plain() {
            return {
                name : this.name,
                url : this.url,
                avatar : this.avatar,
            };
        }
    }
    class UnhideFilteredButton {
        constructor(stateStorage, file) {
            this.element = $("<span class='btn fa unhide-btn fa-eye'/>")
                .click(this.unhide.bind(this));
            this.file = file;
            this.storeId = 'unhidden/' + this.file.fileName;
            this.stateStorage = stateStorage;
            this.listener = () => {};
            this.init();
        }
        init() {
            this.stateStorage.watch(this.storeId, value => {
                if (value === "true") {
                    this.element.hide();
                    this.file.toggleClass("filtered-file", false);
                }
            });
            this.file.toggleClass("filtered-file", true);
        }
        onClick(listener) {
           this.listener = listener;
        }
        unhide() {
            this.stateStorage.set(this.storeId, "true");
            this.file.toggleClass("filtered-file", false);
            this.element.hide();
            this.listener();
            this.file.scrollTo();
        }
    }

    class ChangedFile {
        constructor(fileElement) {
            this.fileName = $('.file-title-name', fileElement).text().replace(/[\n\r]+/gi, "");
            this.fileId = sha256(this.fileName);
            this.element = fileElement;
            this.actions = fileElement.find(".file-actions");
            this.diffContent = this.element.find(".diff-content");
            this.changedLines = [...this.diffContent.find(".new.line_content, .old.line_content")]
                .map(element => $(element).find(".line"))
                .map(line => {
                return {
                    element: line,
                    content: line.text().trim(),
                    highlightedDiff: line.find(".idiff").text().trim()
                };
            });
            this.checksum = this.calcChecksum();
        }
        calcChecksum() {
            const newLines = this.changedLines.filter(l => l.element.is(".new .line")).map(l => l.content).join("\n");
            const oldLines = this.changedLines.filter(l => l.element.is(".old .line")).map(l => l.content).join("\n");
            return sha256(oldLines) + " - " + sha256(newLines);
        }
        addAction(element) {
            this.actions.prepend(element);
        }
        addActionToDiffContent(element) {
            this.diffContent.append(element);
        }
        toggleClass(className, value) {
            this.element.toggleClass(className, value);
        }
        scrollTo() {
           scrollTo(this.element);
        }
        scrollToNext() {
            let target = this.element.nextAll(":not(.filtered-file, .approved)");
            if (!target || !target.length) {
                target = this.element.prevAll(":not(.filtered-file, .approved)");
                if (!target || !target.length) {
                    target = $("body");
                }
            }
            scrollTo(target.eq(0));
        }
    }

    class Gitlab {
        constructor(settings, stateStorageFactory) {
            this.stateStorageFactory = stateStorageFactory;
            this.filters = new Filters(settings);
            this.reviewPanel = new ReviewPanel(settings);
            this.stateStorages = {};
        }
        detectMergeRequestId(href) {
            const urlData = /\/([^/]+)\/merge_requests\/(\d+)/.exec(href);
            if (!urlData || urlData.length < 3) {
                return undefined;
            }
            return sha256(location.host) + "/" + urlData[1] + "/" + urlData[2];
        }
        waitForInjecting() {
            if (!this.mergeRequestId) {
                return;
            }
            if ($('.files .diff-file').length && $(".loading.hide").length) {
                const userInfo = new UserInfo();
                new MergeRequestInjector(
                    userInfo,
                    this.filters,
                    this.reviewPanel,
                    this.stateStorages[this.mergeRequestId],
                    new LocalStateStorage(this.mergeRequestId)
                ).inject();
            } else {
                setTimeout(this.waitForInjecting.bind(this), 500);
            }
        }
        mergeRequestDiffDetected(mergeRequestId) {
            if (mergeRequestId) {
                this.mergeRequestId = mergeRequestId;
                this.stateStorages[mergeRequestId] = this.stateStorages[mergeRequestId] || this.stateStorageFactory.create(mergeRequestId);
                this.waitForInjecting();
            }
        }
        init() {
            $.ajaxPrefilter((options, originalOptions, jqXHR) => {
                if (/\/[^/]+\/[^/]+\/merge_requests\/\d+\/diffs\.json.*/.test(options.url)) {
                    options.data = options.data || "";
                    if ((options.url + options.data).indexOf("expand_all_diffs=1") === -1) {
                        options.data += options.data ? "&" : "";
                        options.data += "expand_all_diffs=1";
                    }
                    this.mergeRequestDiffDetected(this.detectMergeRequestId(options.url));
                }
            });
        }
    }

    class DBStateStorage {
        constructor(database, prefix) {
            this.database = database;
            this.prefix = prefix.replace(/[\.#$\[\]]+/gi, "_");
        }
        key(subkey) {
            return this.prefix + "/" + subkey.replace(/[\.#$\[\]\\\/]+/gi, "_");
        }
        watch(subkey, resolve) {
            this.database.ref(this.key(subkey)).on('value', function (snapshot) {
                resolve(snapshot.val());
            });
        }
        set(key, value) {
            this.database.ref().update({
               [this.key(key)] : value
            });
        }
    }

    class DBStateStorageFactory {
        constructor(settings) {
            DBStateStorageFactory.init(settings);
        }
        create(prefix) {
            return new DBStateStorage(firebase.database(), prefix);
        }
        static init(settings) {
            if (!DBStateStorageFactory.initiated) {
                DBStateStorageFactory.initiated = true;
                localStorage.removeItem("firebase:previous_websocket_failure");
                firebase.initializeApp({
                   apiKey: settings.fireBaseApiKey,
                   databaseURL: settings.fireBaseDBUrl
                });
            }
        }
    }

    class LocalStateStorage {
        constructor(prefix) {
            this.prefix = prefix;
        }
        watch(key, resolve) {
            var value = localStorage[this.prefix + "/" + key];
            resolve(value === undefined ? value : JSON.parse(value));
        }
        set(key, value) {
            localStorage[this.prefix + "/" + key] = JSON.stringify(value);
        }
    }
    class LocalStateStorageFactory {
        create(prefix) {
            return new LocalStateStorage(prefix);
        }
    }

    class Main {
        static init() {
            const settings = new Settings();
            new Gitlab(settings, this.createStateStorage(settings)).init();
        }
        static createStateStorage(settings) {
            if (settings.stateStorage === "local") {
                console.info("Local state storage");
                return new LocalStateStorageFactory();
            }
            console.info("DB state storage");
            return new DBStateStorageFactory(settings);
        }
    }

    class Settings {
        constructor() {
            function get(name) {
                return localStorage[name] || Settings.defaults[name];
            }
            function getBool(name, trueValue="true") {
                return get(name) === trueValue;
            }
            this.stateStorage = get("stateStorage");
            this.isPublicFireBase = (
                getBool("fireBaseDBUrl", Settings.defaults.fireBaseDBUrl) && getBool("fireBaseApiKey", Settings.defaults.fireBaseApiKey)
            );
            this.fireBaseApiKey = get("fireBaseApiKey");
            this.fireBaseDBUrl = get("fireBaseDBUrl");
            this.filterPackageChanges = getBool("filterPackageChanges");
            this.filterImportChanges = getBool("filterImportChanges");
            this.filterEmptyChanges = getBool("filterEmptyChanges");
            this.filters = JSON.parse(get("fileNameFilters")).filter(a => a && a.length > 2);
            this.enableFileNameFilter = this.filters.length != 0 && getBool("enableFileNameFilter");
        }
        update() {
            localStorage.stateStorage = this.stateStorage;
            localStorage.fireBaseApiKey = this.fireBaseApiKey;
            localStorage.fireBaseDBUrl = this.fireBaseDBUrl;
            localStorage.filterPackageChanges = this.filterPackageChanges;
            localStorage.filterImportChanges = this.filterImportChanges;
            localStorage.filterEmptyChanges = this.filterEmptyChanges;
            localStorage.enableFileNameFilter = this.enableFileNameFilter;
            localStorage.fileNameFilters = JSON.stringify(this.filters);
        }
    }
    Settings.defaults = {
        stateStorage : "db",
        filterPackageChanges : "true",
        filterImportChanges : "true",
        filterEmptyChanges : "true",
        enableFileNameFilter : "false",
        fileNameFilters : "[]",
        fireBaseApiKey : "AIzaSyDh6mkW61kb8HbmRZ6gZQjeuZugWTDpLto",
        fireBaseDBUrl : "https://public-gitlab-review.firebaseio.com"
    };

    class SettingsEditor {
        constructor(settings) {
            function checkbox(holder, toggleable) {
                const checkbox = $("<input type='checkbox'>")
                    .appendTo(holder)
                    .wrap("<div class='checkbox'></div>")
                    .wrap("<label></label>");
                return toggleable ? checkbox.change(() => toggleable.toggle(checkbox.is(":checked"))) : checkbox;
            }
            const editor = $("<div class='settings-editor'>").hide();
            const repoHolder = $("<div class='repo-holder'/>").appendTo(editor);
            const filtersHolder = $("<div>").appendTo(editor);
            const buttons = $("<div class='buttons'>").appendTo(editor);
            const saveBtn = $("<button class='btn btn-primary'>Save</button>")
                .appendTo(buttons)
                .click(() => this.save());
            const cancelBtn = $("<button class='btn btn-default'>Cancel</button>")
                .appendTo(buttons)
                .click(() => this.close());
            this.settings = settings;
            this.editor = editor;
            this.firebaseSettings = $("<div/>").addClass("inner-settings");
            this.useDbCheckbox = checkbox(repoHolder, this.firebaseSettings).after("Use firebase DB for sharing approves");
            this.firebaseSettings.appendTo(repoHolder);
            this.urlInput = $("<input class='form-control'>")
                .appendTo(this.firebaseSettings)
                .wrap("<label>Firebase DB url: </label>");
            this.apiKeyInput = $("<input class='form-control'>")
                .appendTo(this.firebaseSettings)
                .wrap("<label>Firebase Api key: </label>");
            this.filterImportChanges = checkbox(filtersHolder).after("Filter for 'import' changes");
            this.filterEmptyChanges = checkbox(filtersHolder).after("Filter for 'empty' changes");
            this.fileNameFilterHolder = $("<div/>").addClass("inner-settings");
            this.enableFileNameFilter = checkbox(filtersHolder, this.fileNameFilterHolder).after("Filter by file names");
            this.fileNameFilterHolder.appendTo(filtersHolder);
            this.filtersInput = $('<textarea rows=7 class="form-control"></textarea>')
                .appendTo(this.fileNameFilterHolder)
                .wrap("<label>File name filters(exclude): </label>");
        }
        show() {
            this.update();
            this.editor.show().appendTo("body");
        }
        update() {
            this.urlInput.val(this.settings.fireBaseDBUrl);
            this.apiKeyInput.val(this.settings.fireBaseApiKey);
            this.filtersInput.val(this.settings.filters.join("\n"));
            const isDatabaseStateStorage = this.settings.stateStorage === "db";
            this.useDbCheckbox.prop("checked", isDatabaseStateStorage);
            this.firebaseSettings.toggle(isDatabaseStateStorage);
            this.filterEmptyChanges.prop("checked", this.settings.filterEmptyChanges);
            this.filterImportChanges.prop("checked", this.settings.filterImportChanges);
            this.enableFileNameFilter.prop("checked", this.settings.enableFileNameFilter);
            this.fileNameFilterHolder.toggle(this.settings.enableFileNameFilter);
        }
        save() {
            this.settings.fireBaseDBUrl = this.urlInput.val();
            this.settings.fireBaseApiKey = this.apiKeyInput.val();
            this.settings.filters = this.filtersInput.val()
                .split("\n")
                .map(s => s.trim())
                .filter(s => s.length > 0)
                .filter((v, i, arr) => arr.indexOf(v) === i);
            this.settings.stateStorage = this.useDbCheckbox.is(":checked") ? "db" : "local";
            this.settings.filterImportChanges = this.filterImportChanges.is(":checked");
            this.settings.filterPackageChanges = this.filterImportChanges.is(":checked");
            this.settings.filterEmptyChanges = this.filterEmptyChanges.is(":checked");
            this.settings.enableFileNameFilter = this.enableFileNameFilter.is(":checked");
            this.settings.update();
            this.close();
        }
        close() {
            this.editor.hide();
        }
    }

    Main.init();

})();