Jump to content

User:DVRTed/multiContribs.js

From Wikipedia, the free encyclopedia
The printable version is no longer supported and may have rendering errors. Please update your browser bookmarks and please use the default browser print function instead.
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"
  );
});