Advertisement
OValt

Suggested Edits Reviewer Helper [JS]

May 3rd, 2021 (edited)
36
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. "use strict";
  2. (async () => {
  3.     const toApiDate = (date) => (date.valueOf() / 1e3).toString();
  4.     const toPercent = (ratio) => `${Math.trunc(ratio * 100)}%`;
  5.     const last = (arr) => arr[arr.length - 1];
  6.     const safeMatch = (text, regex, def = "") => (text.match(regex) || [text, def]).slice(1);
  7.     const API_BASE = "https://api.stackexchange.com";
  8.     const DEF_SITE = "stackoverflow";
  9.     const API_VER = 2.2;
  10.     const config = {
  11.         page: {
  12.             suggestionId: last(location.pathname.split("/")),
  13.         },
  14.         classes: {
  15.             grid: {
  16.                 container: "grid",
  17.                 cell: "grid--cell",
  18.             },
  19.         },
  20.         selectors: {
  21.             actions: {
  22.                 sidebar: ".js-actions-sidebar",
  23.                 modal: {
  24.                     form: "form[action='/suggested-edits/reject']",
  25.                     votes: {
  26.                         labels: "label[for^=rejection-reason].s-label",
  27.                         counts: ".s-badge__votes",
  28.                     },
  29.                 },
  30.                 inputs: {
  31.                     reject: "#review-action-Reject",
  32.                 },
  33.             },
  34.             buttons: {
  35.                 submit: ".js-review-submit",
  36.                 skip: ".js-review-actions:not(.d-none) .js-action-button[value=1]",
  37.                 close: ".s-modal--close",
  38.             },
  39.             reviews: {
  40.                 done: ".js-reviews-done",
  41.                 daily: ".js-reviews-per-day",
  42.             },
  43.             diffs: {
  44.                 deleted: ".full-diff .deleted > div",
  45.                 added: ".full-diff .inserted > div",
  46.             },
  47.             page: {
  48.                 links: {
  49.                     question: "a[href*='/questions/']",
  50.                     answer: "a.answer-hyperlink",
  51.                 },
  52.             },
  53.             content: {
  54.                 typeHint: ".js-review-content h2",
  55.                 postSummary: ".s-post-summary",
  56.             },
  57.             title: {
  58.                 description: ".s-page-title--description",
  59.                 actions: ".s-page-title--actions a",
  60.                 learnMore: ".js-show-modal-from-nav.s-link",
  61.                 title: ".s-page-title--text",
  62.                 header: ".s-page-title--header",
  63.             },
  64.             info: {
  65.                 post: {
  66.                     wrapper: ".postcell span",
  67.                 },
  68.                 editor: {
  69.                     card: "a.s-user-card--link",
  70.                 },
  71.             },
  72.         },
  73.     };
  74.     const handleMatchFailure = (selector, returnValue) => {
  75.         console.debug(`Couldn't find the element with selector: ${selector}`);
  76.        return returnValue;
  77.    };
  78.    const selectActions = () => Array.from(document.querySelectorAll(config.selectors.title.actions));
  79.    const getUserInfo = async (id, site = DEF_SITE) => {
  80.        const url = new URL(`${API_BASE}/${API_VER}/users/${id}`);
  81.        url.search = new URLSearchParams({ site }).toString();
  82.        const res = await fetch(url.toString());
  83.        if (!res.ok)
  84.            return null;
  85.        const { items: [userInfo], } = await res.json();
  86.        return userInfo;
  87.    };
  88.    const getSuggestionsUserStats = async (id, options = {}) => {
  89.        const url = new URL(`${API_BASE}/${API_VER}/users/${id}/suggested-edits`);
  90.        const params = {
  91.            site: options.site || DEF_SITE,
  92.        };
  93.        if (Object.keys(options).length) {
  94.            const { from, to = new Date() } = options;
  95.            if (from)
  96.                params.from = toApiDate(from);
  97.            if (to)
  98.                params.to = toApiDate(to);
  99.        }
  100.        url.search = new URLSearchParams(params).toString();
  101.        const res = await fetch(url.toString());
  102.        if (!res.ok)
  103.            return [];
  104.        const { items, } = await res.json();
  105.        return items;
  106.    };
  107.    const getAnswerId = (selector) => {
  108.        const link = document.querySelector(selector);
  109.        return safeMatch((link === null || link === void 0 ? void 0 : link.href) || "", /\/questions\/\d+\/[\w-]+\/(\d+)/, "")[0];
  110.    };
  111.    const getQuestionId = (selector) => {
  112.        const link = document.querySelector(selector);
  113.        return safeMatch((link === null || link === void 0 ? void 0 : link.href) || "", /\/questions\/(\d+)/, "")[0];
  114.    };
  115.    const getPostId = ({ selectors: { page: { links }, }, }) => getAnswerId(links.answer) || getQuestionId(links.question);
  116.    const getEditAuthorId = () => {
  117.        const postWrapSelector = config.selectors.info.post.wrapper;
  118.        const spans = document.querySelectorAll(postWrapSelector);
  119.        if (!spans.length)
  120.            return handleMatchFailure(postWrapSelector, null);
  121.        const userSpan = Array.from(spans).find(({ textContent }) => /proposed/i.test(textContent || ""));
  122.        if (!userSpan)
  123.            return null;
  124.        const cardSelector = config.selectors.info.editor.card;
  125.        const { parentElement } = userSpan;
  126.        const link = parentElement.querySelector(cardSelector);
  127.        if (!link)
  128.            return handleMatchFailure(cardSelector, null);
  129.        const { href } = link;
  130.        const [, userId] = href.match(/users\/(\d+)/) || [];
  131.        if (!userId)
  132.            return null;
  133.        return userId;
  134.    };
  135.    const createGridCell = () => {
  136.        const elem = document.createElement("div");
  137.        elem.classList.add(config.classes.grid.cell);
  138.        return elem;
  139.    };
  140.    const createItem = (...contents) => {
  141.        const elem = document.createElement("div");
  142.        elem.classList.add(config.classes.grid.cell, "p12");
  143.        elem.append(...contents);
  144.        return elem;
  145.    };
  146.    const text = (text) => document.createTextNode(text);
  147.    const br = () => document.createElement("br");
  148.    const a = (link, text = link) => {
  149.        const anchor = document.createElement("a");
  150.        anchor.href = link;
  151.        anchor.textContent = text;
  152.        anchor.target = "_blank";
  153.        anchor.referrerPolicy = "no-referrer";
  154.        return anchor;
  155.    };
  156.    const p = (text) => {
  157.        const par = document.createElement("p");
  158.        par.style.marginBottom = "0";
  159.        par.innerText = text;
  160.        return par;
  161.    };
  162.    const li = (content) => {
  163.        const item = document.createElement("li");
  164.        if (typeof content === "string") {
  165.            item.textContent = content;
  166.            return item;
  167.        }
  168.        item.append(content);
  169.        return item;
  170.    };
  171.    const ul = ({ header, items }) => {
  172.        const list = document.createElement("ul");
  173.        const { style } = list;
  174.        style.listStyle = "none";
  175.        style.margin = "0";
  176.        if (header) {
  177.            const head = document.createElement("h3");
  178.            head.classList.add("mb8");
  179.            head.textContent = header;
  180.            list.append(head);
  181.        }
  182.        const listItems = items.map(li);
  183.        list.append(...listItems);
  184.        return list;
  185.    };
  186.    const createEditAuthorItem = ({ display_name, reputation, link, }) => {
  187.        const namePar = p(`Name: `);
  188.        namePar.append(a(link, display_name));
  189.        return createItem(ul({
  190.            header: "Edit Author",
  191.            items: [namePar, `Reputation: ${reputation}`],
  192.        }));
  193.    };
  194.    const getSuggestionsByPost = async (postId, { site = DEF_SITE, type = "all" }) => {
  195.        const url = new URL(`${API_BASE}/${API_VER}/posts/${postId}/suggested-edits`);
  196.        url.search = new URLSearchParams({ site }).toString();
  197.        const res = await fetch(url.toString());
  198.        if (!res.ok)
  199.            return [];
  200.        const { items, } = (await res.json());
  201.        const filters = {
  202.            approved: ({ approval_date }) => !!approval_date,
  203.            rejected: ({ rejection_date }) => !!rejection_date,
  204.            pending: ({ approval_date, rejection_date }) => !approval_date && !rejection_date,
  205.        };
  206.        const predicate = filters[type];
  207.        return predicate ? items.filter(predicate) : items;
  208.    };
  209.    const getSuggestedEditsInfo = async (...ids) => {
  210.        const url = new URL(`${API_BASE}/${API_VER}/suggested-edits/${ids.join(",")}`);
  211.        const res = await fetch(url.toString());
  212.        if (!res.ok)
  213.            return [];
  214.        const { items, } = (await res.json());
  215.        return items;
  216.    };
  217.    const getSuggestionTotals = (suggestions) => {
  218.        const stats = {
  219.            get ratio() {
  220.                const { approved, rejected, total } = this;
  221.                return {
  222.                    ofApproved: approved / total,
  223.                    ofRejected: rejected / total,
  224.                    approvedToRejected: approved / (rejected === 0 ? 1 : rejected),
  225.                };
  226.            },
  227.            approved: 0,
  228.            rejected: 0,
  229.            total: 0,
  230.        };
  231.        suggestions.forEach(({ approval_date, rejection_date }) => {
  232.            stats.total += 1;
  233.            if (approval_date)
  234.                stats.approved += 1;
  235.            if (rejection_date)
  236.                stats.rejected += 1;
  237.        });
  238.        return stats;
  239.    };
  240.    const decolorDiff = (cnf) => {
  241.        const { added, deleted } = cnf.selectors.diffs;
  242.        const addWrapper = document.querySelector(added);
  243.        const delWrapper = document.querySelector(deleted);
  244.        if (!addWrapper || !delWrapper)
  245.            return false;
  246.        addWrapper.style.backgroundColor = "unset";
  247.        delWrapper.style.backgroundColor = "unset";
  248.        return true;
  249.    };
  250.    const createEditorStatsItem = ({ link }, suggestions) => {
  251.        const { approved, rejected, total, ratio: { approvedToRejected, ofApproved, ofRejected }, } = getSuggestionTotals(suggestions);
  252.        const itemParams = {
  253.            header: "Author Stats",
  254.            items: [],
  255.        };
  256.        if (!total) {
  257.            const infoPar = p(`Tag wiki/excerpt edits are not returned.`);
  258.            infoPar.append(br(), text(`See their `), a(`${link}?tab=activity`, "activity tab"));
  259.            itemParams.items.push(infoPar);
  260.            return createItem(ul(itemParams));
  261.        }
  262.        itemParams.items.push(`Approved: ${approved} (${toPercent(ofApproved)})`, `Rejected: ${rejected} (${toPercent(ofRejected)})`, `Of total: ${total}`, `Ratio: ${approvedToRejected}`);
  263.        return createItem(ul(itemParams));
  264.    };
  265.    const trimNumericString = (text) => text.replace(/\D/g, "");
  266.    const goParentUp = (element, times = 1) => {
  267.        if (times === 0 || !element)
  268.            return element;
  269.        return goParentUp(element.parentElement, times - 1);
  270.    };
  271.    const arraySelect = (ctxt, selector) => Array.from(ctxt.querySelectorAll(selector));
  272.    const removeProgressBar = (reviewStatsElement) => {
  273.        const wrapper = goParentUp(reviewStatsElement, 3);
  274.        if (!wrapper)
  275.            return false;
  276.        wrapper.remove();
  277.        return true;
  278.    };
  279.    const removeTitleLines = (cnf, wrapper) => (wrapper || document)
  280.        .querySelectorAll(cnf.selectors.title.description)
  281.        .forEach((elem) => elem.remove());
  282.    const optimizePageTitle = (cnf) => {
  283.        const titleSelector = cnf.selectors.title.title;
  284.        const titleWrap = document.querySelector(titleSelector);
  285.        if (!titleWrap)
  286.            return handleMatchFailure(titleSelector, false);
  287.        titleWrap.classList.add(cnf.classes.grid.container);
  288.        const header = document.querySelector(cnf.selectors.title.header);
  289.        const titleCell = createGridCell();
  290.        titleCell.classList.add("ml12");
  291.        if (header)
  292.            titleCell.append(header);
  293.        const learnMoreBtn = titleWrap.querySelector(cnf.selectors.title.learnMore);
  294.        const linkCell = titleCell.cloneNode();
  295.        if (learnMoreBtn)
  296.            linkCell.append(learnMoreBtn);
  297.        removeTitleLines(cnf, titleWrap);
  298.        titleWrap.append(titleCell, linkCell);
  299.        return true;
  300.    };
  301.    const moveProgressToTabs = ({ selectors }) => {
  302.        const actions = selectActions();
  303.        const action = actions.find(({ href }) => /\/review\/suggested-edits/.test(href));
  304.        const dailyElem = document.querySelector(selectors.reviews.daily);
  305.        const reviewedElem = document.querySelector(selectors.reviews.done);
  306.        if (!dailyElem || !reviewedElem)
  307.            return false;
  308.        const daily = trimNumericString(dailyElem.textContent || "0");
  309.        const reviewed = trimNumericString(reviewedElem.textContent || "0");
  310.        const ratio = +reviewed / +daily;
  311.        const percentDone = toPercent(ratio);
  312.        if (!action)
  313.            return false;
  314.        const { style } = action;
  315.        style.background = `linear-gradient(90deg, var(--theme-primary-color) ${percentDone}, var(--black-075) ${percentDone})`;
  316.        style.color = `var(--black-600)`;
  317.        action.textContent += ` (${reviewed}/${daily})`;
  318.        return removeProgressBar(dailyElem);
  319.    };
  320.    const addAuditNotification = async ({ selectors: { content } }, postId) => {
  321.        const auditId = "audit_notification";
  322.        if (document.getElementById(auditId))
  323.            return true;
  324.        const { length } = await getSuggestionsByPost(postId, {
  325.            type: "pending",
  326.        });
  327.        if (length)
  328.            return true;
  329.        const editTypeHint = document.querySelector(content.typeHint);
  330.        const summary = document.querySelector(content.postSummary);
  331.        if (!editTypeHint)
  332.            return false;
  333.        const quote = document.createElement("blockquote");
  334.        quote.id = auditId;
  335.        quote.classList.add("mb12", "fs-headline1");
  336.        quote.textContent = "This is an Audit. Tread carefully";
  337.        editTypeHint.after(quote);
  338.        editTypeHint.remove();
  339.        summary === null || summary === void 0 ? void 0 : summary.remove();
  340.        return true;
  341.    };
  342.    const callRejectionModal = (cnf) => {
  343.        const { selectors: { buttons, actions: { inputs, modal }, }, } = cnf;
  344.        const rejectInput = document.querySelector(inputs.reject);
  345.        const submitButton = document.querySelector(buttons.submit);
  346.        if (!rejectInput || !submitButton)
  347.            return null;
  348.        rejectInput.click();
  349.        submitButton.click();
  350.        const modalWrapper = document.querySelector(modal.form);
  351.        if (!modalWrapper)
  352.            return null;
  353.        const dolly = modalWrapper.cloneNode(true);
  354.        const closeBtn = modalWrapper.querySelector(buttons.close);
  355.        closeBtn.click();
  356.        return dolly;
  357.    };
  358.    const getRejectionCount = (cnf) => {
  359.        const { selectors: { actions: { modal }, }, } = cnf;
  360.        const modalWrapper = callRejectionModal(cnf);
  361.        if (!modalWrapper)
  362.            return handleMatchFailure(modal.form, null);
  363.        const withVotes = arraySelect(modalWrapper, modal.votes.labels);
  364.        const count = {
  365.            spam: 0,
  366.            improvement: 0,
  367.            intent: 0,
  368.            reply: 0,
  369.            harm: 0,
  370.        };
  371.        const reasonMap = {
  372.            102: "improvement",
  373.            101: "spam",
  374.            104: "intent",
  375.            105: "reply",
  376.            0: "harm",
  377.        };
  378.        const voteSelector = modal.votes.counts;
  379.        withVotes.forEach((label) => {
  380.            const { htmlFor } = label;
  381.            const [_full, reasonId] = htmlFor.match(/(\d+$)/) || [];
  382.            const reason = reasonMap[reasonId];
  383.            if (label.querySelector(voteSelector))
  384.                count[reason] += 1;
  385.        });
  386.        return count;
  387.    };
  388.    const scase = (word) => word[0].toUpperCase() + word.slice(1).toLowerCase();
  389.    const createRejectionCountItem = (count) => {
  390.        const withVotes = Object.entries(count).filter(([_k, v]) => !!v);
  391.        const items = withVotes.map(([k, v]) => `${scase(k)}: ${v}`);
  392.        return createItem(ul({ items, header: "Reject votes" }));
  393.    };
  394.    const addStatsSidebar = async (cnf) => {
  395.        const sidebar = document.querySelector(cnf.selectors.actions.sidebar);
  396.        if (!sidebar)
  397.            return false;
  398.        const dialog = document.createElement("div");
  399.        dialog.classList.add("s-sidebarwidget", "ml24", "mt24");
  400.        const header = document.createElement("div");
  401.        header.classList.add("s-sidebarwidget--header");
  402.        header.textContent = "Extra Info";
  403.        const itemWrap = document.createElement("div");
  404.        itemWrap.classList.add(cnf.classes.grid.container, "fd-column");
  405.        const authorId = getEditAuthorId();
  406.        if (!authorId)
  407.            return false;
  408.        const [editAuthorInfo, editAuthorStats] = await Promise.all([
  409.            getUserInfo(authorId),
  410.            getSuggestionsUserStats(authorId),
  411.        ]);
  412.        const rejectCount = getRejectionCount(cnf);
  413.        if (!editAuthorInfo || !rejectCount)
  414.            return false;
  415.        const items = [];
  416.        items.push(createEditAuthorItem(editAuthorInfo), createEditorStatsItem(editAuthorInfo, editAuthorStats), createRejectionCountItem(rejectCount));
  417.        itemWrap.append(...items);
  418.        dialog.append(header, itemWrap);
  419.        editAuthorInfo && sidebar.append(dialog);
  420.        return true;
  421.    };
  422.    const postId = getPostId(config);
  423.    if (!postId)
  424.        return;
  425.    const handlerMap = {
  426.        moveProgressToTabs,
  427.        optimizePageTitle,
  428.        decolorDiff,
  429.        addAuditNotification,
  430.    };
  431.    const promises = Object.entries(handlerMap).map(([key, handler]) => [
  432.        key,
  433.        handler(config, postId),
  434.    ]);
  435.    const statuses = await Promise.all(promises);
  436.    const statusMsg = statuses.reduce((acc, [k, v]) => `${acc}\n${k} - ${v ? "ok" : "failed"}`, "Status: ");
  437.    console.debug(statusMsg);
  438.    await addStatsSidebar(config);
  439. })();
  440.  
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement