/**
* Module loaded on pages where we add comment links to history entries (sometimes more).
*
* @module addCommentLinks
*/
import Comment from './Comment';
import PrototypeRegistry from './PrototypeRegistry';
import cd from './cd';
import commentRegistry from './commentRegistry';
import controller from './controller';
import init from './init';
import pageRegistry from './pageRegistry';
import settings from './settings';
import { definedAndNotNull, generatePageNamePattern, isCommentEdit, isProbablyTalkPage, isUndo, removeDirMarks, spacesToUnderlines } from './utils-general';
import { initDayjs, parseTimestamp } from './utils-timestamp';
let colon;
let moveFromBeginning;
let moveToBeginning;
let goToCommentToYou;
let goToCommentWatchedSection;
let currentUserRegexp;
let switchRelevantButton;
let subscriptions;
const prototypes = new PrototypeRegistry();
/**
* Initialize variables.
*
* @private
*/
async function initialize() {
// This could have been executed from init.talkPage() already.
init.initGlobals();
await settings.init();
const requests = [...init.getSiteData()];
if (cd.user.isRegistered() && !settings.get('useTopicSubscription')) {
// Loading the subscriptions is not critical, as opposed to messages, so we catch the possible
// error, not letting it be caught by the try/catch block.
subscriptions = controller.getSubscriptionsInstance();
requests.push(subscriptions.load(undefined, true).catch(() => {}));
}
try {
await Promise.all(requests);
} catch (e) {
throw ['Couldn\'t load the data required for the script.', e];
}
mw.loader.addStyleTag(`:root {
--cd-parentheses-start: '${cd.mws('parentheses-start')}';
--cd-parentheses-end: '${cd.mws('parentheses-end')}';
}`);
colon = cd.mws('colon-separator', { language: 'content' }).trim();
[moveFromBeginning] = cd.s('es-move-from').match(/^[^[$]+/) || [];
[moveToBeginning] = cd.s('es-move-to').match(/^[^[$]+/) || [];
goToCommentToYou = goToCommentWatchedSection = cd.s('lp-comment-tooltip') + ' ';
goToCommentToYou += cd.mws('parentheses', cd.s('lp-comment-toyou'));
goToCommentWatchedSection += cd.mws('parentheses', cd.s('lp-comment-watchedsection'));
const $aRegularPrototype = $('<a>')
.text(cd.s('lp-comment'))
.attr('title', cd.s('lp-comment-tooltip'));
const $spanRegularPrototype = $('<span>')
.addClass('cd-commentLink-innerWrapper')
.append($aRegularPrototype);
const $wrapperRegularPrototype = $('<span>')
.addClass('cd-commentLink')
.append($spanRegularPrototype)
.prepend(' ');
prototypes.add('wrapperRegular', $wrapperRegularPrototype[0]);
prototypes.add(
'wrapperRelevant',
$wrapperRegularPrototype
.clone()
.addClass('cd-commentLink-relevant')[0]
);
const currentUserNamePattern = generatePageNamePattern(cd.g.userName);
currentUserRegexp = new RegExp(
`(?:^|[^${cd.g.letterPattern}])${currentUserNamePattern}(?![${cd.g.letterPattern}])`
);
}
/**
* Show/hide relevant edits.
*
* @private
*/
function switchRelevant() {
// Item grouping switched on. This may be done in the settings or in the URL.
const isEnhanced = !$('.mw-changeslist').find('ul.special').length;
// This is for many watchlist types at once.
const $collapsibles = controller.$content
.find('.mw-changeslist .mw-collapsible:not(.mw-changeslist-legend)');
const $lines = controller.$content.find('.mw-changeslist-line:not(table)');
if (switchRelevantButton.hasFlag('progressive')) {
// Show all
// FIXME: Old watchlist (no JS) + ?enhanced=1&urlversion=2
if (isEnhanced) {
$lines
.filter('table')
.show();
} else {
$lines
.not(':has(.cd-commentLink-relevant)')
.show();
}
$collapsibles
.not(':has(.cd-commentLink-relevant)')
.find('.mw-rcfilters-ui-highlights-enhanced-toplevel')
.show();
$collapsibles
.not('.mw-collapsed')
.find('.mw-enhancedchanges-arrow')
.click();
} else {
// Show relevant only
$collapsibles
.not('.mw-collapsed')
.find('.mw-enhancedchanges-arrow')
.click();
$collapsibles
.has('.cd-commentLink-relevant')
.find('.mw-enhancedchanges-arrow')
.click()
$collapsibles
.not(':has(.cd-commentLink-relevant)')
.find('.mw-rcfilters-ui-highlights-enhanced-toplevel')
.hide();
$lines
.not(':has(.cd-commentLink-relevant)')
.hide();
}
switchRelevantButton.setFlags({ progressive: !switchRelevantButton.hasFlag('progressive') });
}
/**
* Add watchlist menu (a block with buttons).
*
* @private
*/
function addWatchlistMenu() {
if (!subscriptions) return;
// For auto-updating watchlists
mw.hook('wikipage.content').add(() => {
switchRelevantButton?.setFlags({ progressive: false });
});
const $menu = $('<div>').addClass('cd-watchlistMenu');
$('<a>')
.attr('href', mw.util.getUrl(cd.config.scriptPageWikilink))
.attr('target', '_blank')
.addClass('cd-watchlistMenu-scriptPageLink')
.text(cd.s('script-name-short'))
.appendTo($menu);
switchRelevantButton = new OO.ui.ButtonWidget({
framed: false,
icon: 'speechBubble',
label: cd.s('wl-button-switchrelevant-tooltip', mw.user),
invisibleLabel: true,
title: cd.s('wl-button-switchrelevant-tooltip', mw.user),
classes: ['cd-watchlistMenu-button', 'cd-watchlistMenu-button-switchRelevant'],
disabled: !subscriptions.areLoaded(),
});
switchRelevantButton.on('click', () => {
switchRelevant();
});
switchRelevantButton.$element.appendTo($menu);
const editSubscriptionsButtonConfig = {
framed: false,
icon: 'listBullet',
label: cd.s('wl-button-editwatchedsections-tooltip', mw.user),
invisibleLabel: true,
title: cd.s('wl-button-editwatchedsections-tooltip', mw.user),
classes: ['cd-watchlistMenu-button', 'cd-watchlistMenu-button-editSubscriptions'],
};
const editSubscriptionsButton = new OO.ui.ButtonWidget(editSubscriptionsButtonConfig);
editSubscriptionsButton.on('click', () => {
controller.showEditSubscriptionsDialog();
});
editSubscriptionsButton.$element.appendTo($menu);
const settingsButton = new OO.ui.ButtonWidget({
framed: false,
icon: 'settings',
label: cd.s('wl-button-settings-tooltip'),
invisibleLabel: true,
title: cd.s('wl-button-settings-tooltip'),
classes: ['cd-watchlistMenu-button', 'cd-watchlistMenu-button-scriptSettings'],
});
settingsButton.on('click', () => {
initDayjs();
settings.showDialog();
});
settingsButton.$element.appendTo($menu);
// New watchlist
controller.$content.find('.mw-rcfilters-ui-changesLimitAndDateButtonWidget').prepend($menu);
// Old watchlist
controller.$content.find('#mw-watchlist-options .mw-changeslist-legend').after($menu);
}
/**
* Whether the provided link element points to a Wikidata item.
*
* @param {Element} linkElement
* @returns {boolean}
* @private
*/
function isWikidataItem(linkElement) {
return (
cd.g.serverName === 'www.wikidata.org' &&
linkElement.firstElementChild?.classList.contains('wb-itemlink')
)
}
/**
* Extract an author given a revision line.
*
* @param {Element} line
* @returns {?string}
* @private
*/
function extractAuthor(line) {
const authorElement = line.querySelector('.mw-userlink');
if (!authorElement) {
return null;
}
let author = authorElement.textContent;
if (author === 'MediaWiki message delivery') {
return null;
}
if (mw.util.isIPv6Address(author)) {
author = author.toUpperCase();
}
return author;
}
/**
* Check by an edit summary if an edit is probably a move performed by our script.
*
* @param {string} summary
* @returns {boolean}
* @private
*/
function isMoved(summary) {
return (
(moveFromBeginning && summary.includes(': ' + moveFromBeginning)) ||
(moveToBeginning && summary.includes(': ' + moveToBeginning))
);
}
/**
* Check by an edit summary if an edit is probably an archiving operation.
*
* @param {string} summary
* @returns {boolean}
* @private
*/
function isArchiving(summary) {
return summary.includes('Archiving');
}
/**
* Check by an edit summary if it is an edit in a section with given name.
*
* @param {string} summary
* @param {string} name
* @returns {boolean}
* @private
*/
function isInSection(summary, name) {
if (!name) {
return false;
}
// This can run many thousand times, so we use the cheapest way.
return cd.g.contentDirection === 'ltr' ?
summary.includes(`→${name}${colon}`) || summary.endsWith(`→${name}`) :
summary.includes(`←${name}${colon}`) || summary.endsWith(`←${name}`);
}
/**
* Add comment links to a watchlist or a recent changes page. Add a watchlist menu to the watchlist.
*
* @param {external:jQuery} $content
* @private
*/
function processWatchlist($content) {
if (
mw.config.get('wgCanonicalSpecialPageName') === 'Watchlist' &&
!controller.$content.find('.cd-watchlistMenu').length
) {
if (mw.user.options.get('wlenhancedfilters-disable')) {
addWatchlistMenu();
} else {
mw.hook('structuredChangeFilters.ui.initialized').add(() => {
addWatchlistMenu();
});
}
if (subscriptions) {
$('.mw-rcfilters-ui-filterWrapperWidget-showNewChanges a').on('click', async () => {
// Reload in case the subscription list has changed (which should be a pretty common
// occasion)
await subscriptions.load();
});
}
}
// There are 2 ^ 3 = 8 (!) different watchlist modes:
// * expanded and not (Special:Preferences#mw-prefsection-watchlist "Expand watchlist to show all
// changes, not just the most recent")
// * with item grouping and without (Special:Preferences#mw-prefsection-rc "Group changes by page
// in recent changes and watchlist")
// * with enhanced fitlers and without (Special:Preferences#mw-prefsection-watchlist "Use
// non-JavaScript interface")
const lines = $content[0].querySelectorAll('.mw-changeslist-line[data-mw-revid]');
lines.forEach((lineOrBareTr) => {
const line = lineOrBareTr.className ? lineOrBareTr : lineOrBareTr.parentNode.parentNode;
const nsMatch = line.className.match(/mw-changeslist-ns(\d+)/);
const nsNumber = nsMatch && Number(nsMatch[1]);
if (nsNumber === null) return;
const isNested = line.tagName === 'TR';
const linkElement = (isNested ? line.parentNode : line).querySelector('.mw-changeslist-title');
if (!linkElement || isWikidataItem(linkElement)) return;
const pageName = linkElement.textContent;
if (!isProbablyTalkPage(pageName, nsNumber)) return;
if (line.querySelector('.minoredit')) return;
let summary = line.querySelector('.comment')?.textContent;
summary &&= removeDirMarks(summary);
if (summary && (isCommentEdit(summary) || isUndo(summary) || isMoved(summary))) return;
const bytesAddedElement = line.querySelector('.mw-plusminus-pos');
if (!bytesAddedElement) return;
if (bytesAddedElement.tagName !== 'STRONG') {
const bytesAddedMatch = bytesAddedElement.textContent.match(/\d+/);
const bytesAdded = bytesAddedMatch && Number(bytesAddedMatch[0]);
if (!bytesAdded || bytesAdded < cd.config.bytesToDeemComment) return;
}
const timestamp = line.getAttribute('data-mw-ts')?.slice(0, 12);
if (!timestamp) return;
const author = extractAuthor(line);
if (!author) return;
const id = timestamp + '_' + spacesToUnderlines(author);
const link = linkElement.href;
if (!link) return;
let wrapper;
if (summary && currentUserRegexp.test(` ${summary} `)) {
wrapper = prototypes.get('wrapperRelevant');
wrapper.lastChild.lastChild.title = goToCommentToYou;
} else {
let isWatched = false;
if (summary) {
const curLink = (
// Expanded watchlist
line.querySelector('.mw-changeslist-diff-cur') ||
// Non-expanded watchlist
line.querySelector('.mw-changeslist-history')
);
const curIdMatch = curLink?.href?.match(/[&?]curid=(\d+)/);
const curId = curIdMatch && Number(curIdMatch[1]);
if (curId) {
const watchedSectionHeadlines = subscriptions?.getForPageId(curId) || [];
if (watchedSectionHeadlines.length) {
isWatched = watchedSectionHeadlines.find((headline) => isInSection(summary, headline));
if (isWatched) {
wrapper = prototypes.get('wrapperRelevant');
wrapper.lastChild.lastChild.title = goToCommentWatchedSection;
}
}
}
}
if (!isWatched) {
wrapper = prototypes.get('wrapperRegular');
}
}
wrapper.lastChild.lastChild.href = `${link}#${id}`;
const destination = line.querySelector('.comment') || line.querySelector('.mw-usertoollinks');
if (!destination) return;
destination.parentNode.insertBefore(wrapper, destination.nextSibling);
});
}
/**
* Add comment links to a contributions page.
*
* @param {external:jQuery} $content
* @private
*/
function processContributions($content) {
init.initTimestampParsingTools('user');
if (cd.g.uiTimezone === null) return;
[
...$content[0].querySelectorAll('.mw-contributions-list > li:not(.mw-tag-mw-new-redirect)')
].forEach((line) => {
const linkElement = line.querySelector('.mw-contributions-title');
if (!linkElement || isWikidataItem(linkElement)) return;
const pageName = linkElement.textContent;
const page = pageRegistry.get(pageName);
if (!page.isProbablyTalkPage()) return;
const link = linkElement.href;
if (!link) return;
if (line.querySelector('.minoredit')) return;
let summary = line.querySelector('.comment')?.textContent;
summary &&= removeDirMarks(summary);
if (summary && (isCommentEdit(summary) || isUndo(summary) || isMoved(summary))) return;
const bytesAddedElement = line.querySelector('.mw-plusminus-pos');
if (!bytesAddedElement) return;
if (bytesAddedElement.tagName !== 'STRONG') {
const bytesAddedMatch = bytesAddedElement.textContent.match(/\d+/);
const bytesAdded = bytesAddedMatch && Number(bytesAddedMatch[0]);
if (!bytesAdded || bytesAdded < cd.config.bytesToDeemComment) return;
}
const dateElement = line.querySelector('.mw-changeslist-date');
if (!dateElement) return;
const { date } = parseTimestamp(dateElement.textContent, cd.g.uiTimezone) || {};
if (!date) return;
const id = Comment.generateId(date, mw.config.get('wgRelevantUserName'));
let wrapper;
if (summary && currentUserRegexp.test(` ${summary} `)) {
wrapper = prototypes.get('wrapperRelevant');
wrapper.lastChild.lastChild.title = goToCommentToYou;
} else {
// We have no place to extract the article ID from :-(
wrapper = prototypes.get('wrapperRegular');
}
wrapper.lastChild.lastChild.href = `${link}#${id}`;
let destination = line.querySelector('.comment');
if (!destination) {
destination = linkElement;
destination.nextSibling.textContent = destination.nextSibling.textContent.replace(/^\s/, '');
}
destination.parentNode.insertBefore(wrapper, destination.nextSibling);
});
}
/**
* Add comment links to a history page.
*
* @param {external:jQuery} $content
* @private
*/
function processHistory($content) {
init.initTimestampParsingTools('user');
if (cd.g.uiTimezone === null) return;
const link = cd.page.getUrl();
[
...$content[0]
.querySelectorAll('#pagehistory > li, #pagehistory > .mw-contributions-list > li:not(.mw-tag-mw-new-redirect)')
].forEach((line) => {
if (line.querySelector('.minoredit')) return;
let summary = line.querySelector('.comment')?.textContent;
summary &&= removeDirMarks(summary);
if (summary && (isCommentEdit(summary) || isUndo(summary) || isMoved(summary))) return;
const bytesAddedElement = line.querySelector('.mw-plusminus-pos');
if (!bytesAddedElement) return;
if (bytesAddedElement.tagName !== 'STRONG') {
const bytesAddedMatch = bytesAddedElement.textContent.match(/\d+/);
const bytesAdded = bytesAddedMatch && Number(bytesAddedMatch[0]);
if (!bytesAdded || bytesAdded < cd.config.bytesToDeemComment) return;
}
const dateElement = line.querySelector('.mw-changeslist-date');
if (!dateElement) return;
const { date } = parseTimestamp(dateElement.textContent, cd.g.uiTimezone) || {};
if (!date) return;
const author = extractAuthor(line);
if (!author) return;
const id = Comment.generateId(date, author);
let wrapper;
if (summary && currentUserRegexp.test(` ${summary} `)) {
wrapper = prototypes.get('wrapperRelevant');
wrapper.lastChild.lastChild.title = goToCommentToYou;
} else {
let isWatched = false;
if (summary) {
const watchedSectionHeadlines = subscriptions?.getForCurrentPage() || [];
if (watchedSectionHeadlines.length) {
isWatched = watchedSectionHeadlines.find((headline) => isInSection(summary, headline));
if (isWatched) {
wrapper = prototypes.get('wrapperRelevant');
wrapper.lastChild.lastChild.title = goToCommentWatchedSection;
}
}
}
if (!isWatched) {
wrapper = prototypes.get('wrapperRegular');
}
}
wrapper.lastChild.lastChild.href = `${link}#${id}`;
let destination = line.querySelector('.comment');
if (!destination) {
const separators = line.querySelectorAll('.mw-changeslist-separator');
destination = separators?.[separators.length - 1];
}
if (!destination) return;
destination.parentNode.insertBefore(wrapper, destination.nextSibling);
});
}
/**
* Add a comment link to a diff view.
*
* @param {external:jQuery} [$diff]
* @fires commentLinksAdded
* @private
*/
function processDiff($diff) {
// Filter out cases when wikipage.diff was fired for the native MediaWiki's diff at the top of
// the page that is a diff page (unless only a diff, and no content, is displayed - if
// mw.user.options.get('diffonly') or the `diffonly` URL parameter is true). We parse that diff on
// convenientDiscussions.pageReady hook instead.
if ($diff?.parent().is(controller.$content) && controller.$root) return;
if (!cd.g.uiTimestampRegexp) {
init.initTimestampParsingTools('user');
}
if (cd.g.uiTimezone === null) return;
const $root = $diff || controller.$content;
const root = $root[0];
[root.querySelector('.diff-otitle'), root.querySelector('.diff-ntitle')]
.filter(definedAndNotNull)
.forEach((area) => {
if (area.querySelector('.minoredit')) return;
area.querySelector('.cd-commentLink')?.remove();
let summary = area.querySelector('.comment')?.textContent;
summary &&= removeDirMarks(summary);
// In diffs, archivation can't be captured by looking at bytes added.
if (
summary &&
(isCommentEdit(summary) || isUndo(summary) || isMoved(summary) || isArchiving(summary))
) {
return;
}
const dateElement = area.querySelector('#mw-diff-otitle1 a, #mw-diff-ntitle1 a');
if (!dateElement) return;
const { date } = parseTimestamp(dateElement.textContent, cd.g.uiTimezone) || {};
if (!date) return;
const author = extractAuthor(area);
if (!author) return;
const id = Comment.generateId(date, author);
let comment;
let page;
if ($diff) {
page = pageRegistry.get((new URL(dateElement.href)).searchParams.get('title'));
} else {
comment = commentRegistry.getById(id, true);
}
if (comment || ($diff && page.isProbablyTalkPage())) {
let wrapper;
if (summary && currentUserRegexp.test(` ${summary} `)) {
wrapper = prototypes.get('wrapperRelevant');
wrapper.lastChild.lastChild.title = goToCommentToYou;
} else {
let isWatched = false;
const watchedSectionHeadlines = subscriptions?.getForCurrentPage() || [];
if (!$diff && summary && watchedSectionHeadlines.length) {
isWatched = watchedSectionHeadlines.find((headline) => isInSection(summary, headline));
if (isWatched) {
wrapper = prototypes.get('wrapperRelevant');
wrapper.lastChild.lastChild.title = goToCommentWatchedSection;
}
}
if (!isWatched) {
wrapper = prototypes.get('wrapperRegular');
}
}
const linkElement = wrapper.lastChild.lastChild;
if ($diff) {
linkElement.href = page.getUrl() + '#' + id;
// Non-diff pages that have a diff, like with Serhio Magpie's Instant Diffs.
if (controller.isTalkPage()) {
linkElement.target = '_blank';
}
} else {
linkElement.href = '#' + id;
linkElement.onclick = (e) => {
e.preventDefault();
comment.scrollTo({
smooth: false,
pushState: true,
expandThreads: true,
});
};
}
const destination = area.querySelector('#mw-diff-otitle3, #mw-diff-ntitle3');
if (!destination) return;
destination.appendChild(wrapper);
}
});
/**
* Comments links have been added to the revisions listed on the page.
*
* @event commentLinksAdded
* @param {external:jQuery} $root Root element of content to which the comment links were added.
* @param {object} cd {@link convenientDiscussions} object.
* @global
*/
mw.hook('convenientDiscussions.commentLinksAdded').fire($root, cd);
}
/**
* Add comment links to the revisions listed on the page that is a revision list page (not a diff
* page, for instance).
*
* @param {external:jQuery} $content
* @private
*/
function processRevisionListPage($content) {
// Occurs in the watchlist when mediawiki.rcfilters.filters.ui module for some reason fires
// wikipage.content for the second time with an element that is not in the DOM,
// fieldset#mw-watchlist-options (in the mw.rcfilters.ui.FormWrapperWidget#onChangesModelUpdate
// function).
if (!$content.parent().length) return;
if (controller.isWatchlistPage()) {
processWatchlist($content);
} else if (controller.isContributionsPage()) {
processContributions($content);
} else if (controller.isHistoryPage()) {
processHistory($content);
}
mw.hook('convenientDiscussions.commentLinksAdded').fire($content, cd);
}
/**
* _For internal use._ The entry function for the comment links adding mechanism.
*/
export default async function addCommentLinks() {
try {
await initialize();
} catch (e) {
console.warn(e);
return;
}
if (controller.isDiffPage()) {
mw.hook('convenientDiscussions.pageReady').add(() => {
processDiff();
});
} else {
// Hook on wikipage.content to make the code work with the watchlist auto-update feature.
mw.hook('wikipage.content').add(processRevisionListPage);
}
// Diffs generated by scripts, like Serhio Magpie's Instant Diffs.
mw.hook('wikipage.diff').add(processDiff);
}
/**
* _For internal use._ When on the Special:Search page, searching for a comment after choosing that
* option from the "Couldn't find the comment" message, add comment links to titles.
*/
export function addCommentLinksToSpecialSearch() {
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Search') return;
const [, commentId] = location.search.match(/[?&]cdcomment=([^&]+)(?:&|$)/) || [];
if (commentId) {
mw.loader.using('mediawiki.api').then(
async () => {
await Promise.all(init.getSiteData());
$('.mw-search-result-heading').each((i, el) => {
const originalHref = $(el)
.find('a')
.first()
.attr('href');
$(el).append(
' ',
$('<span>')
.addClass('cd-searchCommentLink')
.append(
document.createTextNode(cd.mws('parentheses-start')),
$('<a>')
.attr('href', `${originalHref}#${commentId}`)
.text(cd.s('deadanchor-search-gotocomment')),
document.createTextNode(cd.mws('parentheses-end')),
)
);
});
},
console.error
);
}
}