User:Polygnotus/Scripts/DeduplicateReferences.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump.
This code will be executed when previewing this page.
This code will be executed when previewing this page.
This user script seems to have a documentation page at User:Polygnotus/Scripts/DeduplicateReferences.
// <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, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
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">
count: got ${r.actualCount}, expected ${r.expectCount}<br>
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>