OSRS Wiki Auto-Categorizer with UI, Adaptive Speed, Duplicate Checker (Slow Version)

Adds listed pages to a category upon request with UI, CSRF token, adaptive speed, duplicate checker, and highlighted links option.

// ==UserScript==
// @name         OSRS Wiki Auto-Categorizer with UI, Adaptive Speed, Duplicate Checker (Slow Version)
// @namespace    https://github.com/Nick2bad4u/UserStyles
// @version      4.8
// @description  Adds listed pages to a category upon request with UI, CSRF token, adaptive speed, duplicate checker, and highlighted links option.
// @author       Nick2bad4u
// @match        https://oldschool.runescape.wiki/*
// @match        https://runescape.wiki/*
// @match        https://*.runescape.wiki/*
// @match        https://api.runescape.wiki/*
// @match        https://classic.runescape.wiki/*
// @match        *://*.runescape.wiki/*
// @grant        GM_xmlhttpRequest
// @icon         https://www.google.com/s2/favicons?sz=64&domain=oldschool.runescape.wiki
// @license      UnLicense
// ==/UserScript==

(function () {
  'use strict';
  const versionNumber = '4.6';
  let categoryName = '';
  let pageLinks = [];
  let selectedLinks = [];
  let currentIndex = 0;
  let csrfToken = '';
  let isCancelled = false;
  let isRunning = false;
  let requestInterval = 10000;
  const maxInterval = 20000;
  const excludedPrefixes = [
    'Template:',
    'File:',
    'Category:',
    'Module:',
    'RuneScape:',
    'Update:',
    'Exchange:',
    'RuneScape:',
    'User:',
    'Help:',
  ];
  let actionLog = []; // Track actions for summary

  function addButtonAndProgressBar() {
    console.log('Adding button and progress bar to the UI.');
    const container = document.createElement('div');
    container.id = 'categorize-ui';
    container.style = `position: fixed; bottom: 20px; right: 20px; z-index: 1000;
            background-color: #2b2b2b; color: #ffffff; padding: 12px;
            border: 1px solid #595959; border-radius: 8px; font-family: Arial, sans-serif; width: 250px;`;

    const startButton = document.createElement('button');
    startButton.textContent = 'Start Categorizing';
    startButton.style = `background-color: #4caf50; color: #fff; border: none;
            padding: 6px 12px; border-radius: 5px; cursor: pointer;`;
    startButton.onclick = promptCategoryName;
    container.appendChild(startButton);

    const cancelButton = document.createElement('button');
    cancelButton.textContent = 'Cancel';
    cancelButton.style = `background-color: #d9534f; color: #fff; border: none;
            padding: 6px 12px; border-radius: 5px; cursor: pointer; margin-left: 10px;`;
    cancelButton.onclick = cancelCategorization;
    container.appendChild(cancelButton);

    const progressBarContainer = document.createElement('div');
    progressBarContainer.style = `width: 100%; margin-top: 10px; background-color: #3d3d3d;
            height: 20px; border-radius: 5px; position: relative;`;
    progressBarContainer.id = 'progress-bar-container';

    const progressBar = document.createElement('div');
    progressBar.style = `width: 0%; height: 100%; background-color: #4caf50; border-radius: 5px;`;
    progressBar.id = 'progress-bar';
    progressBarContainer.appendChild(progressBar);

    const progressText = document.createElement('span');
    progressText.id = 'progress-text';
    progressText.style = `position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
            font-size: 12px; color: #ffffff; white-space: nowrap; overflow: visible; text-align: center;`;
    progressBarContainer.appendChild(progressText);

    container.appendChild(progressBarContainer);
    document.body.appendChild(container);
  }

  function promptCategoryName() {
    categoryName = prompt("Enter the category name you'd like to add:");
    console.log('Category name entered:', categoryName);
    if (!categoryName) {
      alert('Category name is required.');
      return;
    }

    getPageLinks();
    if (pageLinks.length === 0) {
      alert('No pages found to categorize.');
      console.log('No pages found after filtering.');
      return;
    }

    displayPageSelectionPopup();
  }

  // Function to check for highlighted text
  function getHighlightedText() {
    const selection = globalThis.getSelection();
    if (selection.rangeCount > 0) {
      const container = document.createElement('div');
      for (let i = 0; i < selection.rangeCount; i++) {
        container.appendChild(selection.getRangeAt(i).cloneContents());
      }
      return container.innerHTML;
    }
    return '';
  }

  // Modify getPageLinks to consider highlighted text
  function getPageLinks() {
    let contextElement = document.querySelector('#mw-content-text');
    const highlightedText = getHighlightedText();

    if (highlightedText) {
      // Create a temporary container to process the highlighted text
      const tempContainer = document.createElement('div');
      tempContainer.innerHTML = highlightedText;
      contextElement = tempContainer;
    }

    pageLinks = Array.from(
      new Set(
        Array.from(contextElement.querySelectorAll('a'))
          .map((link) => link.getAttribute('href'))
          .filter((href) => href && href.startsWith('/w/'))
          .map((href) => decodeURIComponent(href.replace('/w/', '')))
          .filter(
            (page) =>
              !excludedPrefixes.some((prefix) => page.startsWith(prefix)) &&
              !page.includes('?') &&
              !page.includes('/') &&
              !page.includes('#')
          )
      )
    );

    console.log('Filtered unique page links:', pageLinks);
  }

  function displayPageSelectionPopup() {
    console.log('Displaying page selection popup.');
    const popup = document.createElement('div');
    popup.style = `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
    z-index: 1001; background-color: #2b2b2b; padding: 15px; color: white;
    border-radius: 8px; max-height: 80vh; overflow-y: auto; border: 1px solid #595959;`;

    const title = document.createElement('h3');
    title.textContent = 'Select Pages to Categorize';
    title.style = `margin: 0 0 10px; font-family: Arial, sans-serif;`;
    popup.appendChild(title);

    const listContainer = document.createElement('div');
    listContainer.style = `max-height: 300px; overflow-y: auto;`;

    // Declare lastChecked outside the event listener to keep track of the last clicked checkbox
    let lastChecked = null;

    pageLinks.forEach((link) => {
      const listItem = document.createElement('div');
      const checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.checked = true;
      checkbox.value = link;
      listItem.appendChild(checkbox);
      listItem.appendChild(document.createTextNode(` ${link}`));
      listContainer.appendChild(listItem);

      checkbox.addEventListener('click', function (e) {
        if (e.shiftKey && lastChecked) {
          let inBetween = false;
          listContainer.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
            if (checkbox === this || checkbox === lastChecked) {
              inBetween = !inBetween;
            }
            if (inBetween) {
              checkbox.checked = this.checked;
            }
          });
        }
        lastChecked = this;
      });
    });
    popup.appendChild(listContainer);

    const buttonContainer = document.createElement('div');
    buttonContainer.style = `margin-top: 10px; display: flex; justify-content: space-between;`;

    let allSelected = true;
    const selectAllButton = document.createElement('button');
    selectAllButton.textContent = 'Select All';
    selectAllButton.style = `padding: 5px 10px; background-color: #5bc0de; border: none;
        color: white; cursor: pointer; border-radius: 5px;`;
    selectAllButton.onclick = () => {
      listContainer.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
        checkbox.checked = allSelected;
      });
      selectAllButton.textContent = allSelected ? 'Deselect All' : 'Select All';
      allSelected = !allSelected;
      console.log(
        allSelected
          ? 'Select All clicked: all checkboxes selected.'
          : 'Deselect All clicked: all checkboxes deselected.'
      );
    };
    buttonContainer.appendChild(selectAllButton);

    const confirmButton = document.createElement('button');
    confirmButton.textContent = 'Confirm Selection';
    confirmButton.style = `padding: 5px 10px; background-color: #4caf50;
            border: none; color: white; cursor: pointer; border-radius: 5px;`;
    confirmButton.onclick = () => {
      selectedLinks = Array.from(listContainer.querySelectorAll('input:checked')).map(
        (input) => input.value
      );
      console.log('Confirmed selected links:', selectedLinks);
      document.body.removeChild(popup);
      if (selectedLinks.length > 0) {
        startCategorization();
      } else {
        alert('No pages selected.');
      }
    };

    const cancelPopupButton = document.createElement('button');
    cancelPopupButton.textContent = 'Cancel';
    cancelPopupButton.style = `padding: 5px 10px; background-color: #d9534f;
            border: none; color: white; cursor: pointer; border-radius: 5px;`;
    cancelPopupButton.onclick = () => {
      console.log('Popup canceled.');
      document.body.removeChild(popup);
    };

    buttonContainer.appendChild(confirmButton);
    buttonContainer.appendChild(cancelPopupButton);
    popup.appendChild(buttonContainer);

    document.body.appendChild(popup);
  }

  function startCategorization() {
    console.log('Starting categorization process.');
    isCancelled = false;
    isRunning = true;
    currentIndex = 0;
    document.getElementById('progress-bar-container').style.display = 'block';
    fetchCsrfToken(() => processNextPage());
  }

  function processNextPage() {
    if (isCancelled || currentIndex >= selectedLinks.length) {
      console.log('Categorization ended. Reason:', isCancelled ? 'Cancelled' : 'Completed');
      isRunning = false;
      if (!isCancelled) {
        displayCompletionSummary(); // Show summary popup
      }
      resetUI();
      return;
    }

    const pageTitle = selectedLinks[currentIndex];
    updateProgressBar(`Processing: ${pageTitle}`);
    console.log(`Processing page: ${pageTitle}`);
    addCategoryToPage(pageTitle, () => {
      currentIndex++;
      updateProgressBar(`Processed: ${pageTitle}`);
      setTimeout(processNextPage, requestInterval);
    });
  }

  function addCategoryToPage(pageTitle, callback) {
    const categories = []; // Collects all categories from paginated responses

    // Function to standardize category names for comparison
    function standardizeCategoryName(name) {
      return name
        .replace(/^Category:/, '') // Remove prefix "Category:"
        .replace(/\s+/g, '_') // Replace spaces with underscores
        .toLowerCase(); // Convert to lowercase for case-insensitive comparison
    }

    // Recursive function to handle pagination
    function fetchCategories(clcontinue) {
      const apiUrl = `https://oldschool.runescape.wiki/api.php?action=query&prop=categories&titles=${encodeURIComponent(pageTitle)}&format=json${clcontinue ? `&clcontinue=${clcontinue}` : ''}`;
      console.log(
        `Checking categories for page: ${pageTitle}${clcontinue ? ` with clcontinue: ${clcontinue}` : ''}`
      );

      GM_xmlhttpRequest({
        method: 'GET',
        url: apiUrl,
        onload(response) {
          const responseJson = JSON.parse(response.responseText);
          const page = responseJson.query.pages[Object.keys(responseJson.query.pages)[0]];

          // Append the categories from this response to the categories list
          if (page.categories) {
            categories.push(...page.categories.map((cat) => cat.title));
          }

          // Check if more categories need to be fetched (pagination)
          if (responseJson.continue && responseJson.continue.clcontinue) {
            fetchCategories(responseJson.continue.clcontinue); // Fetch next page
          } else {
            // All categories have been fetched
            console.log(`All categories for '${pageTitle}':`, categories);

            // Exit if the page has no categories
            if (categories.length === 0) {
              console.log(`Skipped: '${pageTitle}' has no existing categories.`);
              actionLog.push(`Skipped: '${pageTitle}' has no existing categories.`);
              callback();
              return;
            }

            // Standardize target category name
            const standardizedCategoryName = standardizeCategoryName(`Category:${categoryName}`);

            // Check if page is already categorized
            const alreadyCategorized = categories.some((cat) => {
              return standardizeCategoryName(cat) === standardizedCategoryName;
            });

            if (alreadyCategorized) {
              console.log(`Page '${pageTitle}' is already in the category.`);
              actionLog.push(`Skipped: '${pageTitle}' already in '${categoryName}'`);
              callback();
            } else {
              const editUrl = 'https://oldschool.runescape.wiki/api.php';
              const formData = new URLSearchParams();
              formData.append('action', 'edit');
              formData.append('title', pageTitle);
              formData.append('appendtext', `\n[[Category:${categoryName}]]`);
              formData.append('token', csrfToken);
              formData.append('format', 'json');

              GM_xmlhttpRequest({
                method: 'POST',
                url: editUrl,
                headers: {
                  'Content-Type': 'application/x-www-form-urlencoded',
                },
                data: formData.toString(),
                onload(response) {
                  if (response.status === 200) {
                    actionLog.push(`Added: '${pageTitle}' to '${categoryName}'`);
                    console.log(`Successfully added '${pageTitle}' to category '${categoryName}'.`);
                    callback();
                  } else {
                    console.log(`Failed to add '${pageTitle}' to category.`);
                    callback();
                  }
                },
              });
            }
          }
        },
      });
    }

    // Start fetching categories (pagination will handle all pages)
    fetchCategories();
  }

  function fetchCsrfToken(callback) {
    const apiUrl =
      'https://oldschool.runescape.wiki/api.php?action=query&meta=tokens&type=csrf&format=json';
    GM_xmlhttpRequest({
      method: 'GET',
      url: apiUrl,
      onload(response) {
        const responseJson = JSON.parse(response.responseText);
        csrfToken = responseJson.query.tokens.csrftoken;
        console.log('CSRF token fetched:', csrfToken);
        callback();
      },
    });
  }

  function updateProgressBar(status) {
    const progressBar = document.getElementById('progress-bar');
    const progressText = document.getElementById('progress-text');
    const progress = (currentIndex / selectedLinks.length) * 100;
    progressBar.style.width = `${progress}%`;
    progressText.textContent = `${Math.round(progress)}% - ${status}`;
  }

  function displayCompletionSummary() {
    console.log('Displaying completion summary.');
    const summaryPopup = document.createElement('div');
    summaryPopup.style = `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
        z-index: 1001; background-color: #2b2b2b; padding: 15px; color: white;
        border-radius: 8px; max-height: 80vh; overflow-y: auto; border: 1px solid #595959;`;

    const title = document.createElement('h3');
    title.textContent = 'Categorization Summary';
    title.style = `margin: 0 0 10px; font-family: Arial, sans-serif;`;
    summaryPopup.appendChild(title);

    const logList = document.createElement('ul');
    logList.style = 'max-height: 300px; overflow-y: auto;';

    actionLog.forEach((entry) => {
      const listItem = document.createElement('li');
      listItem.textContent = entry;
      logList.appendChild(listItem);
    });

    summaryPopup.appendChild(logList);

    const closeButton = document.createElement('button');
    closeButton.textContent = 'Close';
    closeButton.style = `margin-top: 10px; padding: 5px 10px; background-color: #4caf50;
            border: none; color: white; cursor: pointer; border-radius: 5px;`;
    closeButton.onclick = () => {
      document.body.removeChild(summaryPopup);
      actionLog = [];
    };

    summaryPopup.appendChild(closeButton);
    document.body.appendChild(summaryPopup);
  }

  function resetUI() {
    document.getElementById('progress-bar').style.width = '0%';
    document.getElementById('progress-text').textContent = '';
    document.getElementById('progress-bar-container').style.display = 'none';
    isRunning = false;
  }

  function cancelCategorization() {
    console.log('Categorization cancelled by user.');
    isCancelled = true;
  }

  addButtonAndProgressBar();
})();