import Button from './Button';
import CdError from './CdError';
import Comment from './Comment';
import LiveTimestamp from './LiveTimestamp';
import PrototypeRegistry from './PrototypeRegistry';
import SectionSkeleton from './SectionSkeleton';
import SectionSource from './SectionSource';
import cd from './cd';
import commentFormRegistry from './commentFormRegistry';
import controller from './controller';
import pageRegistry from './pageRegistry';
import sectionRegistry from './sectionRegistry';
import settings from './settings';
import toc from './toc';
import { handleApiReject } from './utils-api';
import { defined, getHeadingLevel, underlinesToSpaces, unique } from './utils-general';
import { formatDate } from './utils-timestamp';
import { encodeWikilink, maskDistractingCode, normalizeCode } from './utils-wikitext';
import { getRangeContents } from './utils-window';
/**
* Class representing a section.
*
* @augments SectionSkeleton
*/
class Section extends SectionSkeleton {
/**
* Create a section object.
*
* @param {import('./Parser').default} parser
* @param {object} heading Heading object returned by {@link Parser#findHeadings}.
* @param {object[]} targets Sorted target objects returned by {@link Parser#findSignatures} +
* {@link Parser#findHeadings}.
* @param {import('./Subscriptions').default} subscriptions
* @throws {CdError}
*/
constructor(parser, heading, targets, subscriptions) {
super(parser, heading, targets, subscriptions);
this.subscriptions = subscriptions;
this.useTopicSubscription = settings.get('useTopicSubscription');
/**
* Automatically updated sequental number of the section.
*
* @type {?number}
*/
this.liveSectionNumber = this.sectionNumber;
/**
* Revision ID of {@link Section#liveSectionNumber}.
*
* @type {number}
*/
this.liveSectionNumberRevisionId = mw.config.get('wgRevisionId');
/**
* Wiki page that has the source code of the section (may be different from the current page if
* the section is transcluded from another page). This property may be wrong on old version
* pages where there are no edit section links.
*
* @type {import('./pageRegistry').Page}
*/
this.sourcePage = this.sourcePageName ?
pageRegistry.get(this.sourcePageName) :
cd.page;
delete this.sourcePageName;
/**
* Is the section transcluded from a template (usually, that template in turn transludes
* content, like here:
* https://ru.wikipedia.org/wiki/Project:Выборы_арбитров/Лето_2021/Вопросы/Кандидатские_заявления.)
*
* @type {boolean}
*/
this.isTranscludedFromTemplate = this.sourcePage?.namespaceId === 10;
/**
* Is the section actionable. (If it is in a closed discussion or on an old version page, then
* no).
*
* @type {boolean}
*/
this.isActionable = (
cd.page.isActive() &&
!controller.getClosedDiscussions().some((el) => el.contains(this.headingElement)) &&
!this.isTranscludedFromTemplate
);
if (this.isTranscludedFromTemplate) {
this.comments.forEach((comment) => {
comment.isActionable = false;
});
}
this.extractSubscribeId();
/**
* Headline element as a jQuery object.
*
* @type {external:jQuery}
*/
this.$headline = $(this.headlineElement);
/**
* Heading element as a jQuery element.
*
* @type {external:jQuery}
*/
this.$heading = $(this.headingElement);
/**
* Is the section visible (`visibility: visible` as opposed to `visibility: hidden`). Can be
* `true` when the `improvePerformance` setting is enabled.
*
* @type {boolean}
*/
this.isHidden = false;
}
/**
* _For internal use._ Add a {@link Section#replyButton "Reply in section" button} to the end of
* the first chunk of the section.
*/
maybeAddReplyButton() {
if (!this.canBeReplied()) return;
const lastElement = this.lastElementInFirstChunk;
// Sections may have "#" in the code as a placeholder for a vote. In this case, we must create
// the comment form in the <ol> tag.
const isVotePlaceholder = (
lastElement.tagName === 'OL' &&
lastElement.childElementCount === 1 &&
lastElement.children[0].classList.contains('mw-empty-elt')
);
let tag;
let createList = false;
const tagName = lastElement.tagName;
const lastComment = this.commentsInFirstChunk[this.commentsInFirstChunk.length - 1];
if (lastElement.classList.contains('cd-commentLevel') || isVotePlaceholder) {
if (
tagName === 'UL' ||
(
tagName === 'OL' &&
// Check if this is indeed a numbered list with replies as list items, not a numbered list
// as part of the user's comment that has their signature technically inside the last
// item.
(
isVotePlaceholder ||
lastElement !== lastComment?.elements[lastComment.elements.length - 1]
)
)
) {
tag = 'li';
} else if (tagName === 'DL') {
tag = 'dd';
} else {
tag = 'li';
createList = true;
}
} else {
tag = 'dd';
if (!isVotePlaceholder) {
createList = true;
}
}
// Don't set more DOM properties to help performance. We don't need them in practice.
const element = this.constructor.prototypes.get('replyButton');
const button = new Button({
element: element,
buttonElement: element.firstChild,
action: () => {
this.reply();
},
});
const wrapper = document.createElement(tag);
wrapper.className = 'cd-replyButtonWrapper';
wrapper.append(button.element);
// The container contains the wrapper that wraps the element ^_^
let container;
if (createList) {
container = document.createElement('dl');
container.className = 'cd-commentLevel cd-commentLevel-1 cd-section-button-container';
lastElement.parentNode.insertBefore(container, lastElement.nextElementSibling);
} else {
container = lastElement;
container.classList.add('cd-section-button-container');
}
container.append(wrapper);
/**
* Reply button at the bottom of the first chunk of the section.
*
* @type {Button|undefined}
*/
this.replyButton = button;
/**
* Reply button wrapper and part-time reply comment form wrapper, an item element.
*
* @type {external:jQuery|undefined}
*/
this.$replyButtonWrapper = $(wrapper);
/**
* Reply button container and part-time reply comment form container, a list element. It is
* wrapped around the {@link Section#$replyButtonWrapper reply button wrapper}, but it is
* created by the script only when there is no suitable element that already exists. If there
* is, it can contain other elements (and comments) too.
*
* @type {external:jQuery|undefined}
*/
this.$replyButtonContainer = $(container);
}
/**
* _For internal use._ Add an {@link Section#addSubsectionButton "Add subsection" button} that
* appears when hovering over a {@link Section#replyButton "Reply in section" button}.
*/
maybeAddAddSubsectionButton() {
if (this.level !== 2 || !this.canBeSubsectioned()) return;
const element = this.constructor.prototypes.get('addSubsectionButton');
const button = new Button({
element,
buttonElement: element.firstChild,
labelElement: element.querySelector('.oo-ui-labelElement-label'),
label: cd.s('section-addsubsection-to', this.headline),
action: () => {
this.addSubsection();
},
});
button.buttonElement.onmouseenter = this.resetHideAddSubsectionButtonTimeout.bind(this);
button.buttonElement.onmouseleave = this.deferAddSubsectionButtonHide.bind(this);
const container = document.createElement('div');
container.className = 'cd-section-button-container cd-addSubsectionButton-container';
container.style.display = 'none';
container.append(button.element);
this.lastElement.parentNode.insertBefore(container, this.lastElement.nextElementSibling);
/**
* "Add subsection" button at the end of the section.
*
* @type {Button|undefined}
*/
this.addSubsectionButton = button;
/**
* "Add subsection" button container.
*
* @type {external:jQuery|undefined}
*/
this.$addSubsectionButtonContainer = $(container);
}
/**
* Reset the timeout for showing the "Add subsection" button.
*
* @private
*/
resetShowAddSubsectionButtonTimeout() {
clearTimeout(this.showAddSubsectionButtonTimeout);
this.showAddSubsectionButtonTimeout = null;
}
/**
* Reset the timeout for hiding the "Add subsection" button.
*
* @private
*/
resetHideAddSubsectionButtonTimeout() {
clearTimeout(this.hideAddSubsectionButtonTimeout);
this.hideAddSubsectionButtonTimeout = null;
}
/**
* Hide the "Add subsection" button after a second.
*
* @private
*/
deferAddSubsectionButtonHide() {
if (this.hideAddSubsectionButtonTimeout) return;
this.hideAddSubsectionButtonTimeout = setTimeout(() => {
this.$addSubsectionButtonContainer.hide();
}, 1000);
}
/**
* Handle a `mouseenter` event on the reply button.
*
* @private
*/
handleReplyButtonHover() {
if (this.addSubsectionForm) return;
this.resetHideAddSubsectionButtonTimeout();
if (this.showAddSubsectionButtonTimeout) return;
this.showAddSubsectionButtonTimeout = setTimeout(() => {
this.$addSubsectionButtonContainer.show();
}, 1000);
}
/**
* Handle a `mouseleave` event on the reply button.
*
* @private
*/
handleReplyButtonUnhover() {
if (this.addSubsectionForm) return;
this.resetShowAddSubsectionButtonTimeout();
this.deferAddSubsectionButtonHide();
}
/**
* _For internal use._ Make it so that when the user hovers over a reply button at the end of the
* section for a second, an "Add subsection" button shows up under it.
*
* @param {Section} baseSection
*/
showAddSubsectionButtonOnReplyButtonHover(baseSection) {
if (!this.replyButton) return;
this.replyButton.buttonElement.onmouseenter = baseSection.handleReplyButtonHover.bind(baseSection);
this.replyButton.buttonElement.onmouseleave = baseSection.handleReplyButtonUnhover.bind(baseSection);
}
/**
* _For internal use._ Add a "Subscribe" / "Unsubscribe" button to the actions element.
*
* @fires subscribeButtonAdded
*/
addSubscribeButton() {
if (!this.subscribeId) return;
/**
* Subscription state of the section. Currently, `true` stands for "subscribed", `false` for
* "unsubscribed", `null` for n/a.
*
* @type {?boolean}
*/
this.subscriptionState = this.subscriptions.getState(this.subscribeId);
if (controller.isSubscribingDisabled() && !this.subscriptionState) return;
/**
* Subscribe button widget in the {@link Section#actionsElement actions element}.
*
* @type {external:OO.ui.ButtonMenuSelectWidget}
*/
this.actions.subscribeButton = new OO.ui.ButtonWidget({
framed: false,
flags: ['progressive'],
icon: 'bellOutline',
label: cd.s('sm-subscribe'),
title: cd.mws('discussiontools-topicsubscription-button-subscribe-tooltip'),
classes: ['cd-section-bar-button', 'cd-section-bar-button-subscribe'],
});
if (cd.g.skin === 'monobook') {
this.actions.subscribeButton.$element
.find('.oo-ui-iconElement-icon')
.addClass('oo-ui-image-progressive');
}
this.updateSubscribeButtonState();
this.actionsElement.prepend(this.actions.subscribeButton.$element[0]);
/**
* A subscribe button has been added to the section actions element.
*
* @event subscribeButtonAdded
* @param {Section} section
* @param {object} cd {@link convenientDiscussions} object.
*/
mw.hook('convenientDiscussions.subscribeButtonAdded').fire(this);
}
/**
* Check whether the user should get the affordance to edit the first comment from the section
* menu.
*
* @returns {boolean}
*/
canFirstCommentBeEdited() {
return Boolean(
this.isActionable &&
this.commentsInFirstChunk.length &&
this.comments[0].isOpeningSection &&
(this.comments[0].canBeEdited()) &&
!this.comments[0].isCollapsed
);
}
/**
* Check whether the user should get the affordance to move the section to another page.
*
* @returns {boolean}
*/
canBeMoved() {
return (
this.level === 2 &&
!this.isTranscludedFromTemplate &&
(cd.page.isActive() || cd.page.isCurrentArchive())
);
}
/**
* Check whether the user should get the affordance to add a reply to the section.
*
* @returns {boolean}
*/
canBeReplied() {
return Boolean(
this.isActionable &&
// Is the first chunk closed
!(
this.commentsInFirstChunk[0] &&
this.commentsInFirstChunk[0].level === 0 &&
this.commentsInFirstChunk.every((comment) => !comment.isActionable)
) &&
// Is the first chunk empty and precedes a subsection
!(
this.lastElementInFirstChunk !== this.lastElement &&
this.lastElementInFirstChunk === this.headingElement
) &&
// May mean complex formatting, so we better keep out
(
!sectionRegistry.getByIndex(this.index + 1) ||
sectionRegistry.getByIndex(this.index + 1).headingNestingLevel === this.headingNestingLevel
) &&
// Is the section buried in a table.
// https://ru.wikipedia.org/wiki/Project:Запросы_к_администраторам/Быстрые
!['TR', 'TD', 'TH'].includes(this.lastElementInFirstChunk.tagName)
);
}
/**
* Check whether the user should get the affordance to add a subsection to the section.
*
* @returns {boolean}
*/
canBeSubsectioned() {
const nextSameLevelSection = sectionRegistry.getAll()
.slice(this.index + 1)
.find((otherSection) => otherSection.level === this.level);
return Boolean(
this.isActionable &&
this.level >= 2 &&
this.level <= 5 &&
// Not closed
!(
this.comments[0] &&
this.comments[0].level === 0 &&
this.comments.every((comment) => !comment.isActionable)
) &&
(
// While the "Reply" button is added to the end of the first chunk, the "Add subsection"
// button is added to the end of the whole section, so we look the next section of the same
// level.
!nextSameLevelSection ||
// If the next section of the same level has another nesting level (e.g., is inside a <div>
// with a specific style), don't add the "Add subsection" button - it would appear in a
// wrong place.
nextSameLevelSection.headingNestingLevel === this.headingNestingLevel
)
);
}
/**
* Show or hide a popup with the list of users who have posted in the section.
*
* @param {Event} e
* @private
*/
toggleAuthors(e) {
e.preventDefault();
if (!this.authorsPopup) {
/**
* Popup with the list of users who have posted in the section.
*
* @type {external:OO.ui.PopupWidget|undefined}
*/
this.authorsPopup = new OO.ui.PopupWidget({
$content: $(
this.comments
.map((comment) => comment.author)
.filter(unique)
.sort((author1, author2) => author2.getName() > author1.getName() ? -1 : 1)
.map((author) => [author, this.comments.filter((comment) => comment.author === author)])
.flatMap(([author, comments], i, arr) => ([
$('<a>')
.text(author.getName())
.attr('href', `#${comments[0].dtId || comments[0].id}`)
.on('click', Comment.scrollToFirstHighlightAll.bind(Comment, comments))[0],
i === arr.length - 1 ? undefined : document.createTextNode(cd.mws('comma-separator')),
]))
),
head: false,
padded: true,
autoClose: true,
$autoCloseIgnore: $(this.authorCountWrapper),
position: 'above',
$floatableContainer: $(this.authorCountWrapper),
classes: ['cd-section-metadata-authorsPopup'],
});
$(controller.getPopupOverlay()).append(this.authorsPopup.$element);
}
this.authorsPopup.toggle();
}
/**
* Scroll to the latest comment in the section.
*
* @param {Event} e
* @private
*/
scrollToLatestComment(e) {
e.preventDefault();
this.latestComment.scrollTo({ pushState: true });
}
/**
* Create a metadata container (for 2-level sections).
*
* @private
*/
createMetadataElement() {
const authorCount = this.comments.map((comment) => comment.author).filter(unique).length;
const latestComment = Comment.getLatest(this.comments);
let latestCommentWrapper;
let commentCountWrapper;
let authorCountWrapper;
let metadataElement;
if (this.level === 2 && this.comments.length) {
if (latestComment) {
const latestCommentLink = document.createElement('a');
latestCommentLink.href = `#${latestComment.dtId || latestComment.id}`;
latestCommentLink.onclick = this.scrollToLatestComment.bind(this);
latestCommentLink.textContent = formatDate(latestComment.date);
(new LiveTimestamp(latestCommentLink, latestComment.date, false)).init();
latestCommentWrapper = document.createElement('span');
latestCommentWrapper.className = 'cd-section-bar-item';
latestCommentWrapper.append(cd.s('section-metadata-lastcomment'), ' ', latestCommentLink);
}
commentCountWrapper = document.createElement('span');
commentCountWrapper.className = 'cd-section-bar-item';
commentCountWrapper.innerHTML = cd.sParse(
'section-metadata-commentcount-authorcount',
this.comments.length,
authorCount
);
if (this.comments.length === 1) {
commentCountWrapper.querySelector('.cd-section-metadata-authorcount')?.remove();
}
const span = commentCountWrapper.querySelector('.cd-section-metadata-authorcount-link');
if (span) {
authorCountWrapper = document.createElement('a');
authorCountWrapper.textContent = span.textContent;
authorCountWrapper.onclick = this.toggleAuthors.bind(this);
span.firstChild.replaceWith(authorCountWrapper);
}
metadataElement = document.createElement('div');
metadataElement.className = 'cd-section-metadata';
metadataElement.append(...[commentCountWrapper, latestCommentWrapper].filter(defined));
}
/**
* Latest comment in a 2-level section.
*
* @type {?(import('./Comment').default|undefined)}
*/
this.latestComment = latestComment;
/**
* Metadata element in the {@link Section#barElement bar element}.
*
* @type {Element|undefined}
* @private
*/
this.metadataElement = metadataElement;
/**
* Comment count wrapper element in the {@link Section#metadataElement metadata element}.
*
* @type {Element|undefined}
* @private
*/
this.commentCountWrapper = commentCountWrapper;
/**
* Author count wrapper element in the {@link Section#metadataElement metadata element}.
*
* @type {Element|undefined}
* @private
*/
this.authorCountWrapper = authorCountWrapper;
/**
* Latest comment date wrapper element in the {@link Section#metadataElement metadata element}.
*
* @type {Element|undefined}
* @private
*/
this.latestCommentWrapper = latestCommentWrapper;
/**
* Metadata element in the {@link Section#$bar bar element}.
*
* @type {external:jQuery|undefined}
*/
this.$metadata = $(metadataElement);
/**
* Comment count wrapper element in the {@link Section#$metadata metadata element}.
*
* @type {external:jQuery|undefined}
* @private
*/
this.$commentCountWrapper = $(commentCountWrapper);
/**
* Author count wrapper element in the {@link Section#$metadata metadata element}.
*
* @type {external:jQuery|undefined}
*/
this.$authorCountWrapper = $(authorCountWrapper);
/**
* Latest comment date wrapper element in the {@link Section#$metadata metadata element}.
*
* @type {external:jQuery|undefined}
*/
this.$latestCommentWrapper = $(latestCommentWrapper);
}
/**
* Create a real "More options" menu select in place of a dummy one.
*
* @fires moreMenuSelectCreated
* @private
*/
createMoreMenuSelect() {
const moreMenuSelect = this.constructor.prototypes.getWidget('moreMenuSelect')();
const editOpeningCommentOption = this.canFirstCommentBeEdited() ?
new OO.ui.MenuOptionWidget({
data: 'editOpeningComment',
label: cd.s('sm-editopeningcomment'),
title: cd.s('sm-editopeningcomment-tooltip'),
icon: 'edit',
}) :
undefined;
const moveOption = this.canBeMoved() ?
new OO.ui.MenuOptionWidget({
data: 'move',
label: cd.s('sm-move'),
title: cd.s('sm-move-tooltip'),
icon: 'arrowNext',
}) :
undefined;
const addSubsectionOption = this.canBeSubsectioned() ?
new OO.ui.MenuOptionWidget({
data: 'addSubsection',
label: cd.s('sm-addsubsection'),
title: cd.s('sm-addsubsection-tooltip'),
icon: 'speechBubbleAdd',
}) :
undefined;
this.actions.moreMenuSelectDummy.element.remove();
this.actionsElement.append(moreMenuSelect.$element[0]);
const items = [editOpeningCommentOption, moveOption, addSubsectionOption].filter(defined);
moreMenuSelect.getMenu()
.addItems(items)
.on('choose', (option) => {
switch (option.getData()) {
case 'editOpeningComment':
this.comments[0].edit();
break;
case 'move':
this.move();
break;
case 'addSubsection':
this.addSubsection();
break;
}
});
/**
* The button menu select widget in the {@link Section#actionsElement actions element}. Note
* that it is created only when the user hovers over or clicks a dummy button, which fires a
* {@link Section#moreMenuSelectCreated moreMenuSelectCreated hook}.
*
* @type {external:OO.ui.ButtonMenuSelectWidget|undefined}
*/
this.actions.moreMenuSelect = moreMenuSelect;
/**
* A "More options" menu select button has been created and added to the section actions
* element in place of a dummy button.
*
* @event moreMenuSelectCreated
* @param {Section} section
* @param {object} cd {@link convenientDiscussions} object.
*/
mw.hook('convenientDiscussions.moreMenuSelectCreated').fire(this);
}
/**
* Create a real "More options" menu select in place of a dummy one and click it.
*
* @private
*/
createAndClickMoreMenuSelect() {
this.createMoreMenuSelect();
this.actions.moreMenuSelect.focus().emit('click');
}
/**
* Create action buttons and a container for them.
*
* @private
*/
createActionsElement() {
let moreMenuSelectDummy;
if (this.canFirstCommentBeEdited() || this.canBeMoved() || this.canBeSubsectioned()) {
const element = this.constructor.prototypes.get('moreMenuSelect');
moreMenuSelectDummy = new Button({
element,
action: () => {
this.createAndClickMoreMenuSelect();
},
});
moreMenuSelectDummy.buttonElement.onmouseenter = this.createMoreMenuSelect.bind(this);
}
let copyLinkButton;
if (this.headline) {
const element = this.constructor.prototypes.get('copyLinkButton');
copyLinkButton = new Button({
element,
buttonElement: element.firstChild,
iconElement: element.querySelector('.oo-ui-iconElement-icon'),
href: `${cd.page.getUrl()}#${this.id}`,
action: (e) => {
this.copyLink(e);
},
flags: ['progressive'],
});
copyLinkButton.buttonElement.classList.add('mw-selflink-fragment');
}
const actionsElement = document.createElement(this.level === 2 ? 'div' : 'span');
actionsElement.className = [
'cd-section-actions',
this.level === 2 ? 'cd-topic-actions' : 'cd-subsection-actions',
].filter(defined).join(' ');
actionsElement.append(
...[copyLinkButton, moreMenuSelectDummy]
.filter(defined)
.map((button) => button.element)
);
/**
* Actions element under the 2-level section heading _or_ to the right of headings of other
* levels.
*
* @type {Element}
* @private
*/
this.actionsElement = actionsElement;
/**
* Actions element under the 2-level section heading _or_ to the right of headings of other
* levels.
*
* @type {external:jQuery}
*/
this.$actions = $(actionsElement);
/**
* Section actions object. It contains widgets (buttons, menus) triggering the actions of the
* section.
*
* @type {object}
*/
this.actions = {
/**
* Copy link button widget in the {@link Section#actionsElement actions element}.
*
* @type {external:OO.ui.ButtonWidget|undefined}
*/
copyLinkButton,
moreMenuSelectDummy,
};
}
/**
* Create a bar element (for 2-level sections).
*
* @private
*/
addBarElement() {
const barElement = document.createElement('div');
barElement.className = 'cd-section-bar';
if (!this.metadataElement) {
barElement.classList.add('cd-section-bar-nometadata');
}
barElement.append(...[this.metadataElement, this.actionsElement].filter(defined));
if (cd.g.isDtVisualEnhancementsEnabled) {
this.headingElement.querySelector('.ext-discussiontools-init-section-bar')?.remove();
}
this.headingElement.parentNode.insertBefore(
barElement,
this.headingElement.nextElementSibling
);
if (this.lastElement === this.headingElement) {
this.lastElement = barElement;
}
if (this.lastElementInFirstChunk === this.headingElement) {
this.lastElementInFirstChunk = barElement;
}
/**
* Bar element under a 2-level section heading.
*
* @type {Element|undefined}
* @private
*/
this.barElement = barElement;
/**
* Bar element under a 2-level section heading.
*
* @type {external:jQuery|undefined}
*/
this.$bar = $(barElement);
}
/**
* Add the {@link Section#actionsElement actions element} to the
* {@link Secton#headingElement heading element} of a non-2-level section.
*
* @private
*/
addActionsElement() {
const headingInnerWrapper = document.createElement('span');
headingInnerWrapper.append(...this.headingElement.childNodes);
this.headingElement.append(headingInnerWrapper, this.actionsElement);
this.headingElement.classList.add('cd-subsection-heading');
}
/**
* Add the metadata and actions elements below or to the right of the section heading.
*/
addMetadataAndActions() {
this.createActionsElement();
if (this.level === 2) {
this.createMetadataElement();
this.addBarElement();
} else {
this.addActionsElement();
}
}
/**
* Highlight the unseen comments in the section and scroll to the first one of them.
*
* @param {Event} e
* @private
*/
scrollToNewComments(e) {
e.preventDefault();
Comment.scrollToFirstHighlightAll(this.newComments);
}
/**
* Add the new comment count to the metadata element. ("New" actually means "unseen at the moment
* of load".)
*/
addNewCommentCountMetadata() {
if (
this.level !== 2 ||
!this.newComments.length ||
this.newComments.length === this.comments.length
) {
return;
}
const newText = cd.s('section-metadata-newcommentcount', this.newComments.length);
let newLink = document.createElement('a');
newLink.textContent = newText;
newLink.href = `#${this.newComments[0].dtId}`;
newLink.onclick = this.scrollToNewComments.bind(this);
const newCommentCountWrapper = document.createElement('span');
newCommentCountWrapper.className = 'cd-section-bar-item';
newCommentCountWrapper.append(newLink || newText);
this.metadataElement.insertBefore(
newCommentCountWrapper,
this.commentCountWrapper.nextSibling || null
);
this.newCommentCountWrapper = newCommentCountWrapper;
this.$newCommentCountWrapper = $(newCommentCountWrapper);
}
/**
* _For internal use._ Update the new comments data for the section and render the updates.
*/
updateNewCommentsData() {
/**
* List of new comments in the section. ("New" actually means "unseen at the moment of load".)
*
* @type {import('./Comment').default[]}
*/
this.newComments = this.comments.filter((comment) => comment.isSeen === false);
this.addNewCommentCountMetadata();
}
/**
* Extract the section's {@link Section#subscribeId subscribe ID}.
*/
extractSubscribeId() {
if (!this.useTopicSubscription) {
/**
* The section subscribe ID, either in the DiscussionTools format or just a headline if legacy
* subscriptions are used.
*
* @type {string|undefined}
*/
this.subscribeId = this.headline;
return;
}
if (this.level !== 2) return;
let subscribeId = controller.getDtSubscribableThreads()
?.find((thread) => (
thread.id === this.hElement.dataset.mwThreadId ||
thread.id === this.headlineElement.dataset.mwThreadId
))
?.name;
if (!subscribeId) {
// Older versions of MediaWiki
if (cd.g.isDtTopicSubscriptionEnabled) {
if (this.headingElement.querySelector('.ext-discussiontools-init-section-subscribe-link')) {
const headlineJson = this.headlineElement.dataset.mwComment;
try {
subscribeId = JSON.parse(headlineJson).name;
} catch {
// Empty
}
}
} else {
for (let n = this.headingElement.firstChild; n; n = n.nextSibling) {
if (n.nodeType === Node.COMMENT_NODE && n.textContent.includes('__DTSUBSCRIBELINK__')) {
[, subscribeId] = n.textContent.match('__DTSUBSCRIBELINK__(.+)') || [];
break;
}
}
}
}
// Filter out sections with no comments, therefore no meaningful ID
this.subscribeId = subscribeId === 'h-' ? undefined : subscribeId;
}
/**
* Create an {@link Section#replyForm add reply form}.
*
* @param {object} [initialState]
* @param {import('./CommentForm').default} [commentForm]
*/
reply(initialState, commentForm) {
// Check for existence in case replying is called from a script of some kind (there is no button
// to call it from CD).
if (!this.replyForm) {
/**
* Reply form related to the section.
*
* @type {import('./CommentForm').default|undefined}
*/
this.replyForm = commentFormRegistry.setupCommentForm(this, {
mode: 'replyInSection',
}, initialState, commentForm);
this.replyButton.hide();
}
const baseSection = this.getBase();
if (baseSection.$addSubsectionButtonContainer) {
baseSection.$addSubsectionButtonContainer.hide();
clearTimeout(baseSection.showAddSubsectionButtonTimeout);
baseSection.showAddSubsectionButtonTimeout = null;
}
}
/**
* Create an {@link Section#addSubsectionForm add subsection form} form or focus an existing one.
*
* @param {object} [initialState]
* @param {import('./CommentForm').default} [commentForm]
* @throws {CdError}
*/
addSubsection(initialState, commentForm) {
if (!this.canBeSubsectioned()) {
throw new CdError();
}
if (this.addSubsectionForm) {
this.addSubsectionForm.$element.cdScrollIntoView('center');
this.addSubsectionForm.headlineInput.focus();
} else {
/**
* "Add subsection" form related to the section.
*
* @type {import('./CommentForm').default|undefined}
*/
this.addSubsectionForm = commentFormRegistry.setupCommentForm(this, {
mode: 'addSubsection',
}, initialState, commentForm);
this.$addSubsectionButtonContainer?.hide();
}
}
/**
* Add a comment form {@link CommentForm#getTarget targeted} at this section to the page.
*
* @param {string} mode
* @param {import('./CommentForm').default} commentForm
*/
addCommentFormToPage(mode, commentForm) {
if (mode === 'replyInSection') {
this.$replyButtonWrapper
.append(commentForm.$element)
.addClass('cd-replyButtonWrapper-hasCommentForm');
} else if (mode === 'addSubsection') {
/*
In the following structure:
== Level 2 section ==
=== Level 3 section ===
==== Level 4 section ====
..."Add subsection" forms should go in the opposite order. So, if there are "Add
subsection" forms for a level 4 and then a level 2 section and the user clicks "Add
subsection" for a level 3 section, we need to put our form between them.
*/
$(this.findRealLastElement((el) => (
[...el.classList].some((className) => (
className.match(new RegExp(`^cd-commentForm-addSubsection-[${this.level}-6]$`))
))
))).after(commentForm.$element);
}
}
/**
* Clean up traces of a comment form {@link CommentForm#getTarget targeted} at this section from
* the page.
*
* @param {string} mode
*/
cleanUpCommentFormTraces(mode) {
if (mode === 'replyInSection') {
this.replyButton.show();
this.$replyButtonWrapper.removeClass('cd-replyButtonWrapper-hasCommentForm');
}
}
/**
* Show a move section dialog.
*/
move() {
if (controller.isPageOverlayOn()) return;
const MoveSectionDialog = require('./MoveSectionDialog').default;
const dialog = new MoveSectionDialog(this);
controller.getWindowManager().addWindows([dialog]);
controller.getWindowManager().openWindow(dialog);
cd.tests.moveSectionDialog = dialog;
}
/**
* Update the subscribe/unsubscribe section button state.
*
* @private
*/
updateSubscribeButtonState() {
if (this.subscriptionState) {
this.actions.subscribeButton
?.setLabel(cd.s('sm-unsubscribe'))
.setTitle(cd.mws('discussiontools-topicsubscription-button-unsubscribe-tooltip'))
.setIcon('bell')
.off('click')
.on('click', () => {
this.unsubscribe();
});
} else {
this.actions.subscribeButton
?.setLabel(cd.s('sm-subscribe'))
.setTitle(cd.mws('discussiontools-topicsubscription-button-subscribe-tooltip'))
.setIcon('bellOutline')
.off('click')
.on('click', () => {
this.subscribe();
});
}
}
/**
* Add the section to the subscription list.
*
* @param {'quiet'|'silent'} [mode]
* - No value: a notification will be shown.
* - `'quiet'`: don't show a notification.
* - `'silent'`: don't even change any UI, including the subscribe button appearance. If there is
* an error, it will be displayed though.
* @param {string} [renamedFrom] If DiscussionTools' topic subscriptions API is not used and the
* section was renamed, the previous section headline. It is unwatched together with watching
* the current headline if there are no other coinciding headlines on the page.
*/
subscribe(mode, renamedFrom) {
// That's a mechanism mainly for legacy subscriptions but can be used for DT subscriptions as
// well, for which `sections` will have more than one section when there is more than one
// section created by a certain user at a certain moment in time.
const sections = sectionRegistry.getBySubscribeId(this.subscribeId);
let finallyCallback;
if (mode !== 'silent') {
const buttons = sections.map((section) => section.actions.subscribeButton).filter(defined);
buttons.forEach((button) => {
button.setDisabled(true);
});
finallyCallback = () => {
buttons.forEach((button) => {
button.setDisabled(false);
});
};
}
this.subscriptions.subscribe(
this.subscribeId,
this.id,
// Unsubscribe from
renamedFrom && !sectionRegistry.getBySubscribeId(renamedFrom).length ?
renamedFrom :
undefined,
!!mode
)
.then(() => {
// TODO: this condition seems a bad idea because when we could update the subscriptions but
// couldn't reload the page, the UI becomes unsynchronized. But there is also no UI
// flickering when posting. Maybe update the UI in case the page reload was unsuccessful?
if (mode !== 'silent') {
sections.forEach((section) => {
section.changeSubscriptionState(true);
});
}
})
.then(finallyCallback, finallyCallback);
}
/**
* Remove the section from the subscription list.
*
* @param {'quiet'|'silent'} [mode]
* - No value: a notification will be shown.
* - `'quiet'`: don't show a notification.
* - `'silent'`: don't even change any UI, including the subscribe button appearance. If there is
* an error, it will be displayed though.
*/
unsubscribe(mode) {
const sections = sectionRegistry.getBySubscribeId(this.subscribeId);
let finallyCallback;
if (mode !== 'silent') {
const buttons = sections.map((section) => section.actions.subscribeButton).filter(defined);
buttons.forEach((button) => {
button.setDisabled(true);
});
finallyCallback = () => {
buttons.forEach((button) => {
button.setDisabled(false);
});
};
}
this.subscriptions.unsubscribe(this.subscribeId, this.id, !!mode)
.then(() => {
if (mode !== 'silent') {
sections.forEach((section) => {
section.changeSubscriptionState(false);
});
}
})
.then(finallyCallback, finallyCallback);
}
/**
* Change the subscription state after actually subscribing/unsubscribing and change the related
* DOM.
*
* @param {boolean} state
* @private
*/
changeSubscriptionState(state) {
this.subscriptionState = state;
this.updateSubscribeButtonState();
this.updateTocLink();
}
/**
* Resubscribe to a renamed section if legacy topic subscriptions are used.
*
* @param {object} currentCommentData
* @param {object} oldCommentData
*/
resubscribeIfRenamed(currentCommentData, oldCommentData) {
if (
this.useTopicSubscription ||
this.subscriptionState ||
getHeadingLevel({
tagName: currentCommentData.elementNames[0],
className: currentCommentData.elementClassNames[0],
}) ||
oldCommentData.elementNames[0] !== currentCommentData.elementNames[0]
) {
return;
}
const oldHeadingHtml = oldCommentData.elementHtmls[0].replace(
/\x01(\d+)_\w+\x02/g,
(s, num) => currentCommentData.hiddenElementsData[num - 1].html
);
const oldSectionDummy = { headlineElement: $('<span>').html($(oldHeadingHtml).html())[0] };
sectionRegistry.prototype.parseHeadline.call(oldSectionDummy);
if (
this.headline &&
oldSectionDummy.headline !== this.headline &&
this.subscriptions.getOriginalState(oldSectionDummy.headline)
) {
this.subscribe('quiet', oldSectionDummy.headline);
}
}
/**
* _For internal use._ When the section's headline is live-updated in {@link Comment#update}, also
* update some aspects of the section.
*
* @param {external:jQuery} $html
*/
update($html) {
const originalHeadline = this.headline;
this.parseHeadline();
if (this.headline !== originalHeadline) {
if (this.headline && this.subscriptionState && !this.useTopicSubscription) {
this.subscribe('quiet', originalHeadline);
}
this.getTocItem()?.replaceText($html);
}
}
/**
* Copy a link to the section or open a copy link dialog.
*
* @param {Event} e
*/
copyLink(e) {
controller.showCopyLinkDialog(this, e);
}
/**
* Request the wikitext of the section by its number using the API and set some properties of the
* section (and also the page). {@link Section#loadCode} is a more general method.
*
* @throws {CdError}
*/
async requestCode() {
const { query, curtimestamp: queryTimestamp } = await controller.getApi().post({
action: 'query',
titles: this.getSourcePage().name,
prop: 'revisions',
rvsection: this.liveSectionNumber,
rvslots: 'main',
rvprop: ['ids', 'content'],
redirects: !mw.config.get('wgIsRedirect'),
curtimestamp: true,
}).catch(handleApiReject);
const page = query?.pages?.[0];
const revision = page?.revisions?.[0];
const main = revision?.slots?.main;
const content = main?.content;
if (!query || !page) {
throw new CdError({
type: 'api',
code: 'noData',
});
}
if (page.missing) {
throw new CdError({
type: 'api',
code: 'missing',
});
}
if (page.invalid) {
throw new CdError({
type: 'api',
code: 'invalid',
});
}
if (main.nosuchsection) {
throw new CdError({
type: 'api',
code: 'noSuchSection',
});
}
if (!revision || content === undefined) {
throw new CdError({
type: 'api',
code: 'noData',
});
}
const redirectTarget = query.redirects?.[0]?.to || null;
/**
* Section code. Filled upon running {@link Section#loadCode}.
*
* @name code
* @type {string|undefined}
* @memberof Section
* @instance
*/
/**
* ID of the revision that has {@link Section#code}. Filled upon running
* {@link Section#loadCode}.
*
* @name revisionId
* @type {number|undefined}
* @memberof Section
* @instance
*/
/**
* Time when {@link Section#code} was queried (as the server reports it). Filled upon running
* {@link Section#loadCode}.
*
* @name queryTimestamp
* @type {string|undefined}
* @memberof Section
* @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.
presumedCode: content + '\n',
revisionId: revision.revid,
queryTimestamp,
});
Object.assign(cd.page, {
redirectTarget,
realName: redirectTarget || this.name,
});
}
/**
* Load the section wikitext. See also {@link Section#requestCode}.
*
* @param {import('./CommentForm').default} [commentForm] Comment form, if it is submitted or code
* changes are viewed.
* @throws {CdError|Error}
*/
async loadCode(commentForm) {
commentForm?.setSectionSubmitted(false);
try {
if (commentForm && this.liveSectionNumber !== null) {
try {
await this.requestCode();
this.locateInCode(true);
commentForm?.setSectionSubmitted(true);
} catch (e) {
if (!(e instanceof CdError && ['noSuchSection', 'locateSection'].includes(e.data.code))) {
throw e;
}
}
}
if (!commentForm?.isSectionSubmitted()) {
await this.getSourcePage().loadCode();
this.locateInCode(false);
}
} catch (e) {
if (e instanceof CdError) {
throw new CdError(Object.assign({}, {
message: cd.sParse('cf-error-getpagecode'),
}, e.data));
} else {
throw e;
}
}
}
/**
* Search for the section in the source code and return possible matches.
*
* @param {string} contextCode
* @param {boolean} isInSectionContext
* @returns {SectionSource}
* @private
*/
searchInCode(contextCode, isInSectionContext) {
const thisHeadline = normalizeCode(this.headline);
const adjustedContextCode = maskDistractingCode(contextCode);
const sectionHeadingRegexp = /^((=+)(.*)\2[ \t\x01\x02]*)\n/gm;
const sources = [];
const headlines = [];
let sectionIndex = -1;
let sectionHeadingMatch;
while ((sectionHeadingMatch = sectionHeadingRegexp.exec(adjustedContextCode))) {
sectionIndex++;
const source = new SectionSource({
section: this,
sectionHeadingMatch,
contextCode,
adjustedContextCode,
isInSectionContext,
});
source.calculateMatchScore(sectionIndex, thisHeadline, headlines);
if (!source.code || !source.firstChunkCode || source.score <= 1) continue;
sources.push(source);
// Maximal possible score
if (source.score === 3.75) break;
}
return sources.sort((m1, m2) => m2.score - m1.score)[0];
}
/**
* Locate the section in the source code and set the result to the {@link Section#source}
* property.
*
* It is expected that the section or page code is loaded (using {@link Page#loadCode}) before
* this method is called. Otherwise, the method will throw an error.
*
* @param {boolean} useSectionCode Is the section code available to locate the section in instead
* of the page code.
* @throws {CdError}
*/
locateInCode(useSectionCode) {
this.source = null;
const code = useSectionCode ? this.presumedCode : this.getSourcePage().code;
if (code === undefined) {
throw new CdError({
type: 'parse',
code: 'noCode',
});
}
const source = this.searchInCode(code, useSectionCode);
if (!source) {
throw new CdError({
type: 'parse',
code: 'locateSection',
});
}
/**
* Section's source code object.
*
* @type {?(SectionSource|undefined)}
*/
this.source = source;
}
/**
* Get the wiki page that has the source code of the section (may be different from the current
* page if the section is transcluded from another page).
*
* @returns {import('./pageRegistry').Page}
*/
getSourcePage() {
return this.sourcePage;
}
/**
* Get the base section, i.e. a section of level 2 that is an ancestor of the section, or the
* section itself if it is of level 2 (even if there is a level 1 section) or if there is no
* higher level section (the current section may be of level 3 or 1, for example).
*
* @param {boolean} [force2Level=false] Guarantee a 2-level section is returned.
* @returns {?Section}
*/
getBase(force2Level = false) {
const defaultValue = force2Level && this.level !== 2 ? null : this;
return this.level <= 2 ?
defaultValue :
(
sectionRegistry.getAll()
.slice(0, this.index)
.reverse()
.find((section) => section.level === 2) ||
defaultValue
);
}
/**
* Get the collection of the section's subsections.
*
* @param {boolean} [indirect=false] Whether to include subsections of subsections and so on
* (return descendants, in a word).
* @returns {Section[]}
*/
getChildren(indirect = false) {
const children = [];
let haveMetDirect = false;
sectionRegistry.getAll()
.slice(this.index + 1)
.some((section) => {
if (section.level > this.level) {
// If, say, a level 4 section directly follows a level 2 section, it should be considered
// a child. This is why we need the haveMetDirect variable.
if (section.level === this.level + 1) {
haveMetDirect = true;
}
if (indirect || section.level === this.level + 1 || !haveMetDirect) {
children.push(section);
}
return false;
} else {
return true;
}
});
return children;
}
/**
* Get the first upper level section relative to the current section that is subscribed to.
*
* @param {boolean} [includeCurrent=false] Check the current section too.
* @returns {?Section}
*/
getClosestSectionSubscribedTo(includeCurrent = false) {
for (
let otherSection = includeCurrent ? this : this.getParent();
otherSection;
otherSection = otherSection.getParent()
) {
if (otherSection.subscriptionState) {
return otherSection;
}
}
return null;
}
/**
* Get the TOC item for the section if present.
*
* @returns {?import('./toc').TocItem}
*/
getTocItem() {
return toc.getItem(this.id);
}
/**
* Add/remove the section's TOC link according to its subscription state and update the `title`
* attribute.
*/
updateTocLink() {
this.getTocItem()?.updateSubscriptionState(this.subscriptionState);
}
/**
* Get a link to the section with Unicode sequences decoded.
*
* @param {boolean} permanent Get a permanent URL.
* @returns {string}
*/
getUrl(permanent) {
return cd.page.getDecodedUrlWithFragment(this.id, permanent);
}
/**
* Get a section relevant to this section, which means the section itself. (Used for polymorphism
* with {@link Comment#getRelevantSection} and {@link Page#getRelevantSection}.)
*
* @returns {Section}
*/
getRelevantSection() {
return this;
}
/**
* Get a comment relevant to this section, which means the first comment _if_ it is opening the
* section. (Used for polymorphism with {@link Comment#getRelevantComment} and
* {@link Page#getRelevantComment}.)
*
* @returns {?Section}
*/
getRelevantComment() {
return this.comments[0]?.isOpeningSection ? this.comments[0] : null;
}
/**
* Get the data identifying the section when restoring a comment form. (Used for polymorphism with
* {@link Comment#getRelevantComment} and {@link Page#getIdentifyingData}.)
*
* @returns {object}
*/
getIdentifyingData() {
return {
headline: this.headline,
oldestCommentId: this.oldestComment?.id,
index: this.index,
id: this.id,
ancestors: this.getAncestors().map((section) => section.headline),
};
}
/**
* Get the fragment for use in a section wikilink.
*
* @returns {string}
*/
getWikilinkFragment() {
return encodeWikilink(underlinesToSpaces(this.id));
}
/**
* Generate a DT subscribe ID from the oldest timestamp in the section and the current user's name
* if there is no.
*
* @param {string} editTimestamp Timestamp of the edit just made.
*/
ensureSubscribeIdPresent(editTimestamp) {
if (!this.useTopicSubscription || this.subscribeId) return;
this.subscribeId = sectionRegistry.generateDtSubscriptionId(
cd.user.getName(),
this.oldestComment || editTimestamp
);
}
/**
* Get the section used to subscribe to new comments in this section if available.
*
* @returns {?Section}
*/
getSectionSubscribedTo() {
return this.useTopicSubscription ? this.getBase(true) : this;
}
/**
* Find the last element of the section including
*
* @param {Function} [additionalCondition]
* @returns {Element}
*/
findRealLastElement(additionalCondition) {
let realLastElement;
let lastElement = this.lastElement;
do {
realLastElement = lastElement;
lastElement = lastElement.nextElementSibling;
} while (
lastElement &&
(
lastElement.matches('.cd-section-button-container') ||
(!additionalCondition || additionalCondition(lastElement))
)
);
return realLastElement;
}
/**
* _For internal use._ Set the `visibility` CSS value to the section.
*
* @param {boolean} show Show or hide.
*/
updateVisibility(show) {
if (Boolean(show) !== this.isHidden) return;
this.elements ||= getRangeContents(
this.headingElement,
this.findRealLastElement(),
controller.rootElement
);
this.isHidden = !show;
this.elements.forEach((el) => {
el.classList.toggle('cd-section-hidden', !show);
});
}
/**
* If this section is replied to, get the comment that will end up directly above the reply.
*
* @param {import('./CommentForm').default} commentForm
* @returns {Comment}
*/
getCommentAboveReply(commentForm) {
return sectionRegistry.getAll()
.slice(
0,
(
// Section above the reply
(commentForm.getMode() === 'addSubsection' && this.getChildren(true).slice(-1)[0]) || this
).index + 1
)
.reverse()
.reduce((comment, section) => (
comment ||
section.commentsInFirstChunk[section.commentsInFirstChunk.length - 1]
));
}
/**
* After the page is reloaded and this instance doesn't relate to a rendered section on the page,
* get the instance of this section that does.
*
* @returns {?Section}
*/
findNewSelf() {
return sectionRegistry.search({
headline: this.headline,
oldestCommentId: this.oldestComment?.id,
index: this.index,
id: this.id,
// We cache ancestors when saving the session, so this call will return the right value,
// despite the fact that sectionRegistry.items has already changed.
ancestors: this.getAncestors().map((section) => section.headline),
})?.section || null;
}
/**
* Get the name of the section's method creating a comment form with the specified mode.
*
* @param {string} mode
* @returns {string}
*/
getCommentFormMethodName(mode) {
return mode === 'replyInSection' ? 'reply' : mode;
}
/**
* _For internal use._ Create element and widget prototypes to reuse them instead of creating new
* elements from scratch (which is more expensive).
*/
static initPrototypes() {
this.prototypes = new PrototypeRegistry();
this.prototypes.add(
'replyButton',
new OO.ui.ButtonWidget({
label: cd.s('section-reply'),
framed: false,
// Add the thread button class as it behaves as a thread button in fact, being positioned
// inside a "cd-commentLevel" list.
classes: ['cd-button-ooui', 'cd-section-button', 'cd-thread-button'],
}).$element[0]
);
this.prototypes.add(
'addSubsectionButton',
new OO.ui.ButtonWidget({
// Will be replaced
label: ' ',
framed: false,
classes: ['cd-button-ooui', 'cd-section-button'],
}).$element[0]
);
this.prototypes.add(
'copyLinkButton',
new OO.ui.ButtonWidget({
framed: false,
flags: ['progressive'],
icon: 'link',
label: cd.s('sm-copylink'),
invisibleLabel: true,
title: cd.s('sm-copylink-tooltip'),
classes: ['cd-section-bar-button'],
}).$element[0]
);
this.prototypes.addWidget('moreMenuSelect', () => (
new OO.ui.ButtonMenuSelectWidget({
framed: false,
icon: 'ellipsis',
label: cd.s('sm-more'),
invisibleLabel: true,
title: cd.s('sm-more'),
menu: {
horizontalPosition: 'end',
},
classes: ['cd-section-bar-button', 'cd-section-bar-moremenu'],
}))
);
}
}
export default Section;