/**
* Module for URL fragment-related tasks on page load, including scrolling to the target comment if
* needed, and showing a notification if the target comment or section is not found, optionally
* searching in the archive.
*
* @module processFragment
*/
import Comment from './Comment';
import cd from './cd';
import commentRegistry from './commentRegistry';
import controller from './controller';
import sectionRegistry from './sectionRegistry';
import { defined, sleep, underlinesToSpaces } from './utils-general';
import { formatDateNative } from './utils-timestamp';
import { removeWikiMarkup } from './utils-wikitext';
import { wrapHtml } from './utils-window';
let decodedValue;
let date;
let author;
let guessedCommentText;
let guessedSectionText;
let sectionName;
let sectionNameDotDecoded;
let token;
let searchQuery;
let searchResults;
/**
* _For internal use._ Perform URL fragment-related tasks.
*/
export default async function processFragment() {
const value = location.hash.slice(1);
let commentId;
try {
decodedValue = decodeURIComponent(value);
if (Comment.isId(value)) {
commentId = decodedValue;
}
} catch (e) {
console.error(e);
}
let comment;
if (commentId) {
({ date, author } = Comment.parseId(commentId) || {});
comment = commentRegistry.getById(commentId, true);
} else if (decodedValue) {
({ comment, date, author } = commentRegistry.getByDtId(decodedValue, true) || {});
}
if (comment) {
// sleep() is for Firefox - for some reason, without it Firefox positions the underlay
// incorrectly. (TODO: does it still? Need to check.)
sleep().then(() => {
comment.scrollTo({
smooth: false,
expandThreads: true,
});
// Replace CD's comment ID in the fragment with DiscussionTools' if available.
history.replaceState(
Object.assign({}, history.state, { cdJumpedToComment: true }),
'',
comment.dtId ? `#${comment.dtId}` : undefined
);
});
}
if (decodedValue && !cd.page.isArchive()) {
const escapedValue = CSS.escape(value);
const escapedDecodedValue = decodedValue && CSS.escape(decodedValue);
const isTargetFound = (
comment ||
cd.config.idleFragments.some((regexp) => decodedValue.match(regexp)) ||
// `/media/` is from MediaViewer, `noticeApplied` is from RedWarn
/^\/media\/|^noticeApplied-|^h-/.test(decodedValue) ||
$(':target').length ||
$(`*[id="${escapedDecodedValue}"], a[name="${escapedDecodedValue}"], *[id="${escapedValue}"], a[name="${escapedValue}"]`).length
);
if (!isTargetFound) {
await maybeNotifyNotFound();
}
}
}
/**
* Show a notification that a section/comment was not found, a link to search in the archive, a
* link to the section/comment if it was found automatically, and/or a link to a section found
* with a similar name or a comment found with the closest date in the past.
*
* @private
*/
async function maybeNotifyNotFound() {
let label;
guessedCommentText = '';
guessedSectionText = '';
if (date) {
label = cd.sParse('deadanchor-comment-lead');
const priorComment = commentRegistry.findPriorComment(date, author);
if (priorComment) {
guessedCommentText = (' ' + cd.sParse('deadanchor-comment-previous', '#' + priorComment.id))
// Until https://phabricator.wikimedia.org/T288415 is online on most wikis.
.replace(cd.g.articlePathRegexp, '$1');
label += guessedCommentText;
}
} else {
sectionName = underlinesToSpaces(decodedValue);
label = (
cd.sParse('deadanchor-section-lead', sectionName) +
' ' +
cd.sParse('deadanchor-section-reason')
);
const sectionMatch = sectionRegistry.findByHeadlineParts(sectionName);
if (sectionMatch) {
guessedSectionText = (
' ' +
cd.sParse('deadanchor-section-similar', '#' + sectionMatch.id, sectionMatch.headline)
)
// Until https://phabricator.wikimedia.org/T288415 is online on most wikis.
.replace(cd.g.articlePathRegexp, '$1');
label += guessedSectionText;
}
}
if (cd.page.canHaveArchives()) {
searchForNotFoundItem();
} else {
mw.notify(wrapHtml(label), {
type: 'warn',
autoHideSeconds: 'long',
});
}
}
/**
* Make a search request and show an "Item not found" notification.
*
* @private
*/
async function searchForNotFoundItem() {
token = date ?
formatDateNative(date, false, cd.g.contentTimezone) :
sectionName.replace(/"/g, '');
searchQuery = `"${token}"`;
if (!date) {
try {
sectionNameDotDecoded = decodeURIComponent(
sectionName.replace(/\.([0-9A-F]{2})/g, '%$1')
);
} catch {
// Empty
}
}
if (sectionName && sectionName !== sectionNameDotDecoded) {
const tokenDotDecoded = sectionNameDotDecoded.replace(/"/g, '');
searchQuery += ` OR "${tokenDotDecoded}"`;
}
if (date) {
// There can be a time difference between the time we know (taken from the history) and the
// time on the page. We take it to be not more than 3 minutes for the time on the page.
for (let gap = 1; gap <= 3; gap++) {
const adjustedToken = formatDateNative(
new Date(date.getTime() - cd.g.msInMin * gap),
false,
cd.g.contentTimezone
);
searchQuery += ` OR "${adjustedToken}"`;
}
}
const archivePrefix = cd.page.getArchivePrefix();
searchQuery += ` prefix:${archivePrefix}`;
const resp = await controller.getApi().get({
action: 'query',
list: 'search',
srsearch: searchQuery,
srprop: date ? undefined : 'sectiontitle',
// List more recent archives first
srsort: 'create_timestamp_desc',
srlimit: 20,
});
searchResults = resp?.query?.search;
notifyAboutSearchResults();
}
/**
* Show an "Item not found" notification.
*
* @private
*/
function notifyAboutSearchResults() {
const searchUrl = (
cd.g.server +
mw.util.getUrl('Special:Search', {
search: searchQuery,
sort: 'create_timestamp_desc',
cdcomment: date && decodedValue,
})
);
if (searchResults.length === 0) {
let label;
if (date) {
label = (
cd.sParse('deadanchor-comment-lead') +
' ' +
cd.sParse('deadanchor-comment-notfound', searchUrl) +
guessedCommentText
);
} else {
label = (
cd.sParse('deadanchor-section-lead', sectionName) +
(
guessedSectionText && sectionName.includes('{{') ?
// Use of a template in the section title. In such a case, it's almost always the real
// match, so we don't show any fail messages.
'' :
(
' ' +
cd.sParse('deadanchor-section-notfound', searchUrl) +
' ' +
cd.sParse('deadanchor-section-reason', searchUrl)
)
) +
guessedSectionText
);
}
mw.notify(wrapHtml(label), {
type: 'warn',
autoHideSeconds: 'long',
});
} else {
let exactMatchPageTitle;
// Will be either sectionName or sectionNameDotDecoded.
let sectionNameFound = sectionName;
if (date) {
const matches = Object.entries(searchResults)
.map(([, result]) => result)
.filter((result) => (
removeWikiMarkup(result.snippet)?.includes(token)
));
if (matches.length === 1) {
exactMatchPageTitle = matches[0].title;
}
} else {
// Obtain the first exact section title match (which would be from the most recent archive).
// This loop iterates over just one item in the vast majority of cases.
const exactMatch = Object.entries(searchResults)
.map(([, result]) => result)
.find((result) => (
result.sectiontitle &&
[sectionName, sectionNameDotDecoded]
.filter(defined)
.includes(result.sectiontitle)
));
if (exactMatch) {
exactMatchPageTitle = exactMatch.title;
sectionNameFound = underlinesToSpaces(exactMatch.sectiontitle);
}
}
let label;
if (exactMatchPageTitle) {
const fragment = date ? decodedValue : sectionNameFound;
const wikilink = `${exactMatchPageTitle}#${fragment}`;
label = date ?
(
cd.sParse('deadanchor-comment-exactmatch', wikilink, searchUrl) +
guessedCommentText
) :
cd.sParse('deadanchor-section-exactmatch', sectionNameFound, wikilink, searchUrl);
} else {
label = date ?
cd.sParse('deadanchor-comment-inexactmatch', searchUrl) + guessedCommentText :
cd.sParse('deadanchor-section-inexactmatch', sectionNameFound, searchUrl);
}
mw.notify(wrapHtml(label), {
type: 'warn',
autoHideSeconds: 'long',
});
}
}