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:Harej/citation-watchlist.
/**
*
* 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';
}
});
});
})();