Jump to content

User:DVRTed/multiContribs.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/*
multiContribs.js
- allows viewing contributions of multiple users in one page: [[Special:BlankPage/MultiContribs]];
- adds a link to "multiContribs" tool in SPI pages;
*/

/* global mw, $ */
mw.loader.using(["mediawiki.api", "mediawiki.util"]).then(() => {
  const RUN_PAGE = "Special:BlankPage/MultiContribs";
  const RUN_NS = -1;

  class MultiContribs {
    constructor() {
      if (
        mw.config.get("wgNamespaceNumber") !== RUN_NS ||
        mw.config.get("wgPageName").toLowerCase() !== RUN_PAGE.toLowerCase()
      ) {
        return;
      }

      this.content_div = document.getElementById("content");
      this.namespaces = [
        { id: "", name: "All namespaces" },
        { id: "0", name: "Main (articles)" },
        { id: "1", name: "Talk" },
        { id: "2", name: "User" },
        { id: "3", name: "User talk" },
        { id: "4", name: "Wikipedia" },
        { id: "5", name: "Wikipedia talk" },
        { id: "6", name: "File" },
        { id: "7", name: "File talk" },
        { id: "8", name: "MediaWiki" },
        { id: "9", name: "MediaWiki talk" },
        { id: "10", name: "Template" },
        { id: "11", name: "Template talk" },
        { id: "12", name: "Help" },
        { id: "13", name: "Help talk" },
        { id: "14", name: "Category" },
        { id: "15", name: "Category talk" },
        { id: "100", name: "Portal" },
        { id: "101", name: "Portal talk" },
        { id: "118", name: "Draft" },
        { id: "119", name: "Draft talk" },
        { id: "828", name: "Module" },
        { id: "829", name: "Module talk" },
      ];

      this.number_of_users_limit = 50;
      this.limits = [10, 25, 50, 100, 250, 500];
      this.available_tags = [
        "mobile edit",
        "mobile web edit",
        "possible vandalism",
        "twinkle",
        "visualeditor",
        "mw-reverted",
        "mw-undo",
        "advanced mobile edit",
        "mw-replace",
        "visualeditor-wikitext",
        "mw-rollback",
        "mw-new-redirect",
        "mobile app edit",
        "mw-manual-revert",
        "mw-blank",
        "huggle",
        "mw-changed-redirect-target",
        "mw-removed-redirect",
      ];

      this.init();
    }

    init() {
      document.title = "Contributions of multiple users";
      this.load_styles();
      this.render_header();
      this.bind_events();
      this.load_from_url();
    }

    load_styles() {
      mw.loader.load(["mediawiki.interface.helpers.styles", "codex-styles"]);

      const style = document.createElement("style");
      style.textContent = `
  #mctb-form {
    flex-direction: column;
    padding: 15px;
    background-color: #f8f9fa;
  }

  .mctb-card {
    display: flex;
    align-items: center;
    width: 100%;
    margin: 10px 0;
  }

  .mctb-card .input-col1 {
    flex: 1;
  }

  .mctb-option {
    margin: 10px 0;
  }

  #users-input {
    min-height: inherit;
    padding: 8px;
    background-color: #fff;
    font-size: 14px;
    border-radius: 4px;
    resize: vertical;
  }

  #users-input:focus {
    outline: none;
    border-color: #0645ad;
  }

  .users-input-container {
    max-width: 600px;
    min-height: 200px;
  }

  #mctb-form select {
    width: auto;
    min-width: 50px;
    max-width: 300px;
  }

  .mw-uctop {
    font-weight: bold;
  }

  .mw-tag-markers {
    margin-right: 5px;
    color: #0645ad;
    font-size: 0.8em;
  }

  .mw-tag-markers abbr {
    border-bottom: 1px dotted;
    cursor: help;
  }

  .mw-tag {
    padding: 0 4px;
    border: 1px solid #a2a9b1;
    margin-left: 5px;
    background-color: #eef2ff;
    color: #0645ad;
    font-size: 0.85em;
    border-radius: 2px;
  }
      `;
      document.head.appendChild(style);
    }

    render_header() {
      this.content_div.innerHTML = `
<div class="vector-body">
  <details class="cdx-accordion" open>
    <summary>
      <h3 class="cdx-accordion__header">Contributions of multiple users</h3>
    </summary>
    <div id="mctb-form" class="cdx-card">
      <div class="cdx-card__text__description mctb-card">
        <div class="input-col1">
          <label for="users-input">Users/IPs (one per line):</label><br />
          <div class="cdx-text-area users-input-container">
            <textarea
              id="users-input"
              class="cdx-text-area__textarea"
              rows="5"
              cols="50"
              placeholder="Enter usernames or IP addresses, one per line"
            ></textarea>
          </div>
        </div>
        <div class="input-col2">
          <div class="mctb-option">
            <label for="limit-input">Results per user:</label>
            <select id="limit-input" class="cdx-select">
              ${this.limits
                .map(
                  (limit) => `
              <option value="${limit}">${limit}</option>
              `
                )
                .join("")}
            </select>
          </div>

          <div class="mctb-option">
            <label for="namespace-input">Namespace:</label>
            <select id="namespace-input" class="cdx-select">
              ${this.namespaces
                .map(
                  (ns) => `
              <option value="${ns.id}">${ns.name}</option>
              `
                )
                .join("")}
            </select>
          </div>

          <div class="mctb-option">
            <label for="tag-input">Filter by tag:</label>
            <select id="tag-input" class="cdx-select">
              <option value="">All tags</option>
              ${this.available_tags
                .map(
                  (tag) => `
              <option value="${tag}">${tag}</option>
              `
                )
                .join("")}
            </select>
          </div>

          <div class="mctb-option">
            <div class="cdx-checkbox">
              <div class="cdx-checkbox__wrapper">
                <input
                  id="show-new-only"
                  class="cdx-checkbox__input"
                  type="checkbox"
                />
                <span class="cdx-checkbox__icon"></span>
                <div class="cdx-checkbox__label cdx-label">
                  <label for="show-new-only" class="cdx-label__label">
                    <span class="cdx-label__label__text">
                      Show only page creations
                    </span>
                  </label>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      <button
        id="load-contribs"
        class="cdx-button cdx-button--action-progressive cdx-button--weight-primary"
      >
        Load Contributions
      </button>
    </div>
  </details>
  <div id="mctb-results" class="mctb-option"></div>
</div>
      `;
    }

    bind_events() {
      document.getElementById("load-contribs").addEventListener("click", () => {
        this.load_contributions();
      });
    }

    load_from_url() {
      const params = new URLSearchParams(window.location.search);

      document.getElementById("limit-input").value = 50;
      if (params.has("limit")) {
        const limit = params.get("limit");
        const is_valid_limit = this.limits.includes(parseInt(limit));

        document.getElementById("limit-input").value = is_valid_limit
          ? limit
          : "50";
      }

      if (params.has("namespace")) {
        const namespace = params.get("namespace");
        const valid_namespaces = this.namespaces.map((ns) => ns.id);
        document.getElementById("namespace-input").value =
          valid_namespaces.includes(namespace) ? namespace : "";
      }

      if (params.has("tag")) {
        const tag = params.get("tag");
        document.getElementById("tag-input").value = tag;
      }

      if (params.has("new")) {
        const new_param = params.get("new");
        document.getElementById("show-new-only").checked =
          new_param === "1" || new_param === "true";
      }

      if (params.has("users")) {
        document.getElementById("users-input").value = params
          .get("users")
          .split(",")
          .join("\n");
        this.load_contributions();
      }
    }

    update_url() {
      const users = document
        .getElementById("users-input")
        .value.trim()
        .split("\n")
        .filter((u) => u.trim());
      const limit = document.getElementById("limit-input").value;
      const namespace = document.getElementById("namespace-input").value;
      const tag = document.getElementById("tag-input").value;
      const show_new_only = document.getElementById("show-new-only").checked;

      const params = new URLSearchParams();

      if (users.length > 0) {
        params.set("users", users.join(","));
      }

      if (limit !== "50") {
        params.set("limit", limit);
      }

      if (namespace !== "") {
        params.set("namespace", namespace);
      }

      if (tag !== "") {
        params.set("tag", tag);
      }

      if (show_new_only) {
        params.set("new", "1");
      }

      const new_url =
        window.location.pathname +
        (params.toString() ? "?" + params.toString() : "");
      window.history.replaceState({}, "", new_url);
    }

    async load_contributions() {
      const raw_users = document
        .getElementById("users-input")
        .value.trim()
        .split("\n")
        .map((u) => {
          if (!u.trim()) return null;
          const parse_title = mw.Title.newFromText(u, 2);
          return parse_title ? parse_title.title : null;
        })
        .filter((u) => u);

      const users = [...new Set(raw_users)];
      const results_div = document.getElementById("mctb-results");
      const load_button = document.getElementById("load-contribs");

      if (users.length === 0) {
        results_div.innerHTML =
          "<p>Please enter at least one username or IP address.</p>";
        return;
      }

      if (users.length > this.number_of_users_limit) {
        results_div.innerHTML = `<p>Exceeded the ${this.number_of_users_limit} users limit.</p>`;
        return;
      }

      load_button.disabled = true;
      const original_text = load_button.textContent;
      load_button.textContent = "Loading...";

      this.update_url();

      const limit = parseInt(document.getElementById("limit-input").value);
      const namespace = document.getElementById("namespace-input").value;
      const tag = document.getElementById("tag-input").value;
      const show_new_only = document.getElementById("show-new-only").checked;

      results_div.innerHTML = "<p>Loading contributions...</p>";

      try {
        const all_contribs = [];

        for (const user of users) {
          const api = new mw.Api();
          const params = {
            action: "query",
            list: "usercontribs",
            ucuser: user.trim(),
            uclimit: limit,
            ucprop: "ids|title|timestamp|comment|size|flags|sizediff|tags",
          };

          if (namespace !== "") {
            params.ucnamespace = namespace;
          }

          if (tag !== "") {
            params.uctag = tag;
          }

          if (show_new_only) {
            params.ucshow = "new";
          }

          const result = await api.get(params);

          if (result.query.usercontribs) {
            result.query.usercontribs.forEach((contrib) => {
              contrib.user = user.trim();
              all_contribs.push(contrib);
            });
          }
        }

        all_contribs.sort(
          (a, b) => new Date(b.timestamp) - new Date(a.timestamp)
        );

        this.render_results(all_contribs, results_div);
      } catch (error) {
        results_div.innerHTML =
          "<p>Error loading contributions: " + error.message + "</p>";
      } finally {
        load_button.disabled = false;
        load_button.textContent = original_text;
      }
    }

    render_results(contribs, results_div) {
      if (contribs.length === 0) {
        results_div.innerHTML =
          "<p>No contributions found with the selected filters.</p>";
        return;
      }

      let html = `<p>Found ${contribs.length} contributions</p>
      <ul class="mw-contributions-list">`;

      contribs.forEach((contrib) => {
        const full_date_time = this.format_timestamp(contrib.timestamp);

        let flags = []; // might add more in future
        if ("new" in contrib)
          flags.push('<abbr title="This edit created a new page">N</abbr>');

        const flags_html =
          flags.length > 0
            ? `<span class="mw-tag-markers">${flags.join(" ")}</span> `
            : "";

        let tags_html = "";
        if (contrib.tags && contrib.tags.length > 0) {
          const tag_spans = contrib.tags.map(
            (tag) => `<span class="mw-tag" title="${tag}">${tag}</span>`
          );
          tags_html = tag_spans.join("");
        }

        // constructing actual list
        //
        html += `<li data-mw-revid="${contrib.revid}">`;

        // diff and history links
        html += `
            <span class="mw-changeslist-links">
            <span><a href="/w/index.php?title=${contrib.title}&diff=prev&oldid=${contrib.revid}" 
                     class="mw-changeslist-diff" title="${contrib.title}">diff</a></span>
            <span><a href="/w/index.php?title=${contrib.title}&action=history" 
                     class="mw-changeslist-history" title="${contrib.title}">hist</a></span>
          </span>
        
        `;

        // relevant user
        html += `[<a href="/wiki/Special:Contributions/${contrib.user}" style="font-weight: bold;">${contrib.user}</a>]`;

        // UTC date and time
        html += `
        <bdi>
            <a href="/w/index.php?title=${contrib.title}&oldid=${contrib.revid}" 
               class="mw-changeslist-date" title="${contrib.title}">${full_date_time}</a>
        </bdi>
        `;

        // separator and flags
        html += `<span class="mw-changeslist-separator"></span>${flags_html}`;

        // size diff
        const intensity = Math.min(Math.abs(contrib.sizediff) / 1000, 1);
        const green_intensity = Math.floor(200 - intensity * 100);
        const red_intensity = Math.floor(200 - intensity * 100);

        const fnt_color =
          contrib.sizediff > 0
            ? `rgb(0, ${green_intensity}, 0)`
            : `rgb(${red_intensity}, 0, 0)`;

        // bold if diff is either higher than 500 OR lower than -500-- not in-between
        const fnt_weight =
          contrib.sizediff >= 500
            ? "bold"
            : contrib.sizediff <= -500
            ? "bold"
            : "";

        const plus_sign = contrib.sizediff > 0 ? "+" : "";

        html += `
    <span dir="ltr" class="mw-plusminus-pos mw-diff-bytes" title="${
      contrib.size
    } bytes after change" style="color: ${fnt_color}; font-weight: ${fnt_weight}">${
          plus_sign + (contrib.sizediff || 0)
        }</span>
    <span class="mw-changeslist-separator"></span>`;

        // title and edit summary
        html += `
        <bdi>
            <a href="/wiki/${contrib.title}" 
               class="mw-contributions-title" title="${contrib.title}">${
          contrib.title
        }</a>
          </bdi>
          <span class="comment comment--without-parentheses">${
            contrib.comment || ""
          }</span>
        `;

        if (tags_html) {
          html += tags_html;
        }

        // if it's the current revision
        if ("top" in contrib) {
          html += `
            <span class="mw-changeslist-separator"></span>
            <span class="mw-uctop">current</span>
            `;
        }

        html += `</li>`;
      });

      html += "</ul>";
      results_div.innerHTML = html;
      mw.hook("wikipage.content").fire($(results_div));
    }

    format_timestamp(timestamp) {
      const date = new Date(timestamp);
      const hours = date.getUTCHours().toString().padStart(2, "0");
      const minutes = date.getUTCMinutes().toString().padStart(2, "0");
      const day = date.getUTCDate();
      const month = date.toLocaleDateString("en-US", {
        month: "long",
        timeZone: "UTC",
      });
      const year = date.getUTCFullYear();
      return `${hours}:${minutes}, ${day} ${month} ${year}`;
    }
  }

  new MultiContribs();

  // multiContribs on suspected sockpuppets' lists
  if (
    mw.config
      .get("wgPageName")
      .startsWith("Wikipedia:Sockpuppet_investigations/")
  ) {
    $("ul:has(span.cuEntry)").each(function () {
      const users = $(this)
        .find("span.cuEntry .plainlinks a")
        .map(function () {
          return $(this).text();
        })
        .get();

      $(this)
        .find("li")
        .last()
        .find("a")
        .first()
        .before(
          `<a href="/wiki/${RUN_PAGE}?users=${encodeURIComponent(
            users.join(",")
          )}" style="font-style: italic;">multiContribs</a> <b>·</b> `
        );
    });
  }

  // add portlet link
  mw.util.addPortletLink(
    "p-tb",
    "/wiki/" + RUN_PAGE,
    "multiContribs",
    "t-multicontribs",
    "View contributions of multiple users"
  );
});