User:Harej/citation-watchlist.js

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.
/**
 *
 * Citation Watchlist
 * https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
 *
 */

(function() {
  'use strict';

  /**
   * ==========================================================================
   * LOCALIZATION
   * ==========================================================================
   */


  /* Labeling tool in the user interface */
  const toolName              =  'Citation Watchlist';
  const aboutLink             =  'https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist';

  /* Revision inspection popup */
  const addedLabel            =  'Added';
  const removedLabel          =  'Removed';
  const viewDiffLabel         =  'View Diff';
  const viewRevisionLabel     =  'View Revision';
  const aboutLabel            =  'About Citation Watchlist';

  /* Filter widget */
  const showEditsLabel        =  'Show edits:';
  const loadingLabel          =  'Loading...';
  const allLabel              =  'All';
  const linkChangesLabel      =  'Link Changes';
  const linkChangesTooltip    =  'Edits that add or remove links in articles';
  const noMatchingEditsLabel  =  'No matching edits';


  /**
   * ==========================================================================
   * DOMAIN LIST CONFIGURATION
   * ==========================================================================
   *
   * Citation Watchlist requires the following wiki pages to function:
   *
   * 1. Public Suffix List
   *    - A local copy of the public suffix list, used for domain parsing.
   *    - Copy the contents of:
   *        https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Public_Suffix_List
   *      to a page on your own wiki.
   *    - Update the `publicSuffixList` variable below to reflect your page title.
   *
   * 2. List of Lists
   *    - A page linking to one or more domain list pages.
   *    - Format as a bullet list: "* [[Page Title]]" (space after asterisk).
   *    - Reference formatting example:
   *        https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Lists
   *    - Update the `listOfLists` variable below accordingly.
   *
   * 3+. Domain List Pages
   *    - One or more pages listing suspicious or noteworthy domains.
   *    - Each page must contain section headers that match the `indicators` config
   *      below (e.g., "==Warn==", "==Caution==").
   *    - Under each section, list domains in the format: "* example.com"
   *    - Do not use link formatting—just plain text.
   */


  const publicSuffixList       =  'Wikipedia:Citation_Watchlist/Public_Suffix_List';
  const listOfLists            =  'Wikipedia:Citation_Watchlist/Lists';
  const enabledNamespaces      =  [-1, 0, 118];
  const enabledSectionHeaders  =  ['References'];

  /**
   * ==========================================================================
   * INDICATOR CONFIGURATION
   * ==========================================================================
   *
   * Defines metadata for domain indicators used in the watchlist UI.
   * Each indicator is associated with a level of urgency and a unique symbol.
   *
   * Fields:
   * - msg:      Display label for the level (e.g., "Warning", "Caution").
   * - emoji:    Unicode character for the visual indicator (escaped as `\uXXXX`).
   * - section:  Must exactly match the section headers in the domain list pages.
   * - priority: Higher values override lower ones for conflicting domain matches.
   *             Priority scale: 1 (lowest) to N (highest).
   * - list:     Defined as "new Set()" for all indicator types.
   *
   * If a domain appears in multiple lists, the one with the highest priority
   * takes precedence.
   */


  const indicators = {
    warning: {
      msg:          'Warning',
      emoji:        '\u2757',
      section:      '==Warn==',
      explanation:  'This domain is classified as an unreliable source',
      priority:     3,
      dataAttr:     'data-cw-linkwarn',
      list:         new Set()
    },
    caution: {
      msg:          'Caution',
      emoji:        '\u270B',
      section:      '==Caution==',
      explanation:  'This source may be unreliable depending on context',
      priority:     2,
      dataAttr:     'data-cw-linkcaution',
      list:         new Set()
    },
    inspect: {
      msg:          'Inspect',
      emoji:        '\uD83D\uDD0E',
      section:      '==Inspect==',
      explanation:  'Take a closer look at this particular source',
      priority:     1,
      dataAttr:     'data-cw-linkinspect',
      list:         new Set()
    },
    added: {
      msg:          addedLabel,
      emoji:        '\u{1F517}',
      section:      null,
      explanation:  'All added links',
      priority:     0,
      list:         new Set()
    },
    removed: {
      msg:          removedLabel,
      emoji:        '\u{1F5D1}',
      section:      null,
      explanation:  'All removed links',
      priority:     -1,
      list:         new Set()
    }
  };


  /**
   * Citation Watchlist
   *
   * Highlights potentially questionable citations added in Wikipedia revisions
   * based on community-built lists of domain names
   *
   *
   * Documentation: https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
   *
   * Author: James Hare
   * License: GNU General Public License v3.0 (GPL-3.0)
   *
   * @version 2.4
   * @since 2026-05-13
   */



  /**
   * IF YOU WANT TO MODIFY any part of the script below this point, please submit
   * your edits to https://test.wikipedia.org/wiki/User:Harej/citation-watchlist.js
   * so that your modifications can be tested.
   */


  let publicSuffixSet = new Set();
  const MAX_DOMAINS_PER_LIST = 50000;
  const MIN_PSL_ENTRIES = 1000;

  /**
   * ==========================================================================
   * 1. Primary Workflow
   * ==========================================================================
   */

  /**
   * Main entry point for Citation Watchlist.
   * Determines if the current page should be analyzed, fetches domain and suffix
   * lists, processes each change/revision in the recent changes or history page,
   * and triggers analysis to highlight questionable domains.
   */
  async function analyzeView() {
    purgeExpiredCache();
    purgeStaleVersionedCaches();
    const ns = mw.config.get('wgNamespaceNumber');
    const inspectEnabled = enabledNamespaces.includes(ns);

    // Only fetch heavy resources if we will actually inspect entries
    if (inspectEnabled) {
      publicSuffixSet = await fetchPublicSuffixList();
      if (publicSuffixSet.size === 0) {
        console.error('Public Suffix List loading failed');
        return;
      }
      console.log('Welcome to Citation Watchlist');
      const listPages = await fetchDomainListPages(listOfLists);
      if (listPages) {
        const lists = await fetchAndOrganizeDomainLists(listPages);
        if (lists) {
          for (const type in indicators) {
            lists[type].list.forEach(indicators[type].list.add, indicators[type].list);
          }
        }
      }
    }

    // Article surface: scan enabled sections for watchlisted domains
    const action = mw.config.get('wgAction');
    if (action === 'view' && ns >= 0 && inspectEnabled) {
      await analyzeArticleSections();
      return;
    }

    const entriesContainers = document.querySelectorAll('.mw-changeslist-links');

    // Pre-pad change log lines immediately on Special:RecentChanges for visual stability
    if (ns === -1) {
      const lines = document.querySelectorAll('li.mw-changeslist-line, .mw-changeslist-line');
      lines.forEach(line => {
        try {
          renderTwoColumnEmojis(line, {}, [], []);
        } catch (e) {
          /* ignore */
        }
      });
      flushGutterPadding();
    }

    if (!inspectEnabled) {
      return;
    }
    createFilterWidget();
    let noLinks = true;
    let usedLineMode = false;

    // Prefer iterating per-change rows when available (JS-enhanced watchlist/RC)
    const changeLines = document.querySelectorAll(
      '[data-mw-revid]:not(.mw-userlink), .mw-changeslist-line');
    if (changeLines.length > 0) {
      usedLineMode = true;
      for (const line of changeLines) {
        // Pre-pad immediately to avoid layout shift
        try {
          renderTwoColumnEmojis(line, {}, [], []);
        } catch (e) {
          /* ignore */
        }
      }
      flushGutterPadding();
      for (const line of changeLines) {
        const result = await extractRevisionFromLinks(line, line, null);
        if (result.hasLink) noLinks = false;
        if (result.skip) continue;
        if (result.revision) {
          await analyzeRevision(result.revision);
        }
      }
    }

    // Fallback path (non-enhanced layouts): iterate link containers
    if (!usedLineMode) {
      for (const container of entriesContainers) {
        const result = await extractRevisionFromLinks(container, container, (_el, link) => {
          const target = link.parentNode.parentNode;
          // Pre-pad immediately
          renderTwoColumnEmojis(target, {}, [], []);
          flushGutterPadding();
          return target;
        });
        if (result.hasLink) noLinks = false;
        if (result.skip) continue;
        if (result.revision) {
          await analyzeRevision(result.revision);
        }
      }
    }

    // If no links were found, extract the first revision ID
    if (noLinks === true) {
      const pageTitle = mw.config.get('wgTitle');
      const firstID = await fetchFirstRevisionId(pageTitle);
      const revision = {
        oldrevision: firstID,
        element: (usedLineMode ? changeLines[0] : entriesContainers[0]),
        viewHref: buildPermalinkUrl(firstID),
        viewLabel: viewRevisionLabel
      };
      await analyzeRevision(revision);
    }

  }

  /**
   * Scans the article page for sections whose H2 headers match enabledSectionHeaders,
   * then analyzes each matching section for watchlisted domains.
   */
  async function analyzeArticleSections() {
    const headings = document.querySelectorAll('.mw-heading2 h2, h2');
    for (const h2 of headings) {
      const headerText = h2.textContent.trim();
      if (!enabledSectionHeaders.includes(headerText)) continue;

      // Collect all content between this heading and the next heading
      const headingContainer = h2.closest('.mw-heading') || h2;
      let sibling = headingContainer.nextElementSibling;
      const sectionElements = [];
      while (sibling && !sibling.classList.contains('mw-heading')) {
        sectionElements.push(sibling);
        sibling = sibling.nextElementSibling;
      }

      await analyzeSection(headerText, sectionElements);

      // Insert filter widget before the section content
      createArticleFilterWidget(headingContainer);
    }
  }

  /**
   * Analyzes a single article section: extracts URLs from each <li>, screens them
   * against domain lists, and prepends indicator icons to matching list items.
   *
   * @param {string} sectionName - The section header text (e.g. "References").
   * @param {Element[]} sectionElements - DOM elements between this heading and the next.
   */
  async function analyzeSection(sectionName, sectionElements) {
    const revisionId = mw.config.get('wgRevisionId');
    const wikiDomain = location.hostname;
    const cacheKey = `sectionURLs:${wikiDomain}:${revisionId}:${sectionName}`;
    const oneMonth = 30 * 24 * 60 * 60 * 1000;

    // Map: array of { liId, urls }
    let liURLMap = null;

    // Try cache
    const cached = localStorage.getItem(cacheKey);
    if (cached) {
      try {
        const parsed = JSON.parse(cached);
        if (Date.now() - parsed.timestamp < oneMonth) {
          liURLMap = parsed.liURLMap;
        }
      } catch (e) { /* refetch */ }
    }

    // Build from DOM if not cached
    if (!liURLMap) {
      liURLMap = [];
      for (const el of sectionElements) {
        const listItems = el.querySelectorAll('li[id^="cite_note"]');
        for (const li of listItems) {
          const urls = [];
          const links = li.querySelectorAll('a.external[href]');
          for (const a of links) {
            const href = a.getAttribute('href');
            if (/^https?:\/\//i.test(href)) {
              urls.push(href);
            }
          }
          liURLMap.push({ liId: li.id, urls });
        }
      }
      try {
        localStorage.setItem(cacheKey, JSON.stringify({
          timestamp: Date.now(),
          liURLMap
        }));
      } catch (e) { /* ignore */ }
    }

    // Add padding to all list items so citation text is flush with icons
    for (const el of sectionElements) {
      const allItems = el.querySelectorAll('li[id^="cite_note"]');
      for (const li of allItems) {
        li.style.paddingLeft = '1.5em';
      }
    }

    // Screen each li's URLs against domain lists and prepend icons with popups
    for (const entry of liURLMap) {
      const li = document.getElementById(entry.liId);
      if (!li) continue;

      let highestIndicator = null;
      const matchedURLs = []; // { url, domain, type }

      for (const url of entry.urls) {
        try {
          const hostname = new URL(url).hostname;
          const domain = getRootDomain(hostname, publicSuffixSet);
          let urlIndicator = null;
          for (const type in indicators) {
            if (type === 'removed' || type === 'added') continue;
            if (indicators[type].list.has(domain)) {
              if (!urlIndicator ||
                indicators[type].priority > indicators[urlIndicator].priority) {
                urlIndicator = type;
              }
            }
          }
          if (urlIndicator) {
            matchedURLs.push({ url, domain, type: urlIndicator });
            if (!highestIndicator ||
              indicators[urlIndicator].priority > indicators[highestIndicator].priority) {
              highestIndicator = urlIndicator;
            }
          }
        } catch (e) { /* skip */ }
      }

      if (highestIndicator) {
        const wrapper = document.createElement('span');
        wrapper.style.position = 'relative';
        wrapper.style.cursor = 'pointer';
        wrapper.style.marginLeft = '-1.5em';
        wrapper.style.display = 'inline-block';
        wrapper.style.width = '1.5em';

        const icon = document.createElement('span');
        icon.textContent = indicators[highestIndicator].emoji;
        wrapper.appendChild(icon);

        // Build popup
        const popup = createPopupElement();
        popup.appendChild(createCloseButton(popup));

        const contentWrap = document.createElement('div');
        contentWrap.className = 'cw-popup-content';
        contentWrap.style.display = 'flex';
        contentWrap.style.flexDirection = 'column';
        contentWrap.style.rowGap = '2px';

        for (const m of matchedURLs) {
          const row = document.createElement('span');
          row.style.whiteSpace = 'nowrap';
          row.style.display = 'inline-block';
          const label = document.createElement('span');
          label.textContent = indicators[m.type].msg + ':';
          label.title = indicators[m.type].explanation;
          label.setAttribute('aria-label',
            `${indicators[m.type].msg}: ${indicators[m.type].explanation}`
          );
          label.style.fontWeight = 'bold';
          label.style.textDecoration = 'underline';
          label.style.textDecorationStyle = 'dotted';
          label.style.cursor = 'help';
          row.appendChild(label);
          row.appendChild(document.createTextNode('\u00A0'));
          const a = document.createElement('a');
          a.href = m.url;
          a.textContent = m.domain;
          a.rel = 'nofollow noopener noreferrer';
          a.target = '_blank';
          a.style.whiteSpace = 'nowrap';
          row.appendChild(a);
          contentWrap.appendChild(row);
        }
        popup.appendChild(contentWrap);

        // Footer
        popup.appendChild(createPopupFooter());

        wrapper.appendChild(popup);
        wrapper._cwEnablePopup = true;
        installPopupHandlers(wrapper);

        li.prepend(wrapper);
      }

      if (highestIndicator) {
        li.dataset.cwIndicator = highestIndicator;
      }
    }
  }

  /**
   * Creates and inserts a filter widget for the article surface, placed after
   * the section heading. Excludes the "Link Changes" option.
   *
   * @param {Element} headingContainer - The heading element to insert the widget after.
   */
  function createArticleFilterWidget(headingContainer) {
    const indicatorFilters = Object.entries(indicators)
      .filter(([, v]) => v.priority > 0)
      .sort((a, b) => b[1].priority - a[1].priority)
      .map(([id, v]) => ({
        id,
        label: `${v.emoji} ${v.msg}`,
        tooltip: v.explanation
      }));

    const container = buildFilterWidgetContainer({
      containerId: 'cw-article-filter-widget',
      radioName: 'cw-article-filter',
      filters: [
        { id: 'all', label: allLabel, checked: true },
        ...indicatorFilters
      ],
      onFilter: applyArticleFilter,
      initRadio: (radio, filter, wrapper) => {
        if (filter.id !== 'all') {
          const hasResults = !!document.querySelector(
            `li[id^="cite_note"][data-cw-indicator="${filter.id}"]`
          );
          if (!hasResults) {
            radio.disabled = true;
            wrapper.style.cursor = 'default';
            wrapper.style.color = 'gray';
          }
        }
      }
    });

    headingContainer.after(container);
  }

  /**
   * Applies the selected filter to article section list items.
   *
   * @param {string} filterId - The ID of the filter to apply
   *   ('all', or an indicator type like 'warning', 'caution', 'inspect').
   */
  function applyArticleFilter(filterId) {
    const items = document.querySelectorAll('li[id^="cite_note"]');
    items.forEach(li => {
      if (filterId === 'all') {
        li.style.display = '';
        return;
      }
      const indicator = li.dataset.cwIndicator;
      li.style.display = (indicator === filterId) ? '' : 'none';
    });
  }

  /**
   * Extracts revision information from diff/hist/prev/cur links within a container.
   *
   * @param {Element} container - The DOM element to query for revision links.
   * @param {Element} element - The DOM element to attach to the revision object.
   * @param {Function|null} prePadCallback - Optional callback invoked with (element, link) before
   *     processing each branch. In fallback mode this derives the target element and pre-pads it.
   * @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
   *     - revision: the extracted revision object, or null if no usable link was found.
   *     - hasLink: true if any recognized link was present (even if skipped).
   *     - skip: true if the entry should be skipped (non-article after pre-padding).
   */
  async function extractRevisionFromLinks(container, element, prePadCallback) {
    const diffLink = container.querySelector('a.mw-changeslist-diff');
    const histLink = container.querySelector('a.mw-changeslist-history');
    const prevLink = container.querySelector('a.mw-history-histlinks-previous');
    const curLink = container.querySelector('a.mw-history-histlinks-current');

    let revision = null;
    let hasLink = false;
    let skip = false;

    if (diffLink) {
      hasLink = true;
      const diffUrl = new URL(diffLink.href);
      const urlParams = new URLSearchParams(diffUrl.search);
      const pageTitle = urlParams.get('title');
      const target = prePadCallback ? prePadCallback(element, diffLink) : element;
      if (isNotArticle(pageTitle)) {
        skip = true;
        return {
          revision,
          hasLink,
          skip
        };
      }
      // MediaWiki convention: diff = newer, oldid = older
      const diffParam = urlParams.get('diff');
      const oldidParam = urlParams.get('oldid');
      let oldId = oldidParam;
      let newId = diffParam;
      // Handle diff=prev case (e.g., contributions): oldid is current, need previous
      if (diffParam === 'prev' && oldidParam) {
        const previousRevisionMap = await fetchPreviousRevisionIds([oldidParam]);
        const prevId = previousRevisionMap[oldidParam];
        if (prevId) {
          oldId = prevId;
          newId = oldidParam;
        }
      }
      revision = {
        oldrevision: oldId,
        newrevision: newId,
        element: target,
        viewHref: diffLink.href,
        viewLabel: viewDiffLabel,
        pageTitle
      };
    } else if (histLink) {
      hasLink = true;
      const histUrl = new URL(histLink.href);
      const urlParams = new URLSearchParams(histUrl.search);
      const pageTitle = urlParams.get('title');
      const target = prePadCallback ? prePadCallback(element, histLink) : element;
      if (isNotArticle(pageTitle)) {
        skip = true;
        return {
          revision,
          hasLink,
          skip
        };
      }
      const firstID = await fetchFirstRevisionId(pageTitle);
      if (!firstID) {
        return {
          revision,
          hasLink,
          skip
        };
      }
      revision = {
        oldrevision: firstID,
        element: target,
        viewHref: buildPermalinkUrl(firstID),
        viewLabel: viewRevisionLabel,
        pageTitle
      };
    } else if (prevLink) {
      hasLink = true;
      const urlParams = new URL(prevLink.href).searchParams;
      const target = prePadCallback ? prePadCallback(element, prevLink) : element;
      const currentId = urlParams.get('oldid');
      const previousRevisionMap = await fetchPreviousRevisionIds([currentId]);
      const prevId = previousRevisionMap[currentId];
      {
        let titleForDiff = '';
        try {
          titleForDiff = new URL(prevLink.href).searchParams.get('title') || '';
        } catch (e) {}
        revision = {
          oldrevision: prevId || '',
          newrevision: currentId || '',
          element: target,
          viewHref: (titleForDiff && prevId && currentId) ? buildDiffUrl(titleForDiff,
            prevId, currentId) : '',
          viewLabel: viewDiffLabel,
          pageTitle: titleForDiff || undefined
        };
      }
    } else if (curLink) {
      hasLink = true;
      const urlParams = new URL(curLink.href).searchParams;
      const target = prePadCallback ? prePadCallback(element, curLink) : element;
      revision = {
        oldrevision: urlParams.get('oldid'),
        element: target,
        viewHref: buildPermalinkUrl(urlParams.get('oldid')),
        viewLabel: viewRevisionLabel
      };
    }

    return {
      revision,
      hasLink,
      skip
    };
  }

  /**
   * Analyzes a revision (or a pair of revisions) for newly added URLs,
   * compares them against domain watchlists, and highlights matches.
   *
   * @param {Object} revision - Object containing oldrevision, optional
   *   newrevision, and DOM element.
   */
  async function analyzeRevision(revision) {
    const lookup = [revision.oldrevision];
    if (revision.newrevision) {
      lookup.push(revision.newrevision);
    }
    const wikiDomain = location.hostname;
    const cacheKey =
      `revisionDiff:${wikiDomain}:${revision.oldrevision}:${revision.newrevision || 'null'}`;
    const oneMonth = 30 * 24 * 60 * 60 * 1000;
    let addedURLs = [];
    let removedURLs = [];

    // Try reading from cache
    const cached = localStorage.getItem(cacheKey);
    if (cached) {
      try {
        const parsed = JSON.parse(cached);
        const age = Date.now() - parsed.timestamp;
        if (age < oneMonth) {
          console.log(`Cache hit for revision ${cacheKey}`);
          const isValidCachedURL = (u) => {
            try {
              return /^https?:\/\//i.test(new URL(u).href);
            } catch {
              return false;
            }
          };
          if (Array.isArray(parsed.addedURLs)) {
            addedURLs = parsed.addedURLs.filter(isValidCachedURL);
          }
          if (Array.isArray(parsed.removedURLs)) {
            removedURLs = parsed.removedURLs.filter(isValidCachedURL);
          }
        }
      } catch (e) {
        console.warn('Cache parse error, refetching:', e);
      }
    }

    // If not cached, fetch and process
    if (addedURLs.length === 0 && removedURLs.length === 0) {
      const wikitext = await fetchRevisionContent(lookup);
      const fromList = extractURLs(wikitext.oldrevision) || [];
      const toList = extractURLs(wikitext.newrevision) || [];

      const count = arr => arr.reduce((m, u) => {
        m[u] = (m[u] || 0) + 1;
        return m;
      }, {});
      const fromMap = count(fromList);
      const toMap = count(toList);

      if (revision.newrevision) {
        // Multiset difference: new - old => added; old - new => removed
        addedURLs = [];
        removedURLs = [];
        const allKeys = new Set([...Object.keys(fromMap), ...Object.keys(toMap)]);
        for (const key of allKeys) {
          const addCount = Math.max(0, (toMap[key] || 0) - (fromMap[key] || 0));
          const remCount = Math.max(0, (fromMap[key] || 0) - (toMap[key] || 0));
          for (let i = 0; i < addCount; i++) addedURLs.push(key);
          for (let i = 0; i < remCount; i++) removedURLs.push(key);
        }
      } else {
        // For first revision, all URLs are considered added
        addedURLs = fromList.slice();
        removedURLs = [];
      }
      try {
        localStorage.setItem(cacheKey, JSON.stringify({
          timestamp: Date.now(),
          addedURLs,
          removedURLs
        }));
      } catch (e) {
        console.warn('Failed to store cache:', e);
      }
    }
    console.log(`Added URLs: ${addedURLs.join(' ')}
  Removed URLs: ${removedURLs.join(' ')}`);

    const matchedDomains = Object.keys(indicators).reduce((acc, key) => {
      acc[key] = [];
      return acc;
    }, {});

    const targetElement = revision.element.closest('[data-mw-revid]:not(.mw-userlink)') ||
      revision.element.closest('li') || revision.element.closest('.mw-changeslist-line, tr') ||
      revision.element;
    targetElement.setAttribute('data-cw-processed', 'true');

    // Process removed URLs first - these always get the "removed" indicator
    if (removedURLs.length > 0) {
      const removedDomains = [];
      for (const url of removedURLs) {
        try {
          const hostname = new URL(url).hostname;
          const domain = getRootDomain(hostname, publicSuffixSet);
          if (!removedDomains.includes(domain)) {
            removedDomains.push(domain);
          }
        } catch (e) {
          console.warn(`Error processing removed URL ${url}:`, e);
        }
      }
      matchedDomains.removed = removedDomains;
    }

    // Process added URLs
    // Always track domains for the generic "added" indicator
    if (addedURLs.length > 0) {
      const addedDomains = [];
      for (const url of addedURLs) {
        try {
          const hostname = new URL(url).hostname;
          const domain = getRootDomain(hostname, publicSuffixSet);
          if (!addedDomains.includes(domain)) {
            addedDomains.push(domain);
          }
        } catch (e) {
          console.warn(`Error processing added URL ${url}:`, e);
        }
      }
      matchedDomains.added = addedDomains;
    }

    for (const url of addedURLs) {
      try {
        const hostname = new URL(url).hostname;
        const domain = getRootDomain(hostname, publicSuffixSet);
        let highestPriorityType = null;
        for (const type in indicators) {
          if (type !== 'removed' && type !== 'added' && indicators[type].list.has(domain)) {
            if (
              highestPriorityType === null ||
              indicators[type].priority > indicators[highestPriorityType].priority
            ) {
              highestPriorityType = type;
            }
          }
        }
        if (
          highestPriorityType !== null &&
          !matchedDomains[highestPriorityType].includes(domain)
        ) {
          matchedDomains[highestPriorityType].push(domain);
          // Remove this domain from lower priority lists
          for (const type in indicators) {
            if (
              type !== 'removed' &&
              type !== 'added' && // Never remove from "removed" or "added" lists
              indicators[type].priority < indicators[highestPriorityType].priority
            ) {
              matchedDomains[type] = matchedDomains[type].filter(d => d !== domain);
            }
          }
        }
      } catch (e) {
        console.warn(`Error processing added URL ${url}:`, e);
      }
    }

    try {
      if (revision && revision.element) {
        if (revision.viewHref) {
          revision.element.dataset.cwViewHref = revision.viewHref;
        }
        if (revision.viewLabel) {
          revision.element.dataset.cwViewLabel = revision.viewLabel;
        }
      }
    } catch (e) {
      /* ignore */
    }
    // Render two-column emojis: left = addition (highest-priority), right = removal (bin)
    renderTwoColumnEmojis(revision.element, matchedDomains, addedURLs, removedURLs);
    flushGutterPadding();

    if (targetElement) {
      const allChangedDomains = new Set([
        ...(matchedDomains.added || []),
        ...(matchedDomains.removed || [])
      ]);
      if (allChangedDomains.size > 0) {
        targetElement.dataset.cwLinkchange = Array.from(allChangedDomains).sort().join('|');
      }

      for (const [type, config] of Object.entries(indicators)) {
        if (!config.dataAttr) continue;
        const dataAttr = config.dataAttr;
        const domains = new Set();
        if (matchedDomains[type]) {
          matchedDomains[type].forEach(d => domains.add(d));
        }
        for (const url of removedURLs) {
          try {
            const hostname = new URL(url).hostname;
            const domain = getRootDomain(hostname, publicSuffixSet);
            if (indicators[type].list.has(domain)) {
              domains.add(domain);
            }
          } catch (e) {
            /* ignore */
          }
        }
        if (domains.size > 0) {
          targetElement.setAttribute(dataAttr, Array.from(domains).sort().join('|'));
        }
      }
    }
  }

  /**
   * ==========================================================================
   * 2. User Interface
   * ==========================================================================
   */

  /**
   * Pending hosts that need gutter padding applied. Collected during
   * renderTwoColumnEmojis and flushed in a single batch via flushGutterPadding()
   * so that all rows shift at the same time instead of one at a time.
   */
  const pendingGutterPadding = [];

  /**
   * Applies the gutter padding to a single host element, reserving horizontal
   * space for the two-column emoji gutter.
   *
   * @param {HTMLElement} host - The element to pad.
   */
  function applyGutterPadding(host) {
    const pad = 'calc(2em + 0.5em)';
    try {
      const cs = window.getComputedStyle(host);
      const existing = parseFloat(cs.paddingLeft || '0');
      const fontSizePx = parseFloat(cs.fontSize || '16');
      const desiredPx = 2.5 * fontSizePx; // 2em + 0.5em
      if (isNaN(existing) || existing < desiredPx - 0.5) {
        host.style.paddingLeft = pad;
      }
    } catch (e) {
      host.style.paddingLeft = pad;
    }
  }

  /**
   * Applies gutter padding to all collected hosts at once, then clears the
   * queue. Call this after a batch of renderTwoColumnEmojis calls to avoid
   * one-at-a-time layout shifts.
   */
  function flushGutterPadding() {
    for (const host of pendingGutterPadding) {
      applyGutterPadding(host);
    }
    pendingGutterPadding.length = 0;
  }

  /**
   * Renders exactly two fixed-width emoji columns before a revision entry:
   * - Left column: highest-priority addition indicator
   *   (warning/caution/inspect/added), or spacer if none.
   * - Right column: removal indicator if removals exist, otherwise spacer.
   *
   * This preserves consistent alignment even when no links are added/removed.
   *
   * @param {HTMLElement} element - The revision list DOM element.
   * @param {Object} matchedDomains - Map from indicator type to array of domains.
   */
  function renderTwoColumnEmojis(element, matchedDomains, addedURLs = [], removedURLs = []) {
    if (!element) return;
    const hasPopupContent = (Array.isArray(addedURLs) && addedURLs.length > 0) || (Array.isArray(
      removedURLs) && removedURLs.length > 0);

    // Find the best container representing the whole entry line.
    const container =
      element.closest('[data-mw-revid]:not(.mw-userlink)') ||
      element.closest('li') ||
      element.closest('.mw-changeslist-line, tr') || element.parentNode || element;
    if (!container) {
      return;
    }

    const makeSpan = (char) => {
      const span = document.createElement('span');
      span.style.display = 'inline-block';
      span.style.width = '1em';
      span.style.textAlign = 'center';
      span.style.userSelect = 'none';
      span.textContent = char;
      return span;
    };

    // Determine highest-priority addition indicator present
    const additionCandidates = Object.entries(indicators)
      .filter(([, v]) => v.priority >= 0)
      .map(([id]) => id);
    let chosenAddType = null;
    for (const t of additionCandidates) {
      if (matchedDomains[t] && matchedDomains[t].length > 0) {
        if (!chosenAddType || indicators[t].priority > indicators[chosenAddType].priority) {
          chosenAddType = t;
        }
      }
    }

    // Build addition span (left column)
    let addSpan;
    if (chosenAddType) {
      const emoji = indicators[chosenAddType].emoji;
      addSpan = makeSpan(emoji);
    } else {
      addSpan = makeSpan('\u00A0');
    }

    // Build removal span (right column)
    let remSpan;
    if (matchedDomains.removed && matchedDomains.removed.length > 0) {
      const emoji = indicators.removed.emoji;
      remSpan = makeSpan(emoji);
    } else {
      remSpan = makeSpan('\u00A0');
    }

    // Determine the best element to host a left gutter: prefer the
    // list container or, for table rows, the first cell
    let host = container;
    if (host.tagName && host.tagName.toUpperCase() === 'TR') {
      // For nested enhanced RC rows, place the gutter in the content cell so
      // icons align with singleton rows that use .mw-changeslist-line-inner.
      const nestedContent = host.querySelector('td.mw-enhanced-rc-nested[data-target-page]');
      host = nestedContent || host.querySelector('td, th') || host;
    }
    // Prefer inner change-line wrappers on Watchlist/RC if present
    const innerHost = host.querySelector('.mw-changeslist-line-inner, .mw-changeslist-line-main');
    if (innerHost) {
      host = innerHost;
    }

    if (!host) {
      return;
    }

    // Propagate dataset for view link from source element to host
    try {
      if (element && element.dataset) {
        if (element.dataset.cwViewHref) host.dataset.cwViewHref = element.dataset.cwViewHref;
        if (element.dataset.cwViewLabel) host.dataset.cwViewLabel = element.dataset.cwViewLabel;
      }
    } catch (e) {
      /* ignore */
    }

    const buildPopupContent = (popupEl) => {
      const toPairs = (urls) => {
        const out = [];
        for (const u of urls || []) {
          try {
            const hostname = new URL(u).hostname;
            const domain = getRootDomain(hostname, publicSuffixSet);
            out.push({
              domain,
              url: u
            });
          } catch (e) {
            // ignore parsing errors
          }
        }
        return out;
      };
      const addPairs = toPairs(addedURLs);
      const remPairs = toPairs(removedURLs);

      const priorityIndicatorFor = (domain) => {
        let chosen = null;
        for (const t of Object.keys(indicators).filter(k => indicators[k].priority > 0)) {
          if (indicators[t].list.has(domain)) {
            if (!chosen || indicators[t].priority > indicators[chosen].priority) {
              chosen = t;
            }
          }
        }
        return chosen; // may be null
      };

      // Build line nodes as two grid columns: label and content with dot-separated links
      const makeLine = (labelText, pairs) => {
        if (!pairs || pairs.length === 0) return null;
        const label = document.createElement('strong');
        const emojiPrefix = (labelText === addedLabel) ? indicators.added.emoji :
          indicators.removed.emoji;
        const labelExplanation = (labelText === addedLabel) ? indicators.added
          .explanation : indicators.removed.explanation;
        label.title = labelExplanation;
        label.setAttribute('aria-label', `${labelText}: ${labelExplanation}`);
        label.textContent = `${emojiPrefix}\u00A0${labelText}:`;
        label.style.alignSelf = 'start';
        label.style.justifySelf = 'end';
        label.style.textAlign = 'right';
        label.style.whiteSpace = 'nowrap';

        const content = document.createElement('div');
        content.style.display = 'inline';

        pairs.forEach((p, idx) => {
          const item = document.createElement('span');
          item.style.whiteSpace = 'nowrap';
          item.style.display = 'inline-block';
          const chosenKey = priorityIndicatorFor(p.domain);
          if (chosenKey) {
            const pre = document.createElement('span');
            pre.textContent = indicators[chosenKey].emoji +
              '\u00A0'; // emoji plus NBSP
            pre.title = indicators[chosenKey].explanation;
            pre.setAttribute('aria-label',
              `${indicators[chosenKey].msg}: ${indicators[chosenKey].explanation}`
            );
            pre.className = 'cw-emoji';
            item.appendChild(pre);
          }
          const a = document.createElement('a');
          a.href = p.url;
          a.textContent = p.domain;
          a.rel = 'nofollow noopener noreferrer';
          a.target = '_blank';
          a.style.whiteSpace = 'nowrap';
          item.appendChild(a);
          content.appendChild(item);
          if (idx < pairs.length - 1) {
            const sep = document.createElement('span');
            sep.textContent = ' • ';
            content.appendChild(sep);
          }
        });

        return {
          label,
          content
        };
      };

      // Ensure a persistent close button and a dedicated content container
      if (!popupEl.querySelector('.cw-close')) {
        popupEl.appendChild(createCloseButton(popupEl));
      }

      // Ensure content wrapper exists
      let contentWrap = popupEl.querySelector('.cw-popup-content');
      if (!contentWrap) {
        contentWrap = document.createElement('div');
        contentWrap.className = 'cw-popup-content';
        popupEl.appendChild(contentWrap);
      }
      // Apply grid layout so labels are flush-aligned with content
      contentWrap.style.display = 'grid';
      contentWrap.style.gridTemplateColumns = 'max-content 1fr';
      contentWrap.style.columnGap = '0.5em';
      contentWrap.style.rowGap = '2px';

      // Clear and build content only
      contentWrap.innerHTML = '';
      const addedLine = makeLine(addedLabel, addPairs);
      const removedLine = makeLine(removedLabel, remPairs);
      if (addedLine) {
        contentWrap.appendChild(addedLine.label);
        contentWrap.appendChild(addedLine.content);
      }
      if (removedLine) {
        contentWrap.appendChild(removedLine.label);
        contentWrap.appendChild(removedLine.content);
      }

      // Footer
      const footer = popupEl.querySelector('.cw-popup-footer');
      if (footer) {
        footer.remove();
      }
      popupEl.appendChild(createPopupFooter({
        viewHref: host.dataset.cwViewHref || '',
        viewLabel: host.dataset.cwViewLabel || ''
      }));
      // If both lines are absent, do not render anything; popup will not be created/shown upstream
    };

    // Ensure a single gutter exists, then update/create popup
    const existingGutter = host.querySelector('.cw-gutter');
    if (existingGutter) {
      const addNode = existingGutter.querySelector('.cw-add');
      const remNode = existingGutter.querySelector('.cw-rem');
      if (addNode) {
        addNode.textContent = (chosenAddType ? indicators[chosenAddType].emoji : '\u00A0');
      }
      if (remNode) {
        const hasRem = matchedDomains.removed && matchedDomains.removed.length > 0;
        remNode.textContent = (hasRem ? indicators.removed.emoji : '\u00A0');
      }
      // Update popup content if exists
      let popup = existingGutter.querySelector('.cw-popup');
      if (hasPopupContent) {
        if (!popup) {
          popup = createPopupElement();
          existingGutter.appendChild(popup);
        } else {
          popup.style.fontSize = '0.92em';
          popup.style.padding = '8px 26px 8px 10px';
        }
        buildPopupContent(popup);
        installPopupHandlers(existingGutter);
        existingGutter._cwEnablePopup = true;
      } else {
        if (popup) {
          popup.style.display = 'none';
        }
        existingGutter._cwEnablePopup = false;
      }

      host.setAttribute('data-processed-twocol', 'true');
      return;
    }

    // Ensure the host can anchor absolutely positioned children
    const prevPos = (host.style && host.style.position) || '';
    if (!prevPos || prevPos === '') {
      host.style.position = 'relative';
    }

    // Create a left gutter that does not participate in text wrapping
    const gutter = document.createElement('span');
    gutter.className = 'cw-gutter';
    gutter.style.position = 'absolute';
    gutter.style.left = '0';
    gutter.style.top = '0';
    gutter.style.display = 'inline-flex';
    gutter.style.gap = '0.25em';
    gutter.style.width = 'calc(2em + 0.25em)';
    gutter.style.alignItems = 'flex-start';
    gutter.style.userSelect = 'none';

    // Tag spans for future updates
    addSpan.className = 'cw-add';
    remSpan.className = 'cw-rem';

    // Append the two emoji spans into the gutter
    gutter.appendChild(addSpan);
    gutter.appendChild(remSpan);

    // Create the popup element only if there is content
    if (hasPopupContent) {
      const popup = createPopupElement();
      buildPopupContent(popup);
      gutter.appendChild(popup);
      gutter._cwEnablePopup = true;
    } else {
      gutter._cwEnablePopup = false;
    }

    // Insert the gutter into the host; padding is deferred to flushGutterPadding()
    host.insertBefore(gutter, host.firstChild);

    // Queue padding so all rows shift at the same time
    pendingGutterPadding.push(host);

    // Install interaction handlers for hover/tap (only if there is popup content)
    if (hasPopupContent) {
      installPopupHandlers(gutter);
    }

    host.setAttribute('data-processed-twocol', 'true');
  }

  /**
   * Prepends an emoji and tooltip to a revision list entry DOM element if any
   * domains matched a warning list.
   *
   * @param {HTMLElement} element - The container element to prepend the emoji to.
   * @param {string} type - The type of indicator ('warning', 'caution', 'inspect').
   * @param {string[]} domains - The list of matched domains for the indicator.
   */
  function prependEmojiWithTooltip(element, type, domains) {
    const indicator = indicators[type];
    if (!indicator || element.getAttribute(`data-processed-${type}`) === 'true') {
      return;
    }
    const emojiSpan = document.createElement('span');
    emojiSpan.textContent = indicator.emoji + ' ';
    emojiSpan.title = `${indicator.msg}: ${domains.join(', ')}`;
    element.parentNode.insertBefore(emojiSpan, element);
    element.setAttribute(`data-processed-${type}`, 'true');
  }

  /**
   * Creates a styled popup element for displaying domain information.
   *
   * @returns {HTMLDivElement} The configured popup element.
   */
  function createPopupElement() {
    const popup = document.createElement('div');
    popup.className = 'mwe-popups cw-popup';
    popup.style.position = 'absolute';
    popup.style.minWidth = '260px';
    popup.style.maxWidth = '420px';
    popup.style.zIndex = '1000';
    popup.style.display = 'none';
    popup.style.background = 'var(--background-color, #fff)';
    popup.style.border = '1px solid rgba(0,0,0,.15)';
    popup.style.boxShadow = '0 2px 8px rgba(0,0,0,.2)';
    popup.style.padding = '8px 26px 8px 10px';
    popup.style.borderRadius = '4px';
    popup.style.fontSize = '0.92em';
    return popup;
  }

  /**
   * Creates a styled close button for popups.
   *
   * @param {HTMLElement} popupEl - The popup element to hide when the button is clicked.
   * @returns {HTMLButtonElement} The configured close button.
   */
  function createCloseButton(popupEl) {
    const closeBtn = document.createElement('button');
    closeBtn.className = 'cw-close';
    closeBtn.setAttribute('type', 'button');
    closeBtn.setAttribute('aria-label', 'Close');
    closeBtn.title = 'Close';
    closeBtn.textContent = '\u00D7';
    closeBtn.style.position = 'absolute';
    closeBtn.style.top = '4px';
    closeBtn.style.right = '6px';
    closeBtn.style.border = 'none';
    closeBtn.style.background = 'transparent';
    closeBtn.style.cursor = 'pointer';
    closeBtn.style.fontSize = '14px';
    closeBtn.style.lineHeight = '1';
    closeBtn.style.padding = '0';
    closeBtn.style.margin = '0';
    closeBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      e.preventDefault();
      popupEl.style.display = 'none';
    });
    return closeBtn;
  }

  /**
   * Creates a popup footer element with an optional primary link and the
   * "About Citation Watchlist" link.
   *
   * @param {Object} [options] - Optional configuration.
   * @param {string} [options.viewHref] - URL for the primary link (e.g., diff or revision).
   * @param {string} [options.viewLabel] - Label text for the primary link.
   * @returns {HTMLDivElement} The configured footer element.
   */
  function createPopupFooter(options) {
    const footer = document.createElement('div');
    footer.className = 'cw-popup-footer';
    footer.style.marginTop = '10px';
    footer.style.color = 'inherit';

    const emWrap = document.createElement('span');
    if (options && options.viewHref && options.viewLabel &&
        /^https?:\/\/|^\//.test(options.viewHref)) {
      const strong = document.createElement('strong');
      const viewA = document.createElement('a');
      viewA.href = options.viewHref;
      viewA.textContent = options.viewLabel;
      viewA.target = '_blank';
      viewA.rel = 'nofollow noopener noreferrer';
      strong.appendChild(viewA);
      emWrap.appendChild(strong);
      const sep = document.createElement('span');
      sep.textContent = ' • ';
      emWrap.appendChild(sep);
    }

    const linkCW = document.createElement('a');
    linkCW.href = aboutLink;
    linkCW.textContent = aboutLabel;
    linkCW.target = '_blank';
    linkCW.rel = 'nofollow noopener noreferrer';
    emWrap.appendChild(linkCW);
    footer.appendChild(emWrap);
    return footer;
  }

  /**
   * Builds the shared filter widget container with radio buttons and an about link.
   *
   * @param {Object} config - Widget configuration.
   * @param {string} config.containerId - The ID for the container element.
   * @param {string} config.radioName - The name attribute for the radio button group.
   * @param {Array<Object>} config.filters - Array of filter option objects
   *   ({id, label, checked?, tooltip?}).
   * @param {Function} config.onFilter - Callback invoked with the selected filter ID.
   * @param {Function} [config.initRadio] - Optional callback to customize each radio
   *   after creation, receiving (radio, filter).
   * @returns {HTMLDivElement} The configured filter widget container.
   */
  function buildFilterWidgetContainer(config) {
    const container = document.createElement('div');
    container.id = config.containerId;
    container.style.display = 'flex';
    container.style.alignItems = 'center';
    container.style.justifyContent = 'space-between';
    container.style.border = '1px solid black';
    container.style.borderRadius = '25px';
    container.style.padding = '10px 20px';
    container.style.margin = '10px 0';
    container.style.width = 'fit-content';

    const leftSection = document.createElement('div');
    leftSection.style.display = 'flex';
    leftSection.style.alignItems = 'center';
    leftSection.style.gap = '15px';

    const label = document.createElement('span');
    label.textContent = config.initialLabel || showEditsLabel;
    label.style.fontWeight = '500';
    if (config.labelClass) {
      label.classList.add(config.labelClass);
    }
    leftSection.appendChild(label);

    config.filters.forEach(filter => {
      const wrapper = document.createElement('label');
      wrapper.style.display = 'flex';
      wrapper.style.alignItems = 'center';
      wrapper.style.gap = '5px';
      wrapper.style.cursor = 'pointer';

      const radio = document.createElement('input');
      radio.type = 'radio';
      radio.name = config.radioName;
      radio.value = filter.id;
      radio.checked = !!filter.checked;
      radio.style.margin = '0';

      if (config.initRadio) {
        config.initRadio(radio, filter, wrapper);
      }

      const text = document.createElement('span');
      text.textContent = filter.label;
      if (filter.tooltip) {
        text.title = filter.tooltip;
        text.style.borderBottom = '1px dotted';
        text.style.cursor = 'help';
      }

      wrapper.appendChild(radio);
      wrapper.appendChild(text);
      leftSection.appendChild(wrapper);

      radio.addEventListener('change', () => {
        if (radio.checked) {
          config.onFilter(filter.id);
        }
      });
    });

    container.appendChild(leftSection);

    const rightSection = document.createElement('div');
    rightSection.style.marginLeft = '4em';
    const link = document.createElement('a');
    link.href = aboutLink;
    link.textContent = toolName;
    link.style.textDecoration = 'underline';
    link.style.color = '#0645ad';
    rightSection.appendChild(link);
    container.appendChild(rightSection);

    return container;
  }

  /**
   * Installs hover, click, touch, and outside-click handlers on a gutter element
   * to show/hide its popup.
   *
   * @param {HTMLElement} gutter - The gutter element containing a .cw-popup child.
   */
  function installPopupHandlers(gutter) {
    if (gutter._cwHandlersInstalled) return;
    let hideTimer = null;
    const positionPopup = () => {
      const p = gutter.querySelector('.cw-popup');
      if (!gutter._cwEnablePopup || !p) return;
      p.style.display = 'block';
      p.style.top = '1.4em';
      p.style.left = '0px';
      const rect = p.getBoundingClientRect();
      if (rect.right > window.innerWidth - 10) {
        const overflow = rect.right - (window.innerWidth - 10);
        p.style.left = Math.max(0, (parseFloat(p.style.left) || 0) - overflow) + 'px';
      }
    };
    const show = () => {
      if (!gutter._cwEnablePopup) return;
      if (hideTimer) {
        clearTimeout(hideTimer);
        hideTimer = null;
      }
      positionPopup();
    };
    const scheduleHide = () => {
      hideTimer = setTimeout(() => {
        const p = gutter.querySelector('.cw-popup');
        if (p) p.style.display = 'none';
      }, 150);
    };

    gutter.addEventListener('mouseenter', show);
    gutter.addEventListener('mouseleave', scheduleHide);
    const pInit = gutter.querySelector('.cw-popup');
    if (pInit) {
      pInit.addEventListener('mouseenter', () => {
        if (hideTimer) {
          clearTimeout(hideTimer);
          hideTimer = null;
        }
      });
      pInit.addEventListener('mouseleave', scheduleHide);
    }
    const toggle = (e) => {
      if (!gutter._cwEnablePopup) return;
      e.stopPropagation();
      const p = gutter.querySelector('.cw-popup');
      if (!p) return;
      if (p.style.display === 'none' || p.style.display === '') {
        show();
      } else {
        p.style.display = 'none';
      }
    };
    gutter.addEventListener('click', toggle);
    gutter.addEventListener('touchstart', toggle, {
      passive: true
    });
    document.addEventListener('click', (ev) => {
      const p = gutter.querySelector('.cw-popup');
      if (p && !gutter.contains(ev.target)) {
        p.style.display = 'none';
      }
    });
    gutter._cwHandlersInstalled = true;
  }

  const enabledNsStrings = new Set(enabledNamespaces.filter(n => n >= 0).map(String));
  const namespaces = Object.entries(mw.config.get('wgFormattedNamespaces'))
    .filter(([num, name]) => num !== '0' && !enabledNsStrings.has(num))
    .map(([_, name]) => name.replace(/ /g, '_') + ':');

  /**
   * Creates and inserts the Citation Watchlist filtering widget before specified elements.
   *
   * @returns {HTMLElement|null} The created filter container or null if not created.
   */
  function createFilterWidget() {
    const ns = mw.config.get('wgNamespaceNumber');
    const pageName = mw.config.get('wgPageName');
    let target = document.querySelector('.mw-changeslist');

    if (!target) {
      target = document.getElementById('mw-history-compare');
    }

    if (!target && ns === -1 && pageName.startsWith('Special:Contributions')) {
      target = document.querySelector('.mw-pager-body');
    }

    if (!target) {
      return null;
    }

    const indicatorFilters = Object.entries(indicators)
      .filter(([, v]) => v.priority > 0)
      .sort((a, b) => b[1].priority - a[1].priority)
      .map(([id, v]) => ({
        id,
        label: `${v.emoji} ${v.msg}`,
        tooltip: v.explanation
      }));

    const container = buildFilterWidgetContainer({
      containerId: 'cw-filter-widget',
      radioName: 'cw-filter',
      filters: [
        { id: 'all', label: allLabel, checked: true },
        { id: 'linkchange', label: linkChangesLabel, tooltip: linkChangesTooltip },
        ...indicatorFilters
      ],
      onFilter: applyFilter,
      labelClass: 'cw-filter-label',
      initialLabel: loadingLabel,
      initRadio: (radio) => {
        radio.disabled = true; // Disabled until annotation is complete
      }
    });

    target.parentNode.insertBefore(container, target);
    return container;
  }

  /**
   * Applies the selected filter to the revision list.
   *
   * @param {string} filterId - The ID of the filter to apply
   *   ('all', 'linkchange', 'warning', 'caution', 'inspect').
   */
  let lastAppliedFilter = 'all';

  function applyFilter(filterId) {
    // Remove any existing "no matching edits" message before applying the new filter
    const existingMsg = document.getElementById('cw-no-matching-edits');
    if (existingMsg) existingMsg.remove();

    const revisions = document.querySelectorAll(
      '[data-mw-revid]:not(.mw-userlink), [data-mw-logid]'
    );
    revisions.forEach(container => {
      container.style.display = ''; // Reset

      if (filterId === 'all') {
        return;
      }

      let shouldShow = false;

      // Log entries (data-mw-logid) don't have revision-based attributes to
      // match against filters — hide them when any specific filter is active.
      if (!container.hasAttribute('data-mw-revid')) {
        container.style.display = 'none';
        return;
      }
      shouldShow = elementMatchesFilter(container, filterId);

      // If the container itself doesn't match, check descendant <li> elements
      // (e.g., on enhanced Watchlist/RC where individual revisions are nested
      // inside a grouped parent that also carries data-cw-processed)
      if (!shouldShow) {
        const nestedItems = container.querySelectorAll('li[data-cw-processed="true"]');
        for (const nested of nestedItems) {
          if (elementMatchesFilter(nested, filterId)) {
            shouldShow = true;
            break;
          }
        }
      }

      if (!shouldShow) {
        container.style.display = 'none';
      }
    });

    // Hide h4 date headers when revision rows are hidden
    document.querySelectorAll('div.mw-changeslist h4').forEach(h4 => {
      if (filterId === 'all') {
        h4.style.display = '';
        return;
      }
      const sibling = h4.nextElementSibling;
      if (!sibling || sibling.tagName !== 'DIV') return;
      const revItems = sibling.querySelectorAll('[data-mw-revid]');
      if (revItems.length === 0) return;
      const allHidden = Array.from(revItems).every(el => el.style.display === 'none');
      h4.style.display = allHidden ? 'none' : '';
    });

    // For enhanced RC, hide the first tr (group header) in each tbody if all
    // other tr elements are hidden; restore it when the filter is reset.
    if (document.querySelector('table.mw-enhanced-rc')) {
      document.querySelectorAll('table.mw-enhanced-rc tbody').forEach(tbody => {
        const rows = tbody.querySelectorAll('tr');
        if (rows.length < 2) return;
        const headerRow = rows[0];
        if (filterId === 'all') {
          headerRow.style.display = '';
          return;
        }
        const allOthersHidden = Array.from(rows).slice(1).every(
          tr => tr.style.display === 'none'
        );
        headerRow.style.display = allOthersHidden ? 'none' : '';
      });
    }

    // Expand or collapse grouped revision sections (enhanced RC/Watchlist).
    // When a specific filter is active, expand all collapsed groups so that
    // matching nested revisions are visible. Restore collapsed state for "all".
    // Only run on pages that actually have enhanced recent changes.
    if (!document.querySelector('table.mw-enhanced-rc')) {
      const allRevisions = document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)');
      const allHidden = allRevisions.length > 0 && Array.from(allRevisions).every(el => el.style
        .display === 'none');
      if (allHidden) {
        const msg = document.createElement('p');
        msg.id = 'cw-no-matching-edits';
        msg.textContent = noMatchingEditsLabel;
        msg.style.margin = '10px 0';
        msg.style.fontStyle = 'italic';
        const widget = document.getElementById('cw-filter-widget');
        if (widget && widget.nextSibling) {
          widget.parentNode.insertBefore(msg, widget.nextSibling);
        }
      }
      lastAppliedFilter = filterId;
      return;
    }
    // Only expand/collapse when transitioning between "all" and a specific
    // filter.  Switching between two non-all filters should leave the
    // expanded state untouched.
    const wasAll = lastAppliedFilter === 'all';
    const isAll = filterId === 'all';

    if (wasAll !== isAll) {
      const groupedSections = document.querySelectorAll('table.mw-enhanced-rc');
      groupedSections.forEach(table => {
        const arrow = table.querySelector('.mw-enhancedchanges-arrow');
        if (!arrow) return;
        const checkboxId = arrow.getAttribute('for');
        const checkbox = checkboxId ? document.getElementById(checkboxId) : null;
        if (!checkbox) return;
        if (isAll) {
          // Collapse back if currently open
          if (checkbox.checked) {
            checkbox.checked = false;
            checkbox.dispatchEvent(new Event('change'));
          }
        } else {
          // Expand if currently collapsed
          if (!checkbox.checked) {
            checkbox.checked = true;
            checkbox.dispatchEvent(new Event('change'));
          }
        }
      });
    }

    // Show a "No matching edits" message if all revid elements are hidden
    const allRevisions = document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)');
    const allHidden = allRevisions.length > 0 && Array.from(allRevisions).every(el => el.style
      .display === 'none');
    if (allHidden) {
      const msg = document.createElement('p');
      msg.id = 'cw-no-matching-edits';
      msg.textContent = noMatchingEditsLabel;
      msg.style.margin = '10px 0';
      msg.style.fontStyle = 'italic';
      const widget = document.getElementById('cw-filter-widget');
      if (widget && widget.nextSibling) {
        widget.parentNode.insertBefore(msg, widget.nextSibling);
      }
    }

    lastAppliedFilter = filterId;
  }

  /**
   * ==========================================================================
   * 3. Builders and Extractors
   * ==========================================================================
   */

  /**
   * Constructs a URL for viewing a diff between two revisions.
   *
   * @param {string} title - The page title.
   * @param {string} fromId - The older revision ID.
   * @param {string} toId - The newer revision ID.
   * @returns {string} The diff URL, or empty string on error.
   */
  function buildDiffUrl(title, fromId, toId) {
    try {
      const script = mw.config.get('wgScript') || '/w/index.php';
      return `${script}` +
        `?title=${encodeURIComponent(title)}` +
        `&diff=${encodeURIComponent(toId)}` +
        `&oldid=${encodeURIComponent(fromId)}`;
    } catch (e) {
      return '';
    }
  }

  /**
   * Constructs a permalink URL for a specific revision.
   *
   * @param {string} revId - The revision ID.
   * @returns {string} The permalink URL, or empty string on error.
   */
  function buildPermalinkUrl(revId) {
    try {
      const articlePath = mw.config.get('wgArticlePath');
      if (articlePath && articlePath.includes('$1')) {
        return articlePath.replace('$1', `Special:Permalink/${revId}`);
      }
      const script = mw.config.get('wgScript') || '/w/index.php';
      return `${script}?oldid=${encodeURIComponent(revId)}`;
    } catch (e) {
      return '';
    }
  }

  /**
   * Strips all HTML comments from a string.
   *
   * @param {string} text - Input text potentially containing HTML comments.
   * @returns {string} Text with all HTML comments removed.
   */
  function stripHTMLComments(text) {
    if (!text) return text;
    return text.replace(/<!--[\s\S]*?-->/g, '');
  }

  /**
   * Extracts all HTTP(S) URLs from a given wikitext string.
   *
   * @param {string} wikitext - Raw wikitext revision content.
   * @returns {string[]} List of valid extracted URLs.
   */
  function extractURLs(wikitext) {
    const urls = [];
    if (!wikitext) return urls;

    const urlRegex = // eslint-disable-next-line max-len
      /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/g;
    let match;
    while ((match = urlRegex.exec(wikitext)) !== null) {
      try {
        const url = new URL(match[0]);
        urls.push(url.href);
      } catch (error) {
        console.error(`Invalid URL rejected: ${match[0]}`, error.message);
      }
    }
    return urls;
  }

  /**
   * Extracts the top-level domain from a full hostname using a public suffix set.
   *
   * @param {string} hostname - Full hostname (e.g., sub.example.co.uk).
   * @param {Set<string>} publicSuffixSet - Set of known public suffixes.
   * @returns {string} The top-level domain (e.g., example.co.uk).
   */
  function getRootDomain(hostname, publicSuffixSet) {
    // Handle empty or invalid hostnames
    if (!hostname || typeof hostname !== 'string') {
      console.warn('Invalid hostname provided to getRootDomain:', hostname);
      return '';
    }

    // Check if this is an IP address (simple check for IPv4)
    if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
      return hostname; // Return IP addresses as-is
    }

    const domainParts = hostname.split('.');

    // Handle hostnames that are too short
    if (domainParts.length < 2) {
      return hostname; // Return as-is if it's a single-part hostname
    }

    // Try to find a matching public suffix
    for (let i = 0; i < domainParts.length; i++) {
      const candidate = domainParts.slice(i).join('.');

      // Check both normal and exception (prefixed with !) entries
      if (publicSuffixSet.has(candidate) || publicSuffixSet.has(`!${candidate}`)) {
        // If we found a match, return the domain part plus the public suffix
        // But make sure we don't go out of bounds
        if (i > 0) {
          return domainParts.slice(i - 1).join('.');
        } else {
          // Edge case: the entire hostname is a public suffix
          return hostname;
        }
      }
    }

    // If no match in public suffix list, use a simple fallback:
    // For hostnames with 2 parts, return the whole thing
    // For hostnames with >2 parts, return the last 2 parts
    if (domainParts.length === 2) {
      return hostname;
    } else {
      return domainParts.slice(-2).join('.');
    }
  }

  /**
   * Extracts the first page object from MediaWiki API query response.
   *
   * @param {Object} data - MediaWiki API response.
   * @returns {Object|null} The first page object or null if unavailable.
   */
  async function getFirstPage(data) {
    if (!data || !data.query || !data.query.pages) return null;
    const pages = data.query.pages;
    return Object.values(pages)[0]; // Return the first page
  }

  /**
   * Retrieves the first revision from a page object.
   *
   * @param {Object} page - Page object containing revisions.
   * @returns {Object|null} First revision object or null.
   */
  async function getFirstRevision(page) {
    if (page.revisions && page.revisions.length > 0) {
      return page.revisions[0];
    }
    return null;
  }

  /**
   * ==========================================================================
   * 4. Validation
   * ==========================================================================
   */

  /**
   * Validates that a string is a well-formed domain name.
   *
   * @param {string} str - The string to validate.
   * @returns {boolean} True if the string is a valid domain.
   */
  function isValidDomain(str) {
    if (!str || str.length === 0 || str.length > 253) return false;
    if (!/^[a-zA-Z0-9._-]+$/.test(str)) return false;
    if (!str.includes('.')) return false;
    if (/\.\./.test(str) || str.startsWith('.') || str.endsWith('.')) return false;
    const labels = str.split('.');
    if (labels.some(l => l.length === 0 || l.length > 63)) return false;
    return true;
  }

  /**
   * Validates that a string is a well-formed public suffix list entry.
   * Allows wildcard entries (e.g., *.ck) and exception entries (e.g., !www.ck).
   *
   * @param {string} str - The string to validate.
   * @returns {boolean} True if the string is a valid PSL entry.
   */
  function isValidSuffixEntry(str) {
    const cleaned = str.replace(/^[!*.]/, '');
    return /^[a-zA-Z0-9._-]+$/.test(cleaned) && cleaned.length > 0 && cleaned.length <= 253;
  }

  /**
   * Validates that a string is a plausible MediaWiki page title.
   * Rejects titles with invalid characters, excessive length, or suspicious patterns.
   *
   * @param {string} title - The page title to validate.
   * @returns {boolean} True if the title looks like a valid page link.
   */
  function isValidPageTitle(title) {
    if (!title || title.length === 0 || title.length > 255) return false;
    if (/[\[\]{}|#<>\n\r\t]/.test(title)) return false;
    if (title.includes('..')) return false;
    if (title.startsWith(':') || title.startsWith(' ') || title.endsWith(' ')) return false;
    return true;
  }

  /**
   * Determines whether a given page title does *not* belong to the main or draft namespaces.
   *
   * @param {string} pageTitle - The title of the page.
   * @returns {boolean} True if not an article namespace.
   */
  function isNotArticle(pageTitle) {
    return namespaces.some(namespace => pageTitle.startsWith(namespace));
  }

  /**
   * Checks whether an element matches the specified filter.
   *
   * @param {Element} element - The DOM element to test.
   * @param {string} filterId - The active filter ID.
   * @returns {boolean} True if the element matches the filter.
   */
  function elementMatchesFilter(element, filterId) {
    if (filterId === 'linkchange') {
      return element.hasAttribute('data-cw-linkchange') ||
        Object.values(indicators).some(config => config.dataAttr && element.hasAttribute(config
          .dataAttr));
    }
    if (indicators[filterId] && indicators[filterId].dataAttr) {
      return element.hasAttribute(indicators[filterId].dataAttr);
    }
    return false;
  }

  /**
   * ==========================================================================
   * 5. Fetch
   * ==========================================================================
   */

  /**
   * Fetches wikitext content for one or two revisions by ID.
   *
   * @param {string[]} revIds - Array of revision IDs.
   * @returns {Object} Object with `oldrevision` and optionally `newrevision` as wikitext strings.
   */
  async function fetchRevisionContent(revIds) {
    const data = await fetchRevisionData({
      revids: revIds,
      rvprop: ['content'],
      rvslots: ['main']
    });
    const page = await getFirstPage(data);
    const wikitext = {
      oldrevision: null,
      newrevision: null
    };
    if (page.revisions && page.revisions.length > 0) {
      wikitext.oldrevision = stripHTMLComments(page.revisions[0].slots.main['*']) || null;
      if (page.revisions.length > 1) {
        wikitext.newrevision = stripHTMLComments(page.revisions[1].slots.main['*']) || null;
      }
    }
    return wikitext;
  }

  /**
   * Fetches the parent revision IDs for a given list of revision IDs.
   *
   * @param {string[]} revisionIds - Array of revision IDs.
   * @returns {Object} Map of revision ID to its parent ID.
   */
  async function fetchPreviousRevisionIds(revisionIds) {
    const data = await fetchRevisionData({
      revids: revisionIds,
      rvprop: ['ids']
    });
    const page = await getFirstPage(data);
    if (!page) return {};
    const revisionMap = {};
    for (const revision of page.revisions) {
      revisionMap[revision.revid] = revision.parentid;
    }
    return revisionMap;
  }

  /**
   * Fetches the ID of the first revision of a page.
   *
   * @param {string} pageTitle - The page title to look up.
   * @returns {number|null} Revision ID or null.
   */
  async function fetchFirstRevisionId(pageTitle) {
    const data = await fetchRevisionData({
      titles: [pageTitle],
      rvlimit: 1,
      rvdir: 'newer',
      rvprop: ['ids'],
    });
    const page = await getFirstPage(data);
    if (!page) return null;
    const revision = await getFirstRevision(page);
    return revision ? revision.revid : null;
  }

  /**
   * Fetches the list of subpages from the list of lists, parses wikilinks, caches
   * the result, and returns list of subpage titles.
   *
   * @param {string} pageName - Title of the list-of-lists page.
   * @returns {Promise<string[]>} List of subpage titles.
   */
  async function fetchDomainListPages(pageName) {
    const cacheKey = `citationWatchlistFetchDomainListPages_${pageName}`;
    const cacheExpiration = 4 * 60 * 60 * 1000;
    const now = Date.now();
    const cachedData = localStorage.getItem(cacheKey);
    const cachedTimestamp = localStorage.getItem(`${cacheKey}_timestamp`);
    if (cachedData && cachedTimestamp && (now - parseInt(cachedTimestamp, 10)) <
      cacheExpiration) {
      console.log('Loaded list of lists from cache');
      return JSON.parse(cachedData).filter(title => isValidPageTitle(title));
    }
    const data = await fetchRevisionData({
      titles: [pageName],
      rvprop: ['content'],
      rvslots: ['*']
    });
    const page = await getFirstPage(data);
    if (!page) return [];
    const content = page.revisions[0].slots.main['*'];
    const pageTitles = [];
    const lines = content.split('\n');
    for (const line of lines) {
      if (line.startsWith('* [[')) {
        const match = line.match(
          /\[\[([^\]]+)\]\]/); // Matches the first instance of [[Page Title]]
        if (match) {
          const title = match[1];
          if (isValidPageTitle(title)) {
            pageTitles.push(title);
          } else {
            console.warn(`Rejected invalid page title: "${title}"`);
          }
        }
      }
    }
    localStorage.setItem(cacheKey, JSON.stringify(pageTitles));
    localStorage.setItem(`${cacheKey}_timestamp`, now.toString());
    console.log('Loaded from API and stored in cache');
    return pageTitles;
  }

  /**
   * Loads domain lists from a set of pages, categorizes them by indicator section
   * headers, and populates the corresponding `Set` in the global `indicators` object.
   *
   * @param {string[]} pageNames - List of page titles to fetch.
   * @returns {Object} Updated indicators object with domain sets.
   */
  async function fetchAndOrganizeDomainLists(pageNames) {
    const cacheTTL = 6 * 60 * 60 * 1000;
    const now = Date.now();
    const cachedData = {};
    const pagesToFetch = [];
    for (const title of pageNames) {
      const cacheKey = `domainList:${location.hostname}:${title}`;
      const cached = localStorage.getItem(cacheKey);
      if (cached) {
        try {
          const parsed = JSON.parse(cached);
          if (now - parsed.timestamp < cacheTTL && parsed.content) {
            console.log(`Using cached content for page: ${title}`);
            cachedData[title] = parsed.content;
            continue;
          } else {
            console.log(`Cache expired for page: ${title}`);
          }
        } catch (e) {
          console.warn(`Cache error for ${title}:`, e);
        }
      }
      console.log(`Will fetch page: ${title}`);
      pagesToFetch.push(title);
    }
    const fetchedPages = {};
    if (pagesToFetch.length > 0) {
      const apiData = await fetchRevisionData({
        titles: pagesToFetch,
        rvprop: ['content'],
        rvslots: ['*'],
      });
      const pages = apiData.query.pages;
      for (const pageId in pages) {
        const page = pages[pageId];
        const title = page.title;
        const content = page.revisions[0].slots.main['*'];
        fetchedPages[title] = content;
        const cacheKey = `domainList:${location.hostname}:${title}`;
        try {
          localStorage.setItem(cacheKey, JSON.stringify({
            timestamp: now,
            content,
          }));
          console.log(`Cached content for page: ${title}`);
        } catch (e) {
          console.warn(`Failed to cache ${title}:`, e);
        }
      }
    }
    const allContent = {
      ...cachedData,
      ...fetchedPages
    };
    for (const title in allContent) {
      const content = allContent[title];
      let currentList = null;
      const lines = content.split('\n');
      for (const line of lines) {
        for (const type in indicators) {
          if (line.trim() === indicators[type].section) {
            currentList = indicators[type].list;
            break;
          }
        }
        if (line.startsWith('*') && currentList) {
          const domain = line.substring(1).trim().toLowerCase();
          if (isValidDomain(domain)) {
            if (currentList.size < MAX_DOMAINS_PER_LIST) {
              currentList.add(domain);
            } else {
              console.warn('Domain list cap reached for current section');
            }
          } else if (domain) {
            console.warn(`Rejected invalid domain entry: "${domain}"`);
          }
        }
      }
    }
    return indicators;
  }

  /**
   * Fetches and caches the public suffix list used to identify top-level domains.
   *
   * @returns {Promise<Set<string>>} Set of public suffixes.
   */
  async function fetchPublicSuffixList() {
    const cacheKey = 'publicSuffixListCache';
    const cacheTTL = 24 * 60 * 60 * 1000;
    const cached = localStorage.getItem(cacheKey);
    if (cached) {
      try {
        const parsed = JSON.parse(cached);
        const age = Date.now() - parsed.timestamp;
        if (age < cacheTTL && parsed.content) {
          console.log('Using cached public suffix list');
          const cachedSet = new Set(parsed.content.split('\n').filter(line =>
            line.trim() && !line.trim().startsWith('//') && isValidSuffixEntry(
              line.trim())
          ).map(line => line.trim()));
          if (cachedSet.size < MIN_PSL_ENTRIES) {
            console.error(
              `Cached public suffix list suspiciously small (${cachedSet.size} entries), refetching`
            );
          } else {
            return cachedSet;
          }
        }
      } catch (e) {
        console.warn('Error parsing cache, refetching:', e);
      }
    }
    const pslUrl = mw.config.get('wgArticlePath').replace('$1', publicSuffixList) +
      '?action=raw';
    console.log(`Raw page text request: ${pslUrl}`);
    const content = await safeFetch(fetch, pslUrl).then(response => response ?
      response.text() : null);
    if (!content) return new Set();
    try {
      localStorage.setItem(cacheKey, JSON.stringify({
        timestamp: Date.now(),
        content
      }));
    } catch (e) {
      console.warn('Failed to write to cache:', e);
    }
    const suffixSet = new Set();
    const lines = content.split('\n');
    for (const line of lines) {
      const trimmed = line.trim();
      if (trimmed && !trimmed.startsWith('//')) {
        if (isValidSuffixEntry(trimmed)) {
          suffixSet.add(trimmed);
        }
      }
    }
    if (suffixSet.size < MIN_PSL_ENTRIES) {
      console.error(
        `Public suffix list suspiciously small (${suffixSet.size} entries), rejecting`);
      return new Set();
    }
    return suffixSet;
  }

  /**
   * Makes a MediaWiki API call to fetch revision metadata or content.
   *
   * @param {Object} data - Options for the API call, such as `revids`, `titles`, `rvprop`, etc.
   * @returns {Promise<Object>} MediaWiki API query result.
   */
  async function fetchRevisionData(data) {
    const paramKeys = ['rvprop', 'revids', 'titles', 'rvslots'];
    const params = {
      action: 'query',
      prop: 'revisions',
      format: 'json',
      rvdir: data.rvdir || 'older',
      origin: '*'
    };
    if (data.rvlimit) {
      params.rvlimit = data.rvlimit;
    }
    paramKeys.forEach(key => {
      if (data[key]) {
        params[key] = Array.isArray(data[key]) ? data[key].join('|') : data[key];
      }
    });
    const api = new mw.Api();
    return await safeFetch(api.get.bind(api), params);
  }

  /**
   * Wraps any asynchronous fetch function with retry logic and error handling.
   *
   * @param {Function} fn - The function to execute (usually an API call).
   * @param {...any} args - Arguments to pass to the fetch function.
   * @param {Object} options - Optional configuration for the fetch operation.
   * @param {number} options.retries - Number of retry attempts (default: 2).
   * @param {number} options.retryDelay - Delay between retries in ms (default: 1000).
   * @returns {Promise<any|null>} Result of the fetch or null on failure.
   */
  async function safeFetch(fn, ...args) {
    // Extract options if the last argument is an options object
    let options = {
      retries: 2,
      retryDelay: 1000
    };
    if (args.length > 0 && typeof args[args.length - 1] === 'object' && args[args.length - 1]
      ._isSafeFetchOptions) {
      options = {
        ...options,
        ...args.pop()
      };
    }

    let lastError = null;
    let attempt = 0;
    const maxAttempts = options.retries + 1;

    while (attempt < maxAttempts) {
      try {
        attempt++;
        const result = await fn(...args);

        if (result === null || result === undefined) {
          throw new Error('Received null or undefined response');
        }

        if (result && typeof result.ok === 'boolean' && !result.ok) {
          throw new Error(
            `HTTP error ${result.status}: ${result.statusText || 'Unknown error'}`);
        }

        return result;
      } catch (error) {
        lastError = error;

        if (attempt < maxAttempts) {
          console.warn(
            `Error during ${fn.name || 'fetch operation'} (attempt ${attempt}/${maxAttempts}):`,
            error.message || error);

          await new Promise(resolve => setTimeout(resolve, options.retryDelay));
        } else {

          console.error(
            `All ${maxAttempts} attempts failed for ${fn.name || 'fetch operation'}:`,
            error.message || error);
        }
      }
    }

    return null;
  }

  // Helper function to create options for safeFetch
  safeFetch.withOptions = function(retries, retryDelay) {
    return {
      retries: retries || 2,
      retryDelay: retryDelay || 1000,
      _isSafeFetchOptions: true
    };
  };

  /**
   * ==========================================================================
   * 6. Cache
   * ==========================================================================
   */

  /**
   * Cache version definitions for each cache type.
   * Bump a version number when the corresponding cache format or logic changes
   * to force invalidation of stale entries on next load.
   */
  const cacheVersions = {
    'revisionDiff:':                              1,
    'sectionURLs:':                               1,
    'domainList:':                                0,
    'publicSuffixListCache':                      0,
    'citationWatchlistFetchDomainListPages_':     0
  };

  /**
   * Purges cached entries whose stored version is older than the current version.
   * Each cache type tracks its own version independently via a localStorage key.
   */
  function purgeStaleVersionedCaches() {
    for (const [prefix, currentVersion] of Object.entries(cacheVersions)) {
      const versionKey = `citationWatchlist_cacheVersion:${prefix}`;
      const storedVersion = parseInt(localStorage.getItem(versionKey), 10);
      const effectiveVersion = isNaN(storedVersion) ? 0 : storedVersion;

      if (effectiveVersion < currentVersion) {
        for (let i = localStorage.length - 1; i >= 0; i--) {
          const key = localStorage.key(i);
          if (key && key.startsWith(prefix)) {
            localStorage.removeItem(key);
          }
        }
        localStorage.setItem(versionKey, currentVersion.toString());
        console.log(`Cache invalidated for "${prefix}": version ${effectiveVersion}${currentVersion}`);
      }
    }
  }

  /**
   * Cleans up expired localStorage cache entries based on known cache key prefixes and TTLs.
   */
  function purgeExpiredCache() {
    const now = Date.now();
    // Define cache configurations with their TTLs in milliseconds
    const knownCaches = [{
      prefix: 'revisionDiff:',
      ttl: 30 * 24 * 60 * 60 * 1000,
      description: 'Revision diff cache',
      version: 1
    },
    {
      prefix: 'sectionURLs:',
      ttl: 30 * 24 * 60 * 60 * 1000,
      description: 'Article section URL cache',
      version: 1
    },
      {
        prefix: 'domainList:',
        ttl: 6 * 60 * 60 * 1000,
        description: 'Domain list cache',
        version: 0
      },
      {
        prefix: 'publicSuffixListCache',
        ttl: 24 * 60 * 60 * 1000,
        description: 'Public suffix list cache',
        version: 0
      },
      {
        prefix: 'citationWatchlistFetchDomainListPages_',
        ttl: 4 * 60 * 60 * 1000,
        description: 'Domain list pages cache',
        version: 0
      }
    ];

    const stats = {
      checked: 0,
      expired: 0,
      errors: 0
    };

    try {
      for (let i = 0; i < localStorage.length; i++) {
        const key = localStorage.key(i);
        if (!key) continue; // Skip if key is null (shouldn't happen but being defensive)

        // Check if this key belongs to one of our known caches
        for (const cache of knownCaches) {
          if (key.startsWith(cache.prefix)) {
            stats.checked++;
            try {
              if (key.endsWith('_timestamp')) {
                // Handle paired key-timestamp entries
                const baseKey = key.replace(/_timestamp$/, '');
                const timestampStr = localStorage.getItem(key);

                if (!timestampStr) {
                  // Orphaned timestamp key without a value
                  localStorage.removeItem(key);
                  console.log(`Removed orphaned timestamp key: ${key}`);
                  stats.expired++;
                  continue;
                }

                const timestamp = parseInt(timestampStr, 10);
                if (isNaN(timestamp) || now - timestamp > cache.ttl) {
                  // Expired or invalid timestamp
                  localStorage.removeItem(key);

                  // Also remove the base key if it exists
                  if (localStorage.getItem(baseKey) !== null) {
                    localStorage.removeItem(baseKey);
                    console.log(`Purged expired ${cache.description}: ${baseKey}`);
                  } else {
                    console.log(
                      `Removed orphaned timestamp for missing key: ${baseKey}`);
                  }
                  stats.expired++;
                }
              } else {
                // Handle JSON entries with embedded timestamps
                const value = localStorage.getItem(key);
                if (!value) {
                  localStorage.removeItem(key);
                  console.log(`Removed empty cache entry: ${key}`);
                  stats.expired++;
                  continue;
                }

                try {
                  const parsed = JSON.parse(value);
                  if (parsed && parsed.timestamp && now - parsed.timestamp > cache
                    .ttl) {
                    // Expired based on embedded timestamp
                    localStorage.removeItem(key);
                    console.log(`Purged expired ${cache.description}: ${key}`);
                    stats.expired++;
                  }
                } catch (jsonError) {
                  // Invalid JSON, remove the entry
                  localStorage.removeItem(key);
                  console.warn(`Removed invalid JSON cache entry: ${key}`, jsonError
                    .message);
                  stats.errors++;
                  stats.expired++;
                }
              }
            } catch (itemError) {
              console.warn(`Error processing cache item ${key}:`, itemError.message);
              stats.errors++;
              // Try to remove problematic entries
              try {
                localStorage.removeItem(key);
                console.log(`Removed problematic cache entry: ${key}`);
                stats.expired++;
              } catch (removeError) {
                console.error(`Failed to remove problematic entry ${key}:`, removeError
                  .message);
              }
            }
            break;
          }
        }
      }

      if (stats.checked > 0) {
        console.log(
          `Cache cleanup complete: checked ${stats.checked} items, ` +
          `removed ${stats.expired} expired items, ` +
          `encountered ${stats.errors} errors`
        );
      }
    } catch (globalError) {
      console.error('Fatal error during cache cleanup:', globalError.message);
    }
  }

  /**
   * ==========================================================================
   * 7. Entry Point
   * ==========================================================================
   */

  analyzeView().then(() => {
    console.log('Citation Watchlist script finished executing');

    // On enhanced RC pages, toggle the top-row cw-gutter when sections are
    // expanded or collapsed.  When expanded the individual revision rows each
    // show their own gutter, so the summary gutter on the header row should
    // be hidden to avoid visual duplication.
    // The expand/collapse is driven by a hidden checkbox that the arrow label
    // toggles, so we listen for change events on those checkboxes.
    document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
      const arrow = table.querySelector('.mw-enhancedchanges-arrow');
      if (!arrow) return;
      const checkboxId = arrow.getAttribute('for');
      const checkbox = checkboxId ? document.getElementById(checkboxId) : null;
      if (!checkbox) return;

      const syncGutter = () => {
        const topRow = table.querySelector('tr');
        if (!topRow) return;
        const gutter = topRow.querySelector('.cw-gutter');
        if (gutter) {
          const host = gutter.parentElement;
          if (checkbox.checked) {
            gutter.style.display = 'none';
            if (host) host.style.paddingLeft = '';
          } else {
            gutter.style.display = 'inline-flex';
            if (host) host.style.paddingLeft = 'calc(2em + 0.5em)';
          }
        }
      };

      checkbox.addEventListener('change', syncGutter);
    });

    const filterLabel = document.querySelector('.cw-filter-label');
    if (filterLabel) {
      filterLabel.textContent = showEditsLabel;
    }

    const filterRadios = document.querySelectorAll('input[name="cw-filter"]');
    const revisions = document.querySelectorAll(
      '[data-mw-revid]:not(.mw-userlink), [data-mw-logid]'
    );

    filterRadios.forEach(radio => {
      const filterId = radio.value;
      let hasResults = true;

      if (filterId !== 'all') {
        hasResults = Array.from(revisions).some(container => {
          if (!container.hasAttribute('data-mw-revid')) return false;
          if (elementMatchesFilter(container, filterId)) return true;
          const nestedItems = container.querySelectorAll('li[data-cw-processed="true"]');
          for (const nested of nestedItems) {
            if (elementMatchesFilter(nested, filterId)) return true;
          }
          return false;
        });
      }

      const label = radio.closest('label');

      if (hasResults) {
        radio.disabled = false;
      } else if (label) {
        label.style.cursor = 'default';
        label.style.color = 'gray';
      }
    });
  });

})();