Jump to content

User:Polygnotus/Scripts/DeduplicateReferences.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// <nowiki>
/**
 * DeduplicateReferences
 * Renames exact-duplicate unnamed <ref> tags so they share a named reference.
 * [[User:Polygnotus/Scripts/DeduplicateReferences]]
 */
(function () {
  'use strict';

  // ── Configuration ───────────────────────────────────────────────────────────

  // Set to false to disable debug logging
  const DEBUG = true;
  const debug = (...args) => {
    if (DEBUG) console.log('[DeduplicateReferences]', ...args);
  };

  const SCRIPT_LINK = '[[User:Polygnotus/Scripts/DeduplicateReferences|DeduplicateReferences]]';

  // Change this to your actual test page title (spaces, not underscores)
  const TEST_PAGE = 'User:Polygnotus/Scripts/DeduplicateReferencesTest';

  // Namespaces the script is allowed to run on: 0 = Main/Article, 118 = Draft
  const ALLOWED_NAMESPACES = new Set([0, 118]);

  // Hostnames whose slug would produce a useless ref name (e.g. "doi_org").
  // When matched, name generation falls through to the next strategy.
  const DOMAIN_BLACKLIST = new Set([
    'doi.org', 'dx.doi.org', 'jstor.org', 'amazon.com',
    'books.google.com', 'web.archive.org', 'archive.org',
    'worldcat.org', 'patents.google.com',
  ]);

  // Generated names that must never be used (template names picked up from
  // plain text when a cite template has no usable title). Kept from v0;
  // v3 dropped this protection. When matched, generation falls through.
  const NAME_BLACKLIST = new Set([
    'cite_journal', 'cite_book', 'cite_web', 'cite_news', 'cite_magazine',
    'cite_newspaper', 'cite_thesis', 'cite_conference', 'cite_encyclopedia',
    'cite_album_notes', 'cite_comic', 'cite_court', 'cite_act', 'cite_episode',
    'cite_mailing_list', 'cite_map', 'cite_newsgroup', 'cite_patent',
    'cite_press_release', 'cite_report', 'cite_video_game', 'citation',
  ]);

  // Strip combining diacritics so "café" and "cafe" compare equal
  function normalizeDiacritics(str) {
    return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
  }

  // ── Protected regions ───────────────────────────────────────────────────────
  //
  // Content that the Cite extension never actually parses: HTML comments and
  // a handful of literal-text tags. A <ref> sitting inside one of these is
  // invisible to Cite, so it must never be treated as a real occurrence -
  // pairing a visible ref with one hiding in a comment/nowiki block produces
  // "named reference X was invoked but never defined" once published.

  const PROTECTED_TAGS = ['nowiki', 'pre', 'syntaxhighlight', 'source', 'math'];

  function findProtectedRanges(text) {
    const ranges = [];
    const commentRe = /<!--[\s\S]*?-->/g;
    let m;
    while ((m = commentRe.exec(text))) {
      ranges.push([m.index, m.index + m[0].length]);
    }
    for (const tag of PROTECTED_TAGS) {
      const re = new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi');
      while ((m = re.exec(text))) {
        ranges.push([m.index, m.index + m[0].length]);
      }
    }
    ranges.sort((a, b) => a[0] - b[0]);
    return ranges;
  }

  function inProtectedRange(pos, ranges) {
    for (const [s, e] of ranges) {
      if (s > pos) break; // ranges sorted by start; nothing further can match
      if (pos >= s && pos < e) return true;
    }
    return false;
  }

  // ── Ref parsing ─────────────────────────────────────────────────────────────
  //
  // Returns an array of token objects: { start, end, raw, selfClose }.
  // Positions are character offsets into the original string, so replacements
  // can be applied back-to-front without recalculating offsets. (v2/v3)
  //
  // Refs whose opening '<ref' falls inside a protected region (comment,
  // nowiki, pre, syntaxhighlight, source, math) are skipped entirely - they
  // are not real refs as far as Cite is concerned.

  function parseRefs(text) {
    const tokens = [];
    const protectedRanges = findProtectedRanges(text);
    let i = 0;

    while (i < text.length) {
      const s = text.indexOf('<ref', i);
      if (s === -1) break;

      if (inProtectedRange(s, protectedRanges)) {
        i = s + 4;
        continue;
      }

      // '<ref' must be followed by '>', '/' or whitespace.
      // This excludes '<references>' and similar.
      const c = text[s + 4];
      if (c !== '>' && c !== '/' && !/\s/.test(c || '')) {
        i = s + 4;
        continue;
      }

      const gt = text.indexOf('>', s);
      if (gt === -1) { i = s + 1; continue; }

      const openTag = text.slice(s, gt + 1);

      if (openTag.endsWith('/>')) {
        // Self-closing back-reference: <ref name="foo" />
        tokens.push({ start: s, end: gt + 1, raw: openTag, selfClose: true });
        i = gt + 1;
      } else {
        // Full ref: find the matching </ref>
        const closeStart = text.indexOf('</ref>', gt + 1);
        if (closeStart === -1) { i = gt + 1; continue; }
        const end = closeStart + 6; // length of '</ref>'
        tokens.push({ start: s, end, raw: text.slice(s, end), selfClose: false });
        i = end;
      }
    }

    return tokens;
  }

  // ── Attribute extraction ────────────────────────────────────────────────────

  // Extract the value of a named attribute from a ref opening tag.
  // Handles double-quoted, single-quoted, and unquoted values.
  // Empty values return null. Only looks inside the opening tag,
  // never inside the citation content. (v2/v3)
  function attrValue(raw, attrName) {
    const openMatch = raw.match(/^<ref[^>]*>/i) || raw.match(/^<ref[^>]*\/>/i);
    const scope = openMatch ? openMatch[0] : raw;
    const re = new RegExp(
      `\\b${attrName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\u201c([^\u201d]*)\u201d|\u2018([^\u2019]*)\u2019|([^\\s/>]*))`,
      'i'
    );
    const m = scope.match(re);
    if (!m) return null;
    const val = (m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5] ?? '').trim();
    return val === '' ? null : val;
  }

  const getName  = raw => attrValue(raw, 'name');
  const getGroup = raw => attrValue(raw, 'group');

  // Defense in depth: getName() only recognises straight and common
  // typographic quote pairs. If a ref's opening tag contains something that
  // looks like a name= attribute in a form getName() can't parse (mismatched
  // or exotic quote characters, etc.), treat it as named anyway. Silently
  // stripping a name we failed to read would orphan every back-reference to
  // it - the failure mode must be "leave it alone", never "treat as unnamed".
  function isEffectivelyNamed(raw) {
    if (getName(raw) !== null) return true;
    const openMatch = raw.match(/^<ref[^>]*>/i) || raw.match(/^<ref[^>]*\/>/i);
    const scope = openMatch ? openMatch[0] : raw;
    return /\bname\s*=/i.test(scope);
  }

  // Extract the content between <ref …> and </ref>.
  const getContent = raw => {
    const m = raw.match(/^<ref[^>]*>([\s\S]*)<\/ref>$/i);
    return m ? m[1] : '';
  };

  // ── Normalisation ───────────────────────────────────────────────────────────
  //
  // Two refs are considered identical when their normalised forms match.
  // Normalisation removes the name attribute, sorts cite-template parameters
  // (so parameter order does not matter, v2), collapses whitespace, and
  // strips diacritics. Other attributes (e.g. group=) are kept, so refs in
  // different groups never merge.

  const NAME_ATTR_RE = /\s*\bname\s*=\s*(?:"[^"]*"|'[^']*'|[^\s/>]*)/gi;

  // Collapse whitespace in a template parameter and around its first '=' sign
  function normaliseParam(p) {
    return p.replace(/\s+/g, ' ').trim().replace(/\s*=\s*/, '=');
  }

  // Find the first {{cite …}} / {{citation …}} template in the content and
  // rewrite it with its parameters sorted. Respects nested templates and
  // wikilinks by tracking {{ }} / [[ ]] depth.
  function sortTemplateParams(content) {
    const start = content.search(/\{\{\s*(?:cite|citation)/i);
    if (start === -1) return content;

    const params = [];
    let current = '';
    let depth = 0;
    let end = -1;

    for (let i = start; i < content.length; i++) {
      const ch = content[i];
      const next = content[i + 1] || '';

      if ((ch === '{' && next === '{') || (ch === '[' && next === '[')) {
        depth++;
        current += ch + next;
        i++;
      } else if ((ch === '}' && next === '}') || (ch === ']' && next === ']')) {
        depth--;
        current += ch + next;
        i++;
        if (depth === 0) { end = i + 1; break; }
      } else if (ch === '|' && depth === 1) {
        params.push(current);
        current = '';
      } else {
        current += ch;
      }
    }

    // Unbalanced template: leave the content untouched
    if (end === -1) return content;
    params.push(current);

    if (params.length === 1) {
      // Template without parameters, e.g. {{citation needed}}
      const name = params[0]
        .replace(/^\{\{/, '').replace(/\}\}$/, '')
        .replace(/\s+/g, ' ').trim();
      return content.slice(0, start) + '{{' + name + '}}' + content.slice(end);
    }

    const head = params[0].replace(/^\{\{/, '').replace(/\s+/g, ' ').trim();
    const tail = params[params.length - 1].replace(/\}\}$/, '');
    const sorted = params.slice(1, -1).concat([tail])
      .map(normaliseParam)
      .filter(Boolean)
      .sort();

    return content.slice(0, start) +
           '{{' + head + '|' + sorted.join('|') + '}}' +
           content.slice(end);
  }

  function normalise(raw) {
    const stripped = raw.replace(NAME_ATTR_RE, '');

    const m = stripped.match(/^<ref([^>]*)>([\s\S]*)<\/ref>$/i);
    if (!m) {
      // Self-closing or malformed: just collapse whitespace and diacritics
      return normalizeDiacritics(stripped.replace(/\s+/g, ' ').trim());
    }

    const attrs = m[1].replace(/\s+/g, ' ').trim();
    let content = m[2];

    if (/\{\{\s*(?:cite|citation)/i.test(content)) {
      content = sortTemplateParams(content);
    }
    content = content.replace(/\s+/g, ' ').trim();

    return normalizeDiacritics(`<ref ${attrs}>${content}</ref>`);
  }

  // ── Name generation ─────────────────────────────────────────────────────────

  // Convert an arbitrary string to a safe, lowercase ref-name fragment
  function slug(s) {
    return normalizeDiacritics(s)
      .replace(/[^A-Za-z0-9]+/g, '_')
      .replace(/^_+|_+$/g, '')
      .toLowerCase()
      .slice(0, 40);
  }

  // Return `base` if unused, otherwise `base_2`, `base_3`, … and register it
  function uniqueName(base, used) {
    let name = base;
    for (let n = 2; used.has(name); n++) name = `${base}_${n}`;
    used.add(name);
    return name;
  }

  // Strategy 1: derive a name from |title= in a cite template
  function nameFromCiteTitle(content) {
    const m = content.match(/\|\s*title\s*=\s*([^|{}\n]+)/i);
    if (!m) return null;
    // Strip diacritics before word extraction so "Café" becomes "Cafe",
    // not "Caf" + a broken word boundary
    const words = normalizeDiacritics(m[1].replace(/<[^>]*>/g, '').trim())
      .match(/\b[A-Za-z0-9]+\b/g);
    return (words && words.length) ? slug(words.slice(0, 3).join('_')) : null;
  }

  // Strategy 2: derive a name from the first URL in the content
  function nameFromUrl(content) {
    const m = content.match(/https?:\/\/([^\s/<>"]+)/i);
    if (!m) return null;

    let host = m[1].replace(/^www\./, '');

    // For archive.org, try to recover the real hostname from the archived URL
    if (/^(web\.)?archive\.org/.test(host)) {
      const arc = content.match(
        /archive\.org\/web\/\d+\/https?:\/\/(?:www\.)?([^\s/<>"]+)/i
      );
      if (arc) host = arc[1];
    }

    const domain = host.split('/')[0];
    return DOMAIN_BLACKLIST.has(domain) ? null : slug(domain);
  }

  // Strategy 3: derive a name from the first few plain-text words
  function nameFromText(content) {
    const plain = normalizeDiacritics(content
      .replace(/<[^>]*>/g, '')
      .replace(/\[\[([^|\]]*)[^\]]*\]\]/g, '$1')  // strip wikilinks, keep label
      .trim());
    const words = plain.match(/\b[A-Za-z0-9]+\b/g);
    return (words && words.length) ? slug(words.slice(0, 3).join('_')) : null;
  }

  // Pick the best available name for a ref.
  // Priority: cite-template title > URL > plain title= > plain text > 'ref'
  function generateName(raw, used) {
    const content = getContent(raw);
    const hasCite = /\{\{\s*(?:cite|citation)/i.test(content);

    const candidates = [];
    if (hasCite) candidates.push(nameFromCiteTitle(content));
    candidates.push(nameFromUrl(content));
    if (!hasCite) candidates.push(nameFromCiteTitle(content));
    candidates.push(nameFromText(content));

    const base = candidates.find(c => c && !NAME_BLACKLIST.has(c)) ?? 'ref';
    return uniqueName(base, used);
  }

  // ── Core deduplication ──────────────────────────────────────────────────────

  /**
   * Deduplicate unnamed refs in wikitext.
   *
   * Rules:
   * - Self-closing refs (<ref name="x" />) are never modified.
   * - Named refs (<ref name="x">…</ref>) are never modified.
   * - Unnamed full refs that appear more than once get a generated name on
   *   the first occurrence; subsequent occurrences become self-closing
   *   back-refs. The first occurrence keeps its original content verbatim.
   * - group= attributes are preserved.
   *
   * @param   {string} text  Raw wikitext
   * @returns {{ text: string, count: number }}
   *   `count` = number of duplicate occurrences replaced
   */
  function deduplicate(text) {
    const tokens = parseRefs(text);
    debug('Found', tokens.length, 'reference tags');

    // Register every existing name (raw and slugged) before generating any,
    // so generated names never clash with names already in the article
    const usedNames = new Set();
    for (const t of tokens) {
      const n = getName(t.raw);
      if (n) {
        usedNames.add(n);
        const s = slug(n);
        if (s) usedNames.add(s);
      }
    }

    // Group unnamed full refs by normalised content
    // Map: normalisedRef → { first: Token, dupes: Token[] }
    const groups = new Map();

    for (const tok of tokens) {
      if (tok.selfClose) continue;               // back-references: skip
      if (isEffectivelyNamed(tok.raw)) continue;  // named refs: skip

      const key = normalise(tok.raw);
      if (!groups.has(key)) {
        groups.set(key, { first: tok, dupes: [] });
      } else {
        groups.get(key).dupes.push(tok);
      }
    }

    debug('Unnamed reference groups:', groups.size);

    const replacements = [];
    let count = 0;

    for (const { first, dupes } of groups.values()) {
      if (dupes.length === 0) continue;

      const name    = generateName(first.raw, usedNames);
      const group   = getGroup(first.raw);
      const gAttr   = group ? ` group="${group}"` : '';
      const content = getContent(first.raw);

      debug('Generated name for duplicate group:', name);

      replacements.push({
        start: first.start,
        end:   first.end,
        text:  `<ref name="${name}"${gAttr}>${content}</ref>`,
      });

      for (const dup of dupes) {
        replacements.push({
          start: dup.start,
          end:   dup.end,
          text:  `<ref name="${name}"${gAttr} />`,
        });
        count++;
      }
    }

    if (count === 0) return { text, count: 0 };

    // Apply replacements from back to front so earlier positions stay valid
    replacements.sort((a, b) => b.start - a.start);
    let result = text;
    for (const r of replacements) {
      result = result.slice(0, r.start) + r.text + result.slice(r.end);
    }

    return { text: result, count };
  }

  // ── Safety check: orphaned references ───────────────────────────────────────
  //
  // Returns the list of "name (group)" identifiers that are invoked by a
  // self-closing <ref name="x" /> somewhere in `text` but never defined by a
  // full <ref name="x">...</ref> anywhere in `text`. This is exactly the
  // condition that produces "Cite error: The named reference x was invoked
  // but never defined." Used to abort BEFORE writing a broken edit to the
  // textarea, regardless of what caused the mismatch.

  function findOrphanReferences(text) {
    const tokens = parseRefs(text);
    const defined = new Set();
    const used = new Set();

    for (const t of tokens) {
      const name = getName(t.raw);
      if (name === null) continue;
      const key = name + '\u0000' + (getGroup(t.raw) || '');
      if (t.selfClose) used.add(key); else defined.add(key);
    }

    const orphans = [];
    for (const key of used) {
      if (!defined.has(key)) {
        const [name, group] = key.split('\u0000');
        orphans.push(group ? `${name} (group="${group}")` : name);
      }
    }
    return orphans;
  }

  // ── Edit-page integration ───────────────────────────────────────────────────

  function isCurrentRevision() {
    const p = new URLSearchParams(window.location.search);
    if (p.get('oldid')) {
      debug('Editing old revision (oldid=' + p.get('oldid') + '), skipping');
      return false;
    }
    if (p.get('diff')) {
      debug('In diff view, skipping');
      return false;
    }
    // Additional check kept from v0: look for revision warning messages
    const warning = document.querySelector('.mw-revision-warning, .mw-editnotice-base');
    if (warning && warning.textContent.toLowerCase().includes('old revision')) {
      debug('Old revision warning detected, skipping');
      return false;
    }
    return true;
  }

  // manual = true when triggered by the button; only then do we alert
  // on "nothing found" (auto-run stays silent, as in v0)
  function runOnPage(manual = false) {
    debug('Starting deduplication process...');
    if (!isCurrentRevision()) return;

    const ta  = document.getElementById('wpTextbox1');
    const sum = document.getElementById('wpSummary');
    if (!ta || !sum) {
      debug('Edit textarea or summary input not found');
      return;
    }

    const original = ta.value;
    const { text, count } = deduplicate(original);
    debug('Deduplication complete. Count:', count);

    if (count === 0) {
      if (manual) alert('No unnamed duplicate references found to deduplicate.');
      return;
    }

    // Only flag orphans our own edit introduces - don't block on pre-existing,
    // unrelated cite errors that were already on the page.
    const beforeOrphans = new Set(findOrphanReferences(original));
    const newOrphans = findOrphanReferences(text).filter(o => !beforeOrphans.has(o));

    if (newOrphans.length > 0) {
      console.error('[DeduplicateReferences] Aborting, would create orphan reference(s):', newOrphans);
      alert(
        'DeduplicateReferences aborted: this change would leave the following ' +
        'named reference(s) invoked but never defined: ' + newOrphans.join(', ') + '. ' +
        'No changes were made. Please report this at the script talk page with the article title.'
      );
      return;
    }

    ta.value = text;

    const msg = `[[WP:NAMEDREFS|Deduplicated]] ${count} reference${count !== 1 ? 's' : ''} using ${SCRIPT_LINK}`;
    sum.value = sum.value ? `${sum.value} • ${msg}` : msg;

    const minor = document.querySelector('#wpMinoredit, input[name="wpMinoredit"]');
    if (minor) minor.checked = true;

    const diffBtn = document.querySelector('#wpDiff, input[name="wpDiff"]');
    const shownDiff = !!diffBtn;
    if (diffBtn) diffBtn.click();

    alert(
      `Deduplicated ${count} reference${count !== 1 ? 's' : ''}.` +
      (shownDiff
        ? ' Review the diff now loading below before publishing.'
        : ' Please review the textarea before publishing.')
    );
  }

  function addButton() {
    if (document.getElementById('dedupe-button')) return;

    const ta     = document.getElementById('wpTextbox1');
    const anchor = document.querySelector('.wikiEditor-ui-toolbar') ?? ta?.parentNode;
    if (!anchor) return;

    const btn = document.createElement('button');
    btn.id    = 'dedupe-button';
    btn.type  = 'button';
    btn.textContent  = 'Deduplicate References';
    btn.style.margin = '5px';

    if (isCurrentRevision()) {
      btn.onclick = () => runOnPage(true);
      debug('Added deduplication button (enabled)');
    } else {
      btn.disabled = true;
      btn.title    = 'Deduplication only works when editing the current version of an article';
      debug('Added deduplication button (disabled - old revision)');
    }

    anchor.appendChild(btn);
  }

  // Wait until the edit textarea actually has content before running,
  // since WikiEditor may inject it asynchronously
  function waitAndRun(fn) {
    const ta = document.getElementById('wpTextbox1');
    if (ta && ta.value && document.getElementById('wpSummary')) {
      fn();
    } else {
      debug('Not ready yet, retrying...');
      setTimeout(() => waitAndRun(fn), 150);
    }
  }

  // ── Self-tests ─────────────────────────────────────────────────────────
  //
  // These run against deduplicate() directly, so they are stable regardless
  // of the test page content. The panel is shown when visiting the test page.

  const TESTS = [
    {
      id: 'simple-dup',
      desc: 'Two identical unnamed refs: first gets name, second becomes self-closing',
      input: 'A<ref>{{cite web|url=https://example.com|title=Foo Bar Baz}}</ref> B<ref>{{cite web|url=https://example.com|title=Foo Bar Baz}}</ref>',
      expectCount: 1,
      check: out => out.includes('<ref name=') && out.includes('/>'),
    },
    {
      id: 'named-ref-unchanged',
      desc: 'Named ref is never modified',
      input: 'A<ref name="Smith2020">content</ref> B<ref name="Smith2020" />',
      expectCount: 0,
      check: out => out === 'A<ref name="Smith2020">content</ref> B<ref name="Smith2020" />',
    },
    {
      id: 'triple-dup',
      desc: 'Three identical unnamed refs: 1 named + 2 self-closing, count = 2',
      input: 'A<ref>same</ref> B<ref>same</ref> C<ref>same</ref>',
      expectCount: 2,
      check: out => (out.match(/<ref name=/g) ?? []).length === 3,
    },
    {
      id: 'no-dup',
      desc: 'Two different unnamed refs are not touched',
      input: 'A<ref>first content</ref> B<ref>second content</ref>',
      expectCount: 0,
      check: out => !out.includes('<ref name='),
    },
    {
      id: 'group-preserved',
      desc: 'group= attribute is preserved on both the named and self-closing ref',
      input: 'A<ref group="note">note text</ref> B<ref group="note">note text</ref>',
      expectCount: 1,
      check: out => (out.match(/group="note"/g) ?? []).length === 2,
    },
    {
      id: 'param-order-normalised',
      desc: 'Cite params in different order are treated as identical',
      input: 'A<ref>{{cite web|title=x|url=y}}</ref> B<ref>{{cite web|url=y|title=x}}</ref> C<ref>{{cite web|title=x    |url=y}}</ref>',
      expectCount: 2,
      check: out => out.includes('<ref name="x">{{cite web|title=x|url=y}}</ref>') &&
                    (out.match(/<ref name="x" \/>/g) ?? []).length === 2,
    },
    {
      id: 'diacritics-normalised',
      desc: 'Refs differing only in diacritics are treated as identical',
      input: 'A<ref>Café reference source</ref> B<ref>Cafe reference source</ref>',
      expectCount: 1,
      check: out => /name="cafe_reference_source"/.test(out),
    },
    {
      id: 'name-from-cite-title',
      desc: 'Name is derived from |title= in a cite template',
      input: 'A<ref>{{cite web|title=Hello World Test|url=https://example.com}}</ref>B<ref>{{cite web|title=Hello World Test|url=https://example.com}}</ref>',
      expectCount: 1,
      check: out => /name="hello_world_test"/.test(out),
    },
    {
      id: 'name-from-url',
      desc: 'Name is derived from the URL when there is no cite template',
      input: 'A<ref>https://bbc.co.uk/article-123</ref>B<ref>https://bbc.co.uk/article-123</ref>',
      expectCount: 1,
      check: out => /name="bbc_co_uk"/.test(out),
    },
    {
      id: 'blacklisted-domain-falls-through',
      desc: 'Blacklisted domain does not become the name; falls through',
      input: 'A<ref>{{cite journal|doi=10.1000/xyz|title=Some Journal Article|year=2020}}</ref>B<ref>{{cite journal|doi=10.1000/xyz|title=Some Journal Article|year=2020}}</ref>',
      expectCount: 1,
      check: out => out.includes('<ref name=') && !/name="doi/.test(out),
    },
    {
      id: 'template-name-not-used-as-ref-name',
      desc: 'Cite template without title does not produce name "cite_web" (v0 blacklist)',
      input: 'A<ref>{{cite web}}</ref>B<ref>{{cite web}}</ref>',
      expectCount: 1,
      check: out => out.includes('<ref name=') && !/name="cite_web"/.test(out),
    },
    {
      id: 'whitespace-normalised',
      desc: 'Refs differing only in whitespace are treated as identical',
      input: 'A<ref>content here</ref> B<ref>content  here</ref>',
      expectCount: 1,
      check: out => out.includes('<ref name='),
    },
    {
      id: 'no-name-clash',
      desc: 'Generated name does not collide with an existing named ref',
      input: 'A<ref name="foo_bar_baz">existing</ref> B<ref>{{cite web|title=Foo Bar Baz}}</ref>C<ref>{{cite web|title=Foo Bar Baz}}</ref>',
      expectCount: 1,
      check: out => {
        const m = out.match(/name="([^"]+)"[^>]*>\{\{cite web\|title=Foo Bar Baz/);
        return m !== null && m[1] !== 'foo_bar_baz';
      },
    },
    {
      id: 'archive-url-unwrapped',
      desc: 'archive.org URL resolves the inner domain for the name',
      input: 'A<ref>https://web.archive.org/web/20230101/https://www.bbc.co.uk/news</ref>B<ref>https://web.archive.org/web/20230101/https://www.bbc.co.uk/news</ref>',
      expectCount: 1,
      check: out => /name="bbc_co_uk"/.test(out),
    },
    {
      id: 'self-closing-not-modified',
      desc: 'A lone self-closing ref is never touched',
      input: 'A<ref name="foo" /> B<ref name="foo" />',
      expectCount: 0,
      check: out => out === 'A<ref name="foo" /> B<ref name="foo" />',
    },
    {
      id: 'references-tag-ignored',
      desc: '<references /> is not parsed as a ref',
      input: 'A<ref>dup content</ref> B<ref>dup content</ref>\n<references />',
      expectCount: 1,
      check: out => out.includes('<references />'),
    },
    {
      id: 'ref-in-comment-ignored',
      desc: 'A <ref> inside an HTML comment is not paired with a real duplicate',
      input: 'A<ref>dup content</ref> <!-- old: <ref>dup content</ref> --> B<ref>dup content</ref>',
      expectCount: 1,
      check: out => out.includes('<!-- old: <ref>dup content</ref> -->') &&
                    (out.match(/<ref name="dup_content">/g) ?? []).length === 1 &&
                    (out.match(/<ref name="dup_content" \/>/g) ?? []).length === 1,
    },
    {
      id: 'ref-in-nowiki-ignored',
      desc: 'A <ref> inside <nowiki> is not paired with a real duplicate',
      input: 'A<ref>dup content</ref> <nowiki><ref>dup content</ref></nowiki> B<ref>dup content</ref>',
      expectCount: 1,
      check: out => out.includes('<nowiki><ref>dup content</ref></nowiki>'),
    },
    {
      id: 'orphan-detection',
      desc: 'findOrphanReferences flags a self-closing ref with no definition',
      input: 'A<ref name="lonely" />',
      expectCount: 0,
      check: () => findOrphanReferences('<ref name="lonely" />').includes('lonely'),
    },
    {
      id: 'orphan-detection-clean',
      desc: 'findOrphanReferences reports nothing when every ref is defined',
      input: 'A<ref name="ok">content</ref> B<ref name="ok" />',
      expectCount: 0,
      check: () => findOrphanReferences('<ref name="ok">content</ref> B<ref name="ok" />').length === 0,
    },
    {
      id: 'curly-quoted-name-not-treated-as-unnamed',
      desc: 'A name= attribute using typographic quotes is still recognized as named, never merged as a duplicate',
      input: 'A<ref name=\u201cCrain\u2019s New York Business 2009\u201d>{{cite web|title=Central Park visitors}}</ref> B<ref>{{cite web|title=Central Park visitors}}</ref>',
      expectCount: 0,
      check: out => out === 'A<ref name=\u201cCrain\u2019s New York Business 2009\u201d>{{cite web|title=Central Park visitors}}</ref> B<ref>{{cite web|title=Central Park visitors}}</ref>',
    },
    {
      id: 'curly-quoted-name-parsed-correctly',
      desc: 'getName() correctly reads a typographic-quoted name value',
      input: 'A<ref name=\u201cFoo Bar\u201d>content</ref>',
      expectCount: 0,
      check: () => getName('<ref name=\u201cFoo Bar\u201d>content</ref>') === 'Foo Bar',
    },
  ];

  function runTests() {
    const results = TESTS.map(tc => {
      const { text: out, count } = deduplicate(tc.input);
      const ok = count === tc.expectCount && tc.check(out);
      return { ...tc, ok, actualCount: count, out };
    });
    showTestPanel(results);
  }

  function esc(s) {
    return String(s)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;');
  }

  function showTestPanel(results) {
    const existing = document.getElementById('dedup-test-panel');
    if (existing) existing.remove();

    const pass   = results.filter(r => r.ok).length;
    const fail   = results.length - pass;
    const border = fail === 0 ? '#388e3c' : '#c62828';

    const rows = results.map(r => {
      const colour = r.ok ? '#1b5e20' : '#b71c1c';
      const extra  = !r.ok
        ? `<br><span style="color:#666;font-size:11px">
             &nbsp;&nbsp;count: got ${r.actualCount}, expected ${r.expectCount}<br>
             &nbsp;&nbsp;output: ${esc(r.out.slice(0, 140))}
           </span>`
        : '';
      return `<tr style="color:${colour}">
        <td style="padding:2px 8px 2px 0;vertical-align:top">${r.ok ? '✓' : '✗'}</td>
        <td style="padding:2px 0">${esc(r.desc)}${extra}</td>
      </tr>`;
    }).join('');

    const panel = document.createElement('div');
    panel.id = 'dedup-test-panel';
    panel.style.cssText = [
      'position:fixed', 'top:12px', 'right:12px', 'z-index:9999',
      'background:#fafafa', `border:2px solid ${border}`,
      'border-radius:6px', 'padding:14px 18px',
      'max-width:560px', 'max-height:80vh', 'overflow-y:auto',
      'font-family:monospace', 'font-size:12px',
      'box-shadow:0 3px 12px rgba(0,0,0,.25)',
    ].join(';');

    panel.innerHTML = `
      <strong style="font-size:13px">
        DeduplicateReferences — ${pass} / ${results.length} tests passed
      </strong>
      <button id="dedup-panel-close" type="button"
        style="float:right;background:none;border:none;cursor:pointer;font-size:16px;line-height:1;padding:0">✕</button>
      <table style="margin-top:10px;border-collapse:collapse;width:100%">${rows}</table>
    `;

    document.body.appendChild(panel);
    document.getElementById('dedup-panel-close').onclick = () => panel.remove();
  }

  // ── Bootstrap ───────────────────────────────────────────────────────────────

  function whenLoaded(fn) {
    if (document.readyState === 'complete') {
      fn();
    } else {
      window.addEventListener('load', fn);
    }
  }

  function init() {
    if (typeof mw === 'undefined') {
      console.log('DeduplicateReferences: MediaWiki not found, aborting.');
      return;
    }

    const wgAction   = mw.config.get('wgAction');
    const wgNs       = mw.config.get('wgNamespaceNumber');
    const wgTitle    = mw.config.get('wgPageName');
    const isTestPage = wgTitle === TEST_PAGE.replace(/ /g, '_');

    // Show the test panel whenever the test page is loaded (view or edit)
    if (isTestPage) {
      whenLoaded(runTests);
    }

    // Run deduplication on article edit AND preview/show-changes pages
    // (action=submit, v2), on the test page in edit mode, and now also
    // in the Draft namespace (118)
    const shouldRun = (wgAction === 'edit' || wgAction === 'submit') &&
                      (ALLOWED_NAMESPACES.has(wgNs) || isTestPage);

    if (shouldRun) {
      debug('Article/draft edit page detected, setting up deduplication...');
      whenLoaded(() => waitAndRun(() => { runOnPage(false); addButton(); }));
      // Belt-and-suspenders: retry after WikiEditor finishes injecting the toolbar
      setTimeout(addButton, 2000);
    } else {
      debug('Not on article/draft edit page - action:', wgAction, 'namespace:', wgNs);
    }
  }

  init();

})();
// </nowiki>