/**
* Singleton used to obtain instances of the {@link Page} class while avoiding creating duplicates.
*
* @module pageRegistry
*/
import CdError from './CdError';
import CommentForm from './CommentForm';
import TextMasker from './TextMasker';
import cd from './cd';
import commentFormRegistry from './commentFormRegistry';
import commentRegistry from './commentRegistry';
import controller from './controller';
import sectionRegistry from './sectionRegistry';
import { handleApiReject, requestInBackground } from './utils-api';
import { areObjectsEqual, isProbablyTalkPage, mergeRegexps } from './utils-general';
import { parseTimestamp } from './utils-timestamp';
import { findFirstTimestamp, maskDistractingCode } from './utils-wikitext';
/**
* Main MediaWiki object.
*
* @external mw
* @global
* @see https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw
*/
/**
* @class Title
* @memberof external:mw
* @see https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.Title
*/
// Export for the sake of VS Code IntelliSense. FIXME: make the class of the current page extend the
// page's class? The current page has more methods effectively.
/**
* Class representing a wiki page (a page for which the
* {@link https://www.mediawiki.org/wiki/Manual:Interface/JavaScript#All_pages_(user/page-specific) wgIsArticle}
* config value is `true`) in both of its facets – a rendered instance (for the current page) and an
* entry in the database with data and content.
*
* To create an instance, use {@link module:pageRegistry.get} (the constructor is only exported for
* means of code completion).
*/
export class Page {
/**
* Create a page instance.
*
* @param {external:mw.Title} mwTitle
* @param {string} [genderedName]
* @throws {CdError} If the string in the first parameter is not a valid title.
*/
constructor(mwTitle, genderedName) {
// TODO: remove after outside uses are replaced.
if (!(mwTitle instanceof mw.Title)) {
mwTitle = new mw.Title(mwTitle);
}
/**
* Page's {@link external:mw.Title mw.Title} object.
*/
this.mwTitle = mwTitle;
/**
* Page name, with a namespace name, not necessarily normalized (not normalized if a gendered
* name is available). The word separator is a space, not an underline.
*
* @type {string}
*/
this.name = genderedName || mwTitle.getPrefixedText();
/**
* Page title, with no namespace name, normalized. The word separator is a space, not an
* underline.
*
* @type {string}
*/
this.title = mwTitle.getMainText();
/**
* Namespace number.
*
* @type {number}
*/
this.namespaceId = mwTitle.getNamespaceId();
/**
* Page's source code object. This is mostly for polymorphism with {@link CommentSource} and
* {@link SectionSource}; the source code is in {@link Page#code}.
*
* @type {PageSource}
*/
this.source = new PageSource(this);
/**
* Is the page actionable, i.e. you can add a section to it. Can be `true` only for the current
* page.
*
* @type {boolean}
*/
this.isActionable = this.isCurrent() ? this.isCommentable() : false;
}
/**
* Check whether the page is the one the user is visiting.
*
* @returns {boolean}
* @private
*/
isCurrent() {
return this.name === cd.g.pageName;
}
/**
* Check whether the page is the user's own talk page.
*
* @returns {boolean}
*/
isOwnTalkPage() {
return mw.config.get('wgNamespaceNumber') === 3 && this.title === cd.user.getName();
}
/**
* Check whether the current page is eligible for submitting comments to.
*
* @returns {?boolean}
*/
isCommentable() {
if (!this.isCurrent()) {
return null;
}
return controller.isTalkPage() && (this.isActive() || !this.exists());
}
/**
* Check whether the current page exists (is not 404).
*
* @returns {boolean}
*/
exists() {
if (!this.isCurrent()) {
return null;
}
return Boolean(mw.config.get('wgArticleId'));
}
/**
* Check whether the current page is an active talk page: existing, the current revision, not an
* archive page.
*
* This value is constant in most cases, but there are exceptions:
* 1. The user may switch to another revision using
* {@link https://www.mediawiki.org/wiki/Extension:RevisionSlider RevisionSlider}.
* 2. On a really rare occasion, an active page may become inactive if it becomes identified as
* an archive page. This was switched off when I wrote this.
*
* @returns {?boolean}
*/
isActive() {
if (!this.isCurrent()) {
return null;
}
return (
controller.isTalkPage() &&
this.exists() &&
controller.isCurrentRevision() &&
!this.isArchive()
);
}
/**
* Check whether the current page is an archive and the displayed revision the current one.
*
* @returns {boolean}
*/
isCurrentArchive() {
return controller.isCurrentRevision() && this.isArchive();
}
/**
* Get the URL of the page with the specified parameters.
*
* @param {object} parameters
* @returns {string}
*/
getUrl(parameters) {
return mw.util.getUrl(this.name, parameters);
}
/**
* Get a decoded URL with a fragment identifier.
*
* @param {string} fragment
* @param {boolean} permanent Get a permanent URL.
* @returns {string}
*/
getDecodedUrlWithFragment(fragment, permanent) {
const decodedPageUrl = decodeURI(
this.getUrl({
...(permanent ? { oldid: mw.config.get('wgRevisionId') } : {})
})
);
return `${cd.g.server}${decodedPageUrl}#${fragment}`;
}
/**
* Find an archiving info element on the page.
*
* @returns {?external:jQuery}
* @private
*/
findArchivingInfoElement() {
if (!this.isCurrent()) {
return null;
}
// This is not reevaluated after page reloads. Since archive settings we need rarely change, the
// reevaluation is unlikely to make any difference. `$root?` because the $root can not be set
// when it runs from the addCommentLinks module.
this.$archivingInfo ||= controller.$root?.find('.cd-archivingInfo');
return this.$archivingInfo;
}
/**
* Check if the page is probably a talk page.
*
* @returns {boolean}
*/
isProbablyTalkPage() {
return isProbablyTalkPage(this.realName || this.name, this.namespaceId);
}
/**
* Check if the page is an archive page. Relies on {@link module:defaultConfig.archivePaths}
* and/or, for the current page, elements with the class `cd-archivingInfo` and attribute
* `data-is-archive-page`.
*
* @returns {boolean}
*/
isArchive() {
let result = this.findArchivingInfoElement()?.data('isArchivePage');
if (result === undefined || result === null) {
result = false;
const name = this.realName || this.name;
for (const sourceRegexp of this.constructor.getSourcePagesMap().keys()) {
if (sourceRegexp.test(name)) {
result = true;
break;
}
}
}
return Boolean(result);
}
/**
* Check if this page can have archives. If the page is an archive page, returns `false`. Relies
* on {@link module:defaultConfig.pagesWithoutArchives} and
* {@link module:defaultConfig.archivePaths} and/or, for the current page, elements with the class
* `cd-archivingInfo` and attribute `data-can-have-archives`.
*
* @returns {?boolean}
*/
canHaveArchives() {
if (this.isArchive()) {
return false;
}
let result = this.findArchivingInfoElement()?.data('canHaveArchives');
if (result === undefined || result === null) {
result = !mergeRegexps(cd.config.pagesWithoutArchives)?.test(this.realName || this.name);
}
return Boolean(result);
}
/**
* Get the archive prefix for the page. If no prefix is found based on
* {@link module:defaultConfig.archivePaths} and/or, for the current page, elements with the class
* `cd-archivingInfo` and attribute `data-archive-prefix`, returns the current page's name. If the
* page is an archive page or can't have archives, returns `null`.
*
* @param {boolean} [onlyExplicit=false]
* @returns {?string}
*/
getArchivePrefix(onlyExplicit = false) {
if (!this.canHaveArchives()) {
return null;
}
let result = this.findArchivingInfoElement()?.data('archivePrefix');
const name = this.realName || this.name;
if (!result) {
for (const [sourceRegexp, replacement] of this.constructor.getArchivePagesMap().entries()) {
if (sourceRegexp.test(name)) {
result = name.replace(sourceRegexp, replacement);
break;
}
}
}
return result ? String(result) : (onlyExplicit ? null : name + '/');
}
/**
* Get the source page for the page (i.e., the page from which archiving is happening). Returns
* the page itself if it is not an archive page. Relies on
* {@link module:defaultConfig.archivePaths} and/or, for the current page, elements with the class
* `cd-archivingInfo` and attribute `data-archived-page`.
*
* @returns {Page}
*/
getArchivedPage() {
let result = this.findArchivingInfoElement()?.data('archivedPage');
if (!result) {
const name = this.realName || this.name;
for (const [archiveRegexp, replacement] of this.constructor.getSourcePagesMap().entries()) {
if (archiveRegexp.test(name)) {
result = name.replace(archiveRegexp, replacement);
break;
}
}
}
return result ? pageRegistry.get(String(result)) : this;
}
/**
* Make a revision request (see {@link https://www.mediawiki.org/wiki/API:Revisions}) to load the
* wikitext of the page, together with a few revision properties: the timestamp, redirect target,
* and query timestamp (`curtimestamp`). Enrich the page instance with those properties. Also set
* the `realName` property that indicates either the redirect target if it's present or the page
* name.
*
* @param {boolean} [tolerateMissing=true] Assign `''` to the `code` property if the page is
* missing instead of throwing an error.
*
* @throws {CdError}
*/
async loadCode(tolerateMissing = true) {
const { query, curtimestamp: queryTimestamp } = await controller.getApi().post({
action: 'query',
titles: this.name,
prop: 'revisions',
rvslots: 'main',
rvprop: ['ids', 'content'],
redirects: !(this.isCurrent() && mw.config.get('wgIsRedirect')),
curtimestamp: true,
}).catch(handleApiReject);
const page = query?.pages?.[0];
const revision = page?.revisions?.[0];
const content = revision?.slots?.main?.content;
if (!query || !page) {
throw new CdError({
type: 'api',
code: 'noData',
});
}
if (page.missing) {
Object.assign(this, {
code: '',
revisionId: undefined,
redirectTarget: undefined,
realName: this.name,
queryTimestamp,
});
if (tolerateMissing) {
return;
} else {
throw new CdError({
type: 'api',
code: 'missing',
});
}
}
if (page.invalid) {
throw new CdError({
type: 'api',
code: 'invalid',
});
}
if (!revision || content === undefined) {
throw new CdError({
type: 'api',
code: 'noData',
});
}
const redirectTarget = query.redirects?.[0]?.to || null;
/**
* Page ID on the wiki. Filled upon running {@link Page#loadCode} or {@link Page#edit}. In the
* latter case, it is useful for newly created pages.
*
* @name pageId
* @type {number|undefined}
* @memberof module:pageRegistry.Page
* @instance
*/
/**
* Page's source code (wikitext), ending with `\n`. Filled upon running {@link Page#loadCode}.
*
* @name code
* @type {string|undefined}
* @memberof module:pageRegistry.Page
* @instance
*/
/**
* ID of the revision that has {@link Page#code}. Filled upon running {@link Page#loadCode}.
*
* @name revisionId
* @type {number|undefined}
* @memberof module:pageRegistry.Page
* @instance
*/
/**
* Page where {@link Page#name} redirects. Filled upon running {@link Page#loadCode}.
*
* @name redirectTarget
* @type {?(string|undefined)}
* @memberof module:pageRegistry.Page
* @instance
*/
/**
* If {@link Page#name} redirects to some other page, the value is that page. If not, the value
* is the same as {@link Page#name}. Filled upon running {@link Page#loadCode}.
*
* @name realName
* @type {string|undefined}
* @memberof module:pageRegistry.Page
* @instance
*/
/**
* Time when {@link Page#code} was queried (as the server reports it). Filled upon running
* {@link Page#loadCode}.
*
* @name queryTimestamp
* @type {string|undefined}
* @memberof module:pageRegistry.Page
* @instance
*/
Object.assign(this, {
// It's more convenient to unify regexps to have \n as the last character of anything, not
// (?:\n|$), and it doesn't seem to affect anything substantially.
code: content + '\n',
revisionId: revision.revid,
redirectTarget,
realName: redirectTarget || this.name,
queryTimestamp,
});
}
/**
* Make a parse request (see {@link https://www.mediawiki.org/wiki/API:Parsing_wikitext}).
*
* @param {boolean} [customOptions]
* @param {boolean} [inBackground=false] Make a request that won't set the process on hold when
* the tab is in the background.
* @param {boolean} [markAsRead=false] Mark the current page as read in the watchlist.
* @returns {Promise.<object>}
* @throws {CdError}
*/
async parse(customOptions, inBackground = false, markAsRead = false) {
const defaultOptions = {
action: 'parse',
// If we know that this page is a redirect, use its target. Otherwise, use the regular name.
page: this.realName || this.name,
disabletoc: cd.g.skin === 'vector-2022',
useskin: cd.g.skin,
redirects: true,
prop: ['text', 'revid', 'modules', 'jsconfigvars', 'sections', 'subtitle', 'categorieshtml'],
parsoid: cd.g.isParsoidUsed,
...cd.g.apiErrorFormatHtml,
};
const options = Object.assign({}, defaultOptions, customOptions);
// `page` and `oldid` can not be used together.
if (customOptions?.oldid) {
delete options.page;
}
const { parse } = await (
inBackground ?
requestInBackground(options) :
controller.getApi().post(options)
).catch(handleApiReject);
if (parse?.text === undefined) {
throw new CdError({
type: 'api',
code: 'noData',
});
}
if (markAsRead) {
this.markAsRead(parse.revid);
}
return parse;
}
/**
* Get a list of revisions of the page (the `redirects` parameter is set to `true` by default).
*
* @param {object} [customOptions={}]
* @param {boolean} [inBackground=false] Make a request that won't set the process on hold when
* the tab is in the background.
* @returns {Promise.<Array>}
*/
async getRevisions(customOptions = {}, inBackground = false) {
const options = Object.assign({}, {
action: 'query',
titles: customOptions.revids ? undefined : this.name,
rvslots: 'main',
prop: 'revisions',
redirects: !(this.isCurrent() && mw.config.get('wgIsRedirect')),
}, customOptions);
const revisions = (
await (
inBackground ?
requestInBackground(options) :
controller.getApi().post(options)
).catch(handleApiReject)
).query?.pages?.[0]?.revisions;
if (!revisions) {
throw new CdError({
type: 'api',
code: 'noData',
});
}
return revisions;
}
/**
* Make an edit API request ({@link https://www.mediawiki.org/wiki/API:Edit}).
*
* @param {object} customOptions See {@link https://www.mediawiki.org/wiki/API:Edit}. At least
* `text` should be set. `summary` is recommended. `baserevid` and `starttimestamp` are needed
* to avoid edit conflicts. `baserevid` can be taken from {@link Page#revisionId};
* `starttimestamp` can be taken from {@link Page#queryTimestamp}.
* @returns {Promise.<string>} Timestamp of the edit in the ISO format or `'nochange'` if nothing
* has changed.
*/
async edit(customOptions) {
const options = controller.getApi().assertCurrentUser(
Object.assign({}, {
action: 'edit',
// If we know that this page is a redirect, use its target. Otherwise, use the regular name.
title: this.realName || this.name,
notminor: !customOptions.minor,
// Should be `undefined` instead of `null`, otherwise will be interepreted as a string.
tags: cd.user.isRegistered() && cd.config.tagName || undefined,
...cd.g.apiErrorFormatHtml,
}, customOptions)
);
let resp;
try {
resp = await controller.getApi().postWithEditToken(options, {
// Beneficial when sending long unicode texts, which is what we do here.
contentType: 'multipart/form-data',
}).catch(handleApiReject);
} catch (e) {
if (e instanceof CdError) {
const { type, apiResp } = e.data;
if (type === 'network') {
throw e;
} else {
const error = apiResp?.errors[0];
let message;
let isRawMessage = false;
let logMessage;
let code;
if (error) {
code = error.code;
switch (code) {
case 'editconflict': {
message = cd.sParse('error-editconflict');
break;
}
case 'missingtitle': {
message = cd.sParse('error-pagedeleted');
break;
}
default: {
message = error.html;
isRawMessage = message.includes('<table') || message.includes('<div');
}
}
logMessage = [code, apiResp];
} else {
logMessage = apiResp;
}
throw new CdError({
type: 'api',
code: 'error',
apiResp: resp,
details: { code, message, isRawMessage, logMessage },
});
}
} else {
throw e;
}
}
if (resp.edit.result !== 'Success') {
const code = resp.edit.captcha ? 'captcha' : undefined;
throw new CdError({
type: 'api',
code: 'error',
apiResp: resp,
details: {
code,
isRawMessage: true,
logMessage: [code, resp],
},
})
}
return resp.edit.newtimestamp || 'nochange';
}
/**
* Enrich the page instance with the properties regarding whether new topics go on top on this
* page (based on various factors) and, if new topics are on top, the start index of the first
* section.
*
* @throws {CdError}
*/
guessNewTopicPlacement() {
if (this.code === undefined) {
throw new CdError('Can\'t analyze the new topics placement: Page#code is undefined.');
}
let areNewTopicsOnTop = cd.config.areNewTopicsOnTop?.(this.name, this.code) || null;
const adjustedCode = maskDistractingCode(this.code);
const sectionHeadingRegexp = /^==[^=].*?==[ \t\x01\x02]*\n/gm;
let firstSectionStartIndex;
let sectionHeadingMatch;
// Search for the first section's index. If areNewTopicsOnTop is false, we don't need it.
if (areNewTopicsOnTop !== false) {
sectionHeadingMatch = sectionHeadingRegexp.exec(adjustedCode);
firstSectionStartIndex = sectionHeadingMatch?.index;
sectionHeadingRegexp.lastIndex = 0;
}
if (areNewTopicsOnTop === null) {
// Detect the topic order: newest first or newest last.
let previousDate;
let difference = 0;
while ((sectionHeadingMatch = sectionHeadingRegexp.exec(adjustedCode))) {
const timestamp = findFirstTimestamp(this.code.slice(sectionHeadingMatch.index));
const { date } = timestamp && parseTimestamp(timestamp) || {};
if (date) {
if (previousDate) {
difference += date > previousDate ? -1 : 1;
}
previousDate = date;
}
}
areNewTopicsOnTop = difference === 0 ? this.namespaceId % 2 === 0 : difference > 0;
}
/**
* Whether new topics go on top on this page. Filled upon running
* {@link Page#guessNewTopicPlacement}.
*
* @name areNewTopicsOnTop
* @type {boolean|undefined}
* @memberof module:pageRegistry.Page
* @instance
*/
/**
* The start index of the first section, if new topics are on top on this page. Filled upon
* running {@link Page#guessNewTopicPlacement}.
*
* @name firstSectionStartIndex
* @type {number|undefined}
* @memberof module:pageRegistry.Page
* @instance
*/
Object.assign(this, { areNewTopicsOnTop, firstSectionStartIndex });
}
/**
* {@link https://www.mediawiki.org/wiki/Manual:Purge Purge cache} of the page.
*/
async purge() {
await controller.getApi().post({
action: 'purge',
titles: this.name,
}).catch(() => {
mw.notify(cd.s('error-purgecache'), { type: 'warn' });
});
}
/**
* Mark the page as read, optionally setting the revision to mark as read.
*
* @param {number} revisionId Revision to mark as read (setting all newer revisions unread).
*/
async markAsRead(revisionId) {
await controller.getApi().postWithEditToken({
action: 'setnotificationtimestamp',
titles: this.name,
newerthanrevid: revisionId,
});
}
/**
* _For internal use._ Add an "Add topic" button to the bottom of the page if there is an "Add
* topic" tab. (Otherwise, it may be added to a wrong place.)
*/
addAddTopicButton() {
if (
!$('#ca-addsection').length ||
// There is a special welcome text in New Topic Tool for 404 pages.
(cd.g.isDtNewTopicToolEnabled && !this.exists())
) {
return;
}
this.$addSectionButtonContainer = $('<div>')
.addClass('cd-section-button-container cd-addTopicButton-container')
.append(
(new OO.ui.ButtonWidget({
label: cd.s('addtopic'),
framed: false,
classes: ['cd-button-ooui', 'cd-section-button'],
})).on('click', () => {
this.addSection();
}).$element
)
// If appending to this.rootElement, it can land on a wrong place, like on 404 pages with
// New Topic Tool enabled.
.insertAfter(controller.$root);
}
/**
* Add an "Add section" form or not on page load depending on the URL and presence of a
* DiscussionTools' "New topic" form.
*
* @param {object} dtFormData
*/
autoAddSection(dtFormData) {
const { searchParams } = new URL(location.href);
// &action=edit§ion=new when DT's New Topic Tool is enabled.
if (
searchParams.get('section') === 'new' ||
Number(searchParams.get('cdaddtopic')) ||
dtFormData
) {
this.addSection(dtFormData);
}
}
/**
* Create an add section form if not existent.
*
* @param {object} [initialState]
* @param {import('./CommentForm').default} [commentForm]
* @param {object} [preloadConfig={@link CommentForm.getDefaultPreloadConfig CommentForm.getDefaultPreloadConfig()}]
* @param {boolean} [newTopicOnTop=false]
*/
addSection(
initialState,
commentForm,
preloadConfig = CommentForm.getDefaultPreloadConfig(),
newTopicOnTop = false
) {
if (this.addSectionForm) {
// Sometimes there is more than one "Add section" button on the page, and they lead to opening
// forms with different content.
if (!areObjectsEqual(preloadConfig, this.addSectionForm.getPreloadConfig())) {
mw.notify(cd.s('cf-error-formconflict'), { type: 'error' });
return;
}
this.addSectionForm.$element.cdScrollIntoView('center');
// Headline input may be missing if the `nosummary` preload parameter is truthy.
(this.addSectionForm.headlineInput || this.addSectionForm.commentInput).focus();
} else {
/**
* "Add section" form.
*
* @type {CommentForm|undefined}
*/
this.addSectionForm = commentFormRegistry.setupCommentForm(this, {
mode: 'addSection',
preloadConfig,
newTopicOnTop,
}, initialState, commentForm);
this.$addSectionButtonContainer?.hide();
if (!this.exists()) {
controller.$content.children('.noarticletext, .warningbox').hide();
}
$('#ca-addsection').addClass('selected');
$('#ca-view').removeClass('selected');
this.addSectionForm.on('teardown', () => {
$('#ca-addsection').removeClass('selected');
$('#ca-view').addClass('selected');
});
}
}
/**
* Clean up traces of a comment form {@link CommentForm#getTarget targeted} at this page to the
* page.
*
* @param {string} mode
* @param {import('./CommentForm').default} commentForm
*/
addCommentFormToPage(mode, commentForm) {
if (commentForm.isNewTopicOnTop() && sectionRegistry.getByIndex(0)) {
sectionRegistry.getByIndex(0).$heading.before(commentForm.$element);
} else {
controller.$root.after(commentForm.$element);
}
}
/**
* Remove a comment form {@link CommentForm#getTarget targeted} at this page from the page.
*/
cleanUpCommentFormTraces() {
if (!this.exists()) {
controller.$content
// In case DT's new topic tool is enabled. This is responsible for correct styles being set.
.removeClass('ext-discussiontools-init-replylink-open')
.children('.noarticletext, .warningbox')
.show();
}
this.$addSectionButtonContainer?.show();
}
/**
* Get the name of the page's method creating a comment form with the specified mode. Used for
* polymorphism with {@link Section}.
*
* @param {string} mode
* @returns {string}
*/
getCommentFormMethodName(mode) {
return mode;
}
/**
* Used for polymorphism with {@link Comment#getRelevantSection} and
* {@link Section#getRelevantSection}.
*
* @returns {null}
*/
getRelevantSection() {
return null;
}
/**
* Used for polymorphism with {@link Comment#getRelevantComment} and
* {@link Section#getRelevantComment}.
*
* @returns {null}
*/
getRelevantComment() {
return null;
}
/**
* Used for polymorphism with {@link Comment#getIdentifyingData} and
* {@link Section#getIdentifyingData}.
*
* @returns {null}
*/
getIdentifyingData() {
return null;
}
/**
* If a new section is added to the page, get the comment that will end up directly above the
* section.
*
* @param {import('./CommentForm').default} commentForm
* @returns {?import('./Comment').default}
*/
getCommentAboveReply(commentForm) {
return commentForm.isNewTopicOnTop() ? null : commentRegistry.getByIndex(-1);
}
/**
* Used for polymorphism with {@link Comment} and {@link Section}.
*
* @returns {Page}
*/
findNewSelf() {
return this;
}
/**
* Get a diff between two revisions of the page.
*
* @param {number} revisionIdFrom
* @param {number} revisionIdTo
* @returns {Promise.<string>}
*/
async compareRevisions(revisionIdFrom, revisionIdTo) {
return (await controller.getApi().post({
action: 'compare',
fromtitle: this.name,
fromrev: revisionIdFrom,
torev: revisionIdTo,
prop: ['diff'],
}).catch(handleApiReject))?.compare?.body;
}
/**
* Set some map object variables related to archive pages.
*
* @private
*/
static initArchivePagesMaps() {
this.archivePagesMap = new Map();
this.sourcePagesMap = new Map();
const pathToRegexp = (s, replacements, isArchivePath) => (
new RegExp(
(new TextMasker(s))
.mask(/\\[$\\]/g)
.withText((pattern) => {
pattern = mw.util.escapeRegExp(pattern);
if (replacements) {
pattern = pattern
.replace(/\\\$/, '$')
.replace(/\$(\d+)/, (s, n) => {
const replacement = replacements[n - 1];
return replacement ? `(${replacement.source})` : s;
});
}
pattern = '^' + pattern + (isArchivePath ? '.*' : '') + '$';
return pattern;
})
.unmask()
.getText()
)
);
cd.config.archivePaths.forEach((entry) => {
if (entry instanceof RegExp) {
this.sourcePagesMap.set(new RegExp(entry.source + '.*'), '');
} else {
this.archivePagesMap.set(pathToRegexp(entry.source, entry.replacements), entry.archive);
this.sourcePagesMap.set(pathToRegexp(entry.archive, entry.replacements, true), entry.source);
}
});
}
/**
* Lazy initialization for archive pages map.
*
* @returns {Map}
* @private
*/
static getArchivePagesMap() {
if (!this.archivePagesMap) {
this.initArchivePagesMaps();
}
return this.archivePagesMap;
}
/**
* Lazy initialization for source pages map.
*
* @returns {Map}
* @private
*/
static getSourcePagesMap() {
if (!this.sourcePagesMap) {
this.initArchivePagesMaps();
}
return this.sourcePagesMap;
}
}
/**
* Class that keeps the methods and data related to the page's source code.
*/
class PageSource {
/**
* Create a comment's source object.
*
* @param {Page} page Page.
*/
constructor(page) {
this.page = page;
}
/**
* Modify the page code string in accordance with an action. The `'addSection'` action is
* presumed.
*
* @param {object} options
* @param {string} options.commentCode Comment code, including trailing newlines and the
* signature.
* @param {CommentForm} options.commentForm Comment form that has the code.
* @returns {object}
*/
modifyContext({ commentCode, commentForm }) {
const originalContextCode = this.page.code;
let contextCode;
if (commentForm.isNewTopicOnTop()) {
const firstSectionStartIndex = maskDistractingCode(originalContextCode)
.search(/^(=+).*\1[ \t\x01\x02]*$/m);
contextCode = (
(
firstSectionStartIndex === -1 ?
(originalContextCode ? originalContextCode + '\n' : '') :
originalContextCode.slice(0, firstSectionStartIndex)
) +
commentCode +
'\n' +
originalContextCode.slice(firstSectionStartIndex)
);
} else {
contextCode = (
(commentForm.isNewSectionApi() ? '' : (originalContextCode + '\n').trimLeft()) +
commentCode
);
}
return { contextCode, commentCode };
}
}
/**
* @exports pageRegistry
*/
const pageRegistry = {
/**
* Collection of pages.
*
* @type {object}
* @private
*/
items: {},
/**
* Get a page object for a page with the specified name (either a new one or already existing).
*
* @param {string|external:mw.Title} nameOrMwTitle
* @param {boolean} [isGendered=true] Used to keep the gendered namespace name (if `nameOrMwTitle`
* is a string).
* @returns {Page}
*/
get(nameOrMwTitle, isGendered) {
const title = nameOrMwTitle instanceof mw.Title ?
nameOrMwTitle :
new mw.Title(nameOrMwTitle);
const name = title.getPrefixedText();
if (!this.items[name]) {
this.items[name] = new Page(title, isGendered ? nameOrMwTitle : undefined);
} else if (isGendered) {
this.items[name].name = nameOrMwTitle;
}
return this.items[name];
},
/**
* Get the page the user is visiting.
*
* @returns {Page}
*/
getCurrent() {
return this.get(cd.g.pageName, true);
},
};
export default pageRegistry;