Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.58% covered (danger)
15.58%
294 / 1887
11.49% covered (danger)
11.49%
10 / 87
CRAP
0.00% covered (danger)
0.00%
0 / 1
EditPage
15.58% covered (danger)
15.58%
294 / 1887
11.49% covered (danger)
11.49%
10 / 87
166983.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
3
 getArticle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setContextTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContextTitle
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setApiEditOverride
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 handleRetrievedData
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 edit
0.00% covered (danger)
0.00%
0 / 121
0.00% covered (danger)
0.00%
0 / 1
1892
 maybeActivateTempUserCreate
83.33% covered (warning)
83.33%
25 / 30
0.00% covered (danger)
0.00%
0 / 1
9.37
 createTempUser
9.52% covered (danger)
9.52%
2 / 21
0.00% covered (danger)
0.00%
0 / 1
15.85
 getAuthority
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserForPermissions
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 getUserForPreview
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
8.30
 getUserForSave
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 getEditPermissionStatus
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 displayPermissionStatus
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
 displayViewSourcePage
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 previewOnOpen
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
132
 isSectionEditSupported
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 importFormData
31.03% covered (danger)
31.03%
18 / 58
0.00% covered (danger)
0.00%
0 / 1
99.97
 importFormDataPosted
69.32% covered (warning)
69.32%
61 / 88
0.00% covered (danger)
0.00%
0 / 1
55.99
 importContentFormData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initialiseForm
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
240
 getContentObject
0.00% covered (danger)
0.00%
0 / 91
0.00% covered (danger)
0.00%
0 / 1
420
 generateUndoEditSummary
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
110
 getParentRevId
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getCurrentContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tokenOk
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 setPostEditCookie
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 attemptSave
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 incrementResolvedConflicts
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 handleStatus
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
1892
 formatConstraintStatus
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 createMessageBox
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 doPostEditRedirect
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 setNewSectionSummary
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 internalAttemptSave
88.75% covered (warning)
88.75%
71 / 80
0.00% covered (danger)
0.00%
0 / 1
13.24
 handleFailedConstraint
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
11.02
 getExpectedParentRevision
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 setHeaders
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
182
 showIntro
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 toEditContent
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 assertSupportedContent
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 showEditForm
0.00% covered (danger)
0.00%
0 / 137
0.00% covered (danger)
0.00%
0 / 1
756
 makeTemplatesOnThisPageList
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 extractSectionTitle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 showHeader
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
240
 getSummaryInputAttributes
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getSummaryInputWidget
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 showSummaryInput
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 getSummaryPreview
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 showFormBeforeText
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 showFormAfterText
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 showContentForm
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showTextbox1
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 showTextbox
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 displayPreviewArea
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 showPreview
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 showDiff
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
132
 showTosSummary
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 showEditTools
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getCopyrightWarning
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 getPreviewLimitReport
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
90
 showStandardInputs
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
90
 showConflict
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 incrementConflictStats
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpLink
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getCancelLink
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getActionURL
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getPreviewText
0.00% covered (danger)
0.00%
0 / 110
0.00% covered (danger)
0.00%
0 / 1
992
 incrementEditFailureStats
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getPreviewParserOptions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 doPreviewParse
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 getTemplates
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getEditToolbar
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getCheckboxesDefinition
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 getCheckboxesDefinitionForWatchlist
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
132
 getCheckboxesWidget
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 getWatchlistLabelsWidget
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
110
 getEditButtons
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
20
 noSuchSectionPage
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 spamPageWithContent
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 addLongPageWarningHeader
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 addExplainConflictHeader
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setEditConflictHelperFactory
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getEditConflictHelper
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\EditPage;
8
9use BadMethodCallException;
10use MediaWiki\Actions\WatchAction;
11use MediaWiki\Auth\AuthManager;
12use MediaWiki\CommentStore\CommentStore;
13use MediaWiki\Config\Config;
14use MediaWiki\Content\Content;
15use MediaWiki\Content\ContentHandler;
16use MediaWiki\Content\ContentSerializationException;
17use MediaWiki\Content\IContentHandlerFactory;
18use MediaWiki\Content\UnknownContentModelException;
19use MediaWiki\Content\UnsupportedContentFormatException;
20use MediaWiki\Context\DerivativeContext;
21use MediaWiki\Context\IContextSource;
22use MediaWiki\EditPage\Constraint\AccidentalRecreationConstraint;
23use MediaWiki\EditPage\Constraint\AuthorizationConstraint;
24use MediaWiki\EditPage\Constraint\DefaultTextConstraint;
25use MediaWiki\EditPage\Constraint\EditConstraintFactory;
26use MediaWiki\EditPage\Constraint\EditConstraintRunner;
27use MediaWiki\EditPage\Constraint\EditFilterMergedContentHookConstraint;
28use MediaWiki\EditPage\Constraint\ExistingSectionEditConstraint;
29use MediaWiki\EditPage\Constraint\NewSectionMissingSubjectConstraint;
30use MediaWiki\EditPage\Constraint\RedirectConstraint;
31use MediaWiki\EditPage\Constraint\RevisionDeletedConstraint;
32use MediaWiki\EditPage\Constraint\SpamRegexConstraint;
33use MediaWiki\Exception\ErrorPageError;
34use MediaWiki\Exception\PermissionsError;
35use MediaWiki\Exception\ReadOnlyError;
36use MediaWiki\Exception\ThrottledError;
37use MediaWiki\Exception\UserBlockedError;
38use MediaWiki\HookContainer\HookRunner;
39use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
40use MediaWiki\Html\Html;
41use MediaWiki\Language\MessageLocalizer;
42use MediaWiki\Language\RawMessage;
43use MediaWiki\Linker\Linker;
44use MediaWiki\Linker\LinkRenderer;
45use MediaWiki\Logger\LoggerFactory;
46use MediaWiki\MainConfigNames;
47use MediaWiki\MediaWikiServices;
48use MediaWiki\Message\Message;
49use MediaWiki\Page\Article;
50use MediaWiki\Page\CategoryPage;
51use MediaWiki\Page\LinkBatchFactory;
52use MediaWiki\Page\PageIdentity;
53use MediaWiki\Page\PageReference;
54use MediaWiki\Page\WikiPage;
55use MediaWiki\PageEdit\PageEditFactory;
56use MediaWiki\PageEdit\PageEditInputs;
57use MediaWiki\PageEdit\PageEditStatus;
58use MediaWiki\Parser\Parser;
59use MediaWiki\Parser\ParserOptions;
60use MediaWiki\Parser\ParserOutput;
61use MediaWiki\Parser\ParserOutputLinkTypes;
62use MediaWiki\Permissions\Authority;
63use MediaWiki\Permissions\PermissionManager;
64use MediaWiki\Permissions\PermissionStatus;
65use MediaWiki\Permissions\RestrictionStore;
66use MediaWiki\Request\WebRequest;
67use MediaWiki\Revision\RevisionRecord;
68use MediaWiki\Revision\RevisionStore;
69use MediaWiki\Revision\RevisionStoreRecord;
70use MediaWiki\Revision\SlotRecord;
71use MediaWiki\Session\SessionManager;
72use MediaWiki\ShadowPage\ShadowPageLoader;
73use MediaWiki\Skin\Skin;
74use MediaWiki\Status\Status;
75use MediaWiki\Title\Title;
76use MediaWiki\Title\TitleValue;
77use MediaWiki\User\ExternalUserNames;
78use MediaWiki\User\Options\UserOptionsLookup;
79use MediaWiki\User\Registration\UserRegistrationLookup;
80use MediaWiki\User\TempUser\CreateStatus;
81use MediaWiki\User\TempUser\TempUserCreator;
82use MediaWiki\User\User;
83use MediaWiki\User\UserEditTracker;
84use MediaWiki\User\UserFactory;
85use MediaWiki\User\UserIdentity;
86use MediaWiki\User\UserIdentityUtils;
87use MediaWiki\Watchlist\WatchedItem;
88use MediaWiki\Watchlist\WatchedItemStoreInterface;
89use MediaWiki\Watchlist\WatchlistLabelStore;
90use MediaWiki\Watchlist\WatchlistManager;
91use MediaWiki\Widget\MenuTagMultiselectWidget;
92use OOUI;
93use OOUI\ButtonWidget;
94use OOUI\CheckboxInputWidget;
95use OOUI\CheckboxMultiselectInputWidget;
96use OOUI\DropdownInputWidget;
97use OOUI\FieldLayout;
98use RuntimeException;
99use StatusValue;
100use Wikimedia\Assert\Assert;
101use Wikimedia\Message\MessageSpecifier;
102use Wikimedia\Message\MessageValue;
103use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
104use Wikimedia\Rdbms\IConnectionProvider;
105use Wikimedia\Timestamp\ConvertibleTimestamp;
106use Wikimedia\Timestamp\TimestampFormat as TS;
107
108/**
109 * The HTML user interface for page editing.
110 *
111 * This was originally split from the Article class, with some database and text
112 * munging logic still partly there.
113 *
114 * EditPage cares about two distinct titles:
115 * - $this->mContextTitle is the page that forms submit to, links point to,
116 *   redirects go to, etc.
117 * - $this->page->getTitle() (as well as $mArticle) is the page in the database that is
118 *   actually being edited.
119 *
120 * These are usually the same, but they are now allowed to be different.
121 *
122 * Surgeon General's Warning: prolonged exposure to this class is known to cause
123 * headaches, which may be fatal.
124 *
125 * @newable
126 * @note marked as newable in 1.35 for lack of a better alternative,
127 *       but should be split up into service objects and command objects
128 *       in the future (T157658).
129 */
130#[\AllowDynamicProperties]
131class EditPage implements IEditObject {
132    use ProtectedHookAccessorTrait;
133    use DataStashTrait;
134
135    /**
136     * Used for Unicode support checks
137     */
138    public const UNICODE_CHECK = 'ℳ𝒲♥𝓊𝓃𝒾𝒸ℴ𝒹ℯ';
139
140    /**
141     * HTML id and name for the beginning of the edit form.
142     */
143    public const EDITFORM_ID = 'editform';
144
145    /**
146     * Prefix of key for cookie used to pass post-edit state.
147     * The revision id edited is added after this
148     */
149    public const POST_EDIT_COOKIE_KEY_PREFIX = 'PostEditRevision';
150
151    /**
152     * Duration of PostEdit cookie, in seconds.
153     * The cookie will be removed on the next page view of this article (Article::view()).
154     *
155     * Otherwise, though, we don't want the cookies to accumulate.
156     * RFC 2109 ( https://www.ietf.org/rfc/rfc2109.txt ) specifies a possible
157     * limit of only 20 cookies per domain. This still applies at least to some
158     * versions of IE without full updates:
159     * https://blogs.msdn.com/b/ieinternals/archive/2009/08/20/wininet-ie-cookie-internals-faq.aspx
160     *
161     * A value of 20 minutes should be enough to take into account slow loads and minor
162     * clock skew while still avoiding cookie accumulation when JavaScript is turned off.
163     *
164     * Some say this is too long (T211233), others say it is too short (T289538).
165     * The same value is used for client-side post-edit storage (in mediawiki.action.view.postEdit).
166     */
167    public const POST_EDIT_COOKIE_DURATION = 1200;
168
169    /**
170     * @var Article
171     */
172    private $mArticle;
173
174    /** @var WikiPage */
175    private $page;
176
177    /** @var null|Title */
178    private $mContextTitle = null;
179
180    /** Whether an edit conflict needs to be resolved. Detected based on whether
181     * $editRevId is different than the latest revision. When a conflict has successfully
182     * been resolved by a 3-way-merge, this field is set to false.
183     */
184    public bool $isConflict = false;
185
186    /** New page or new section */
187    private bool $isNew = false;
188
189    /** @var string */
190    public $formtype;
191
192    /** @var bool
193     * True the first time the edit form is rendered, false after re-rendering
194     * with diff, save prompts, etc.
195     */
196    public $firsttime;
197
198    private bool $mTokenOk = false;
199
200    private bool $mTriedSave = false;
201
202    private bool $incompleteForm = false;
203
204    private bool $missingSummary = false;
205
206    private bool $allowBlankSummary = false;
207
208    private bool $blankArticle = false;
209
210    private bool $allowBlankArticle = false;
211
212    /** @var ?Title */
213    private $problematicRedirectTarget = null;
214
215    /** @var ?Title */
216    private $allowedProblematicRedirectTarget = null;
217
218    private bool $ignoreProblematicRedirects = false;
219
220    private string $autoSumm = '';
221
222    private string $hookError = '';
223
224    private ?ParserOutput $mParserOutput = null;
225
226    public bool $mShowSummaryField = true;
227
228    # Form values
229
230    public bool $save = false;
231
232    public bool $preview = false;
233
234    private bool $diff = false;
235
236    private bool $minoredit = false;
237
238    private bool $watchthis = false;
239
240    /** Corresponds to $wgWatchlistExpiry */
241    private bool $watchlistExpiryEnabled;
242
243    /** The expiry time of the watch item, or null if it is not watched temporarily. */
244    private ?string $watchlistExpiry = null;
245
246    /** Corresponds to $wgEnableWatchlistLabels */
247    private bool $watchlistLabelsEnabled;
248
249    /** @var ?int[] Watchlist label IDs submitted in the form, or null if not submitted */
250    private ?array $watchlistLabels = null;
251
252    private bool $recreate = false;
253
254    private bool $ignoreRevisionDeletedWarning = false;
255
256    /**
257     * Page content input field.
258     */
259    public string $textbox1 = '';
260
261    public string $summary = '';
262
263    /** If true, hide the summary field. */
264    private bool $nosummary = false;
265
266    /**
267     * Timestamp of the latest revision of the page when editing was initiated
268     * on the client.
269     */
270    public ?string $edittime = '';
271
272    /**
273     * Revision ID of the latest revision of the page when editing
274     * was initiated on the client.  This is used to detect and resolve edit
275     * conflicts.
276     *
277     * @note 0 if the page did not exist at that time.
278     * @note When starting an edit from an old revision, this still records the current
279     * revision at the time, not the one the edit is based on.
280     *
281     * @see $oldid
282     * @see getExpectedParentRevision()
283     */
284    private ?int $editRevId = null;
285
286    public string $section = '';
287
288    public ?string $sectiontitle = null;
289
290    /**
291     * Timestamp from the first time the edit form was rendered.
292     */
293    public ?string $starttime = '';
294
295    /**
296     * Revision ID the edit is based on, or 0 if it's the latest revision.
297     * FIXME: This isn't used in conflict resolution--provide a better
298     * justification or merge with parentRevId.
299     * @see $editRevId
300     */
301    public int $oldid = 0;
302
303    /**
304     * Revision ID the edit is based on, adjusted when an edit conflict is resolved.
305     * @see $editRevId
306     * @see $oldid
307     * @see getparentRevId()
308     */
309    private int $parentRevId = 0;
310
311    private ?int $scrolltop = null;
312
313    private bool $markAsBot = true;
314
315    public string $contentModel;
316
317    public ?string $contentFormat = null;
318
319    private array $changeTags = [];
320
321    # Placeholders for text injection by hooks (must be HTML)
322    # extensions should take care to _append_ to the present value
323
324    /** @var string Before even the preview */
325    public $editFormPageTop = '';
326    /** @var string */
327    public $editFormTextTop = '';
328    /** @var string */
329    public $editFormTextBeforeContent = '';
330    /** @var string */
331    public $editFormTextAfterWarn = '';
332    /** @var string */
333    public $editFormTextAfterTools = '';
334    /** @var string */
335    public $editFormTextBottom = '';
336    /** @var string */
337    public $editFormTextAfterContent = '';
338    /** @var string */
339    public $previewTextAfterContent = '';
340
341    /** Should be set to true whenever an article was successfully altered. */
342    public bool $didSave = false;
343    public int $undidRev = 0;
344    private int $undoAfter = 0;
345
346    public bool $suppressIntro = false;
347
348    private int|false $contentLength = false;
349
350    /**
351     * Set in ApiEditPage, based on ContentHandler::allowsDirectApiEditing
352     */
353    private bool $enableApiEditOverride = false;
354
355    protected IContextSource $context;
356
357    /** Whether an old revision is edited */
358    private bool $isOldRev = false;
359
360    /** What the user submitted in the 'wpUnicodeCheck' field */
361    private string $unicodeCheck = '';
362
363    /** @var callable|null */
364    private $editConflictHelperFactory = null;
365    private ?TextConflictHelper $editConflictHelper = null;
366
367    private AuthManager $authManager;
368    private EditConstraintFactory $constraintFactory;
369    private IContentHandlerFactory $contentHandlerFactory;
370    private IConnectionProvider $dbProvider;
371    private LinkBatchFactory $linkBatchFactory;
372    private LinkRenderer $linkRenderer;
373    private PageEditFactory $pageEditFactory;
374    private PageEditingHelper $pageEditingHelper;
375    private PermissionManager $permManager;
376    private RestrictionStore $restrictionStore;
377    private RevisionStore $revisionStore;
378    private SessionManager $sessionManager;
379    private ShadowPageLoader $shadowPageLoader;
380    private TempUserCreator $tempUserCreator;
381    private TextboxBuilder $textboxBuilder;
382    private UserEditTracker $userEditTracker;
383    private UserFactory $userFactory;
384    private UserIdentityUtils $userIdentityUtils;
385    private UserOptionsLookup $userOptionsLookup;
386    private UserRegistrationLookup $userRegistrationLookup;
387    private WatchedItemStoreInterface $watchedItemStore;
388    private WatchlistLabelStore $watchlistLabelStore;
389    private WatchlistManager $watchlistManager;
390
391    /** @var User|null */
392    private $placeholderTempUser;
393
394    /** @var User|null */
395    private $unsavedTempUser;
396
397    /** @var User|null */
398    private $savedTempUser;
399
400    /** Whether temp user creation will be attempted */
401    private bool $tempUserCreateActive = false;
402
403    /** If a temp user name was acquired, this is the name */
404    private ?string $tempUserName = null;
405
406    /** Whether temp user creation was successful */
407    private bool $tempUserCreateDone = false;
408
409    /** Whether temp username acquisition failed (false indicates no failure or not attempted) */
410    private bool $unableToAcquireTempName = false;
411
412    /** Whether reauthentication is required for saving the edit (operation name, or null) */
413    private ?string $reauthRequired = null;
414
415    /**
416     * @stable to call
417     * @param Article $article
418     */
419    public function __construct( Article $article ) {
420        $this->mArticle = $article;
421        $this->page = $article->getPage(); // model object
422
423        // Make sure the local context is in sync with other member variables.
424        // Particularly make sure everything is using the same WikiPage instance.
425        // This should probably be the case in Article as well, but it's
426        // particularly important for EditPage, to make use of the in-place caching
427        // facility in WikiPage::prepareContentForEdit.
428        $this->context = new DerivativeContext( $article->getContext() );
429        $this->context->setWikiPage( $this->page );
430
431        $services = MediaWikiServices::getInstance();
432        $this->authManager = $services->getAuthManager();
433        $this->constraintFactory = $services->getService( '_EditConstraintFactory' );
434        $this->contentHandlerFactory = $services->getContentHandlerFactory();
435        $this->dbProvider = $services->getConnectionProvider();
436        $this->linkBatchFactory = $services->getLinkBatchFactory();
437        $this->linkRenderer = $services->getLinkRenderer();
438        $this->pageEditFactory = $services->getService( '_PageEditFactory' );
439        $this->pageEditingHelper = $services->getService( '_PageEditingHelper' );
440        $this->permManager = $services->getPermissionManager();
441        $this->restrictionStore = $services->getRestrictionStore();
442        $this->revisionStore = $services->getRevisionStore();
443        $this->sessionManager = $services->getSessionManager();
444        $this->shadowPageLoader = $services->getShadowPageLoader();
445        $this->tempUserCreator = $services->getTempUserCreator();
446        $this->textboxBuilder = $services->getTextboxBuilder();
447        $this->userEditTracker = $services->getUserEditTracker();
448        $this->userFactory = $services->getUserFactory();
449        $this->userIdentityUtils = $services->getUserIdentityUtils();
450        $this->userOptionsLookup = $services->getUserOptionsLookup();
451        $this->userRegistrationLookup = $services->getUserRegistrationLookup();
452        $this->watchedItemStore = $services->getWatchedItemStore();
453        $this->watchlistLabelStore = $services->getWatchlistLabelStore();
454        $this->watchlistManager = $services->getWatchlistManager();
455
456        $this->watchlistExpiryEnabled = $this->getContext()->getConfig() instanceof Config
457            && $this->getContext()->getConfig()->get( MainConfigNames::WatchlistExpiry );
458        $this->watchlistLabelsEnabled = $this->getContext()->getConfig() instanceof Config
459            && $this->getContext()->getConfig()->get( MainConfigNames::EnableWatchlistLabels );
460
461        $this->contentModel = $this->getTitle()->getContentModel();
462        $this->contentFormat = $this->contentHandlerFactory
463            ->getContentHandler( $this->contentModel )
464            ->getDefaultFormat();
465    }
466
467    /**
468     * @return Article
469     */
470    public function getArticle() {
471        return $this->mArticle;
472    }
473
474    /**
475     * @since 1.28
476     * @return IContextSource
477     */
478    public function getContext() {
479        return $this->context;
480    }
481
482    /**
483     * @since 1.19
484     * @return Title
485     */
486    public function getTitle() {
487        return $this->page->getTitle();
488    }
489
490    /**
491     * @param Title|null $title
492     */
493    public function setContextTitle( $title ) {
494        $this->mContextTitle = $title;
495    }
496
497    /**
498     * @throws RuntimeException if no context title was set
499     * @return Title
500     */
501    public function getContextTitle() {
502        if ( $this->mContextTitle === null ) {
503            throw new RuntimeException( "EditPage does not have a context title set" );
504        } else {
505            return $this->mContextTitle;
506        }
507    }
508
509    /**
510     * Allow editing of content that supports API direct editing, but not general
511     * direct editing. Set to false by default.
512     * @internal Must only be used by ApiEditPage
513     */
514    public function setApiEditOverride( bool $enableOverride ): void {
515        $this->enableApiEditOverride = $enableOverride;
516    }
517
518    /**
519     * Overriden for DataStashTrait
520     */
521    protected function handleRetrievedData( array $data ): void {
522        $this->textbox1 = $data['wpTextbox1'] ?? $this->textbox1;
523        $this->summary = $data['wpSummary'] ?? $this->summary;
524        $this->minoredit = array_key_exists( 'wpMinoredit', $data );
525        $this->watchthis = array_key_exists( 'wpWatchthis', $data );
526        $this->edittime = $data['wpEdittime'] ?? $this->edittime;
527        $this->editRevId = isset( $data['editRevId'] ) ? (int)$data['editRevId'] : $this->editRevId;
528        $this->starttime = $data['wpStarttime'] ?? $this->starttime;
529        $this->section = $data['wpSection'] ?? $this->section;
530        $this->diff = true;
531    }
532
533    /**
534     * This is the function that gets called for "action=edit". It
535     * sets up various member variables, then passes execution to
536     * another function, usually showEditForm()
537     *
538     * The edit form is self-submitting, so that when things like
539     * preview and edit conflicts occur, we get the same form back
540     * with the extra stuff added.  Only when the final submission
541     * is made and all is well do we actually save and redirect to
542     * the newly-edited page.
543     */
544    public function edit() {
545        // Allow extensions to modify/prevent this form or submission
546        if ( !$this->getHookRunner()->onAlternateEdit( $this ) ) {
547            return;
548        }
549
550        wfDebug( __METHOD__ . ": enter" );
551
552        $request = $this->context->getRequest();
553        // If they used redlink=1 and the page exists, redirect to the main article
554        if ( $request->getBool( 'redlink' ) && $this->page->exists() ) {
555            $this->context->getOutput()->redirect( $this->getTitle()->getFullURL() );
556            return;
557        }
558
559        // check for stashed form data for post-submit reauths, set
560        // EditPage request context if data exists, prior to importFormData
561        $this->setStashKey( self::EDITFORM_ID . ':' . $this->getTitle()->getPrefixedDBkey() );
562        $stashedDataRetrieved = $this->retrieveStashedData();
563        if ( !$stashedDataRetrieved ) {
564            $this->importFormData();
565        }
566
567        $this->firsttime = false;
568
569        $readOnlyMode = MediaWikiServices::getInstance()->getReadOnlyMode();
570        if ( $this->save && $readOnlyMode->isReadOnly() ) {
571            // Force preview
572            $this->save = false;
573            $this->preview = true;
574        }
575
576        if ( $this->save ) {
577            $this->formtype = 'save';
578        } elseif ( $this->preview ) {
579            $this->formtype = 'preview';
580        } elseif ( $this->diff ) {
581            $this->formtype = 'diff';
582        } else { # First time through
583            $this->firsttime = true;
584            if ( $this->previewOnOpen() ) {
585                $this->formtype = 'preview';
586            } else {
587                $this->formtype = 'initial';
588            }
589        }
590
591        // Check permissions after possibly creating a placeholder temp user.
592        // This allows anonymous users to edit via a temporary account, if the site is
593        // configured to (1) disallow anonymous editing and (2) autocreate temporary
594        // accounts on edit.
595        $this->unableToAcquireTempName = !$this->maybeActivateTempUserCreate( !$this->firsttime )->isOK();
596
597        $status = $this->getEditPermissionStatus(
598            $this->save ? PermissionManager::RIGOR_SECURE : PermissionManager::RIGOR_FULL
599        );
600        if ( !$status->isGood() && $this->formtype !== 'save' ) {
601            wfDebug( __METHOD__ . ": User can't edit" );
602
603            $user = $this->context->getUser();
604            if ( $user->getBlock() && !$readOnlyMode->isReadOnly() ) {
605                // Auto-block user's IP if the account was "hard" blocked
606                $user->scheduleSpreadBlock();
607            }
608            $this->displayPermissionStatus( $status );
609
610            return;
611        }
612
613        $this->reauthRequired = $status->getReauthOperation();
614
615        $revRecord = $this->mArticle->fetchRevisionRecord();
616        // Disallow editing revisions with content models different from the current one
617        // Undo edits being an exception in order to allow reverting content model changes.
618        $revContentModel = $revRecord ?
619            $revRecord->getMainContentModel() :
620            false;
621        if ( $revContentModel && $revContentModel !== $this->contentModel ) {
622            $prevRevRecord = null;
623            $prevContentModel = false;
624            if ( $this->undidRev ) {
625                $undidRevRecord = $this->revisionStore
626                    ->getRevisionById( $this->undidRev );
627                $prevRevRecord = $undidRevRecord ?
628                    $this->revisionStore->getPreviousRevision( $undidRevRecord ) :
629                    null;
630
631                $prevContentModel = $prevRevRecord ?
632                    $prevRevRecord->getMainContentModel() :
633                    '';
634            }
635
636            if ( !$this->undidRev
637                || !$prevRevRecord
638                || $prevContentModel !== $this->contentModel
639            ) {
640                $this->displayViewSourcePage(
641                    $this->getContentObject(),
642                    $this->context->msg(
643                        'contentmodelediterror',
644                        $revContentModel,
645                        $this->contentModel
646                    )->plain()
647                );
648                return;
649            }
650        }
651
652        $this->isConflict = false;
653
654        # Attempt submission here.  This will check for edit conflicts,
655        # and redundantly check for locked database, blocked IPs, etc.
656        # that edit() already checked just in case someone tries to sneak
657        # in the back door with a hand-edited submission URL.
658
659        if ( $this->formtype === 'save' ) {
660            $this->setStashKey( self::EDITFORM_ID . ':' . $this->getTitle()->getPrefixedDBkey() );
661
662            if ( $this->reauthRequired !== null ) {
663                // reauth at the attempted post/form save, stash user-submitted data
664                $queryParams = $this->stashDataOnPost();
665                $this->doReauthRedirect( $status, $queryParams );
666            }
667
668            $this->destroyStashedData();
669
670            // we need to check this again here, for other potential issues, since
671            // we're not checking formtype == save for the same validation above
672            if ( !$status->isGood() ) {
673                wfDebug( __METHOD__ . ": User can't edit" );
674                $user = $this->context->getUser();
675                if ( $user->getBlock() && !$readOnlyMode->isReadOnly() ) {
676                    // Auto-block user's IP if the account was "hard" blocked
677                    $user->scheduleSpreadBlock();
678                }
679                $this->displayPermissionStatus( $status );
680                return;
681            }
682
683            $resultDetails = null;
684            $status = $this->attemptSave( $resultDetails );
685            if ( !( $status instanceof PageEditStatus ) ) {
686                // Hooks and subclasses can cause attemptSave to return a normal Status, so cast it if necessary
687                $status = PageEditStatus::cast( $status );
688            }
689            if ( !$this->handleStatus( $status, $resultDetails ) ) {
690                return;
691            }
692        }
693
694        # First time through: get contents, set time for conflict
695        # checking, etc.
696        if ( $this->formtype === 'initial' || $this->firsttime ) {
697            if ( !$stashedDataRetrieved && !$this->initialiseForm() ) {
698                return;
699            }
700
701            if ( $this->page->getId() ) {
702                $this->getHookRunner()->onEditFormInitialText( $this );
703            }
704        }
705
706        // If we're displaying an old revision, and there are differences between it and the
707        // latest revision outside the main slot, then we can't allow the old revision to be
708        // editable, as what would happen to the non-main-slot data if someone saves the old
709        // revision is undefined.
710        // When this is the case, display a read-only version of the page instead, with a link
711        // to a diff page from which the old revision can be restored
712        $curRevisionRecord = $this->page->getRevisionRecord();
713        if ( $curRevisionRecord
714            && $revRecord
715            && $curRevisionRecord->getId() !== $revRecord->getId()
716            && ( WikiPage::hasDifferencesOutsideMainSlot(
717                    $revRecord,
718                    $curRevisionRecord
719                ) || !$this->pageEditingHelper->isSupportedContentModel(
720                    $revRecord->getSlot(
721                        SlotRecord::MAIN,
722                        RevisionRecord::RAW
723                    )->getModel(),
724                    $this->enableApiEditOverride
725                ) )
726        ) {
727            $restoreLink = $this->getTitle()->getFullURL(
728                [
729                    'action' => 'mcrrestore',
730                    'restore' => $revRecord->getId(),
731                ]
732            );
733            $this->displayViewSourcePage(
734                $this->getContentObject(),
735                $this->context->msg(
736                    'nonmain-slot-differences-therefore-readonly',
737                    $restoreLink
738                )->plain()
739            );
740            return;
741        }
742
743        // always attach AuthPopup to editform with the caveat that
744        // it will not fire if the user is recently re-authed and
745        // reauthRequired/getReauthOperation() are null
746        $reauthRight = $this->reauthRequired ?: '';
747        $this->enableReauthPopup( 'mediawiki.editPage.reauthPopup', $reauthRight );
748
749        $this->showEditForm();
750    }
751
752    /**
753     * Check the configuration and current user and enable automatic temporary
754     * user creation if possible.
755     *
756     * @param bool $doAcquire Whether to acquire a name for the temporary account
757     *
758     * @since 1.39
759     * @return Status Will return a fatal status if $doAcquire was true and the acquire failed.
760     */
761    public function maybeActivateTempUserCreate( $doAcquire ): Status {
762        if ( $this->tempUserCreateActive ) {
763            // Already done
764            return Status::newGood();
765        }
766        $user = $this->context->getUser();
767
768        // Log out any user using an expired temporary account, so that we can give them a new temporary account.
769        // As described in T389485, we need to do this because the maintenance script to expire temporary accounts
770        // may fail to run or not be configured to run.
771        if ( $user->isTemp() ) {
772            $expireAfterDays = $this->tempUserCreator->getExpireAfterDays();
773            if ( $expireAfterDays ) {
774                $expirationCutoff = (int)ConvertibleTimestamp::now( TS::UNIX ) - ( 86_400 * $expireAfterDays );
775
776                // If the user was created before the expiration cutoff, then log them out, expire any other existing
777                // sessions, and revoke any access to the account that may exist.
778                // If no registration is set then do nothing, as if registration date system is broken it would
779                // cause a new temporary account for each edit.
780                $firstUserRegistration = $this->userRegistrationLookup->getFirstRegistration( $user );
781                if (
782                    $firstUserRegistration &&
783                    ConvertibleTimestamp::convert( TS::UNIX, $firstUserRegistration ) < $expirationCutoff
784                ) {
785                    // Log the user out of the expired temporary account.
786                    $user->logout();
787
788                    // Clear any stashed temporary account name (if any is set), as we want a new name for the user.
789                    $session = $this->context->getRequest()->getSession();
790                    $session->set( 'TempUser:name', null );
791                    $session->save();
792
793                    // Invalidate any sessions for the expired temporary account
794                    $this->sessionManager->invalidateSessionsForUser(
795                        $this->userFactory->newFromUserIdentity( $user )
796                    );
797                }
798            }
799        }
800
801        if ( $this->tempUserCreator->shouldAutoCreate( $user, 'edit' ) ) {
802            if ( $doAcquire ) {
803                $name = $this->tempUserCreator->acquireAndStashName(
804                    $this->context->getRequest()->getSession() );
805                if ( $name === null ) {
806                    $status = Status::newFatal( 'temp-user-unable-to-acquire' );
807                    $status->value = self::AS_UNABLE_TO_ACQUIRE_TEMP_ACCOUNT;
808                    return $status;
809                }
810                $this->unsavedTempUser = $this->userFactory->newUnsavedTempUser( $name );
811                $this->tempUserName = $name;
812            } else {
813                $this->placeholderTempUser = $this->userFactory->newTempPlaceholder();
814            }
815            $this->tempUserCreateActive = true;
816        }
817        return Status::newGood();
818    }
819
820    /**
821     * If automatic user creation is enabled, create the user.
822     *
823     * This is a helper for internalAttemptSave().
824     *
825     * If the edit is a null edit, the user will not be created.
826     */
827    private function createTempUser(): Status {
828        if ( !$this->tempUserCreateActive ) {
829            return Status::newGood();
830        }
831        $request = $this->context->getRequest();
832        $status = $this->tempUserCreator->create(
833            $this->tempUserName,
834            $request
835        );
836        if ( $status->isOK() ) {
837            $this->placeholderTempUser = null;
838            $this->unsavedTempUser = null;
839            $this->savedTempUser = $status->getUser();
840            $this->authManager->setRequestContextUserFromSessionUser();
841            $this->tempUserCreateDone = true;
842        }
843        LoggerFactory::getInstance( 'authevents' )->info(
844            'Temporary account creation attempt: {user}',
845            [
846                'user' => $this->tempUserName,
847                'success' => $status->isOK(),
848            ] + $request->getSecurityLogContext( $status->isOK() ? $status->getUser() : null )
849        );
850        return $status;
851    }
852
853    /**
854     * Get the authority for permissions purposes.
855     *
856     * On an initial edit page GET request, if automatic temporary user creation
857     * is enabled, this may be a placeholder user with a fixed name. Such users
858     * are unsuitable for anything that uses or exposes the name, like
859     * throttling. The only thing a placeholder user is good for is fooling the
860     * permissions system into allowing edits by anons.
861     */
862    private function getAuthority(): Authority {
863        return $this->getUserForPermissions();
864    }
865
866    /**
867     * Get the user for permissions purposes, with declared type User instead
868     * of Authority for compatibility with PermissionManager.
869     *
870     * @return User
871     */
872    private function getUserForPermissions() {
873        if ( $this->savedTempUser ) {
874            return $this->savedTempUser;
875        } elseif ( $this->unsavedTempUser ) {
876            return $this->unsavedTempUser;
877        } elseif ( $this->placeholderTempUser ) {
878            return $this->placeholderTempUser;
879        } else {
880            return $this->context->getUser();
881        }
882    }
883
884    /**
885     * Get the user for preview or PST purposes. During the temporary user
886     * creation flow this may be an unsaved temporary user.
887     */
888    private function getUserForPreview(): UserIdentity {
889        if ( $this->savedTempUser ) {
890            return $this->savedTempUser;
891        } elseif ( $this->unsavedTempUser ) {
892            return $this->unsavedTempUser;
893        } elseif ( $this->firsttime && $this->placeholderTempUser ) {
894            // Mostly a GET request and no temp user was acquired,
895            // but needed for pst or content transform for preview,
896            // fallback to a placeholder for this situation (T330943)
897            return $this->placeholderTempUser;
898        } elseif ( $this->tempUserCreateActive ) {
899            throw new BadMethodCallException(
900                "Can't use the request user for preview with IP masking enabled" );
901        } else {
902            return $this->context->getUser();
903        }
904    }
905
906    /**
907     * Get the user suitable for permanent attribution in the database. This
908     * asserts that an anonymous user won't be used in IP masking mode.
909     */
910    private function getUserForSave(): UserIdentity {
911        if ( $this->savedTempUser ) {
912            return $this->savedTempUser;
913        } elseif ( $this->tempUserCreateActive ) {
914            throw new BadMethodCallException(
915                "Can't use the request user for storage with IP masking enabled" );
916        } else {
917            return $this->context->getUser();
918        }
919    }
920
921    /**
922     * @param string $rigor PermissionManager::RIGOR_ constant
923     * @return PermissionStatus
924     */
925    private function getEditPermissionStatus( string $rigor = PermissionManager::RIGOR_SECURE ): PermissionStatus {
926        $user = $this->getUserForPermissions();
927        return $this->permManager->getPermissionStatus(
928            'edit',
929            $user,
930            $this->getTitle(),
931            $rigor
932        );
933    }
934
935    /**
936     * Display a permissions error page, like OutputPage::showPermissionStatus(),
937     * but with the following differences:
938     * - If redlink=1, the user will be redirected to the page
939     * - If there is content to display or the error occurs while either saving,
940     *   previewing or showing the difference, it will be a
941     *   "View source for ..." page displaying the source code after the error message.
942     *
943     * @param PermissionStatus $status Permissions errors
944     * @throws PermissionsError
945     */
946    private function displayPermissionStatus( PermissionStatus $status ): void {
947        $out = $this->context->getOutput();
948        if ( $this->context->getRequest()->getBool( 'redlink' ) ) {
949            // The edit page was reached via a red link.
950            // Redirect to the article page and let them click the edit tab if
951            // they really want a permission error.
952            $out->redirect( $this->getTitle()->getFullURL() );
953            return;
954        }
955
956        $content = $this->getContentObject();
957
958        // Use the normal message if there's nothing to display:
959        // page or section does not exist (T249978), and the user isn't in the middle of an edit
960        if ( !$content || ( $this->firsttime && !$this->page->exists() && $content->isEmpty() ) ) {
961            $action = $this->page->exists() ? 'edit' :
962                ( $this->getTitle()->isTalkPage() ? 'createtalk' : 'createpage' );
963            throw new PermissionsError( $action, $status );
964        }
965
966        $this->displayViewSourcePage(
967            $content,
968            $out->formatPermissionStatus( $status, 'edit' )
969        );
970    }
971
972    /**
973     * Display a read-only View Source page
974     * @param Content $content
975     * @param string $errorMessage additional wikitext error message to display
976     */
977    private function displayViewSourcePage( Content $content, string $errorMessage ): void {
978        $out = $this->context->getOutput();
979        $this->getHookRunner()->onEditPage__showReadOnlyForm_initial( $this, $out );
980
981        $out->setRobotPolicy( 'noindex,nofollow' );
982        $out->setPageTitleMsg( $this->context->msg(
983            'viewsource-title'
984        )->plaintextParams(
985            $this->getContextTitle()->getPrefixedText()
986        ) );
987        $out->addBacklinkSubtitle( $this->getContextTitle() );
988        $out->addHTML( $this->editFormPageTop );
989        $out->addHTML( $this->editFormTextTop );
990
991        if ( $errorMessage !== '' ) {
992            $out->addWikiTextAsInterface( $errorMessage );
993            $out->addHTML( "<hr />\n" );
994        }
995
996        # If the user made changes, preserve them when showing the markup
997        # (This happens when a user is blocked during edit, for instance)
998        if ( !$this->firsttime ) {
999            $text = $this->textbox1;
1000            $out->addWikiMsg( 'viewyourtext' );
1001        } else {
1002            // Convert the content to editable text, or serialize using the default format if the content model is not
1003            // supported (e.g. for an old revision with a different model)
1004            try {
1005                $text = $this->pageEditingHelper->toEditText(
1006                    $content, $this->contentFormat, $this->enableApiEditOverride
1007                ) ?? $content->serialize();
1008            } catch ( UnsupportedContentFormatException ) {
1009                // T419883: If the content format isn't supported, Content::serialize throws an exception
1010                $text = $content->serialize();
1011            }
1012            $out->addWikiMsg( 'viewsourcetext' );
1013        }
1014
1015        $out->addHTML( $this->editFormTextBeforeContent );
1016        $this->showTextbox( $text, 'wpTextbox1', [ 'readonly' ] );
1017        $out->addHTML( $this->editFormTextAfterContent );
1018
1019        $out->addHTML( $this->makeTemplatesOnThisPageList( $this->getTemplates() ) );
1020
1021        $out->addModules( 'mediawiki.action.edit.collapsibleFooter' );
1022
1023        $out->addHTML( $this->editFormTextBottom );
1024        if ( $this->page->exists() ) {
1025            $out->returnToMain( null, $this->page );
1026        }
1027    }
1028
1029    /**
1030     * Should we show a preview when the edit form is first shown?
1031     *
1032     * @return bool
1033     */
1034    protected function previewOnOpen() {
1035        $config = $this->context->getConfig();
1036        $previewOnOpenNamespaces = $config->get( MainConfigNames::PreviewOnOpenNamespaces );
1037        $request = $this->context->getRequest();
1038        if ( $config->get( MainConfigNames::RawHtml ) ) {
1039            // If raw HTML is enabled, disable preview on open
1040            // since it has to be posted with a token for
1041            // security reasons
1042            return false;
1043        }
1044        $preview = $request->getRawVal( 'preview' );
1045        if ( $preview === 'yes' ) {
1046            // Explicit override from request
1047            return true;
1048        } elseif ( $preview === 'no' ) {
1049            // Explicit override from request
1050            return false;
1051        } elseif ( $this->section === 'new' ) {
1052            // Nothing *to* preview for new sections
1053            return false;
1054        } elseif ( ( $request->getCheck( 'preload' ) || $this->page->exists() )
1055            && $this->userOptionsLookup->getOption( $this->context->getUser(), 'previewonfirst' )
1056        ) {
1057            // Standard preference behavior
1058            return true;
1059        } elseif ( !$this->page->exists()
1060            && isset( $previewOnOpenNamespaces[$this->page->getNamespace()] )
1061            && $previewOnOpenNamespaces[$this->page->getNamespace()]
1062        ) {
1063            // Categories are special
1064            return true;
1065        } else {
1066            return false;
1067        }
1068    }
1069
1070    /**
1071     * Section editing is supported when the page content model allows
1072     * section edit and we are editing latest revision.
1073     *
1074     * @return bool True if this edit page supports sections, false otherwise.
1075     */
1076    private function isSectionEditSupported(): bool {
1077        $currentRev = $this->page->getRevisionRecord();
1078
1079        // $currentRev is null for non-existing pages, use the page default content model.
1080        $revContentModel = $currentRev
1081            ? $currentRev->getMainContentModel()
1082            : $this->page->getContentModel();
1083
1084        return (
1085            ( $this->mArticle->getRevIdFetched() === $this->page->getLatest() ) &&
1086            $this->contentHandlerFactory->getContentHandler( $revContentModel )->supportsSections()
1087        );
1088    }
1089
1090    /**
1091     * This function collects the form data and uses it to populate various member variables.
1092     * @param WebRequest|null $request Supplying this parameter is deprecated since 1.47.
1093     * @throws ErrorPageError
1094     */
1095    public function importFormData( $request = null ) {
1096        if ( $request !== null ) {
1097            wfDeprecated( __METHOD__ . ' with non-null $request', '1.47' );
1098        }
1099        $request ??= $this->getContext()->getRequest();
1100
1101        # Section edit can come from either the form or a link
1102        $this->section = $request->getVal( 'wpSection', $request->getVal( 'section', '' ) );
1103
1104        if ( $this->section !== '' && !$this->isSectionEditSupported() ) {
1105            throw new ErrorPageError( 'sectioneditnotsupported-title', 'sectioneditnotsupported-text' );
1106        }
1107
1108        $this->isNew = !$this->page->exists() || $this->section === 'new';
1109
1110        if ( $request->wasPosted() ) {
1111            $this->importFormDataPosted( $request );
1112        } else {
1113            # Not a posted form? Start with nothing.
1114            wfDebug( __METHOD__ . ": Not a posted form." );
1115            $this->textbox1 = '';
1116            $this->summary = '';
1117            $this->sectiontitle = null;
1118            $this->edittime = '';
1119            $this->editRevId = null;
1120            $this->starttime = wfTimestampNow();
1121            $this->preview = false;
1122            $this->save = false;
1123            $this->diff = false;
1124            $this->minoredit = false;
1125            // Watch may be overridden by request parameters
1126            $this->watchthis = $request->getBool( 'watchthis', false );
1127            if ( $this->watchlistExpiryEnabled ) {
1128                $this->watchlistExpiry = null;
1129            }
1130            $this->recreate = false;
1131
1132            // When creating a new section, we can preload a section title by passing it as the
1133            // preloadtitle parameter in the URL (T15100)
1134            if ( $this->section === 'new' && $request->getCheck( 'preloadtitle' ) ) {
1135                $this->sectiontitle = $request->getVal( 'preloadtitle' );
1136                $this->setNewSectionSummary();
1137            } elseif ( $this->section !== 'new' && $request->getRawVal( 'summary' ) !== '' ) {
1138                $this->summary = $request->getText( 'summary' );
1139                if ( $this->summary !== '' ) {
1140                    // If a summary has been preset using &summary= we don't want to prompt for
1141                    // a different summary. Only prompt for a summary if the summary is blanked.
1142                    // (T19416)
1143                    $this->autoSumm = md5( '' );
1144                }
1145            }
1146
1147            if ( $request->getVal( 'minor' ) ) {
1148                $this->minoredit = true;
1149            }
1150        }
1151
1152        if ( $request !== $this->getContext()->getRequest() ) {
1153            // TODO Remove this once the $request parameter has been removed from this method
1154            $this->oldid = $request->getInt( 'oldid' );
1155        } else {
1156            // Article::getOldID falls back to the request if no oldid is set
1157            $this->oldid = $this->mArticle->getOldID();
1158        }
1159        $this->parentRevId = $request->getInt( 'parentRevId' );
1160
1161        $this->markAsBot = $request->getBool( 'bot', true );
1162        $this->nosummary = $request->getBool( 'nosummary' );
1163
1164        // May be overridden by revision.
1165        $this->contentModel = $request->getText( 'model', $this->contentModel );
1166        // May be overridden by revision.
1167        $this->contentFormat = $request->getText( 'format', $this->contentFormat );
1168
1169        try {
1170            $handler = $this->contentHandlerFactory->getContentHandler( $this->contentModel );
1171        } catch ( UnknownContentModelException ) {
1172            throw new ErrorPageError(
1173                'editpage-invalidcontentmodel-title',
1174                'editpage-invalidcontentmodel-text',
1175                [ wfEscapeWikiText( $this->contentModel ) ]
1176            );
1177        }
1178
1179        if ( !$handler->isSupportedFormat( $this->contentFormat ) ) {
1180            throw new ErrorPageError(
1181                'editpage-notsupportedcontentformat-title',
1182                'editpage-notsupportedcontentformat-text',
1183                [
1184                    wfEscapeWikiText( $this->contentFormat ),
1185                    wfEscapeWikiText( ContentHandler::getLocalizedName( $this->contentModel ) )
1186                ]
1187            );
1188        }
1189
1190        // Allow extensions to modify form data
1191        $this->getHookRunner()->onEditPage__importFormData( $this, $request );
1192    }
1193
1194    private function importFormDataPosted( WebRequest $request ): void {
1195        # These fields need to be checked for encoding.
1196        # Also remove trailing whitespace, but don't remove _initial_
1197        # whitespace from the text boxes. This may be significant formatting.
1198        $this->textbox1 = rtrim( $request->getText( 'wpTextbox1' ) );
1199        if ( !$request->getCheck( 'wpTextbox2' ) ) {
1200            // Skip this if wpTextbox2 has input, it indicates that we came
1201            // from a conflict page with raw page text, not a custom form
1202            // modified by subclasses
1203            $textbox1 = $this->importContentFormData( $request );
1204            if ( $textbox1 !== null ) {
1205                $this->textbox1 = $textbox1;
1206            }
1207        }
1208
1209        $this->unicodeCheck = $request->getText( 'wpUnicodeCheck' );
1210
1211        if ( $this->section === 'new' ) {
1212            # Allow setting sectiontitle different from the edit summary.
1213            # Note that wpSectionTitle is not yet a part of the actual edit form, as wpSummary is
1214            # currently doing double duty as both edit summary and section title. Right now this
1215            # is just to allow API edits to work around this limitation, but this should be
1216            # incorporated into the actual edit form when EditPage is rewritten (T20654, T28312).
1217            if ( $request->getCheck( 'wpSectionTitle' ) ) {
1218                $this->sectiontitle = $request->getText( 'wpSectionTitle' );
1219                if ( $request->getCheck( 'wpSummary' ) ) {
1220                    $this->summary = $request->getText( 'wpSummary' );
1221                }
1222            } else {
1223                $this->sectiontitle = $request->getText( 'wpSummary' );
1224            }
1225        } else {
1226            $this->sectiontitle = null;
1227            $this->summary = $request->getText( 'wpSummary' );
1228        }
1229
1230        # If the summary consists of a heading, e.g. '==Foobar==', extract the title from the
1231        # header syntax, e.g. 'Foobar'. This is mainly an issue when we are using wpSummary for
1232        # section titles. (T3600)
1233        # It is weird to modify 'sectiontitle', even when it is provided when using the API, but API
1234        # users have come to rely on it: https://github.com/wikimedia-gadgets/twinkle/issues/1625
1235        $this->summary = preg_replace( '/^\s*=+\s*(.*?)\s*=+\s*$/', '$1', $this->summary );
1236        if ( $this->sectiontitle !== null ) {
1237            $this->sectiontitle = preg_replace( '/^\s*=+\s*(.*?)\s*=+\s*$/', '$1', $this->sectiontitle );
1238        }
1239
1240        if ( $this->section === 'new' ) {
1241            $this->setNewSectionSummary();
1242        }
1243
1244        $this->edittime = $request->getVal( 'wpEdittime' );
1245        $this->editRevId = $request->getIntOrNull( 'editRevId' );
1246        $this->starttime = $request->getVal( 'wpStarttime' );
1247
1248        $undidRev = $request->getInt( 'wpUndidRevision' );
1249        if ( $undidRev ) {
1250            $this->undidRev = $undidRev;
1251        }
1252        $undoAfter = $request->getInt( 'wpUndoAfter' );
1253        if ( $undoAfter ) {
1254            $this->undoAfter = $undoAfter;
1255        }
1256
1257        $this->scrolltop = $request->getIntOrNull( 'wpScrolltop' );
1258
1259        if ( $this->textbox1 === '' && !$request->getCheck( 'wpTextbox1' ) ) {
1260            // wpTextbox1 field is missing, possibly due to being "too big"
1261            // according to some filter rules that may have been configured
1262            // for security reasons.
1263            $this->incompleteForm = true;
1264        } else {
1265            // If we receive the last parameter of the request, we can fairly
1266            // claim the POST request has not been truncated.
1267            $this->incompleteForm = !$request->getVal( 'wpUltimateParam' );
1268        }
1269        if ( $this->incompleteForm ) {
1270            # If the form is incomplete, force to preview.
1271            wfDebug( __METHOD__ . ": Form data appears to be incomplete" );
1272            wfDebug( "POST DATA: " . var_export( $request->getPostValues(), true ) );
1273            $this->preview = true;
1274        } else {
1275            $this->preview = $request->getCheck( 'wpPreview' );
1276            $this->diff = $request->getCheck( 'wpDiff' );
1277
1278            // Remember whether a save was requested, so we can indicate
1279            // if we forced preview due to session failure.
1280            $this->mTriedSave = !$this->preview;
1281
1282            if ( $this->tokenOk( $request ) ) {
1283                # Some browsers will not report any submit button
1284                # if the user hits enter in the comment box.
1285                # The unmarked state will be assumed to be a save,
1286                # if the form seems otherwise complete.
1287                wfDebug( __METHOD__ . ": Passed token check." );
1288            } elseif ( $this->diff ) {
1289                # Failed token check, but only requested "Show Changes".
1290                wfDebug( __METHOD__ . ": Failed token check; Show Changes requested." );
1291            } else {
1292                # Page might be a hack attempt posted from
1293                # an external site. Preview instead of saving.
1294                wfDebug( __METHOD__ . ": Failed token check; forcing preview" );
1295                $this->preview = true;
1296            }
1297        }
1298        $this->save = !$this->preview && !$this->diff;
1299        if ( !$this->edittime || !preg_match( '/^\d{14}$/', $this->edittime ) ) {
1300            $this->edittime = null;
1301        }
1302
1303        if ( !$this->starttime || !preg_match( '/^\d{14}$/', $this->starttime ) ) {
1304            $this->starttime = null;
1305        }
1306
1307        $this->recreate = $request->getCheck( 'wpRecreate' );
1308
1309        $this->ignoreRevisionDeletedWarning = $request->getCheck( 'wpIgnoreRevisionDeleted' );
1310
1311        $this->minoredit = $request->getCheck( 'wpMinoredit' );
1312        $this->watchthis = $request->getCheck( 'wpWatchthis' );
1313        $submittedExpiry = $request->getText( 'wpWatchlistExpiry' );
1314        if ( $this->watchlistExpiryEnabled && $submittedExpiry !== '' ) {
1315            // This parsing of the user-posted expiry is done for both preview and saving. This
1316            // is necessary because ApiEditPage uses preview when it saves (yuck!). Note that it
1317            // only works because the unnormalized value is retrieved again below in
1318            // getCheckboxesDefinitionForWatchlist().
1319            $submittedExpiry = ExpiryDef::normalizeExpiry( $submittedExpiry, TS::ISO_8601 );
1320            if ( $submittedExpiry !== false ) {
1321                $this->watchlistExpiry = $submittedExpiry;
1322            }
1323        }
1324        if ( $this->watchlistLabelsEnabled ) {
1325            $watchlistLabelsSubmitted = $request->getCheck( 'wpWatchlistLabelsSubmitted' );
1326            $this->watchlistLabels = $watchlistLabelsSubmitted
1327                ? $request->getIntArray( 'wpWatchlistLabels', [] )
1328                : null;
1329        }
1330
1331        $this->autoSumm = $request->getText( 'wpAutoSummary' );
1332
1333        $this->allowBlankSummary = $request->getBool( 'wpIgnoreBlankSummary' );
1334        $this->allowBlankArticle = $request->getBool( 'wpIgnoreBlankArticle' );
1335        $allowedProblematicRedirectTargetText = $request->getText( 'wpAllowedProblematicRedirectTarget' );
1336        $this->allowedProblematicRedirectTarget = $allowedProblematicRedirectTargetText === ''
1337            ? null : Title::newFromText( $allowedProblematicRedirectTargetText );
1338        $this->ignoreProblematicRedirects = $request->getBool( 'wpIgnoreProblematicRedirects' );
1339
1340        $changeTags = $request->getVal( 'wpChangeTags' );
1341        $changeTagsAfterPreview = $request->getVal( 'wpChangeTagsAfterPreview' );
1342        if ( $changeTags === null || $changeTags === '' ) {
1343            $this->changeTags = [];
1344        } else {
1345            $this->changeTags = array_filter(
1346                array_map(
1347                    'trim',
1348                    explode( ',', $changeTags )
1349                )
1350            );
1351        }
1352        if ( $changeTagsAfterPreview !== null && $changeTagsAfterPreview !== '' ) {
1353            $this->changeTags = array_merge( $this->changeTags, array_filter(
1354                array_map(
1355                    'trim',
1356                    explode( ',', $changeTagsAfterPreview )
1357                )
1358            ) );
1359        }
1360    }
1361
1362    /**
1363     * Subpage overridable method for extracting the page content data from the
1364     * posted form to be placed in $this->textbox1, if using customized input
1365     * this method should be overridden and return the page text that will be used
1366     * for saving, preview parsing and so on...
1367     *
1368     * @param WebRequest &$request
1369     * @return string|null
1370     */
1371    protected function importContentFormData( &$request ) {
1372        return null; // Don't do anything, EditPage already extracted wpTextbox1
1373    }
1374
1375    /**
1376     * Initialise form fields in the object
1377     * Called on the first invocation, e.g. when a user clicks an edit link
1378     * @return bool If the requested section is valid
1379     */
1380    private function initialiseForm(): bool {
1381        $this->edittime = $this->page->getTimestamp();
1382        $this->editRevId = $this->page->getLatest();
1383
1384        $dummy = $this->contentHandlerFactory
1385            ->getContentHandler( $this->contentModel )
1386            ->makeEmptyContent();
1387        $content = $this->getContentObject( $dummy ); # TODO: track content object?!
1388        if ( $content === $dummy ) { // Invalid section
1389            $this->noSuchSectionPage();
1390            return false;
1391        }
1392
1393        if ( !$content ) {
1394            $this->editFormPageTop .= Html::errorBox( $this->context->msg(
1395                'missing-revision-content',
1396                $this->oldid,
1397                wfEscapeWikiText( $this->getTitle()->getPrefixedText() )
1398            )->parse() );
1399        } elseif ( !$this->pageEditingHelper->isSupportedContentModel(
1400            $content->getModel(), $this->enableApiEditOverride,
1401        ) ) {
1402            $modelMsg = $this->getContext()->msg( 'content-model-' . $content->getModel() );
1403            $modelName = $modelMsg->exists() ? $modelMsg->text() : $content->getModel();
1404
1405            $out = $this->context->getOutput();
1406            $out->showErrorPage(
1407                'modeleditnotsupported-title',
1408                'modeleditnotsupported-text',
1409                [ $modelName ]
1410            );
1411            return false;
1412        }
1413
1414        try {
1415            $this->textbox1 = $this->pageEditingHelper->toEditText(
1416                $content, $this->contentFormat, $this->enableApiEditOverride
1417            ) ?? $content->serialize();
1418        } catch ( UnsupportedContentFormatException ) {
1419            // T391524: If the content format isn't supported, Content::serialize throws an exception
1420            $this->textbox1 = $content->serialize();
1421        }
1422
1423        $user = $this->context->getUser();
1424        // activate checkboxes if user wants them to be always active
1425        # Sort out the "watch" checkbox
1426        if ( $this->userOptionsLookup->getOption( $user, 'watchdefault' ) ) {
1427            # Watch all edits
1428            $this->watchthis = true;
1429        } elseif ( $this->userOptionsLookup->getOption( $user, 'watchcreations' ) && !$this->page->exists() ) {
1430            # Watch creations
1431            $this->watchthis = true;
1432        } elseif ( $this->watchlistManager->isWatched( $user, $this->page ) ) {
1433            # Already watched
1434            $this->watchthis = true;
1435        }
1436        if ( $this->watchthis && $this->watchlistExpiryEnabled ) {
1437            $watchedItem = $this->watchedItemStore->getWatchedItem( $user, $this->getTitle() );
1438            $this->watchlistExpiry = $watchedItem ? $watchedItem->getExpiry() : null;
1439        }
1440        if ( !$this->isNew && $this->userOptionsLookup->getOption( $user, 'minordefault' ) ) {
1441            $this->minoredit = true;
1442        }
1443        return true;
1444    }
1445
1446    /**
1447     * @param Content|null $defaultContent The default value to return
1448     * @return Content|false|null Content on success, $defaultContent for invalid sections
1449     * @since 1.21
1450     */
1451    protected function getContentObject( $defaultContent = null ) {
1452        $services = MediaWikiServices::getInstance();
1453        $request = $this->context->getRequest();
1454
1455        $content = false;
1456
1457        // For non-existent articles and new sections, use preload text if any.
1458        if ( !$this->page->exists() || $this->section === 'new' ) {
1459            $content = $services->getPreloadedContentBuilder()->getPreloadedContent(
1460                $this->page,
1461                $this->context->getUser(),
1462                $request->getVal( 'preload' ),
1463                array_filter( $request->getArray( 'preloadparams', [] ), is_string( ... ) ),
1464                $request->getVal( 'section' )
1465            );
1466        // For existing pages, get text based on "undo" or section parameters.
1467        } elseif ( $this->section !== '' ) {
1468            // Get section edit text (returns $def_text for invalid sections)
1469            $orig = $this->pageEditingHelper->getOriginalContent(
1470                $this->getAuthority(),
1471                $this->page,
1472                $this->mArticle->fetchRevisionRecord(),
1473                $this->contentModel,
1474                $this->section,
1475            );
1476            $content = $orig ? $orig->getSection( $this->section ) : null;
1477
1478            if ( !$content ) {
1479                $content = $defaultContent;
1480            }
1481        } else {
1482            $undoafter = $request->getInt( 'undoafter' );
1483            $undo = $request->getInt( 'undo' );
1484
1485            if ( $undo > 0 && $undoafter > 0 ) {
1486                // The use of getRevisionByTitle() is intentional, as allowing access to
1487                // arbitrary revisions on arbitrary pages bypass partial visibility restrictions (T297322).
1488                $undorev = $this->revisionStore->getRevisionByTitle( $this->page, $undo );
1489                $oldrev = $this->revisionStore->getRevisionByTitle( $this->page, $undoafter );
1490                $undoMsg = null;
1491
1492                # Make sure it's the right page,
1493                # the revisions exist and they were not deleted.
1494                # Otherwise, $content will be left as-is.
1495                if ( $undorev !== null && $oldrev !== null &&
1496                    !$undorev->isDeleted( RevisionRecord::DELETED_TEXT ) &&
1497                    !$oldrev->isDeleted( RevisionRecord::DELETED_TEXT )
1498                ) {
1499                    if ( WikiPage::hasDifferencesOutsideMainSlot( $undorev, $oldrev )
1500                        || !$this->pageEditingHelper->isSupportedContentModel(
1501                            $oldrev->getMainContentModel(), $this->enableApiEditOverride
1502                        )
1503                    ) {
1504                        // Hack for undo while EditPage can't handle multi-slot editing
1505                        $this->context->getOutput()->redirect( $this->getTitle()->getFullURL( [
1506                            'action' => 'mcrundo',
1507                            'undo' => $undo,
1508                            'undoafter' => $undoafter,
1509                        ] ) );
1510                        return false;
1511                    } else {
1512                        $content = $this->pageEditingHelper->getUndoContent( $this->page, $undorev, $oldrev, $undoMsg );
1513                    }
1514
1515                    if ( $undoMsg === null ) {
1516                        $oldContent = $this->page->getContent( RevisionRecord::RAW );
1517                        $parserOptions = ParserOptions::newFromUserAndLang(
1518                            $this->getUserForPreview(),
1519                            $services->getContentLanguage()
1520                        );
1521                        $contentTransformer = $services->getContentTransformer();
1522                        $newContent = $contentTransformer->preSaveTransform(
1523                            $content, $this->page, $this->getUserForPreview(), $parserOptions
1524                        );
1525
1526                        if ( $newContent->getModel() !== $oldContent->getModel() ) {
1527                            // The undo may change content
1528                            // model if its reverting the top
1529                            // edit. This can result in
1530                            // mismatched content model/format.
1531                            $this->contentModel = $newContent->getModel();
1532                            $oldMainSlot = $oldrev->getSlot(
1533                                SlotRecord::MAIN,
1534                                RevisionRecord::RAW
1535                            );
1536                            $this->contentFormat = $oldMainSlot->getFormat();
1537                            if ( $this->contentFormat === null ) {
1538                                $this->contentFormat = $this->contentHandlerFactory
1539                                    ->getContentHandler( $oldMainSlot->getModel() )
1540                                    ->getDefaultFormat();
1541                            }
1542                        }
1543
1544                        if ( $newContent->equals( $oldContent ) ) {
1545                            # Tell the user that the undo results in no change,
1546                            # i.e. the revisions were already undone.
1547                            $undoMsg = 'nochange';
1548                            $content = false;
1549                        } else {
1550                            # Inform the user of our success and set an automatic edit summary
1551                            $undoMsg = 'success';
1552                            $this->generateUndoEditSummary( $oldrev, $undo, $undorev, $services );
1553                            $this->undidRev = $undo;
1554                            $this->undoAfter = $undoafter;
1555                            $this->formtype = 'diff';
1556                        }
1557                    }
1558                } else {
1559                    // Failed basic checks.
1560                    // Older revisions may have been removed since the link
1561                    // was created, or we may simply have got bogus input.
1562                    $undoMsg = 'norev';
1563                }
1564
1565                $out = $this->context->getOutput();
1566                // Messages: undo-success, undo-failure, undo-main-slot-only, undo-norev,
1567                // undo-nochange.
1568                $class = "mw-undo-{$undoMsg}";
1569                $html = $this->context->msg( 'undo-' . $undoMsg )->parse();
1570                if ( $undoMsg !== 'success' ) {
1571                    $html = Html::errorBox( $html );
1572                }
1573                $this->editFormPageTop .= Html::rawElement(
1574                    'div',
1575                    [ 'class' => $class ],
1576                    $html
1577                );
1578            }
1579
1580            if ( $content === false ) {
1581                $content = $this->pageEditingHelper->getOriginalContent(
1582                    $this->getAuthority(),
1583                    $this->page,
1584                    $this->mArticle->fetchRevisionRecord(),
1585                    $this->contentModel,
1586                    $this->section,
1587                );
1588            }
1589        }
1590
1591        return $content;
1592    }
1593
1594    /**
1595     * When using the "undo" action, generate a default edit summary and save it
1596     * to $this->summary
1597     *
1598     * @param RevisionRecord|null $oldRev The revision in the URI "undoafter" field
1599     * @param int $undo The integer in the URI "undo" field
1600     * @param RevisionRecord|null $undoRev The revision in the URI "undo" field
1601     * @param MediaWikiServices $services Service container
1602     */
1603    private function generateUndoEditSummary(
1604        ?RevisionRecord $oldRev,
1605        int $undo,
1606        ?RevisionRecord $undoRev,
1607        MediaWikiServices $services
1608    ): void {
1609        // Generate an autosummary
1610        $firstRev = $this->revisionStore->getNextRevision( $oldRev );
1611        if ( $firstRev && $firstRev->getId() == $undo ) {
1612            // Undid just one revision
1613            $userText = $undoRev->getUser()?->getName();
1614            if ( $userText === null ) {
1615                $undoSummary = $this->context->msg(
1616                    'undo-summary-username-hidden',
1617                    $undo
1618                )->inContentLanguage()->text();
1619            } elseif ( ExternalUserNames::isExternal( $userText ) ) {
1620                // Handle external users (imported revisions)
1621                $userLinkTitle = ExternalUserNames::getUserLinkTitle( $userText );
1622                if ( $userLinkTitle ) {
1623                    $userLink = $userLinkTitle->getPrefixedText();
1624                    $undoSummary = $this->context->msg(
1625                        'undo-summary-import',
1626                        $undo,
1627                        $userLink,
1628                        $userText
1629                    )->inContentLanguage()->text();
1630                } else {
1631                    $undoSummary = $this->context->msg(
1632                        'undo-summary-import2',
1633                        $undo,
1634                        $userText
1635                    )->inContentLanguage()->text();
1636                }
1637            } else {
1638                $undoIsAnon =
1639                    !$undoRev->getUser() ||
1640                    !$undoRev->getUser()->isRegistered();
1641                $disableAnonTalk = $services->getMainConfig()->get( MainConfigNames::DisableAnonTalk );
1642                $undoMessage = ( $undoIsAnon && $disableAnonTalk ) ?
1643                    'undo-summary-anon' :
1644                    'undo-summary';
1645                $undoSummary = $this->context->msg(
1646                    $undoMessage,
1647                    $undo,
1648                    $userText
1649                )->inContentLanguage()->text();
1650            }
1651        } else {
1652            // Undid multiple revisions
1653            $firstRevisionId = $firstRev->getId();
1654            $lastRevisionId = $undoRev->getId();
1655            $revisionCount = $services->getRevisionStore()->countRevisionsBetween(
1656                $firstRev->getPageId(),
1657                $firstRev,
1658                $undoRev,
1659                null,
1660                [ RevisionStore::INCLUDE_BOTH, RevisionStore::INCLUDE_DELETED_REVISIONS ]
1661            );
1662            $undoSummary = $this->context->msg( 'undo-summary-multiple' )
1663                ->numParams( $revisionCount )
1664                ->params( $firstRevisionId, $lastRevisionId )
1665                ->inContentLanguage()
1666                ->text();
1667        }
1668        if ( $this->summary === '' ) {
1669            $this->summary = $undoSummary;
1670        } else {
1671            $this->summary = $undoSummary . $this->context->msg( 'colon-separator' )
1672                ->inContentLanguage()->text() . $this->summary;
1673        }
1674    }
1675
1676    /**
1677     * Get the edit's parent revision ID
1678     *
1679     * The "parent" revision is the ancestor that should be recorded in this
1680     * page's revision history.  It is either the revision ID of the in-memory
1681     * article content, or in the case of a 3-way merge in order to rebase
1682     * across a recoverable edit conflict, the ID of the newer revision to
1683     * which we have rebased this page.
1684     *
1685     * @return int Revision ID
1686     */
1687    private function getParentRevId() {
1688        if ( $this->parentRevId ) {
1689            return $this->parentRevId;
1690        } else {
1691            return $this->mArticle->getRevIdFetched();
1692        }
1693    }
1694
1695    /**
1696     * Get the current content of the page. This is basically similar to
1697     * WikiPage::getContent( RevisionRecord::RAW ) except that when the page doesn't
1698     * exist an empty content object is returned instead of null.
1699     *
1700     * @since 1.21
1701     * @return Content
1702     */
1703    protected function getCurrentContent() {
1704        return $this->pageEditingHelper->getCurrentContent( $this->contentModel, $this->page );
1705    }
1706
1707    /**
1708     * Make sure the form isn't faking a user's credentials.
1709     *
1710     * @param WebRequest $request
1711     * @return bool
1712     */
1713    private function tokenOk( WebRequest $request ): bool {
1714        $token = $request->getVal( 'wpEditToken' );
1715        $user = $this->context->getUser();
1716        $this->mTokenOk = $user->matchEditToken( $token );
1717        return $this->mTokenOk;
1718    }
1719
1720    /**
1721     * Sets post-edit cookie indicating the user just saved a particular revision.
1722     *
1723     * This uses a temporary cookie for each revision ID so separate saves will never
1724     * interfere with each other.
1725     *
1726     * Article::view deletes the cookie on server-side after the redirect and
1727     * converts the value to the global JavaScript variable wgPostEdit.
1728     *
1729     * If the variable were set on the server, it would be cached, which is unwanted
1730     * since the post-edit state should only apply to the load right after the save.
1731     *
1732     * @param int $statusValue The status value (to check for new article status)
1733     */
1734    private function setPostEditCookie( int $statusValue ): void {
1735        $revisionId = $this->page->getLatest();
1736        $postEditKey = self::POST_EDIT_COOKIE_KEY_PREFIX . $revisionId;
1737
1738        $val = 'saved';
1739        if ( $statusValue === self::AS_SUCCESS_NEW_ARTICLE ) {
1740            $val = 'created';
1741        } elseif ( $this->oldid ) {
1742            $val = 'restored';
1743        }
1744        if ( $this->tempUserCreateDone ) {
1745            $val .= '+tempuser';
1746        }
1747
1748        $response = $this->context->getRequest()->response();
1749        $response->setCookie( $postEditKey, $val, time() + self::POST_EDIT_COOKIE_DURATION );
1750    }
1751
1752    /**
1753     * Attempt submission
1754     * @param array|false &$resultDetails See docs for $result in internalAttemptSave @phan-output-reference
1755     * @throws UserBlockedError|ReadOnlyError|ThrottledError|PermissionsError
1756     * @return StatusValue
1757     */
1758    public function attemptSave( &$resultDetails = false ) {
1759        $status = $this->internalAttemptSave( $resultDetails );
1760        if ( !$status->isOK() ) {
1761            $this->handleFailedConstraint( $status );
1762        }
1763
1764        // Status::wrap() takes references to all internal variables, allowing hook handlers to modify
1765        // the $status, without changing the hook interface to use the PageEditStatus type.
1766        $this->getHookRunner()->onEditPage__attemptSave_after( $this, Status::wrap( $status ), $resultDetails );
1767
1768        return $status;
1769    }
1770
1771    /**
1772     * Log when a page was successfully saved after the edit conflict view
1773     */
1774    private function incrementResolvedConflicts(): void {
1775        if ( $this->context->getRequest()->getText( 'mode' ) !== 'conflict' ) {
1776            return;
1777        }
1778
1779        $this->getEditConflictHelper()->incrementResolvedStats( $this->context->getUser() );
1780    }
1781
1782    /**
1783     * Handle status, such as after attempt save
1784     *
1785     * @param PageEditStatus $status
1786     * @param array|false $resultDetails
1787     *
1788     * @throws ErrorPageError
1789     * @return bool False, if output is done, true if rest of the form should be displayed
1790     */
1791    private function handleStatus( PageEditStatus $status, $resultDetails ): bool {
1792        $statusValue = is_int( $status->value ) ? $status->value : 0;
1793
1794        /**
1795         * @todo FIXME: once the interface for internalAttemptSave() is made
1796         *   nicer, this should use the message in $status
1797         */
1798        if ( $statusValue === self::AS_SUCCESS_UPDATE
1799            || $statusValue === self::AS_SUCCESS_NEW_ARTICLE
1800        ) {
1801            $this->incrementResolvedConflicts();
1802
1803            $this->didSave = true;
1804            if ( !$resultDetails['nullEdit'] ) {
1805                $this->setPostEditCookie( $statusValue );
1806            }
1807        }
1808
1809        $out = $this->context->getOutput();
1810
1811        // "wpExtraQueryRedirect" is a hidden input to modify
1812        // after save URL and is not used by actual edit form
1813        $request = $this->context->getRequest();
1814        $extraQueryRedirect = $request->getVal( 'wpExtraQueryRedirect' );
1815
1816        switch ( $statusValue ) {
1817            // Status codes for which the error/warning message is generated somewhere else in this class.
1818            // They should be refactored to provide their own messages and handled below (T384399).
1819            case self::AS_HOOK_ERROR_EXPECTED:
1820            case self::AS_CONFLICT_DETECTED:
1821                return true;
1822
1823            case self::AS_HOOK_ERROR:
1824                return false;
1825
1826            // Status codes that provide their own error/warning messages. Most error scenarios that don't
1827            // need custom user interface (e.g. edit conflicts) should be handled here, one day (T384399).
1828            case self::AS_ARTICLE_WAS_DELETED:
1829            case self::AS_BLANK_ARTICLE:
1830            case self::AS_BROKEN_REDIRECT:
1831            case self::AS_CONTENT_TOO_BIG:
1832            case self::AS_DOUBLE_REDIRECT:
1833            case self::AS_DOUBLE_REDIRECT_LOOP:
1834            case self::AS_END:
1835            case self::AS_INVALID_REDIRECT_TARGET:
1836            case self::AS_MAX_ARTICLE_SIZE_EXCEEDED:
1837            case self::AS_PARSE_ERROR:
1838            case self::AS_RATE_LIMITED:
1839            case self::AS_REVISION_MISSING:
1840            case self::AS_REVISION_WAS_DELETED:
1841            case self::AS_SELF_REDIRECT:
1842            case self::AS_SUMMARY_NEEDED:
1843            case self::AS_TEXTBOX_EMPTY:
1844            case self::AS_UNABLE_TO_ACQUIRE_TEMP_ACCOUNT:
1845            case self::AS_UNICODE_NOT_SUPPORTED:
1846                $out->addHTML( $this->formatConstraintStatus( $status ) );
1847                return true;
1848
1849            case self::AS_SUCCESS_NEW_ARTICLE:
1850                $queryParts = [];
1851                if ( $resultDetails['redirect'] ) {
1852                    $queryParts[] = 'redirect=no';
1853                }
1854                if ( $extraQueryRedirect ) {
1855                    $queryParts[] = $extraQueryRedirect;
1856                }
1857                $anchor = $resultDetails['sectionanchor'];
1858                $this->doPostEditRedirect( implode( '&', $queryParts ), $anchor );
1859                return false;
1860
1861            case self::AS_SUCCESS_UPDATE:
1862                $extraQuery = '';
1863                $sectionanchor = $resultDetails['sectionanchor'];
1864                // Give extensions a chance to modify URL query on update
1865                $this->getHookRunner()->onArticleUpdateBeforeRedirect( $this->mArticle,
1866                    $sectionanchor, $extraQuery );
1867
1868                $queryParts = [];
1869                if ( $resultDetails['redirect'] ) {
1870                    $queryParts[] = 'redirect=no';
1871                }
1872                if ( $extraQuery ) {
1873                    $queryParts[] = $extraQuery;
1874                }
1875                if ( $extraQueryRedirect ) {
1876                    $queryParts[] = $extraQueryRedirect;
1877                }
1878                $this->doPostEditRedirect( implode( '&', $queryParts ), $sectionanchor );
1879                return false;
1880
1881            case self::AS_SPAM_ERROR:
1882                $this->spamPageWithContent( $resultDetails['spam'] ?? false );
1883                return false;
1884
1885            case self::AS_BLOCKED_PAGE_FOR_USER:
1886            case self::AS_IMAGE_REDIRECT_ANON:
1887            case self::AS_IMAGE_REDIRECT_LOGGED:
1888            case self::AS_NO_CHANGE_CONTENT_MODEL:
1889            case self::AS_NO_CREATE_PERMISSION:
1890            case self::AS_READ_ONLY_PAGE:
1891            case self::AS_READ_ONLY_PAGE_ANON:
1892            case self::AS_READ_ONLY_PAGE_LOGGED:
1893                $status->throwError();
1894                // No break statement here as throwError() will always throw an exception
1895
1896            default:
1897                // We don't recognize $statusValue. The only way that can happen
1898                // is if an extension hook aborted from inside ArticleSave.
1899                // Render the status object into $this->hookError
1900                // FIXME this sucks, we should just use the Status object throughout
1901                $this->hookError = Html::errorBox(
1902                    "\n" . Status::cast( $status )->getWikiText( false, false, $this->context->getLanguage() )
1903                );
1904                return true;
1905        }
1906    }
1907
1908    /**
1909     * Wrap warning/error messages in styled message boxes.
1910     * @return string Html
1911     */
1912    private function formatConstraintStatus( StatusValue $status ): string {
1913        return $this->createMessageBox( $status->getMessages( 'error' ), 'error' ) .
1914            $this->createMessageBox( $status->getMessages( 'warning' ), 'warning' );
1915    }
1916
1917    /**
1918     * Create a message box containing multiple messages.
1919     * @param MessageSpecifier[] $messages
1920     * @param string $type `warning` or `error`
1921     */
1922    private function createMessageBox( array $messages, string $type ): string {
1923        if ( !$messages ) {
1924            // Don't create a box if there are no messages
1925            return '';
1926        }
1927
1928        $html = implode(
1929            Html::openElement( 'br' ),
1930            array_map( fn ( $msg ) => $this->context->msg( $msg )->parse(), $messages )
1931        );
1932
1933        if ( $type === 'warning' ) {
1934            return Html::warningBox( $html );
1935        }
1936        return Html::errorBox( $html );
1937    }
1938
1939    /**
1940     * Emit the post-save redirect. The URL is modifiable with a hook.
1941     */
1942    private function doPostEditRedirect( string $query, string $anchor ): void {
1943        $out = $this->context->getOutput();
1944        $url = $this->getTitle()->getFullURL( $query ) . $anchor;
1945        $user = $this->getUserForSave();
1946        // If the temporary account was created in this request,
1947        // or if the temporary account has zero edits (implying
1948        // that the account was created during a failed edit
1949        // attempt in a previous request), perform the top-level
1950        // redirect to ensure the account is attached.
1951        // Note that the temp user could already have performed
1952        // the top-level redirect if this a first edit on
1953        // a wiki that is not the user's home wiki.
1954        $shouldRedirectForTempUser = $this->tempUserCreateDone ||
1955            ( $this->userIdentityUtils->isTemp( $user ) && $this->userEditTracker->getUserEditCount( $user ) === 0 );
1956        if ( $shouldRedirectForTempUser ) {
1957            $this->getHookRunner()->onTempUserCreatedRedirect(
1958                $this->context->getRequest()->getSession(),
1959                $user,
1960                $this->getTitle()->getPrefixedDBkey(),
1961                $query,
1962                $anchor,
1963                $url
1964            );
1965        }
1966        $out->redirect( $url );
1967    }
1968
1969    /**
1970     * Set the edit summary and link anchor to be used for a new section.
1971     */
1972    private function setNewSectionSummary(): void {
1973        Assert::precondition( $this->section === 'new', 'This method can only be called for new sections' );
1974        Assert::precondition( $this->sectiontitle !== null, 'This method can only be called for new sections' );
1975
1976        $services = MediaWikiServices::getInstance();
1977        $parser = $services->getParser();
1978        $textFormatter = $services->getMessageFormatterFactory()->getTextFormatter(
1979            $services->getContentLanguageCode()->toString()
1980        );
1981
1982        // If no edit summary was specified and the section title is not empty, create
1983        // a summary automatically from the section title and have it link to the new section.
1984        // Otherwise, respect the summary as passed.
1985        if ( $this->sectiontitle !== '' && $this->summary === '' ) {
1986            $messageValue = MessageValue::new( 'newsectionsummary' )
1987                ->plaintextParams( $parser->stripSectionName( $this->sectiontitle ) );
1988            $this->summary = $textFormatter->format( $messageValue );
1989        }
1990    }
1991
1992    /**
1993     * Attempt submission (no UI)
1994     *
1995     * @param array &$result Array to add statuses to, currently with the
1996     *   possible keys:
1997     *   - spam (string): Spam string from content if any spam is detected by
1998     *     matchSpamRegex.
1999     *   - sectionanchor (string): Section anchor for a section save.
2000     *   - nullEdit (bool): Set if doUserEditContent is OK.  True if null edit,
2001     *     false otherwise.
2002     *   - redirect (bool): Set if doUserEditContent is OK. True if resulting
2003     *     revision is a redirect.
2004     *
2005     * @return PageEditStatus Status object, possibly with a message, but always with
2006     *   one of the AS_* constants in $status->value,
2007     *
2008     * @todo FIXME: This interface is TERRIBLE, but hard to get rid of due to
2009     *   various error display idiosyncrasies. There are also lots of cases
2010     *   where error metadata is set in the object and retrieved later instead
2011     *   of being returned, e.g. AS_CONTENT_TOO_BIG and
2012     *   AS_BLOCKED_PAGE_FOR_USER. All that stuff needs to be cleaned up some
2013     * time.
2014     */
2015    private function internalAttemptSave( &$result ) {
2016        // If an attempt to acquire a temporary name failed, don't attempt to do anything else.
2017        if ( $this->unableToAcquireTempName ) {
2018            return PageEditStatus::newFatal( 'temp-user-unable-to-acquire' )
2019                ->setValue( self::AS_UNABLE_TO_ACQUIRE_TEMP_ACCOUNT );
2020        }
2021        // Auto-create the temporary account user, if the feature is enabled.
2022        // We create the account before any constraint checks or edit hooks fire, to ensure
2023        // that we have an actor and user account that can be used for any logs generated
2024        // by the edit attempt, and to ensure continuity in the user experience (if a constraint
2025        // denies an edit to a logged-out user, that history should be associated with the
2026        // eventually successful account creation)
2027        $tempAccountStatus = $this->createTempUser();
2028        if ( !$tempAccountStatus->isOK() ) {
2029            return PageEditStatus::cast( $tempAccountStatus );
2030        }
2031        if ( $tempAccountStatus instanceof CreateStatus ) {
2032            $result['savedTempUser'] = $tempAccountStatus->getUser();
2033        }
2034
2035        if ( !$this->getHookRunner()->onEditPage__attemptSave( $this ) ) {
2036            wfDebug( "Hook 'EditPage::attemptSave' aborted article saving" );
2037            return PageEditStatus::newFatal( 'hookaborted' )
2038                ->setValue( self::AS_HOOK_ERROR );
2039        }
2040
2041        if ( $this->unicodeCheck !== self::UNICODE_CHECK ) {
2042            return PageEditStatus::newFatal( 'unicode-support-fail' )
2043                ->setValue( self::AS_UNICODE_NOT_SUPPORTED );
2044        }
2045
2046        $antispam = $this->getContext()->getRequest()->getText( 'wpAntispam' );
2047        if ( $antispam !== '' ) {
2048            LoggerFactory::getInstance( 'SimpleAntiSpam' )->debug(
2049                '{name} editing "{title}" submitted bogus field "{input}"',
2050                [
2051                    // Use the context user since there is no permissions aspect
2052                    'name' => $this->context->getUser()->getName(),
2053                    'title' => $this->page,
2054                    'input' => $antispam,
2055                ]
2056            );
2057            return PageEditStatus::newFatal( 'spamprotectionmatch', '' )
2058                ->setValue( self::AS_SPAM_ERROR );
2059        }
2060
2061        $allowBlankSummary = $this->allowBlankSummary || ( $this->nosummary && $this->section === 'new' );
2062
2063        $inputs = ( new PageEditInputs(
2064            authority: $this->getAuthority(),
2065            contentModel: $this->contentModel,
2066            context: $this->context,
2067            page: $this->page,
2068            summary: $this->summary,
2069            textbox1: $this->textbox1,
2070        ) )->setAllowBlankArticle( $this->allowBlankArticle )
2071            ->setAllowBlankSummary( $allowBlankSummary )
2072            ->setAllowedProblematicRedirectTarget( $this->allowedProblematicRedirectTarget )
2073            ->setAutoSumm( $this->autoSumm )
2074            ->setChangeTags( $this->changeTags )
2075            ->setContentFormat( $this->contentFormat )
2076            ->setContextPage( $this->mContextTitle )
2077            ->setEdittime( $this->edittime )
2078            ->setEditRevId( $this->editRevId )
2079            ->setEnableApiEditOverride( $this->enableApiEditOverride )
2080            ->setIgnoreProblematicRedirects( $this->ignoreProblematicRedirects )
2081            ->setIgnoreRevisionDeletedWarning( $this->ignoreRevisionDeletedWarning )
2082            ->setMarkAsBot( $this->markAsBot )
2083            ->setMarkAsMinor( $this->minoredit )
2084            ->setOldid( $this->oldid )
2085            ->setParentRevId( $this->parentRevId )
2086            ->setRecreate( $this->recreate )
2087            ->setSection( $this->section )
2088            ->setSectiontitle( $this->sectiontitle )
2089            ->setStarttime( $this->starttime )
2090            ->setUndidRev( $this->undidRev )
2091            ->setUndoAfter( $this->undoAfter )
2092            ->setUserForPreview( $this->getUserForPreview() )
2093            ->setUserForSave( $this->getUserForSave() )
2094            ->setWatchlistExpiry( $this->watchlistExpiry )
2095            ->setWatchlistLabels( $this->watchlistLabels )
2096            ->setWatchthis( $this->watchthis );
2097
2098        $pageEdit = $this->pageEditFactory->newPageEdit( $inputs );
2099        $pageEditResult = $pageEdit->edit();
2100
2101        // PageEdit writes to those properties, so update them for backwards compatibility
2102        $this->contentLength = $pageEditResult->getContentLength();
2103        $this->isConflict = $pageEditResult->isConflict();
2104        $this->parentRevId = $pageEditResult->getParentRevId();
2105        $this->section = $pageEditResult->getSection();
2106        $this->textbox1 = $pageEditResult->getTextbox1();
2107
2108        if ( !$pageEditResult->getStatus()->isOK() ) {
2109            $failed = $pageEditResult->getStatus()->getFailedConstraint();
2110
2111            // Need to check SpamRegexConstraint here, to avoid needing to pass
2112            // $result by reference again
2113            if ( $failed instanceof SpamRegexConstraint ) {
2114                $result['spam'] = $failed->getMatch();
2115            } else {
2116                $this->handleFailedConstraint( $pageEditResult->getStatus() );
2117            }
2118        }
2119
2120        $result['sectionanchor'] = $pageEditResult->getSectionanchor();
2121        if ( $pageEditResult->isNullEdit() !== null ) {
2122            $result['nullEdit'] = $pageEditResult->isNullEdit();
2123        }
2124        if ( $pageEditResult->isRedirect() !== null ) {
2125            $result['redirect'] = $pageEditResult->isRedirect();
2126        }
2127
2128        return $pageEditResult->getStatus();
2129    }
2130
2131    /**
2132     * Apply the specific updates needed for the EditPage fields based on which constraint
2133     * failed, rather than interspersing this logic throughout internalAttemptSave at
2134     * each of the points the constraints are checked. Eventually, this will act on the
2135     * result from the backend.
2136     */
2137    private function handleFailedConstraint( PageEditStatus $status ): void {
2138        $failed = $status->getFailedConstraint();
2139        if ( $failed instanceof AuthorizationConstraint ) {
2140            // Auto-block user's IP if the account was "hard" blocked
2141            if (
2142                !MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly()
2143                && $status->value === self::AS_BLOCKED_PAGE_FOR_USER
2144            ) {
2145                $this->context->getUser()->spreadAnyEditBlock();
2146            }
2147        } elseif ( $failed instanceof DefaultTextConstraint ) {
2148            $this->blankArticle = true;
2149        } elseif ( $failed instanceof EditFilterMergedContentHookConstraint ) {
2150            $this->hookError = $failed->getHookError();
2151        } elseif (
2152            $failed instanceof ExistingSectionEditConstraint ||
2153            $failed instanceof NewSectionMissingSubjectConstraint
2154        ) {
2155            $this->missingSummary = true;
2156            $this->allowBlankSummary = true;
2157        } elseif ( $failed instanceof RedirectConstraint ) {
2158            $this->problematicRedirectTarget = $failed->problematicTarget;
2159        } elseif ( $failed instanceof AccidentalRecreationConstraint ) {
2160            $this->recreate = true;
2161        } elseif ( $failed instanceof RevisionDeletedConstraint ) {
2162            $this->ignoreRevisionDeletedWarning = true;
2163        }
2164    }
2165
2166    /**
2167     * Returns the RevisionRecord corresponding to the revision that was current at the time
2168     * editing was initiated on the client even if the edit was based on an old revision
2169     *
2170     * @since 1.35
2171     * @return RevisionRecord|null Latest revision when editing was initiated on the client
2172     */
2173    public function getExpectedParentRevision(): ?RevisionRecord {
2174        return $this->pageEditingHelper->getExpectedParentRevision(
2175            $this->editRevId,
2176            $this->edittime,
2177            $this->page,
2178        );
2179    }
2180
2181    public function setHeaders() {
2182        $out = $this->context->getOutput();
2183
2184        $out->addModules( 'mediawiki.action.edit' );
2185        $out->addModuleStyles( [
2186            'mediawiki.action.edit.styles',
2187            'mediawiki.codex.messagebox.styles',
2188            'mediawiki.editfont.styles',
2189            'mediawiki.interface.helpers.styles',
2190        ] );
2191
2192        $user = $this->context->getUser();
2193
2194        if ( $this->userOptionsLookup->getOption( $user, 'uselivepreview' ) ) {
2195            $out->addModules( 'mediawiki.action.edit.preview' );
2196        }
2197
2198        if ( $this->userOptionsLookup->getOption( $user, 'useeditwarning' ) ) {
2199            $out->addModules( 'mediawiki.action.edit.editWarning' );
2200        }
2201
2202        if ( $this->context->getConfig()->get( MainConfigNames::EnableEditRecovery )
2203            && $this->userOptionsLookup->getOption( $user, 'editrecovery' )
2204        ) {
2205            $wasPosted = $this->getContext()->getRequest()->getMethod() === 'POST';
2206            $out->addJsConfigVars( 'wgEditRecoveryWasPosted', $wasPosted );
2207            $out->addModules( 'mediawiki.editRecovery.edit' );
2208        }
2209
2210        # Enabled article-related sidebar, toplinks, etc.
2211        $out->setArticleRelated( true );
2212
2213        $contextTitle = $this->getContextTitle();
2214        if ( $this->isConflict ) {
2215            $msg = 'editconflict';
2216        } elseif ( $contextTitle->exists() && $this->section != '' ) {
2217            $msg = $this->section === 'new' ? 'editingcomment' : 'editingsection';
2218        } else {
2219            $msg = $contextTitle->exists()
2220                || $this->shadowPageLoader->get( $contextTitle )?->existsForEdit()
2221                ? 'editing'
2222                : 'creating';
2223        }
2224
2225        # Use the title defined by DISPLAYTITLE magic word when present
2226        # NOTE: getDisplayTitle() returns HTML while getPrefixedText() returns plain text.
2227        #       Escape ::getPrefixedText() so that we have HTML in all cases,
2228        #       and pass as a "raw" parameter to ::setPageTitleMsg().
2229        $displayTitle = $this->mParserOutput ? $this->mParserOutput->getDisplayTitle() : false;
2230        if ( $displayTitle === false ) {
2231            $displayTitle = Parser::formatPageTitle(
2232                str_replace( '_', ' ', $contextTitle->getNsText() ),
2233                ':',
2234                $contextTitle->getText(),
2235                $contextTitle->getPageLanguage()
2236            );
2237        } else {
2238            $out->setDisplayTitle( $displayTitle );
2239        }
2240
2241        // Enclose the title with an element. This is used on live preview to update the
2242        // preview of the display title.
2243        $displayTitle = Html::rawElement( 'span', [ 'id' => 'firstHeadingTitle' ], $displayTitle );
2244
2245        $out->setPageTitleMsg( $this->context->msg( $msg )->rawParams( $displayTitle ) );
2246
2247        $config = $this->context->getConfig();
2248
2249        # Transmit the name of the message to JavaScript. This was added for live preview.
2250        # Live preview doesn't use this anymore. The variable is still transmitted because
2251        # Edit Recovery and user scripts use it.
2252        $out->addJsConfigVars( [
2253            'wgEditMessage' => $msg,
2254        ] );
2255
2256        // Add whether to use 'save' or 'publish' messages to JavaScript for post-edit, other
2257        // editors, etc.
2258        $out->addJsConfigVars(
2259            'wgEditSubmitButtonLabelPublish',
2260            $config->get( MainConfigNames::EditSubmitButtonLabelPublish )
2261        );
2262    }
2263
2264    /**
2265     * Show all applicable editing introductions
2266     */
2267    private function showIntro(): void {
2268        $services = MediaWikiServices::getInstance();
2269
2270        // Hardcoded list of notices that are suppressable for historical reasons.
2271        // This feature was originally added for LiquidThreads, to avoid showing non-essential messages
2272        // when commenting in a thread, but some messages were included (or excluded) by mistake before
2273        // its implementation was moved to one place, and this list doesn't make a lot of sense.
2274        // TODO: Remove the suppressIntro feature from EditPage, and invent a better way for extensions
2275        // to skip individual intro messages.
2276        $skip = $this->suppressIntro ? [
2277            'editintro',
2278            'code-editing-intro',
2279            'sharedupload-desc-create',
2280            'sharedupload-desc-edit',
2281            'userpage-userdoesnotexist',
2282            'blocked-notice-logextract',
2283            'newarticletext',
2284            'newarticletextanon',
2285            'recreate-moveddeleted-warn',
2286        ] : [];
2287
2288        $messages = $services->getIntroMessageBuilder()->getIntroMessages(
2289            IntroMessageBuilder::MORE_FRAMES,
2290            $skip,
2291            $this->context,
2292            $this->page,
2293            $this->mArticle->fetchRevisionRecord(),
2294            $this->context->getUser(),
2295            $this->context->getRequest()->getVal( 'editintro' ),
2296            wfArrayToCgi(
2297                array_diff_key(
2298                    $this->context->getRequest()->getQueryValues(),
2299                    [ 'title' => true, 'returnto' => true, 'returntoquery' => true ]
2300                )
2301            ),
2302            !$this->firsttime,
2303            $this->section !== '' ? $this->section : null
2304        );
2305
2306        foreach ( $messages as $message ) {
2307            $this->context->getOutput()->addHTML( $message );
2308        }
2309    }
2310
2311    /**
2312     * Turns the given text into a Content object by unserializing it.
2313     *
2314     * If the resulting Content object is not of a type that can be edited using
2315     * the text base EditPage, an exception will be raised. Set
2316     * $this->enableApiEditOverride to true to allow editing of non-textual
2317     * content.
2318     *
2319     * @param string|null|false $text Text to unserialize
2320     * @return Content|false|null The content object created from $text. If $text was false
2321     *   or null, then false or null will be returned instead.
2322     *
2323     * @throws NotDirectlyEditableException If unserializing the text results in a Content
2324     *   object that is not an instance of TextContent and
2325     *   $this->enableApiEditOverride is not true.
2326     */
2327    protected function toEditContent( $text ) {
2328        if ( $text === false || $text === null ) {
2329            return $text;
2330        }
2331
2332        $content = ContentHandler::makeContent( $text, $this->getTitle(),
2333            $this->contentModel, $this->contentFormat );
2334        $this->assertSupportedContent( $content );
2335        return $content;
2336    }
2337
2338    /**
2339     * Throw an exception if the content can't be directly edited
2340     *
2341     * @throws NotDirectlyEditableException
2342     */
2343    private function assertSupportedContent( Content $content ): void {
2344        if ( !$this->pageEditingHelper->isSupportedContentModel(
2345            $content->getModel(), $this->enableApiEditOverride
2346        ) ) {
2347            throw new NotDirectlyEditableException( 'This content model is not supported: ' . $content->getModel() );
2348        }
2349    }
2350
2351    /**
2352     * Send the edit form and related headers to OutputPage
2353     */
2354    public function showEditForm() {
2355        # need to parse the preview early so that we know which templates are used,
2356        # otherwise users with "show preview after edit box" will get a blank list
2357        # we parse this near the beginning so that setHeaders can do the title
2358        # setting work instead of leaving it in getPreviewText
2359        $previewOutput = '';
2360        if ( $this->formtype === 'preview' ) {
2361            $previewOutput = $this->getPreviewText();
2362        }
2363
2364        $out = $this->context->getOutput();
2365
2366        // FlaggedRevs depends on running this hook before adding edit notices in showIntro() (T337637)
2367        $this->getHookRunner()->onEditPage__showEditForm_initial( $this, $out );
2368
2369        $this->setHeaders();
2370
2371        // Show applicable editing introductions
2372        $this->showIntro();
2373
2374        if ( !$this->isConflict &&
2375            $this->section !== '' &&
2376            !$this->isSectionEditSupported()
2377        ) {
2378            // We use $this->section to much before this and getVal('wgSection') directly in other places
2379            // at this point we can't reset $this->section to '' to fallback to non-section editing.
2380            // Someone is welcome to try refactoring though
2381            $out->showErrorPage( 'sectioneditnotsupported-title', 'sectioneditnotsupported-text' );
2382            return;
2383        }
2384
2385        $this->showHeader();
2386
2387        $out->addHTML( $this->editFormPageTop );
2388
2389        $user = $this->context->getUser();
2390        if ( $this->userOptionsLookup->getOption( $user, 'previewontop' ) ) {
2391            $this->displayPreviewArea( $previewOutput, true );
2392        }
2393
2394        $out->addHTML( $this->editFormTextTop );
2395
2396        // @todo add EditForm plugin interface and use it here!
2397        //       search for textarea1 and textarea2, and allow EditForm to override all uses.
2398        $out->addHTML( Html::openElement(
2399            'form',
2400            [
2401                'class' => 'mw-editform',
2402                'id' => self::EDITFORM_ID,
2403                'name' => self::EDITFORM_ID,
2404                'method' => 'post',
2405                'action' => $this->getActionURL( $this->getContextTitle() ),
2406                'enctype' => 'multipart/form-data',
2407                'data-mw-editform-type' => $this->formtype
2408            ]
2409        ) );
2410
2411        // Add a check for Unicode support
2412        $out->addHTML( Html::hidden( 'wpUnicodeCheck', self::UNICODE_CHECK ) );
2413
2414        // Add an empty field to trip up spambots
2415        $out->addHTML(
2416            Html::openElement( 'div', [ 'id' => 'antispam-container', 'style' => 'display: none;' ] )
2417            . Html::rawElement(
2418                'label',
2419                [ 'for' => 'wpAntispam' ],
2420                $this->context->msg( 'simpleantispam-label' )->parse()
2421            )
2422            . Html::element(
2423                'input',
2424                [
2425                    'type' => 'text',
2426                    'name' => 'wpAntispam',
2427                    'id' => 'wpAntispam',
2428                    'value' => ''
2429                ]
2430            )
2431            . Html::closeElement( 'div' )
2432        );
2433
2434        $this->getHookRunner()->onEditPage__showEditForm_fields( $this, $out );
2435
2436        // Put these up at the top to ensure they aren't lost on early form submission
2437        $this->showFormBeforeText();
2438
2439        # When the summary is hidden, also hide them on preview/show changes
2440        if ( $this->nosummary ) {
2441            $out->addHTML( Html::hidden( 'nosummary', true ) );
2442        }
2443
2444        # If a blank edit summary was previously provided and an edit constraint fails
2445        # because of it, pass a hidden tag as wpIgnoreBlankSummary. This will stop the
2446        # user being bounced back more than once in the event that a summary
2447        # is not required.
2448        if ( $this->allowBlankSummary ) {
2449            $out->addHTML( Html::hidden( 'wpIgnoreBlankSummary', true ) );
2450        }
2451
2452        if ( $this->undidRev ) {
2453            $out->addHTML( Html::hidden( 'wpUndidRevision', $this->undidRev ) );
2454        }
2455        if ( $this->undoAfter ) {
2456            $out->addHTML( Html::hidden( 'wpUndoAfter', $this->undoAfter ) );
2457        }
2458        if ( $this->recreate ) {
2459            $out->addHTML( Html::hidden( 'wpRecreate', $this->recreate ) );
2460        }
2461        if ( $this->ignoreRevisionDeletedWarning ) {
2462            $out->addHTML( Html::hidden( 'wpIgnoreRevisionDeleted', $this->ignoreRevisionDeletedWarning ) );
2463        }
2464
2465        if ( $this->problematicRedirectTarget !== null ) {
2466            // T395767, T395768: Save the target to a variable so the constraint can fail again if the redirect is
2467            // still problematic but has changed between two save attempts
2468            $out->addHTML( Html::hidden(
2469                'wpAllowedProblematicRedirectTarget',
2470                $this->problematicRedirectTarget->getFullText()
2471            ) );
2472        }
2473
2474        $autosumm = $this->autoSumm !== '' ? $this->autoSumm : md5( $this->summary );
2475        $out->addHTML( Html::hidden( 'wpAutoSummary', $autosumm ) );
2476
2477        $out->addHTML( Html::hidden( 'oldid', $this->oldid ) );
2478        $out->addHTML( Html::hidden( 'parentRevId', $this->getParentRevId() ) );
2479
2480        $out->addHTML( Html::hidden( 'format', $this->contentFormat ) );
2481        $out->addHTML( Html::hidden( 'model', $this->contentModel ) );
2482        if ( $this->changeTags ) {
2483            $out->addHTML( Html::hidden( 'wpChangeTagsAfterPreview', implode( ',', $this->changeTags ) ) );
2484        }
2485
2486        $out->enableOOUI();
2487
2488        if ( $this->section === 'new' ) {
2489            $this->showSummaryInput( true );
2490            $out->addHTML( $this->getSummaryPreview( true ) );
2491        }
2492
2493        $out->addHTML( $this->editFormTextBeforeContent );
2494        if ( $this->isConflict ) {
2495            $currentText = $this->pageEditingHelper->toEditText(
2496                $this->getCurrentContent(), $this->contentFormat, $this->enableApiEditOverride
2497            ) ?? '';
2498
2499            $editConflictHelper = $this->getEditConflictHelper();
2500            $editConflictHelper->setTextboxes( $this->textbox1, $currentText );
2501            $editConflictHelper->setContentModel( $this->contentModel );
2502            $editConflictHelper->setContentFormat( $this->contentFormat );
2503            $out->addHTML( $editConflictHelper->getEditFormHtmlBeforeContent() );
2504
2505            $this->textbox1 = $currentText;
2506        }
2507
2508        if ( !$this->getTitle()->isUserConfigPage() ) {
2509            $out->addHTML( self::getEditToolbar() ?? '' );
2510        }
2511
2512        if ( $this->blankArticle ) {
2513            $out->addHTML( Html::hidden( 'wpIgnoreBlankArticle', true ) );
2514        }
2515
2516        if ( $this->isConflict ) {
2517            // In an edit conflict bypass the overridable content form method
2518            // and fallback to the raw wpTextbox1 since editconflicts can't be
2519            // resolved between page source edits and custom ui edits using the
2520            // custom edit ui.
2521            $conflictTextBoxAttribs = [];
2522            if ( $this->isOldRev ) {
2523                $conflictTextBoxAttribs['class'] = 'mw-textarea-oldrev';
2524            }
2525
2526            // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
2527            // $editConflictHelper is declared, when isConflict is true
2528            $out->addHTML( $editConflictHelper->getEditConflictMainTextBox( $conflictTextBoxAttribs ) );
2529            // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
2530            // $editConflictHelper is declared, when isConflict is true
2531            $out->addHTML( $editConflictHelper->getEditFormHtmlAfterContent() );
2532        } else {
2533            $this->showContentForm();
2534        }
2535
2536        $out->addHTML( $this->editFormTextAfterContent );
2537
2538        $this->showStandardInputs();
2539
2540        $this->showFormAfterText();
2541
2542        $this->showTosSummary();
2543
2544        $this->showEditTools();
2545
2546        $out->addHTML( $this->editFormTextAfterTools . "\n" );
2547
2548        $out->addHTML( $this->makeTemplatesOnThisPageList( $this->getTemplates() ) );
2549
2550        $out->addHTML( Html::rawElement( 'div', [ 'class' => 'hiddencats' ],
2551            Linker::formatHiddenCategories( $this->page->getHiddenCategories() ) ) );
2552
2553        $out->addHTML( Html::rawElement( 'div', [ 'class' => 'limitreport' ],
2554            self::getPreviewLimitReport( $this->mParserOutput ) ) );
2555
2556        $out->addModules( 'mediawiki.action.edit.collapsibleFooter' );
2557
2558        if ( $this->isConflict ) {
2559            try {
2560                $this->showConflict();
2561            } catch ( ContentSerializationException $ex ) {
2562                // this can't really happen, but be nice if it does.
2563                $out->addHTML( Html::errorBox(
2564                    $this->context->msg(
2565                        'content-failed-to-parse',
2566                        $this->contentModel,
2567                        $this->contentFormat,
2568                        $ex->getMessage()
2569                    )->parse()
2570                ) );
2571            }
2572        }
2573
2574        // Set a hidden field so JS knows what edit form mode we are in
2575        if ( $this->isConflict ) {
2576            $mode = 'conflict';
2577        } elseif ( $this->preview ) {
2578            $mode = 'preview';
2579        } elseif ( $this->diff ) {
2580            $mode = 'diff';
2581        } else {
2582            $mode = 'text';
2583        }
2584        $out->addHTML( Html::hidden( 'mode', $mode, [ 'id' => 'mw-edit-mode' ] ) );
2585
2586        // Marker for detecting truncated form data.  This must be the last
2587        // parameter sent in order to be of use, so do not move me.
2588        $out->addHTML( Html::hidden( 'wpUltimateParam', true ) );
2589        $out->addHTML( $this->editFormTextBottom . "\n</form>\n" );
2590
2591        if ( !$this->userOptionsLookup->getOption( $user, 'previewontop' ) ) {
2592            $this->displayPreviewArea( $previewOutput, false );
2593        }
2594    }
2595
2596    /**
2597     * Wrapper around TemplatesOnThisPageFormatter to make
2598     * a "templates on this page" list.
2599     *
2600     * @param PageIdentity[] $templates
2601     * @return string HTML
2602     */
2603    public function makeTemplatesOnThisPageList( array $templates ): string {
2604        $templateListFormatter = new TemplatesOnThisPageFormatter(
2605            $this->context,
2606            $this->linkRenderer,
2607            $this->linkBatchFactory,
2608            $this->restrictionStore
2609        );
2610
2611        // preview if preview, else section if section, else false
2612        $type = false;
2613        if ( $this->preview ) {
2614            $type = 'preview';
2615        } elseif ( $this->section !== '' ) {
2616            $type = 'section';
2617        }
2618
2619        return Html::rawElement( 'div', [ 'class' => 'templatesUsed' ],
2620            $templateListFormatter->format( $templates, $type )
2621        );
2622    }
2623
2624    /**
2625     * Extract the section title from current section text, if any.
2626     */
2627    private static function extractSectionTitle( string $text ): string|false {
2628        if ( preg_match( "/^(=+)(.+)\\1\\s*(\n|$)/i", $text, $matches ) ) {
2629            return MediaWikiServices::getInstance()->getParser()
2630                ->stripSectionName( trim( $matches[2] ) );
2631        } else {
2632            return false;
2633        }
2634    }
2635
2636    private function showHeader(): void {
2637        $out = $this->context->getOutput();
2638        if ( $this->isConflict ) {
2639            $this->addExplainConflictHeader();
2640            $this->editRevId = $this->page->getLatest();
2641        } else {
2642            if ( $this->section !== '' && $this->section !== 'new' && $this->summary === '' &&
2643                !$this->preview && !$this->diff
2644            ) {
2645                // https://github.com/phan/phan/issues/5514
2646                // @phan-suppress-next-line PhanImpossibleTypeComparison
2647                if ( $this->section === '0' ) {
2648                    $this->summary = "/* */ ";
2649                } else {
2650                    $sectionTitle = self::extractSectionTitle( $this->textbox1 ); // FIXME: use Content object
2651                    if ( $sectionTitle !== false && $sectionTitle !== '' ) {
2652                        $this->summary = "/* $sectionTitle */ ";
2653                    }
2654                }
2655            }
2656
2657            if ( $this->hookError !== '' ) {
2658                $out->addWikiTextAsInterface( $this->hookError );
2659            }
2660
2661            if ( $this->section != 'new' ) {
2662                $revRecord = $this->mArticle->fetchRevisionRecord();
2663                if ( $revRecord instanceof RevisionStoreRecord && !$revRecord->isCurrent() ) {
2664                    $this->mArticle->setOldSubtitle( $revRecord->getId() );
2665                    $this->isOldRev = true;
2666                }
2667            }
2668        }
2669
2670        if ( $this->formtype !== 'save' ) {
2671            /** @var EditConstraintFactory $constraintFactory */
2672            $constraintFactory = MediaWikiServices::getInstance()->getService( '_EditConstraintFactory' );
2673            $constraintRunner = new EditConstraintRunner(
2674                $constraintFactory->newAccidentalRecreationConstraint(
2675                    $this->getTitle(),
2676                    // Ignore wpRedirect so the warning is still shown after a save attempt
2677                    false,
2678                    $this->starttime,
2679                ),
2680                new RevisionDeletedConstraint(
2681                    false,
2682                    $this->oldid,
2683                    $this->mArticle->fetchRevisionRecord(),
2684                    $this->section,
2685                    $this->getTitle(),
2686                    $this->context->getUser(),
2687                ),
2688            );
2689
2690            // No call to $this->handleFailedConstraint() here to avoid setting wpRedirect
2691            $out->addHTML( $this->formatConstraintStatus( $constraintRunner->checkAllConstraints() ) );
2692        }
2693
2694        $this->addLongPageWarningHeader();
2695    }
2696
2697    /**
2698     * Helper function for summary input functions, which returns the necessary
2699     * attributes for the input.
2700     */
2701    private function getSummaryInputAttributes( array $inputAttrs ): array {
2702        // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
2703        // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
2704        // Unicode codepoints.
2705        return $inputAttrs + [
2706            'id' => 'wpSummary',
2707            'name' => 'wpSummary',
2708            'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
2709            'tabindex' => 1,
2710            'size' => 60,
2711            'spellcheck' => 'true',
2712        ];
2713    }
2714
2715    /**
2716     * Builds a standard summary input with a label.
2717     *
2718     * @param string $summary The value of the summary input
2719     * @param string $labelText The html to place inside the label
2720     * @param array $inputAttrs Array of attrs to use on the input
2721     *
2722     * @return OOUI\FieldLayout OOUI FieldLayout with Label and Input
2723     */
2724    private function getSummaryInputWidget( $summary, string $labelText, array $inputAttrs ): FieldLayout {
2725        $inputAttrs = OOUI\Element::configFromHtmlAttributes(
2726            $this->getSummaryInputAttributes( $inputAttrs )
2727        );
2728        $inputAttrs += [
2729            'title' => Linker::titleAttrib( 'summary' ),
2730            'accessKey' => Linker::accesskey( 'summary' ),
2731        ];
2732
2733        // For compatibility with old scripts and extensions, we want the legacy 'id' on the `<input>`
2734        $inputAttrs['inputId'] = $inputAttrs['id'];
2735        $inputAttrs['id'] = 'wpSummaryWidget';
2736
2737        return new OOUI\FieldLayout(
2738            new OOUI\TextInputWidget( [
2739                'value' => $summary,
2740                'infusable' => true,
2741            ] + $inputAttrs ),
2742            [
2743                'label' => new OOUI\HtmlSnippet( $labelText ),
2744                'align' => 'top',
2745                'id' => 'wpSummaryLabel',
2746                'classes' => [ $this->missingSummary ? 'mw-summarymissed' : 'mw-summary' ],
2747            ]
2748        );
2749    }
2750
2751    /**
2752     * @param bool $isSubjectPreview True if this is the section subject/title
2753     *   up top, or false if this is the comment summary
2754     *   down below the textarea
2755     */
2756    private function showSummaryInput( bool $isSubjectPreview ): void {
2757        # Add a class if 'missingsummary' is triggered to allow styling of the summary line
2758        $summaryClass = $this->missingSummary ? 'mw-summarymissed' : 'mw-summary';
2759        if ( $isSubjectPreview ) {
2760            if ( $this->nosummary ) {
2761                return;
2762            }
2763        } elseif ( !$this->mShowSummaryField ) {
2764            return;
2765        }
2766
2767        $labelText = $this->context->msg( $isSubjectPreview ? 'subject' : 'summary' )->parse();
2768        $this->context->getOutput()->addHTML(
2769            $this->getSummaryInputWidget(
2770                $isSubjectPreview ? $this->sectiontitle : $this->summary,
2771                $labelText,
2772                [ 'class' => $summaryClass ]
2773            )->toString()
2774        );
2775    }
2776
2777    /**
2778     * @param bool $isSubjectPreview True if this is the section subject/title
2779     *   up top, or false if this is the comment summary
2780     *   down below the textarea
2781     */
2782    private function getSummaryPreview( bool $isSubjectPreview ): string {
2783        // avoid spaces in preview, gets always trimmed on save
2784        $summary = trim( $this->summary );
2785        if ( $summary === '' || ( !$this->preview && !$this->diff ) ) {
2786            return "";
2787        }
2788
2789        $commentFormatter = MediaWikiServices::getInstance()->getCommentFormatter();
2790        $summary = $this->context->msg( 'summary-preview' )->parse()
2791            . $commentFormatter->formatBlock( $summary, $this->getTitle(), $isSubjectPreview );
2792        return Html::rawElement( 'div', [ 'class' => 'mw-summary-preview' ], $summary );
2793    }
2794
2795    private function showFormBeforeText(): void {
2796        $out = $this->context->getOutput();
2797        $out->addHTML( Html::hidden( 'wpSection', $this->section ) );
2798        $out->addHTML( Html::hidden( 'wpStarttime', $this->starttime ) );
2799        $out->addHTML( Html::hidden( 'wpEdittime', $this->edittime ) );
2800        $out->addHTML( Html::hidden( 'editRevId', $this->editRevId ) );
2801        $out->addHTML( Html::hidden( 'wpScrolltop', $this->scrolltop, [ 'id' => 'wpScrolltop' ] ) );
2802    }
2803
2804    protected function showFormAfterText() {
2805        /**
2806         * To make it harder for someone to slip a user a page
2807         * which submits an edit form to the wiki without their
2808         * knowledge, a random token is associated with the login
2809         * session. If it's not passed back with the submission,
2810         * we won't save the page, or render user JavaScript and
2811         * CSS previews.
2812         *
2813         * For anon editors, who may not have a session, we just
2814         * include the constant suffix to prevent editing from
2815         * broken text-mangling proxies.
2816         */
2817        $this->context->getOutput()->addHTML(
2818            "\n" .
2819            Html::hidden( "wpEditToken", $this->context->getUser()->getEditToken() ) .
2820            "\n"
2821        );
2822    }
2823
2824    /**
2825     * Subpage overridable method for printing the form for page content editing
2826     * By default this simply outputs wpTextbox1
2827     * Subclasses can override this to provide a custom UI for editing;
2828     * be it a form, or simply wpTextbox1 with a modified content that will be
2829     * reverse modified when extracted from the post data.
2830     * Note that this is basically the inverse for importContentFormData
2831     */
2832    protected function showContentForm() {
2833        $this->showTextbox1();
2834    }
2835
2836    private function showTextbox1(): void {
2837        $classes = $this->textboxBuilder->getTextboxProtectionCSSClasses( $this->getTitle() );
2838
2839        # Is an old revision being edited?
2840        if ( $this->isOldRev ) {
2841            $classes[] = 'mw-textarea-oldrev';
2842        }
2843
2844        $attribs = [
2845            'aria-label' => $this->context->msg( 'edit-textarea-aria-label' )->text(),
2846            'tabindex' => 1,
2847            'class' => $classes,
2848        ];
2849
2850        $this->showTextbox(
2851            $this->textbox1,
2852            'wpTextbox1',
2853            $attribs
2854        );
2855    }
2856
2857    protected function showTextbox( string $text, string $name, array $customAttribs = [] ): void {
2858        $attribs = $this->textboxBuilder->buildTextboxAttribs(
2859            $name,
2860            $customAttribs,
2861            $this->context->getUser(),
2862            $this->page
2863        );
2864
2865        $this->context->getOutput()->addHTML(
2866            Html::textarea( $name, $this->textboxBuilder->addNewLineAtEnd( $text ), $attribs )
2867        );
2868    }
2869
2870    private function displayPreviewArea( string $previewOutput, bool $isOnTop ): void {
2871        $attribs = [ 'id' => 'wikiPreview' ];
2872        if ( $isOnTop ) {
2873            $attribs['class'] = 'ontop';
2874        }
2875        if ( $this->formtype !== 'preview' ) {
2876            $attribs['style'] = 'display: none;';
2877        }
2878
2879        $out = $this->context->getOutput();
2880        $out->addHTML( Html::openElement( 'div', $attribs ) );
2881
2882        if ( $this->formtype === 'preview' ) {
2883            $this->showPreview( $previewOutput );
2884        }
2885
2886        $out->addHTML( '</div>' );
2887
2888        if ( $this->formtype === 'diff' ) {
2889            try {
2890                $this->showDiff();
2891            } catch ( ContentSerializationException $ex ) {
2892                $out->addHTML( Html::errorBox(
2893                    $this->context->msg(
2894                        'content-failed-to-parse',
2895                        $this->contentModel,
2896                        $this->contentFormat,
2897                        $ex->getMessage()
2898                    )->parse()
2899                ) );
2900            }
2901        }
2902    }
2903
2904    /**
2905     * Append preview output to OutputPage.
2906     * Includes category rendering if this is a category page.
2907     *
2908     * @param string $text The HTML to be output for the preview.
2909     */
2910    private function showPreview( string $text ): void {
2911        if ( $this->mArticle instanceof CategoryPage ) {
2912            $this->mArticle->openShowCategory();
2913        }
2914        # This hook seems slightly odd here, but makes things more
2915        # consistent for extensions.
2916        $out = $this->context->getOutput();
2917        $this->getHookRunner()->onOutputPageBeforeHTML( $out, $text );
2918        $out->addHTML( $text );
2919        if ( $this->mArticle instanceof CategoryPage ) {
2920            $this->mArticle->closeShowCategory();
2921        }
2922    }
2923
2924    /**
2925     * Get a diff between the current contents of the edit box and the
2926     * version of the page we're editing from.
2927     *
2928     * If this is a section edit, we'll replace the section as for final
2929     * save and then make a comparison.
2930     */
2931    public function showDiff(): void {
2932        $oldtitlemsg = 'currentrev';
2933        # if page does not exist, show diff against the preloaded default
2934        if ( !$this->page->exists() ) {
2935            $oldShadow = $this->shadowPageLoader->get( $this->getTitle() );
2936            $oldContent = $oldShadow?->getPreloadContent();
2937            if ( $oldContent ) {
2938                $this->assertSupportedContent( $oldContent );
2939                $oldtitlemsg = $oldShadow->getDiffTitleMessage() ?? $oldtitlemsg;
2940            }
2941        } else {
2942            $oldContent = $this->getCurrentContent();
2943        }
2944
2945        $textboxContent = $this->toEditContent( $this->textbox1 );
2946        if ( $this->editRevId !== null ) {
2947            $newContent = $this->page->replaceSectionAtRev(
2948                $this->section, $textboxContent, $this->sectiontitle, $this->editRevId
2949            );
2950        } else {
2951            $newContent = $this->page->replaceSectionContent(
2952                $this->section, $textboxContent, $this->sectiontitle, $this->edittime
2953            );
2954        }
2955
2956        if ( $newContent ) {
2957            $this->getHookRunner()->onEditPageGetDiffContent( $this, $newContent );
2958
2959            $user = $this->getUserForPreview();
2960            $parserOptions = ParserOptions::newFromUserAndLang( $user,
2961                MediaWikiServices::getInstance()->getContentLanguage() );
2962            $services = MediaWikiServices::getInstance();
2963            $contentTransformer = $services->getContentTransformer();
2964            $newContent = $contentTransformer->preSaveTransform( $newContent, $this->page, $user, $parserOptions );
2965        }
2966
2967        if ( ( $oldContent && !$oldContent->isEmpty() ) || ( $newContent && !$newContent->isEmpty() ) ) {
2968            $oldtitle = $this->context->msg( $oldtitlemsg )->parse();
2969            $newtitle = $this->context->msg( 'yourtext' )->parse();
2970
2971            if ( !$oldContent ) {
2972                $oldContent = $newContent->getContentHandler()->makeEmptyContent();
2973            }
2974
2975            if ( !$newContent ) {
2976                $newContent = $oldContent->getContentHandler()->makeEmptyContent();
2977            }
2978
2979            $de = $oldContent->getContentHandler()->createDifferenceEngine( $this->context );
2980            $de->setContent( $oldContent, $newContent );
2981
2982            $difftext = $de->getDiff( $oldtitle, $newtitle );
2983            $de->showDiffStyle();
2984        } else {
2985            $difftext = '';
2986        }
2987
2988        $this->context->getOutput()->addHTML( Html::rawElement( 'div', [ 'id' => 'wikiDiff' ], $difftext ) );
2989    }
2990
2991    /**
2992     * Give a chance for site and per-namespace customizations of
2993     * terms of service summary link that might exist separately
2994     * from the copyright notice.
2995     *
2996     * This will display between the save button and the edit tools,
2997     * so should remain short!
2998     */
2999    private function showTosSummary(): void {
3000        $msgKey = 'editpage-tos-summary';
3001        $this->getHookRunner()->onEditPageTosSummary( $this->getTitle(), $msgKey );
3002        $msg = $this->context->msg( $msgKey );
3003        if ( !$msg->isDisabled() ) {
3004            $this->context->getOutput()->addHTML( Html::rawElement(
3005                'div',
3006                [ 'class' => 'mw-tos-summary' ],
3007                $msg->parseAsBlock()
3008            ) );
3009        }
3010    }
3011
3012    /**
3013     * Inserts optional text shown below edit and upload forms. Can be used to offer special
3014     * characters not present on most keyboards for copying/pasting.
3015     */
3016    private function showEditTools(): void {
3017        $this->context->getOutput()->addHTML( Html::rawElement(
3018            'div',
3019            [ 'class' => 'mw-editTools' ],
3020            $this->context->msg( 'edittools' )->inContentLanguage()->parse()
3021        ) );
3022    }
3023
3024    /**
3025     * Get the copyright warning.
3026     *
3027     * @param PageReference $page
3028     * @param string $format Output format, valid values are any function of a Message object
3029     *   (e.g. 'parse', 'plain')
3030     * @param MessageLocalizer $localizer
3031     */
3032    public static function getCopyrightWarning(
3033        PageReference $page,
3034        string $format,
3035        MessageLocalizer $localizer
3036    ): string {
3037        $services = MediaWikiServices::getInstance();
3038        $rightsText = $services->getMainConfig()->get( MainConfigNames::RightsText );
3039        if ( $rightsText ) {
3040            $copywarnMsg = [ 'copyrightwarning',
3041                '[[' . $localizer->msg( 'copyrightpage' )->inContentLanguage()->text() . ']]',
3042                $rightsText ];
3043        } else {
3044            $copywarnMsg = [ 'copyrightwarning2',
3045                '[[' . $localizer->msg( 'copyrightpage' )->inContentLanguage()->text() . ']]' ];
3046        }
3047        // Allow for site and per-namespace customization of contribution/copyright notice.
3048        $title = Title::newFromPageReference( $page );
3049        ( new HookRunner( $services->getHookContainer() ) )->onEditPageCopyrightWarning( $title, $copywarnMsg );
3050        if ( !$copywarnMsg ) {
3051            return '';
3052        }
3053
3054        $msg = $localizer->msg( ...$copywarnMsg )->page( $page );
3055        return Html::rawElement( 'div', [ 'id' => 'editpage-copywarn' ], $msg->$format() );
3056    }
3057
3058    /**
3059     * Get the Limit report for page previews
3060     *
3061     * @since 1.22
3062     * @param ParserOutput|null $output ParserOutput object from the parse
3063     * @return string HTML
3064     */
3065    public static function getPreviewLimitReport( ?ParserOutput $output = null ): string {
3066        if ( !$output || !$output->getLimitReportData() ) {
3067            return '';
3068        }
3069
3070        $limitReport = Html::rawElement( 'div', [ 'class' => 'mw-limitReportExplanation' ],
3071            wfMessage( 'limitreport-title' )->parseAsBlock()
3072        );
3073
3074        // Show/hide animation doesn't work correctly on a table, so wrap it in a div.
3075        $limitReport .= Html::openElement( 'div', [ 'class' => 'preview-limit-report-wrapper' ] );
3076
3077        $limitReport .= Html::openElement( 'table', [
3078            'class' => 'preview-limit-report wikitable'
3079        ] ) .
3080            Html::openElement( 'tbody' );
3081
3082        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
3083        foreach ( $output->getLimitReportData() as $key => $value ) {
3084            if ( in_array( $key, [
3085                'cachereport-origin',
3086                'cachereport-timestamp',
3087                'cachereport-ttl',
3088                'cachereport-transientcontent',
3089                'limitreport-timingprofile',
3090            ] ) ) {
3091                // These entries have non-numeric parameters, and can't be displayed by this code.
3092                // They are used by the plaintext limit report (see RenderDebugInfo::debugInfo()).
3093                // TODO: Display this information in the table somehow.
3094                continue;
3095            }
3096
3097            if ( $hookRunner->onParserLimitReportFormat( $key, $value, $limitReport, true, true ) ) {
3098                $keyMsg = wfMessage( $key );
3099                $valueMsg = wfMessage( "$key-value" );
3100                if ( !$valueMsg->exists() ) {
3101                    // This is formatted raw, not as localized number.
3102                    // If you want the parameter formatted as a number,
3103                    // define the `$key-value` message.
3104                    $valueMsg = ( new RawMessage( '$1' ) )->params( $value );
3105                } else {
3106                    // If you define the `$key-value` or `$key-value-html`
3107                    // message then the argument *must* be numeric.
3108                    $valueMsg = $valueMsg->numParams( $value );
3109                }
3110                if ( !$keyMsg->isDisabled() && !$valueMsg->isDisabled() ) {
3111                    $limitReport .= Html::rawElement( 'tr', [],
3112                        Html::rawElement( 'th', [], $keyMsg->parse() ) .
3113                        Html::rawElement( 'td', [], $valueMsg->parse() )
3114                    );
3115                }
3116            }
3117        }
3118
3119        $limitReport .= Html::closeElement( 'tbody' ) .
3120            Html::closeElement( 'table' ) .
3121            Html::closeElement( 'div' );
3122
3123        return $limitReport;
3124    }
3125
3126    protected function showStandardInputs( int &$tabindex = 2 ): void {
3127        $out = $this->context->getOutput();
3128        $out->addHTML( "<div class='editOptions'>\n" );
3129
3130        if ( $this->section !== 'new' ) {
3131            $this->showSummaryInput( false );
3132            $out->addHTML( $this->getSummaryPreview( false ) );
3133        }
3134
3135        // When previewing, override the selected dropdown option to select whatever was posted
3136        // (if it's a valid option) rather than the current value for watchlistExpiry.
3137        // See also above in $this->importFormDataPosted().
3138        $expiryFromRequest = null;
3139        if ( $this->preview || $this->diff || $this->isConflict ) {
3140            $expiryFromRequest = $this->getContext()->getRequest()->getText( 'wpWatchlistExpiry' );
3141        }
3142
3143        $checkboxes = $this->getCheckboxesWidget(
3144            $tabindex,
3145            [ 'minor' => $this->minoredit, 'watch' => $this->watchthis, 'wpWatchlistExpiry' => $expiryFromRequest ]
3146        );
3147        $watchlistLabelsWidget = $this->getWatchlistLabelsWidget( $tabindex );
3148        $watchlistLabelClass = 'mw-watchlistlabels-component';
3149        // Add a message (inline with the checkboxes) when the user doesn't have any labels.
3150        if ( $this->context->getUser()->isNamed() && !$watchlistLabelsWidget && $this->watchlistLabelsEnabled ) {
3151            $checkboxes['watchlistlabels'] = new OOUI\LabelWidget( [
3152                'label' => new OOUI\HtmlSnippet( $this->context->msg( 'watchlistlabels-editpage-nolabels' )->parse() ),
3153                'classes' => [ $watchlistLabelClass ],
3154            ] );
3155        }
3156        $checkboxesHTML = new OOUI\HorizontalLayout( [ 'items' => array_values( $checkboxes ) ] );
3157
3158        $out->addHTML( Html::rawElement( 'div', [ 'class' => 'editCheckboxes' ], (string)$checkboxesHTML ) . "\n" );
3159        if ( $watchlistLabelsWidget ) {
3160            $out->addHTML( Html::rawElement(
3161                'div',
3162                [ 'class' => $watchlistLabelClass . ' mw-editpage-watchlistLabels' ],
3163                Html::hidden( 'wpWatchlistLabelsSubmitted', 1 ) . (string)$watchlistLabelsWidget
3164            ) . "\n" );
3165        }
3166
3167        // Show copyright warning.
3168        $out->addHTML( self::getCopyrightWarning( $this->page, 'parse', $this->context ) );
3169        $out->addHTML( $this->editFormTextAfterWarn );
3170
3171        $out->addHTML( "<div class='editButtons'>\n" );
3172        $out->addHTML( implode( "\n", $this->getEditButtons( $tabindex ) ) . "\n" );
3173
3174        $cancel = $this->getCancelLink( $tabindex++ );
3175
3176        $edithelp = $this->getHelpLink() .
3177            $this->context->msg( 'word-separator' )->escaped() .
3178            $this->context->msg( 'newwindow' )->parse();
3179
3180        $out->addHTML( "    <span class='cancelLink'>{$cancel}</span>\n" );
3181        $out->addHTML( "    <span class='editHelp'>{$edithelp}</span>\n" );
3182        $out->addHTML( "</div><!-- editButtons -->\n" );
3183
3184        $this->getHookRunner()->onEditPage__showStandardInputs_options( $this, $out, $tabindex );
3185
3186        $out->addHTML( "</div><!-- editOptions -->\n" );
3187    }
3188
3189    /**
3190     * Show an edit conflict. textbox1 is already shown in showEditForm().
3191     * If you want to use another entry point to this function, be careful.
3192     */
3193    private function showConflict(): void {
3194        $out = $this->context->getOutput();
3195        if ( $this->getHookRunner()->onEditPageBeforeConflictDiff( $this, $out ) ) {
3196            $this->incrementConflictStats();
3197
3198            $this->getEditConflictHelper()->showEditFormTextAfterFooters();
3199        }
3200    }
3201
3202    private function incrementConflictStats(): void {
3203        $this->getEditConflictHelper()->incrementConflictStats( $this->context->getUser() );
3204    }
3205
3206    private function getHelpLink(): string {
3207        $message = $this->context->msg( 'edithelppage' )->inContentLanguage()->text();
3208        $editHelpUrl = Skin::makeInternalOrExternalUrl( $message );
3209        return Html::element( 'a', [
3210            'href' => $editHelpUrl,
3211            'target' => 'helpwindow'
3212        ], $this->context->msg( 'edithelp' )->text() );
3213    }
3214
3215    /**
3216     * @param int $tabindex Current tabindex
3217     * @return ButtonWidget
3218     */
3219    private function getCancelLink( int $tabindex ): ButtonWidget {
3220        $cancelParams = [];
3221        if ( !$this->isConflict && $this->oldid > 0 ) {
3222            $cancelParams['oldid'] = $this->oldid;
3223        } elseif ( $this->getContextTitle()->isRedirect() ) {
3224            $cancelParams['redirect'] = 'no';
3225        }
3226
3227        return new OOUI\ButtonWidget( [
3228            'id' => 'mw-editform-cancel',
3229            'tabIndex' => $tabindex,
3230            'href' => $this->getContextTitle()->getLinkURL( $cancelParams ),
3231            'label' => new OOUI\HtmlSnippet( $this->context->msg( 'cancel' )->parse() ),
3232            'framed' => false,
3233            'infusable' => true,
3234            'flags' => 'destructive',
3235        ] );
3236    }
3237
3238    /**
3239     * Returns the URL to use in the form's action attribute.
3240     * This is used by EditPage subclasses when simply customizing the action
3241     * variable in the constructor is not enough. This can be used when the
3242     * EditPage lives inside of a Special page rather than a custom page action.
3243     *
3244     * @param Title $title Title object for which is being edited (where we go to for &action= links)
3245     * @return string
3246     */
3247    protected function getActionURL( Title $title ) {
3248        $request = $this->context->getRequest();
3249        $params = $request->getQueryValuesOnly();
3250
3251        $allowedFormParams = [
3252            'section', 'oldid', 'preloadtitle', 'undo', 'undoafter',
3253            // Considered safe in all contexts
3254            'uselang', 'useskin', 'useformat', 'variant', 'debug', 'safemode'
3255        ];
3256        $formParams = [ 'action' => 'submit' ];
3257        foreach ( $params as $arg => $val ) {
3258            if ( in_array( $arg, $allowedFormParams, true ) ) {
3259                $formParams[$arg] = $val;
3260            }
3261        }
3262
3263        return $title->getLocalURL( $formParams );
3264    }
3265
3266    /**
3267     * Get the rendered text for previewing.
3268     * @throws NotDirectlyEditableException
3269     * @return string
3270     */
3271    public function getPreviewText() {
3272        $out = $this->context->getOutput();
3273        $config = $this->context->getConfig();
3274
3275        if ( $config->get( MainConfigNames::RawHtml ) && !$this->mTokenOk ) {
3276            // Could be an offsite preview attempt. This is very unsafe if
3277            // HTML is enabled, as it could be an attack.
3278            $parsedNote = '';
3279            if ( $this->textbox1 !== '' ) {
3280                // Do not put big scary notice, if previewing the empty
3281                // string, which happens when you initially edit
3282                // a category page, due to automatic preview-on-open.
3283                $parsedNote = Html::rawElement( 'div', [ 'class' => 'previewnote' ],
3284                    $out->parseAsInterface(
3285                        $this->context->msg( 'session_fail_preview_html' )->plain()
3286                    ) );
3287            }
3288            $this->incrementEditFailureStats( 'session_loss' );
3289            return $parsedNote;
3290        }
3291
3292        $previewStatus = StatusValue::newGood();
3293        $previewNoteHtml = '';
3294
3295        try {
3296            $content = $this->toEditContent( $this->textbox1 );
3297
3298            $previewHTML = '';
3299            if ( !$this->getHookRunner()->onAlternateEditPreview(
3300                $this, $content, $previewHTML, $this->mParserOutput )
3301            ) {
3302                return $previewHTML;
3303            }
3304
3305            $continueEditingHtml = Html::rawElement(
3306                'span',
3307                [ 'class' => 'mw-continue-editing' ],
3308                $this->linkRenderer->makePreloadedLink(
3309                    new TitleValue( NS_MAIN, '', self::EDITFORM_ID ),
3310                    $this->context->getLanguage()->getArrow() . ' ' . $this->context->msg( 'continue-editing' )->text()
3311                )
3312            );
3313
3314            if ( $this->mTriedSave && !$this->mTokenOk ) {
3315                $previewStatus->fatal( 'session_fail_preview' );
3316                $this->incrementEditFailureStats( 'session_loss' );
3317            } elseif ( $this->incompleteForm ) {
3318                $previewStatus->fatal( 'edit_form_incomplete' );
3319                if ( $this->mTriedSave ) {
3320                    $this->incrementEditFailureStats( 'incomplete_form' );
3321                }
3322            } else {
3323                $previewNoteHtml = Html::noticeBox(
3324                    $this->context->msg( 'previewnote' )->parse() . ' ' . $continueEditingHtml
3325                );
3326            }
3327
3328            # don't parse non-wikitext pages, show message about preview
3329            if ( $this->getTitle()->isUserConfigPage() || $this->getTitle()->isSiteConfigPage() ) {
3330                if ( $this->getTitle()->isUserConfigPage() ) {
3331                    $level = 'user';
3332                } elseif ( $this->getTitle()->isSiteConfigPage() ) {
3333                    $level = 'site';
3334                } else {
3335                    $level = false;
3336                }
3337
3338                if ( $content->getModel() === CONTENT_MODEL_CSS ) {
3339                    $format = 'css';
3340                    if ( $level === 'user' && !$config->get( MainConfigNames::AllowUserCss ) ) {
3341                        $format = false;
3342                    }
3343                } elseif ( $content->getModel() === CONTENT_MODEL_JSON ) {
3344                    $format = 'json';
3345                    if ( $level === 'user' /* No comparable 'AllowUserJson' */ ) {
3346                        $format = false;
3347                    }
3348                } elseif ( $content->getModel() === CONTENT_MODEL_JAVASCRIPT ) {
3349                    $format = 'js';
3350                    if ( $level === 'user' && !$config->get( MainConfigNames::AllowUserJs ) ) {
3351                        $format = false;
3352                    }
3353                } elseif ( $content->getModel() === CONTENT_MODEL_VUE ) {
3354                    $format = 'vue';
3355                    if ( $level === 'user' && !$config->get( MainConfigNames::AllowUserJs ) ) {
3356                        $format = false;
3357                    }
3358                } else {
3359                    $format = false;
3360                }
3361
3362                # Used messages to make sure grep find them:
3363                # Messages: usercsspreview, userjsonpreview, userjspreview, uservuepreview,
3364                #   sitecsspreview, sitejsonpreview, sitejspreview, sitevuepreview
3365                if ( $level && $format ) {
3366                    $previewNoteHtml = Html::noticeBox( Html::rawElement(
3367                        'div',
3368                        [ 'id' => "mw-{$level}{$format}preview" ],
3369                        $this->context->msg( "{$level}{$format}preview" )->parse() . $continueEditingHtml
3370                    ) );
3371                }
3372            }
3373
3374            if ( $this->section === "new" ) {
3375                $content = $content->addSectionHeader( $this->sectiontitle );
3376            }
3377
3378            // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
3379            $this->getHookRunner()->onEditPageGetPreviewContent( $this, $content );
3380
3381            $parserResult = $this->doPreviewParse( $content );
3382            $parserOutput = $parserResult['parserOutput'];
3383            $previewHTML = $parserResult['html'];
3384            $this->mParserOutput = $parserOutput;
3385            $out->addParserOutputMetadata( $parserOutput );
3386            if ( $out->userCanPreview() ) {
3387                $out->addContentOverride( $this->getTitle(), $content );
3388            }
3389
3390            // T394016 - Run some edit constraints on page preview
3391            /** @var EditConstraintFactory $constraintFactory */
3392            $constraintFactory = MediaWikiServices::getInstance()->getService( '_EditConstraintFactory' );
3393            $constraintRunner = new EditConstraintRunner();
3394
3395            $constraintRunner->addConstraint( $constraintFactory->newRedirectConstraint(
3396                null,
3397                $content,
3398                null,
3399                $this->getTitle(),
3400                MessageValue::new( 'edit-constraint-warning-wrapper' ),
3401                $this->contentFormat,
3402            ) );
3403
3404            $previewStatus->merge( $constraintRunner->checkAllConstraints() );
3405
3406            foreach ( $parserOutput->getWarningMsgs() as $warning ) {
3407                $previewStatus->warning( $warning );
3408            }
3409        } catch ( ContentSerializationException $ex ) {
3410            $previewStatus->fatal(
3411                'content-failed-to-parse',
3412                $this->contentModel,
3413                $this->contentFormat,
3414                $ex->getMessage()
3415            );
3416            $previewHTML = '';
3417        }
3418
3419        if ( $this->isConflict ) {
3420            $conflict = Html::warningBox(
3421                $this->context->msg( 'previewconflict' )->escaped(),
3422                'mw-previewconflict'
3423            );
3424        } else {
3425            $conflict = '';
3426        }
3427
3428        $previewhead = Html::rawElement(
3429            'div', [ 'class' => 'previewnote' ],
3430            Html::element(
3431                'h2', [ 'id' => 'mw-previewheader' ],
3432                $this->context->msg( 'preview' )->text()
3433            ) . $this->formatConstraintStatus( $previewStatus ) . $previewNoteHtml . $conflict
3434        );
3435
3436        return $previewhead . $previewHTML . $this->previewTextAfterContent;
3437    }
3438
3439    private function incrementEditFailureStats( string $failureType ): void {
3440        MediaWikiServices::getInstance()->getStatsFactory()
3441            ->getCounter( 'edit_failure_total' )
3442            ->setLabel( 'cause', $failureType )
3443            ->setLabel( 'namespace', 'n/a' )
3444            ->setLabel( 'user_bucket', 'n/a' )
3445            ->increment();
3446    }
3447
3448    /**
3449     * Get parser options for a preview
3450     */
3451    protected function getPreviewParserOptions(): ParserOptions {
3452        $parserOptions = $this->mArticle->getParserOptions();
3453        $parserOptions->setRenderReason( 'page-preview' );
3454        $parserOptions->setIsPreview( true );
3455        $parserOptions->setIsSectionPreview( $this->section !== '' );
3456        $parserOptions->setSuppressSectionEditLinks();
3457
3458        // XXX: we could call $parserOptions->setCurrentRevisionRecordCallback here to force the
3459        // current revision to be null during PST, until setupFakeRevision is called on
3460        // the ParserOptions. Currently, we rely on Parser::getRevisionRecordObject() to ignore
3461        // existing revisions in preview mode.
3462
3463        return $parserOptions;
3464    }
3465
3466    /**
3467     * Parse the page for a preview. Subclasses may override this class, in order
3468     * to parse with different options, or to otherwise modify the preview HTML.
3469     *
3470     * @param Content $content The page content
3471     * @return array with keys:
3472     *   - parserOutput: The ParserOutput object
3473     *   - html: The HTML to be displayed
3474     */
3475    protected function doPreviewParse( Content $content ) {
3476        $user = $this->getUserForPreview();
3477        $parserOptions = $this->getPreviewParserOptions();
3478
3479        // NOTE: preSaveTransform doesn't have a fake revision to operate on.
3480        // Parser::getRevisionRecordObject() will return null in preview mode,
3481        // causing the context user to be used for {{subst:REVISIONUSER}}.
3482        // XXX: Alternatively, we could also call setupFakeRevision()
3483        // before PST with $content.
3484        $services = MediaWikiServices::getInstance();
3485        $contentTransformer = $services->getContentTransformer();
3486        $contentRenderer = $services->getContentRenderer();
3487        $pstContent = $contentTransformer->preSaveTransform( $content, $this->page, $user, $parserOptions );
3488        $parserOutput = $contentRenderer->getParserOutput( $pstContent, $this->page, null, $parserOptions );
3489        $out = $this->context->getOutput();
3490        $skin = $out->getSkin();
3491        $skinOptions = $skin->getOptions();
3492        // TODO T371004 move runOutputPipeline out of $parserOutput
3493        // TODO T371022 ideally we clone here, but for now let's reproduce getText behaviour
3494        $oldHtml = $parserOutput->getContentHolderText();
3495        $html = $parserOutput->runOutputPipeline( $parserOptions, [
3496            'allowClone' => 'false',
3497            'userLang' => $skin->getLanguage(),
3498            'injectTOC' => $skinOptions['toc'],
3499            'includeDebugInfo' => true,
3500        ] )->getContentHolderText();
3501        $parserOutput->setContentHolderText( $oldHtml );
3502        return [
3503            'parserOutput' => $parserOutput,
3504            'html' => $html
3505        ];
3506    }
3507
3508    /**
3509     * @return Title[]
3510     */
3511    public function getTemplates() {
3512        if ( $this->preview || $this->section !== '' ) {
3513            $templates = [];
3514            if ( !$this->mParserOutput ) {
3515                return $templates;
3516            }
3517            foreach (
3518                $this->mParserOutput->getLinkList( ParserOutputLinkTypes::TEMPLATE )
3519                as [ 'link' => $link ]
3520            ) {
3521                $templates[] = Title::newFromLinkTarget( $link );
3522            }
3523            return $templates;
3524        } else {
3525            return $this->getTitle()->getTemplateLinksFrom();
3526        }
3527    }
3528
3529    /**
3530     * Allow extensions to provide a toolbar.
3531     */
3532    public static function getEditToolbar(): ?string {
3533        $startingToolbar = '<div id="toolbar"></div>';
3534        $toolbar = $startingToolbar;
3535
3536        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
3537        if ( !$hookRunner->onEditPageBeforeEditToolbar( $toolbar ) ) {
3538            return null;
3539        }
3540        // Don't add a pointless `<div>` to the page unless a hook caller populated it
3541        return ( $toolbar === $startingToolbar ) ? null : $toolbar;
3542    }
3543
3544    /**
3545     * Return an array of field definitions. Despite the name, not only checkboxes are supported.
3546     *
3547     * Array keys correspond to the `<input>` 'name' attribute to use for each field.
3548     *
3549     * Array values are associative arrays with the following keys:
3550     *  - 'label-message' (required): message for label text
3551     *  - 'id' (required): 'id' attribute for the `<input>`
3552     *  - 'default' (required): default checkedness (true or false)
3553     *  - 'title-message' (optional): used to generate 'title' attribute for the `<label>`
3554     *  - 'tooltip' (optional): used to generate 'title' and 'accesskey' attributes
3555     *    from messages like 'tooltip-foo', 'accesskey-foo'
3556     *  - 'label-id' (optional): 'id' attribute for the `<label>`
3557     *  - 'legacy-name' (optional): short name for backwards-compatibility
3558     *  - 'class' (optional): PHP class name of the OOUI widget to use. Defaults to
3559     *    CheckboxInputWidget.
3560     *  - 'options' (optional): options to use for DropdownInputWidget,
3561     *    ComboBoxInputWidget, etc. following the structure as given in the documentation for those
3562     *    classes.
3563     *  - 'value-attr' (optional): name of the widget config option for the "current value" of the
3564     *    widget. Defaults to 'selected'; for some widget types it should be 'value'.
3565     * @param array<string,mixed> $values Map of field names (matching the 'legacy-name') to current field values.
3566     *   For checkboxes, the value is a bool that indicates the checked status of the checkbox.
3567     * @return array[]
3568     */
3569    public function getCheckboxesDefinition( $values ): array {
3570        $checkboxes = [];
3571
3572        $user = $this->context->getUser();
3573        // don't show the minor edit checkbox if it's a new page or section
3574        if ( !$this->isNew && $this->permManager->userHasRight( $user, 'minoredit' ) ) {
3575            $checkboxes['wpMinoredit'] = [
3576                'id' => 'wpMinoredit',
3577                'label-message' => 'minoredit',
3578                // Uses messages: tooltip-minoredit, accesskey-minoredit
3579                'tooltip' => 'minoredit',
3580                'label-id' => 'mw-editpage-minoredit',
3581                'legacy-name' => 'minor',
3582                'default' => $values['minor'],
3583            ];
3584        }
3585
3586        if ( $user->isNamed() ) {
3587            $checkboxes = array_merge(
3588                $checkboxes,
3589                $this->getCheckboxesDefinitionForWatchlist( $values['watch'], $values['wpWatchlistExpiry'] ?? null )
3590            );
3591        }
3592
3593        $this->getHookRunner()->onEditPageGetCheckboxesDefinition( $this, $checkboxes );
3594
3595        return $checkboxes;
3596    }
3597
3598    /**
3599     * Get the watchthis and watchlistExpiry form field definitions.
3600     *
3601     * @param bool $watch
3602     * @param string $watchexpiry
3603     * @return array[]
3604     */
3605    private function getCheckboxesDefinitionForWatchlist( $watch, $watchexpiry ): array {
3606        $fieldDefs = [
3607            'wpWatchthis' => [
3608                'id' => 'wpWatchthis',
3609                'label-message' => 'watchthis',
3610                // Uses messages: tooltip-watch, accesskey-watch
3611                'tooltip' => 'watch',
3612                'label-id' => 'mw-editpage-watch',
3613                'legacy-name' => 'watch',
3614                'default' => $watch,
3615            ]
3616        ];
3617        if ( $this->watchlistExpiryEnabled ) {
3618            $watchedItem = $this->watchedItemStore->getWatchedItem( $this->getContext()->getUser(), $this->getTitle() );
3619            if ( $watchedItem instanceof WatchedItem && $watchedItem->getExpiry() === null ) {
3620                // Not temporarily watched, so we always default to infinite.
3621                $userPreferredExpiry = 'infinite';
3622            } else {
3623                $userPreferredExpiryOption = !$this->page->exists()
3624                    ? 'watchcreations-expiry'
3625                    : 'watchdefault-expiry';
3626                $userPreferredExpiry = $this->userOptionsLookup->getOption(
3627                    $this->getContext()->getUser(),
3628                    $userPreferredExpiryOption,
3629                    'infinite'
3630                );
3631            }
3632
3633            $expiryOptions = WatchAction::getExpiryOptions(
3634                $this->getContext(),
3635                $watchedItem,
3636                $userPreferredExpiry
3637            );
3638
3639            if ( $watchexpiry && in_array( $watchexpiry, $expiryOptions['options'] ) ) {
3640                $expiryOptions['default'] = $watchexpiry;
3641            }
3642            // When previewing, override the selected dropdown option to select whatever was posted
3643            // (if it's a valid option) rather than the current value for watchlistExpiry.
3644            // See also above in $this->importFormDataPosted().
3645            $expiryFromRequest = $this->getContext()->getRequest()->getText( 'wpWatchlistExpiry' );
3646            if ( ( $this->preview || $this->diff ) && in_array( $expiryFromRequest, $expiryOptions['options'] ) ) {
3647                $expiryOptions['default'] = $expiryFromRequest;
3648            }
3649
3650            // Reformat the options to match what DropdownInputWidget wants.
3651            $options = [];
3652            foreach ( $expiryOptions['options'] as $label => $value ) {
3653                $options[] = [ 'data' => $value, 'label' => $label ];
3654            }
3655
3656            $fieldDefs['wpWatchlistExpiry'] = [
3657                'id' => 'wpWatchlistExpiry',
3658                'label-message' => 'confirm-watch-label',
3659                // Uses messages: tooltip-watchlist-expiry, accesskey-watchlist-expiry
3660                'tooltip' => 'watchlist-expiry',
3661                'label-id' => 'mw-editpage-watchlist-expiry',
3662                'default' => $expiryOptions['default'],
3663                'value-attr' => 'value',
3664                'class' => DropdownInputWidget::class,
3665                'options' => $options,
3666                'invisibleLabel' => true,
3667            ];
3668        }
3669        return $fieldDefs;
3670    }
3671
3672    /**
3673     * Returns an array of fields for the edit form, including 'minor' and 'watch' checkboxes and
3674     * any other added by extensions. Despite the name, not only checkboxes are supported.
3675     *
3676     * @param int &$tabindex Current tabindex
3677     * @param array<string,mixed> $values Map of field names to current field values.
3678     *   For checkboxes, the value is a bool that indicates the checked status of the checkbox.
3679     * @return \OOUI\Element[] Associative array of string keys to \OOUI\Widget or \OOUI\Layout
3680     *  instances
3681     */
3682    public function getCheckboxesWidget( &$tabindex, $values ) {
3683        $checkboxes = [];
3684        $checkboxesDef = $this->getCheckboxesDefinition( $values );
3685
3686        foreach ( $checkboxesDef as $name => $options ) {
3687            $legacyName = $options['legacy-name'] ?? $name;
3688
3689            $title = null;
3690            $accesskey = null;
3691            if ( isset( $options['tooltip'] ) ) {
3692                $accesskey = $this->context->msg( "accesskey-{$options['tooltip']}" )->text();
3693                $title = Linker::titleAttrib( $options['tooltip'] );
3694            }
3695            if ( isset( $options['title-message'] ) ) {
3696                $title = $this->context->msg( $options['title-message'] )->text();
3697            }
3698            // Allow checkbox definitions to set their own class and value-attribute names.
3699            // See $this->getCheckboxesDefinition() for details.
3700            $className = $options['class'] ?? CheckboxInputWidget::class;
3701            $valueAttr = $options['value-attr'] ?? 'selected';
3702            $checkboxes[ $legacyName ] = new FieldLayout(
3703                new $className( [
3704                    'tabIndex' => ++$tabindex,
3705                    'accessKey' => $accesskey,
3706                    'id' => $options['id'] . 'Widget',
3707                    'inputId' => $options['id'],
3708                    'name' => $name,
3709                    $valueAttr => $options['default'],
3710                    'infusable' => true,
3711                    'options' => $options['options'] ?? null,
3712                ] ),
3713                [
3714                    'align' => 'inline',
3715                    'label' => new OOUI\HtmlSnippet( $this->context->msg( $options['label-message'] )->parse() ),
3716                    'title' => $title,
3717                    'id' => $options['label-id'] ?? null,
3718                    'invisibleLabel' => $options['invisibleLabel'] ?? null,
3719                ]
3720            );
3721        }
3722
3723        return $checkboxes;
3724    }
3725
3726    /**
3727     * Build the watchlist labels MenuTagMultiselectWidget for the edit form.
3728     *
3729     * @param int &$tabindex Current tabindex
3730     * @return FieldLayout|null Widget layout for watchlist labels, or null if not applicable
3731     */
3732    private function getWatchlistLabelsWidget( int &$tabindex ): ?FieldLayout {
3733        if ( !$this->watchlistLabelsEnabled ) {
3734            return null;
3735        }
3736
3737        $user = $this->context->getUser();
3738        if ( !$user->isNamed() ) {
3739            return null;
3740        }
3741
3742        $userLabels = $this->watchlistLabelStore->loadAllForUser( $user );
3743        if ( !$userLabels ) {
3744            return null;
3745        }
3746
3747        $this->context->getOutput()->addModuleStyles( 'mediawiki.widgets.TagMultiselectWidget.styles' );
3748        $this->context->getOutput()->addModules( 'mediawiki.widgets.MenuTagMultiselectWidget' );
3749
3750        // Determine which labels are currently selected.
3751        // After the initial render, use submitted values to preserve state across retries.
3752        if ( !$this->firsttime ) {
3753            // getIntArray() should already return int[], but keep this non-null for static analysis.
3754            $selectedLabelIds = (array)$this->getContext()->getRequest()->getIntArray( 'wpWatchlistLabels', [] );
3755        } else {
3756            // Use the labels currently assigned to this watched item.
3757            $watchedItem = $this->watchedItemStore->getWatchedItem( $user, $this->page );
3758            $selectedLabelIds = [];
3759            // Keep this as int[] so array_map( strval(...) ) is always called with an array.
3760            if ( $watchedItem instanceof WatchedItem ) {
3761                foreach ( $watchedItem->getLabels() as $label ) {
3762                    $labelId = $label->getId();
3763                    if ( $labelId !== null ) {
3764                        $selectedLabelIds[] = $labelId;
3765                    }
3766                }
3767            }
3768        }
3769
3770        // Build options for MenuTagMultiselectWidget (flat list, no group header).
3771        $options = [];
3772        foreach ( $userLabels as $label ) {
3773            $options[] = [ 'data' => (string)$label->getId(), 'label' => $label->getName() ];
3774        }
3775
3776        $selectedStrings = array_map( strval( ... ), $selectedLabelIds );
3777
3778        // Build no-JS fallback: a CheckboxMultiselectInputWidget
3779        $noJsFallback = [ new CheckboxMultiselectInputWidget( [
3780            'name' => 'wpWatchlistLabels[]',
3781            'value' => $selectedStrings,
3782            'options' => $options,
3783        ] ) ];
3784
3785        $widget = new MenuTagMultiselectWidget( [
3786            'name' => 'wpWatchlistLabels',
3787            'options' => [ '' => $options ],
3788            'default' => $selectedStrings,
3789            'noJsFallback' => $noJsFallback,
3790            'infusable' => true,
3791            'id' => 'wpWatchlistLabelsWidget',
3792            'tabIndex' => ++$tabindex,
3793            'allowReordering' => false,
3794            'placeholder' => $this->context->msg( 'watchlistlabels-editpage-placeholder' )->text(),
3795        ] );
3796
3797        $helpMsg = $this->context->msg( 'watchlistlabels-editpage-help' );
3798        $help = null;
3799        if ( !$helpMsg->isDisabled() ) {
3800            $help = new OOUI\HtmlSnippet( $helpMsg->parse() );
3801        }
3802        return new FieldLayout(
3803            $widget,
3804            [
3805                'label' => new OOUI\HtmlSnippet(
3806                    $this->context->msg( 'watchlistlabels-editpage-label' )->parse()
3807                ),
3808                'help' => $help,
3809                'helpInline' => true,
3810                'align' => 'top',
3811                'id' => 'mw-editpage-watchlist-labels',
3812                'classes' => [ 'mw-editpage-watchlistLabels' ],
3813            ]
3814        );
3815    }
3816
3817    /**
3818     * Returns an array of html code of the following buttons:
3819     * save, diff and preview
3820     *
3821     * @param int &$tabindex Current tabindex
3822     *
3823     * @return string[] Strings or objects with a __toString() implementation. Usually an array of
3824     *  {@see ButtonInputWidget}, but EditPageBeforeEditButtons hook handlers might inject something
3825     *  else.
3826     */
3827    public function getEditButtons( &$tabindex ) {
3828        $buttons = [];
3829
3830        $labelAsPublish =
3831            $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
3832
3833        $buttonLabel = $this->context->msg( $this->pageEditingHelper->getSubmitButtonLabel( $this->page ) )->text();
3834        $buttonTooltip = $labelAsPublish ? 'publish' : 'save';
3835
3836        $buttons['save'] = new OOUI\ButtonInputWidget( [
3837            'name' => 'wpSave',
3838            'tabIndex' => ++$tabindex,
3839            'id' => 'wpSaveWidget',
3840            'inputId' => 'wpSave',
3841            'flags' => [ 'progressive', 'primary' ],
3842            'label' => $buttonLabel,
3843            'infusable' => true,
3844            'type' => 'submit',
3845            // Messages used: tooltip-save, tooltip-publish
3846            'title' => Linker::titleAttrib( $buttonTooltip ),
3847            // Messages used: accesskey-save, accesskey-publish
3848            'accessKey' => Linker::accesskey( $buttonTooltip ),
3849            'icon' => $this->reauthRequired ? 'lock' : null,
3850        ] );
3851
3852        // Make sure the lock icon is loaded if we need it
3853        if ( $this->reauthRequired ) {
3854            $this->context->getOutput()->addModuleStyles( 'oojs-ui.styles.icons-moderation' );
3855        }
3856
3857        $buttons['preview'] = new OOUI\ButtonInputWidget( [
3858            'name' => 'wpPreview',
3859            'tabIndex' => ++$tabindex,
3860            'id' => 'wpPreviewWidget',
3861            'inputId' => 'wpPreview',
3862            'label' => $this->context->msg( 'showpreview' )->text(),
3863            'infusable' => true,
3864            'type' => 'submit',
3865            // Allow previewing even when the form is in invalid state (T343585)
3866            'formNoValidate' => true,
3867            // Message used: tooltip-preview
3868            'title' => Linker::titleAttrib( 'preview' ),
3869            // Message used: accesskey-preview
3870            'accessKey' => Linker::accesskey( 'preview' ),
3871        ] );
3872
3873        $buttons['diff'] = new OOUI\ButtonInputWidget( [
3874            'name' => 'wpDiff',
3875            'tabIndex' => ++$tabindex,
3876            'id' => 'wpDiffWidget',
3877            'inputId' => 'wpDiff',
3878            'label' => $this->context->msg( 'showdiff' )->text(),
3879            'infusable' => true,
3880            'type' => 'submit',
3881            // Allow previewing even when the form is in invalid state (T343585)
3882            'formNoValidate' => true,
3883            // Message used: tooltip-diff
3884            'title' => Linker::titleAttrib( 'diff' ),
3885            // Message used: accesskey-diff
3886            'accessKey' => Linker::accesskey( 'diff' ),
3887        ] );
3888
3889        $this->getHookRunner()->onEditPageBeforeEditButtons( $this, $buttons, $tabindex );
3890
3891        return $buttons;
3892    }
3893
3894    /**
3895     * Creates a basic error page which informs the user that
3896     * they have attempted to edit a nonexistent section.
3897     */
3898    private function noSuchSectionPage(): void {
3899        $out = $this->context->getOutput();
3900        $out->prepareErrorPage();
3901        $out->setPageTitleMsg( $this->context->msg( 'nosuchsectiontitle' ) );
3902
3903        $res = $this->context->msg( 'nosuchsectiontext', $this->section )->parseAsBlock();
3904
3905        $this->getHookRunner()->onEditPageNoSuchSection( $this, $res );
3906        $out->addHTML( $res );
3907
3908        $out->returnToMain( false, $this->page );
3909    }
3910
3911    /**
3912     * Show "your edit contains spam" page with your diff and text
3913     *
3914     * @param string|array|false $match Text (or array of texts) which triggered one or more filters
3915     */
3916    public function spamPageWithContent( $match = false ): void {
3917        $out = $this->context->getOutput();
3918        $out->prepareErrorPage();
3919        $out->setPageTitleMsg( $this->context->msg( 'spamprotectiontitle' ) );
3920
3921        $spamText = $this->context->msg( 'spamprotectiontext' )->parseAsBlock();
3922
3923        if ( $match ) {
3924            if ( is_array( $match ) ) {
3925                $matchText = Message::listParam( array_map( wfEscapeWikiText( ... ), $match ) );
3926            } else {
3927                $matchText = wfEscapeWikiText( $match );
3928            }
3929
3930            $spamText .= $this->context->msg( 'spamprotectionmatch', $matchText )->parseAsBlock();
3931        }
3932        $out->addHTML( Html::rawElement(
3933            'div',
3934            [ 'id' => 'spamprotected' ],
3935            $spamText
3936        ) );
3937
3938        $out->wrapWikiMsg( '<h2>$1</h2>', "yourdiff" );
3939        $this->showDiff();
3940
3941        $out->wrapWikiMsg( '<h2>$1</h2>', "yourtext" );
3942        $this->showTextbox( $this->textbox1, 'wpTextbox2', [ 'tabindex' => 6, 'readonly' ] );
3943
3944        $out->addReturnTo( $this->getContextTitle(), [ 'action' => 'edit' ] );
3945    }
3946
3947    private function addLongPageWarningHeader(): void {
3948        if ( $this->contentLength === false ) {
3949            $this->contentLength = strlen( $this->textbox1 );
3950        }
3951
3952        $out = $this->context->getOutput();
3953        $longPageHint = $this->context->msg( 'longpage-hint' );
3954        if ( !$longPageHint->isDisabled() ) {
3955            $msgText = trim( $longPageHint->sizeParams( $this->contentLength )
3956                ->params( $this->contentLength ) // Keep this unformatted for math inside message
3957                ->parse() );
3958            if ( $msgText !== '' && $msgText !== '-' ) {
3959                $out->addHTML( "<div id='mw-edit-longpage-hint'>\n$msgText\n</div>" );
3960            }
3961        }
3962    }
3963
3964    private function addExplainConflictHeader(): void {
3965        $this->context->getOutput()->addHTML(
3966            $this->getEditConflictHelper()->getExplainHeader()
3967        );
3968    }
3969
3970    /**
3971     * @param callable $factory Factory function to create a {@see TextConflictHelper}
3972     * @since 1.31
3973     */
3974    public function setEditConflictHelperFactory( callable $factory ): void {
3975        Assert::precondition( !$this->editConflictHelperFactory,
3976            'Can only have one extension that resolves edit conflicts' );
3977        $this->editConflictHelperFactory = $factory;
3978    }
3979
3980    private function getEditConflictHelper(): TextConflictHelper {
3981        if ( !$this->editConflictHelper ) {
3982            $label = $this->pageEditingHelper->getSubmitButtonLabel( $this->page );
3983            if ( $this->editConflictHelperFactory ) {
3984                $this->editConflictHelper = ( $this->editConflictHelperFactory )( $label );
3985            } else {
3986                $this->editConflictHelper = new TextConflictHelper(
3987                    $this->getTitle(),
3988                    $this->getContext()->getOutput(),
3989                    MediaWikiServices::getInstance()->getStatsFactory(),
3990                    $label,
3991                    MediaWikiServices::getInstance()->getContentHandlerFactory()
3992                );
3993            }
3994        }
3995        return $this->editConflictHelper;
3996    }
3997}