Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
26.74% covered (danger)
26.74%
581 / 2173
12.90% covered (danger)
12.90%
12 / 93
CRAP
0.00% covered (danger)
0.00%
0 / 1
EditPage
26.75% covered (danger)
26.75%
581 / 2172
12.90% covered (danger)
12.90%
12 / 93
141620.30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
2
 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
 isSupportedContentModel
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 setApiEditOverride
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 edit
0.00% covered (danger)
0.00%
0 / 104
0.00% covered (danger)
0.00%
0 / 1
1190
 maybeActivateTempUserCreate
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 createTempUser
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
8.21
 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
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
10.50
 getUserForSave
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 getEditPermissionErrors
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 displayPermissionsError
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 displayViewSourcePage
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
30
 previewOnOpen
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
132
 isSectionEditSupported
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 importFormData
27.78% covered (danger)
27.78%
15 / 54
0.00% covered (danger)
0.00%
0 / 1
99.76
 importFormDataPosted
68.42% covered (warning)
68.42%
52 / 76
0.00% covered (danger)
0.00%
0 / 1
55.48
 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 / 43
0.00% covered (danger)
0.00%
0 / 1
240
 getContentObject
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 1
930
 getUndoContent
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
5.01
 getOriginalContent
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 getParentRevId
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getCurrentContent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 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 / 86
0.00% covered (danger)
0.00%
0 / 1
1560
 doPostEditRedirect
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 setNewSectionSummary
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 internalAttemptSave
85.04% covered (warning)
85.04%
290 / 341
0.00% covered (danger)
0.00%
0 / 1
70.65
 handleFailedConstraint
56.25% covered (warning)
56.25%
9 / 16
0.00% covered (danger)
0.00%
0 / 1
18.37
 isUndoClean
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
8.05
 addContentModelChangeLogEntry
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 updateWatchlist
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 mergeChangesIntoContent
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
7.03
 getExpectedParentRevision
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 setHeaders
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
210
 showIntro
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 toEditText
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
6.97
 toEditContent
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 showEditForm
0.00% covered (danger)
0.00%
0 / 165
0.00% covered (danger)
0.00%
0 / 1
1122
 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 / 62
0.00% covered (danger)
0.00%
0 / 1
552
 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 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 showTextbox
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 displayPreviewArea
0.00% covered (danger)
0.00%
0 / 20
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
156
 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 / 27
0.00% covered (danger)
0.00%
0 / 1
72
 showStandardInputs
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 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 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 wasDeletedSinceLastEdit
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
13.78
 getLastDelete
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
30
 getPreviewText
0.00% covered (danger)
0.00%
0 / 90
0.00% covered (danger)
0.00%
0 / 1
812
 incrementEditFailureStats
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getPreviewParserOptions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 doPreviewParse
0.00% covered (danger)
0.00%
0 / 19
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
42
 getEditToolbar
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getCheckboxesDefinition
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
5.40
 getCheckboxesDefinitionForWatchlist
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 getCheckboxesWidget
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 getSubmitButtonLabel
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getEditButtons
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
6
 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 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 addLongPageWarningHeader
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 addExplainConflictHeader
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 guessSectionName
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 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 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\EditPage;
22
23use Article;
24use BadMethodCallException;
25use CategoryPage;
26use Content;
27use ContentHandler;
28use DeprecationHelper;
29use ErrorPageError;
30use IDBAccessObject;
31use LogPage;
32use ManualLogEntry;
33use MediaWiki\Block\BlockErrorFormatter;
34use MediaWiki\Cache\LinkBatchFactory;
35use MediaWiki\CommentStore\CommentStore;
36use MediaWiki\CommentStore\CommentStoreComment;
37use MediaWiki\Config\Config;
38use MediaWiki\Content\IContentHandlerFactory;
39use MediaWiki\Context\DerivativeContext;
40use MediaWiki\Context\IContextSource;
41use MediaWiki\Deferred\DeferredUpdates;
42use MediaWiki\EditPage\Constraint\AccidentalRecreationConstraint;
43use MediaWiki\EditPage\Constraint\AutoSummaryMissingSummaryConstraint;
44use MediaWiki\EditPage\Constraint\ChangeTagsConstraint;
45use MediaWiki\EditPage\Constraint\ContentModelChangeConstraint;
46use MediaWiki\EditPage\Constraint\DefaultTextConstraint;
47use MediaWiki\EditPage\Constraint\EditConstraintFactory;
48use MediaWiki\EditPage\Constraint\EditConstraintRunner;
49use MediaWiki\EditPage\Constraint\EditFilterMergedContentHookConstraint;
50use MediaWiki\EditPage\Constraint\IEditConstraint;
51use MediaWiki\EditPage\Constraint\ImageRedirectConstraint;
52use MediaWiki\EditPage\Constraint\MissingCommentConstraint;
53use MediaWiki\EditPage\Constraint\NewSectionMissingSubjectConstraint;
54use MediaWiki\EditPage\Constraint\PageSizeConstraint;
55use MediaWiki\EditPage\Constraint\SelfRedirectConstraint;
56use MediaWiki\EditPage\Constraint\SpamRegexConstraint;
57use MediaWiki\EditPage\Constraint\UnicodeConstraint;
58use MediaWiki\EditPage\Constraint\UserBlockConstraint;
59use MediaWiki\HookContainer\HookRunner;
60use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
61use MediaWiki\Html\Html;
62use MediaWiki\Language\RawMessage;
63use MediaWiki\Linker\Linker;
64use MediaWiki\Linker\LinkRenderer;
65use MediaWiki\Logger\LoggerFactory;
66use MediaWiki\MainConfigNames;
67use MediaWiki\MediaWikiServices;
68use MediaWiki\Message\Message;
69use MediaWiki\Page\PageIdentity;
70use MediaWiki\Page\PageReference;
71use MediaWiki\Page\RedirectLookup;
72use MediaWiki\Page\WikiPageFactory;
73use MediaWiki\Parser\ParserOutput;
74use MediaWiki\Permissions\Authority;
75use MediaWiki\Permissions\PermissionManager;
76use MediaWiki\Permissions\RestrictionStore;
77use MediaWiki\Request\WebRequest;
78use MediaWiki\Revision\RevisionRecord;
79use MediaWiki\Revision\RevisionStore;
80use MediaWiki\Revision\RevisionStoreRecord;
81use MediaWiki\Revision\SlotRecord;
82use MediaWiki\Status\Status;
83use MediaWiki\Storage\EditResult;
84use MediaWiki\Title\Title;
85use MediaWiki\User\ExternalUserNames;
86use MediaWiki\User\Options\UserOptionsLookup;
87use MediaWiki\User\TempUser\CreateStatus;
88use MediaWiki\User\TempUser\TempUserCreator;
89use MediaWiki\User\User;
90use MediaWiki\User\UserFactory;
91use MediaWiki\User\UserIdentity;
92use MediaWiki\User\UserNameUtils;
93use MediaWiki\Watchlist\WatchlistManager;
94use MessageLocalizer;
95use MWContentSerializationException;
96use MWException;
97use MWUnknownContentModelException;
98use OOUI;
99use OOUI\ButtonWidget;
100use OOUI\CheckboxInputWidget;
101use OOUI\DropdownInputWidget;
102use OOUI\FieldLayout;
103use ParserOptions;
104use PermissionsError;
105use ReadOnlyError;
106use RecentChange;
107use RuntimeException;
108use Skin;
109use stdClass;
110use TextContent;
111use ThrottledError;
112use UserBlockedError;
113use WatchAction;
114use WatchedItemStoreInterface;
115use Wikimedia\Assert\Assert;
116use Wikimedia\Message\MessageValue;
117use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
118use Wikimedia\Rdbms\IConnectionProvider;
119use WikiPage;
120
121/**
122 * The HTML user interface for page editing.
123 *
124 * This was originally split from the Article class, with some database and text
125 * munging logic still partly there.
126 *
127 * EditPage cares about two distinct titles:
128 * - $this->mContextTitle is the page that forms submit to, links point to,
129 *   redirects go to, etc.
130 * - $this->mTitle (as well as $mArticle) is the page in the database that is
131 *   actually being edited.
132 *
133 * These are usually the same, but they are now allowed to be different.
134 *
135 * Surgeon General's Warning: prolonged exposure to this class is known to cause
136 * headaches, which may be fatal.
137 *
138 * @newable
139 * @note marked as newable in 1.35 for lack of a better alternative,
140 *       but should be split up into service objects and command objects
141 *       in the future (T157658).
142 */
143#[\AllowDynamicProperties]
144class EditPage implements IEditObject {
145    use DeprecationHelper;
146    use ProtectedHookAccessorTrait;
147
148    /**
149     * Used for Unicode support checks
150     */
151    public const UNICODE_CHECK = UnicodeConstraint::VALID_UNICODE;
152
153    /**
154     * HTML id and name for the beginning of the edit form.
155     */
156    public const EDITFORM_ID = 'editform';
157
158    /**
159     * Prefix of key for cookie used to pass post-edit state.
160     * The revision id edited is added after this
161     */
162    public const POST_EDIT_COOKIE_KEY_PREFIX = 'PostEditRevision';
163
164    /**
165     * Duration of PostEdit cookie, in seconds.
166     * The cookie will be removed on the next page view of this article (Article::view()).
167     *
168     * Otherwise, though, we don't want the cookies to accumulate.
169     * RFC 2109 ( https://www.ietf.org/rfc/rfc2109.txt ) specifies a possible
170     * limit of only 20 cookies per domain. This still applies at least to some
171     * versions of IE without full updates:
172     * https://blogs.msdn.com/b/ieinternals/archive/2009/08/20/wininet-ie-cookie-internals-faq.aspx
173     *
174     * A value of 20 minutes should be enough to take into account slow loads and minor
175     * clock skew while still avoiding cookie accumulation when JavaScript is turned off.
176     *
177     * Some say this is too long (T211233), others say it is too short (T289538).
178     * The same value is used for client-side post-edit storage (in mediawiki.action.view.postEdit).
179     */
180    public const POST_EDIT_COOKIE_DURATION = 1200;
181
182    /**
183     * @var Article
184     */
185    private $mArticle;
186
187    /** @var WikiPage */
188    private $page;
189
190    /**
191     * @var Title
192     */
193    private $mTitle;
194
195    /** @var null|Title */
196    private $mContextTitle = null;
197
198    /**
199     * @deprecated for public usage since 1.38 with no replacement
200     * @var string
201     */
202    public $action = 'submit';
203
204    /** @var bool Whether an edit conflict needs to be resolved. Detected based on whether
205     * $editRevId is different than the latest revision. When a conflict has successfully
206     * been resolved by a 3-way-merge, this field is set to false.
207     */
208    public $isConflict = false;
209
210    /** @var bool New page or new section */
211    private $isNew = false;
212
213    /** @var bool */
214    private $deletedSinceEdit;
215
216    /** @var string */
217    public $formtype;
218
219    /** @var bool
220     * True the first time the edit form is rendered, false after re-rendering
221     * with diff, save prompts, etc.
222     */
223    public $firsttime;
224
225    /** @var stdClass|null */
226    private $lastDelete;
227
228    /** @var bool */
229    private $mTokenOk = false;
230
231    /** @var bool */
232    private $mTriedSave = false;
233
234    /** @var bool */
235    private $incompleteForm = false;
236
237    /** @var bool */
238    private $tooBig = false;
239
240    /** @var bool */
241    private $missingComment = false;
242
243    /** @var bool */
244    private $missingSummary = false;
245
246    /** @var bool */
247    private $allowBlankSummary = false;
248
249    /** @var bool */
250    protected $blankArticle = false;
251
252    /** @var bool */
253    private $allowBlankArticle = false;
254
255    /** @var bool */
256    private $selfRedirect = false;
257
258    /** @var bool */
259    private $allowSelfRedirect = false;
260
261    /** @var string */
262    private $autoSumm = '';
263
264    /** @var string */
265    private $hookError = '';
266
267    /** @var ParserOutput */
268    private $mParserOutput;
269
270    /**
271     * @var RevisionRecord|false|null
272     *
273     * A RevisionRecord corresponding to $this->editRevId or $this->edittime
274     */
275    private $mExpectedParentRevision = false;
276
277    /** @var bool */
278    public $mShowSummaryField = true;
279
280    # Form values
281
282    /** @var bool */
283    public $save = false;
284
285    /** @var bool */
286    public $preview = false;
287
288    /** @var bool */
289    private $diff = false;
290
291    /** @var bool */
292    private $minoredit = false;
293
294    /** @var bool */
295    private $watchthis = false;
296
297    /** @var bool Corresponds to $wgWatchlistExpiry */
298    private $watchlistExpiryEnabled;
299
300    private WatchedItemStoreInterface $watchedItemStore;
301
302    /** @var string|null The expiry time of the watch item, or null if it is not watched temporarily. */
303    private $watchlistExpiry;
304
305    /** @var bool */
306    private $recreate = false;
307
308    /** @var string
309     * Page content input field.
310     */
311    public $textbox1 = '';
312
313    /** @var string */
314    public $textbox2 = '';
315
316    /** @var string */
317    public $summary = '';
318
319    /**
320     * @var bool
321     * If true, hide the summary field.
322     */
323    private $nosummary = false;
324
325    /** @var string|null
326     * Timestamp of the latest revision of the page when editing was initiated
327     * on the client.
328     */
329    public $edittime = '';
330
331    /** @var int|null Revision ID of the latest revision of the page when editing
332     * was initiated on the client.  This is used to detect and resolve edit
333     * conflicts.
334     *
335     * @note 0 if the page did not exist at that time.
336     * @note When starting an edit from an old revision, this still records the current
337     * revision at the time, not the one the edit is based on.
338     *
339     * @see $oldid
340     * @see getExpectedParentRevision()
341     */
342    private $editRevId = null;
343
344    /** @var string */
345    public $section = '';
346
347    /** @var string|null */
348    public $sectiontitle = null;
349
350    /** @var string|null */
351    private $newSectionAnchor = null;
352
353    /** @var string|null
354     * Timestamp from the first time the edit form was rendered.
355     */
356    public $starttime = '';
357
358    /** @var int Revision ID the edit is based on, or 0 if it's the current revision.
359     * FIXME: This isn't used in conflict resolution--provide a better
360     * justification or merge with parentRevId.
361     * @see $editRevId
362     */
363    public $oldid = 0;
364
365    /**
366     * @var int Revision ID the edit is based on, adjusted when an edit conflict is resolved.
367     * @see $editRevId
368     * @see $oldid
369     * @see getparentRevId()
370     */
371    private $parentRevId = 0;
372
373    /** @var int|null */
374    private $scrolltop = null;
375
376    /** @var bool */
377    private $markAsBot = true;
378
379    /** @var string */
380    public $contentModel;
381
382    /** @var null|string */
383    public $contentFormat = null;
384
385    /** @var null|array */
386    private $changeTags = null;
387
388    # Placeholders for text injection by hooks (must be HTML)
389    # extensions should take care to _append_ to the present value
390
391    /** @var string Before even the preview */
392    public $editFormPageTop = '';
393    public $editFormTextTop = '';
394    public $editFormTextBeforeContent = '';
395    public $editFormTextAfterWarn = '';
396    public $editFormTextAfterTools = '';
397    public $editFormTextBottom = '';
398    public $editFormTextAfterContent = '';
399    public $previewTextAfterContent = '';
400
401    /** @var bool should be set to true whenever an article was successfully altered. */
402    public $didSave = false;
403    public $undidRev = 0;
404    private $undoAfter = 0;
405
406    public $suppressIntro = false;
407
408    /** @var bool */
409    private $edit;
410
411    /** @var int|false */
412    private $contentLength = false;
413
414    /**
415     * @var bool Set in ApiEditPage, based on ContentHandler::allowsDirectApiEditing
416     */
417    private $enableApiEditOverride = false;
418
419    /**
420     * @var IContextSource
421     */
422    protected $context;
423
424    /**
425     * @var bool Whether an old revision is edited
426     */
427    private $isOldRev = false;
428
429    /**
430     * @var string|null What the user submitted in the 'wpUnicodeCheck' field
431     */
432    private $unicodeCheck;
433
434    /** @var callable|null */
435    private $editConflictHelperFactory = null;
436    private ?TextConflictHelper $editConflictHelper = null;
437
438    private IContentHandlerFactory $contentHandlerFactory;
439    private PermissionManager $permManager;
440    private RevisionStore $revisionStore;
441    private WikiPageFactory $wikiPageFactory;
442    private WatchlistManager $watchlistManager;
443    private UserNameUtils $userNameUtils;
444    private RedirectLookup $redirectLookup;
445    private UserOptionsLookup $userOptionsLookup;
446    private TempUserCreator $tempUserCreator;
447    private UserFactory $userFactory;
448    private IConnectionProvider $connectionProvider;
449    private BlockErrorFormatter $blockErrorFormatter;
450
451    /** @var User|null */
452    private $placeholderTempUser;
453
454    /** @var User|null */
455    private $unsavedTempUser;
456
457    /** @var User|null */
458    private $savedTempUser;
459
460    /** @var bool Whether temp user creation will be attempted */
461    private $tempUserCreateActive = false;
462
463    /** @var string|null If a temp user name was acquired, this is the name */
464    private $tempUserName;
465
466    /** @var bool Whether temp user creation was successful */
467    private $tempUserCreateDone = false;
468
469    /** @var bool Whether temp username acquisition failed (false indicates no failure or not attempted) */
470    private $unableToAcquireTempName = false;
471
472    private LinkRenderer $linkRenderer;
473    private LinkBatchFactory $linkBatchFactory;
474    private RestrictionStore $restrictionStore;
475    private CommentStore $commentStore;
476
477    /**
478     * @stable to call
479     * @param Article $article
480     */
481    public function __construct( Article $article ) {
482        $this->mArticle = $article;
483        $this->page = $article->getPage(); // model object
484        $this->mTitle = $article->getTitle();
485
486        // Make sure the local context is in sync with other member variables.
487        // Particularly make sure everything is using the same WikiPage instance.
488        // This should probably be the case in Article as well, but it's
489        // particularly important for EditPage, to make use of the in-place caching
490        // facility in WikiPage::prepareContentForEdit.
491        $this->context = new DerivativeContext( $article->getContext() );
492        $this->context->setWikiPage( $this->page );
493        $this->context->setTitle( $this->mTitle );
494
495        $this->contentModel = $this->mTitle->getContentModel();
496
497        $services = MediaWikiServices::getInstance();
498        $this->contentHandlerFactory = $services->getContentHandlerFactory();
499        $this->contentFormat = $this->contentHandlerFactory
500            ->getContentHandler( $this->contentModel )
501            ->getDefaultFormat();
502        $this->permManager = $services->getPermissionManager();
503        $this->revisionStore = $services->getRevisionStore();
504        $this->watchlistExpiryEnabled = $this->getContext()->getConfig() instanceof Config
505            && $this->getContext()->getConfig()->get( MainConfigNames::WatchlistExpiry );
506        $this->watchedItemStore = $services->getWatchedItemStore();
507        $this->wikiPageFactory = $services->getWikiPageFactory();
508        $this->watchlistManager = $services->getWatchlistManager();
509        $this->userNameUtils = $services->getUserNameUtils();
510        $this->redirectLookup = $services->getRedirectLookup();
511        $this->userOptionsLookup = $services->getUserOptionsLookup();
512        $this->tempUserCreator = $services->getTempUserCreator();
513        $this->userFactory = $services->getUserFactory();
514        $this->linkRenderer = $services->getLinkRenderer();
515        $this->linkBatchFactory = $services->getLinkBatchFactory();
516        $this->restrictionStore = $services->getRestrictionStore();
517        $this->commentStore = $services->getCommentStore();
518        $this->connectionProvider = $services->getConnectionProvider();
519        $this->blockErrorFormatter = $services->getFormatterFactory()
520            ->getBlockErrorFormatter( $this->context );
521
522        // XXX: Restore this deprecation as soon as TwoColConflict is fixed (T305028)
523        // $this->deprecatePublicProperty( 'textbox2', '1.38', __CLASS__ );
524    }
525
526    /**
527     * @return Article
528     */
529    public function getArticle() {
530        return $this->mArticle;
531    }
532
533    /**
534     * @since 1.28
535     * @return IContextSource
536     */
537    public function getContext() {
538        return $this->context;
539    }
540
541    /**
542     * @since 1.19
543     * @return Title
544     */
545    public function getTitle() {
546        return $this->mTitle;
547    }
548
549    /**
550     * @param Title|null $title
551     */
552    public function setContextTitle( $title ) {
553        $this->mContextTitle = $title;
554    }
555
556    /**
557     * @throws RuntimeException if no context title was set
558     * @return Title
559     */
560    public function getContextTitle() {
561        if ( $this->mContextTitle === null ) {
562            throw new RuntimeException( "EditPage does not have a context title set" );
563        } else {
564            return $this->mContextTitle;
565        }
566    }
567
568    /**
569     * Returns if the given content model is editable.
570     *
571     * @param string $modelId The ID of the content model to test. Use CONTENT_MODEL_XXX constants.
572     * @return bool
573     * @throws MWUnknownContentModelException If $modelId has no known handler
574     */
575    private function isSupportedContentModel( string $modelId ): bool {
576        return $this->enableApiEditOverride === true ||
577            $this->contentHandlerFactory->getContentHandler( $modelId )->supportsDirectEditing();
578    }
579
580    /**
581     * Allow editing of content that supports API direct editing, but not general
582     * direct editing. Set to false by default.
583     * @internal Must only be used by ApiEditPage
584     *
585     * @param bool $enableOverride
586     */
587    public function setApiEditOverride( $enableOverride ) {
588        $this->enableApiEditOverride = $enableOverride;
589    }
590
591    /**
592     * This is the function that gets called for "action=edit". It
593     * sets up various member variables, then passes execution to
594     * another function, usually showEditForm()
595     *
596     * The edit form is self-submitting, so that when things like
597     * preview and edit conflicts occur, we get the same form back
598     * with the extra stuff added.  Only when the final submission
599     * is made and all is well do we actually save and redirect to
600     * the newly-edited page.
601     */
602    public function edit() {
603        // Allow extensions to modify/prevent this form or submission
604        if ( !$this->getHookRunner()->onAlternateEdit( $this ) ) {
605            return;
606        }
607
608        wfDebug( __METHOD__ . ": enter" );
609
610        $request = $this->context->getRequest();
611        // If they used redlink=1 and the page exists, redirect to the main article
612        if ( $request->getBool( 'redlink' ) && $this->mTitle->exists() ) {
613            $this->context->getOutput()->redirect( $this->mTitle->getFullURL() );
614            return;
615        }
616
617        $this->importFormData( $request );
618        $this->firsttime = false;
619
620        $readOnlyMode = MediaWikiServices::getInstance()->getReadOnlyMode();
621        if ( $this->save && $readOnlyMode->isReadOnly() ) {
622            // Force preview
623            $this->save = false;
624            $this->preview = true;
625        }
626
627        if ( $this->save ) {
628            $this->formtype = 'save';
629        } elseif ( $this->preview ) {
630            $this->formtype = 'preview';
631        } elseif ( $this->diff ) {
632            $this->formtype = 'diff';
633        } else { # First time through
634            $this->firsttime = true;
635            if ( $this->previewOnOpen() ) {
636                $this->formtype = 'preview';
637            } else {
638                $this->formtype = 'initial';
639            }
640        }
641
642        // Check permissions after possibly creating a placeholder temp user.
643        // This allows anonymous users to edit via a temporary account, if the site is
644        // configured to (1) disallow anonymous editing and (2) autocreate temporary
645        // accounts on edit.
646        $this->unableToAcquireTempName = !$this->maybeActivateTempUserCreate( !$this->firsttime )->isOK();
647
648        $permErrors = $this->getEditPermissionErrors(
649            $this->save ? PermissionManager::RIGOR_SECURE : PermissionManager::RIGOR_FULL
650        );
651        if ( $permErrors ) {
652            wfDebug( __METHOD__ . ": User can't edit" );
653
654            $user = $this->context->getUser();
655            if ( $user->getBlock() && !$readOnlyMode->isReadOnly() ) {
656                // Auto-block user's IP if the account was "hard" blocked
657                DeferredUpdates::addCallableUpdate( static function () use ( $user ) {
658                    $user->spreadAnyEditBlock();
659                } );
660            }
661            $this->displayPermissionsError( $permErrors );
662
663            return;
664        }
665
666        $revRecord = $this->mArticle->fetchRevisionRecord();
667        // Disallow editing revisions with content models different from the current one
668        // Undo edits being an exception in order to allow reverting content model changes.
669        $revContentModel = $revRecord ?
670            $revRecord->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel() :
671            false;
672        if ( $revContentModel && $revContentModel !== $this->contentModel ) {
673            $prevRevRecord = null;
674            $prevContentModel = false;
675            if ( $this->undidRev ) {
676                $undidRevRecord = $this->revisionStore
677                    ->getRevisionById( $this->undidRev );
678                $prevRevRecord = $undidRevRecord ?
679                    $this->revisionStore->getPreviousRevision( $undidRevRecord ) :
680                    null;
681
682                $prevContentModel = $prevRevRecord ?
683                    $prevRevRecord
684                        ->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )
685                        ->getModel() :
686                    '';
687            }
688
689            if ( !$this->undidRev
690                || !$prevRevRecord
691                || $prevContentModel !== $this->contentModel
692            ) {
693                $this->displayViewSourcePage(
694                    $this->getContentObject(),
695                    $this->context->msg(
696                        'contentmodelediterror',
697                        $revContentModel,
698                        $this->contentModel
699                    )->plain()
700                );
701                return;
702            }
703        }
704
705        $this->isConflict = false;
706
707        # Attempt submission here.  This will check for edit conflicts,
708        # and redundantly check for locked database, blocked IPs, etc.
709        # that edit() already checked just in case someone tries to sneak
710        # in the back door with a hand-edited submission URL.
711
712        if ( $this->formtype === 'save' ) {
713            $resultDetails = null;
714            $status = $this->attemptSave( $resultDetails );
715            if ( !$this->handleStatus( $status, $resultDetails ) ) {
716                return;
717            }
718        }
719
720        # First time through: get contents, set time for conflict
721        # checking, etc.
722        if ( $this->formtype === 'initial' || $this->firsttime ) {
723            if ( !$this->initialiseForm() ) {
724                return;
725            }
726
727            if ( $this->mTitle->getArticleID() ) {
728                $this->getHookRunner()->onEditFormInitialText( $this );
729            }
730        }
731
732        // If we're displaying an old revision, and there are differences between it and the
733        // current revision outside the main slot, then we can't allow the old revision to be
734        // editable, as what would happen to the non-main-slot data if someone saves the old
735        // revision is undefined.
736        // When this is the case, display a read-only version of the page instead, with a link
737        // to a diff page from which the old revision can be restored
738        $curRevisionRecord = $this->page->getRevisionRecord();
739        if ( $curRevisionRecord
740            && $revRecord
741            && $curRevisionRecord->getId() !== $revRecord->getId()
742            && ( WikiPage::hasDifferencesOutsideMainSlot(
743                    $revRecord,
744                    $curRevisionRecord
745                ) || !$this->isSupportedContentModel(
746                    $revRecord->getSlot(
747                        SlotRecord::MAIN,
748                        RevisionRecord::RAW
749                    )->getModel()
750                ) )
751        ) {
752            $restoreLink = $this->mTitle->getFullURL(
753                [
754                    'action' => 'mcrrestore',
755                    'restore' => $revRecord->getId(),
756                ]
757            );
758            $this->displayViewSourcePage(
759                $this->getContentObject(),
760                $this->context->msg(
761                    'nonmain-slot-differences-therefore-readonly',
762                    $restoreLink
763                )->plain()
764            );
765            return;
766        }
767
768        $this->showEditForm();
769    }
770
771    /**
772     * Check the configuration and current user and enable automatic temporary
773     * user creation if possible.
774     *
775     * @param bool $doAcquire Whether to acquire a name for the temporary account
776     *
777     * @since 1.39
778     * @return Status Will return a fatal status if $doAcquire was true and the acquire failed.
779     */
780    public function maybeActivateTempUserCreate( $doAcquire ): Status {
781        if ( $this->tempUserCreateActive ) {
782            // Already done
783            return Status::newGood();
784        }
785        $user = $this->context->getUser();
786        if ( $this->tempUserCreator->shouldAutoCreate( $user, 'edit' ) ) {
787            if ( $doAcquire ) {
788                $name = $this->tempUserCreator->acquireAndStashName(
789                    $this->context->getRequest()->getSession() );
790                if ( $name === null ) {
791                    $status = Status::newFatal( 'temp-user-unable-to-acquire' );
792                    $status->value = self::AS_UNABLE_TO_ACQUIRE_TEMP_ACCOUNT;
793                    return $status;
794                }
795                $this->unsavedTempUser = $this->userFactory->newUnsavedTempUser( $name );
796                $this->tempUserName = $name;
797            } else {
798                $this->placeholderTempUser = $this->userFactory->newTempPlaceholder();
799            }
800            $this->tempUserCreateActive = true;
801        }
802        return Status::newGood();
803    }
804
805    /**
806     * If automatic user creation is enabled, create the user.
807     *
808     * This is a helper for internalAttemptSave().
809     *
810     * If the edit is a null edit, the user will not be created.
811     *
812     * @return Status
813     */
814    private function createTempUser(): Status {
815        if ( !$this->tempUserCreateActive ) {
816            return Status::newGood();
817        }
818        $status = $this->tempUserCreator->create(
819            $this->tempUserName,
820            $this->context->getRequest()
821        );
822        if ( $status->isOK() ) {
823            $this->placeholderTempUser = null;
824            $this->unsavedTempUser = null;
825            $this->savedTempUser = $status->getUser();
826            $this->tempUserCreateDone = true;
827        }
828        return $status;
829    }
830
831    /**
832     * Get the authority for permissions purposes.
833     *
834     * On an initial edit page GET request, if automatic temporary user creation
835     * is enabled, this may be a placeholder user with a fixed name. Such users
836     * are unsuitable for anything that uses or exposes the name, like
837     * throttling. The only thing a placeholder user is good for is fooling the
838     * permissions system into allowing edits by anons.
839     *
840     * @return Authority
841     */
842    private function getAuthority(): Authority {
843        return $this->getUserForPermissions();
844    }
845
846    /**
847     * Get the user for permissions purposes, with declared type User instead
848     * of Authority for compatibility with PermissionManager.
849     *
850     * @return User
851     */
852    private function getUserForPermissions() {
853        if ( $this->savedTempUser ) {
854            return $this->savedTempUser;
855        } elseif ( $this->unsavedTempUser ) {
856            return $this->unsavedTempUser;
857        } elseif ( $this->placeholderTempUser ) {
858            return $this->placeholderTempUser;
859        } else {
860            return $this->context->getUser();
861        }
862    }
863
864    /**
865     * Get the user for preview or PST purposes. During the temporary user
866     * creation flow this may be an unsaved temporary user.
867     *
868     * @return User
869     */
870    private function getUserForPreview() {
871        if ( $this->savedTempUser ) {
872            return $this->savedTempUser;
873        } elseif ( $this->unsavedTempUser ) {
874            return $this->unsavedTempUser;
875        } elseif ( $this->firsttime && $this->placeholderTempUser ) {
876            // Mostly a GET request and no temp user was aquired,
877            // but needed for pst or content transform for preview,
878            // fallback to a placeholder for this situation (T330943)
879            return $this->placeholderTempUser;
880        } elseif ( $this->tempUserCreateActive ) {
881            throw new BadMethodCallException(
882                "Can't use the request user for preview with IP masking enabled" );
883        } else {
884            return $this->context->getUser();
885        }
886    }
887
888    /**
889     * Get the user suitable for permanent attribution in the database. This
890     * asserts that an anonymous user won't be used in IP masking mode.
891     *
892     * @return User
893     */
894    private function getUserForSave() {
895        if ( $this->savedTempUser ) {
896            return $this->savedTempUser;
897        } elseif ( $this->tempUserCreateActive ) {
898            throw new BadMethodCallException(
899                "Can't use the request user for storage with IP masking enabled" );
900        } else {
901            return $this->context->getUser();
902        }
903    }
904
905    /**
906     * @param string $rigor PermissionManager::RIGOR_ constant
907     * @return array
908     */
909    private function getEditPermissionErrors( string $rigor = PermissionManager::RIGOR_SECURE ): array {
910        $user = $this->getUserForPermissions();
911
912        $ignoredErrors = [];
913        if ( $this->preview || $this->diff ) {
914            $ignoredErrors = [ 'blockedtext', 'autoblockedtext', 'systemblockedtext' ];
915        }
916        return $this->permManager->getPermissionErrors(
917            'edit',
918            $user,
919            $this->mTitle,
920            $rigor,
921            $ignoredErrors
922        );
923    }
924
925    /**
926     * Display a permissions error page, like OutputPage::showPermissionsErrorPage(),
927     * but with the following differences:
928     * - If redlink=1, the user will be redirected to the page
929     * - If there is content to display or the error occurs while either saving,
930     *   previewing or showing the difference, it will be a
931     *   "View source for ..." page displaying the source code after the error message.
932     *
933     * @param array $permErrors Array of permissions errors
934     * @throws PermissionsError
935     */
936    private function displayPermissionsError( array $permErrors ): void {
937        $out = $this->context->getOutput();
938        if ( $this->context->getRequest()->getBool( 'redlink' ) ) {
939            // The edit page was reached via a red link.
940            // Redirect to the article page and let them click the edit tab if
941            // they really want a permission error.
942            $out->redirect( $this->mTitle->getFullURL() );
943            return;
944        }
945
946        $content = $this->getContentObject();
947
948        // Use the normal message if there's nothing to display
949        // We used to only do this if $this->firsttime was truthy, and there was no content
950        // or the content was empty, but sometimes there was no content even if it not the
951        // first time, we can't use displayViewSourcePage if there is no content (T281400)
952        if ( !$content || ( $this->firsttime && $content->isEmpty() ) ) {
953            $action = $this->mTitle->exists() ? 'edit' :
954                ( $this->mTitle->isTalkPage() ? 'createtalk' : 'createpage' );
955            throw new PermissionsError( $action, $permErrors );
956        }
957
958        $this->displayViewSourcePage(
959            $content,
960            $out->formatPermissionsErrorMessage( $permErrors, 'edit' )
961        );
962    }
963
964    /**
965     * Display a read-only View Source page
966     * @param Content $content
967     * @param string $errorMessage additional wikitext error message to display
968     */
969    private function displayViewSourcePage( Content $content, string $errorMessage ): void {
970        $out = $this->context->getOutput();
971        $this->getHookRunner()->onEditPage__showReadOnlyForm_initial( $this, $out );
972
973        $out->setRobotPolicy( 'noindex,nofollow' );
974        $out->setPageTitleMsg( $this->context->msg(
975            'viewsource-title'
976        )->plaintextParams(
977            $this->getContextTitle()->getPrefixedText()
978        ) );
979        $out->addBacklinkSubtitle( $this->getContextTitle() );
980        $out->addHTML( $this->editFormPageTop );
981        $out->addHTML( $this->editFormTextTop );
982
983        if ( $errorMessage !== '' ) {
984            $out->addWikiTextAsInterface( $errorMessage );
985            $out->addHTML( "<hr />\n" );
986        }
987
988        # If the user made changes, preserve them when showing the markup
989        # (This happens when a user is blocked during edit, for instance)
990        if ( !$this->firsttime ) {
991            $text = $this->textbox1;
992            $out->addWikiMsg( 'viewyourtext' );
993        } else {
994            try {
995                $text = $this->toEditText( $content );
996            } catch ( MWException $e ) {
997                # Serialize using the default format if the content model is not supported
998                # (e.g. for an old revision with a different model)
999                $text = $content->serialize();
1000            }
1001            $out->addWikiMsg( 'viewsourcetext' );
1002        }
1003
1004        $out->addHTML( $this->editFormTextBeforeContent );
1005        $this->showTextbox( $text, 'wpTextbox1', [ 'readonly' ] );
1006        $out->addHTML( $this->editFormTextAfterContent );
1007
1008        $out->addHTML( $this->makeTemplatesOnThisPageList( $this->getTemplates() ) );
1009
1010        $out->addModules( 'mediawiki.action.edit.collapsibleFooter' );
1011
1012        $out->addHTML( $this->editFormTextBottom );
1013        if ( $this->mTitle->exists() ) {
1014            $out->returnToMain( null, $this->mTitle );
1015        }
1016    }
1017
1018    /**
1019     * Should we show a preview when the edit form is first shown?
1020     *
1021     * @return bool
1022     */
1023    protected function previewOnOpen() {
1024        $config = $this->context->getConfig();
1025        $previewOnOpenNamespaces = $config->get( MainConfigNames::PreviewOnOpenNamespaces );
1026        $request = $this->context->getRequest();
1027        if ( $config->get( MainConfigNames::RawHtml ) ) {
1028            // If raw HTML is enabled, disable preview on open
1029            // since it has to be posted with a token for
1030            // security reasons
1031            return false;
1032        }
1033        if ( $request->getVal( 'preview' ) === 'yes' ) {
1034            // Explicit override from request
1035            return true;
1036        } elseif ( $request->getVal( 'preview' ) === 'no' ) {
1037            // Explicit override from request
1038            return false;
1039        } elseif ( $this->section === 'new' ) {
1040            // Nothing *to* preview for new sections
1041            return false;
1042        } elseif ( ( $request->getCheck( 'preload' ) || $this->mTitle->exists() )
1043            && $this->userOptionsLookup->getOption( $this->context->getUser(), 'previewonfirst' )
1044        ) {
1045            // Standard preference behavior
1046            return true;
1047        } elseif ( !$this->mTitle->exists()
1048            && isset( $previewOnOpenNamespaces[$this->mTitle->getNamespace()] )
1049            && $previewOnOpenNamespaces[$this->mTitle->getNamespace()]
1050        ) {
1051            // Categories are special
1052            return true;
1053        } else {
1054            return false;
1055        }
1056    }
1057
1058    /**
1059     * Section editing is supported when the page content model allows
1060     * section edit and we are editing current revision.
1061     *
1062     * @return bool True if this edit page supports sections, false otherwise.
1063     */
1064    private function isSectionEditSupported(): bool {
1065        $currentRev = $this->page->getRevisionRecord();
1066
1067        // $currentRev is null for non-existing pages, use the page default content model.
1068        $revContentModel = $currentRev
1069            ? $currentRev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel()
1070            : $this->page->getContentModel();
1071
1072        return (
1073            ( $this->mArticle->getRevIdFetched() === $this->page->getLatest() ) &&
1074            $this->contentHandlerFactory->getContentHandler( $revContentModel )->supportsSections()
1075        );
1076    }
1077
1078    /**
1079     * This function collects the form data and uses it to populate various member variables.
1080     * @param WebRequest &$request
1081     * @throws ErrorPageError
1082     */
1083    public function importFormData( &$request ) {
1084        # Section edit can come from either the form or a link
1085        $this->section = $request->getVal( 'wpSection', $request->getVal( 'section', '' ) );
1086
1087        if ( $this->section !== null && $this->section !== '' && !$this->isSectionEditSupported() ) {
1088            throw new ErrorPageError( 'sectioneditnotsupported-title', 'sectioneditnotsupported-text' );
1089        }
1090
1091        $this->isNew = !$this->mTitle->exists() || $this->section === 'new';
1092
1093        if ( $request->wasPosted() ) {
1094            $this->importFormDataPosted( $request );
1095        } else {
1096            # Not a posted form? Start with nothing.
1097            wfDebug( __METHOD__ . ": Not a posted form." );
1098            $this->textbox1 = '';
1099            $this->summary = '';
1100            $this->sectiontitle = null;
1101            $this->edittime = '';
1102            $this->editRevId = null;
1103            $this->starttime = wfTimestampNow();
1104            $this->edit = false;
1105            $this->preview = false;
1106            $this->save = false;
1107            $this->diff = false;
1108            $this->minoredit = false;
1109            // Watch may be overridden by request parameters
1110            $this->watchthis = $request->getBool( 'watchthis', false );
1111            if ( $this->watchlistExpiryEnabled ) {
1112                $this->watchlistExpiry = null;
1113            }
1114            $this->recreate = false;
1115
1116            // When creating a new section, we can preload a section title by passing it as the
1117            // preloadtitle parameter in the URL (T15100)
1118            if ( $this->section === 'new' && $request->getCheck( 'preloadtitle' ) ) {
1119                $this->sectiontitle = $request->getVal( 'preloadtitle' );
1120                $this->setNewSectionSummary();
1121            } elseif ( $this->section !== 'new' && $request->getVal( 'summary' ) !== '' ) {
1122                $this->summary = $request->getText( 'summary' );
1123                if ( $this->summary !== '' ) {
1124                    // If a summary has been preset using &summary= we don't want to prompt for
1125                    // a different summary. Only prompt for a summary if the summary is blanked.
1126                    // (T19416)
1127                    $this->autoSumm = md5( '' );
1128                }
1129            }
1130
1131            if ( $request->getVal( 'minor' ) ) {
1132                $this->minoredit = true;
1133            }
1134        }
1135
1136        $this->oldid = $request->getInt( 'oldid' );
1137        $this->parentRevId = $request->getInt( 'parentRevId' );
1138
1139        $this->markAsBot = $request->getBool( 'bot', true );
1140        $this->nosummary = $request->getBool( 'nosummary' );
1141
1142        // May be overridden by revision.
1143        $this->contentModel = $request->getText( 'model', $this->contentModel );
1144        // May be overridden by revision.
1145        $this->contentFormat = $request->getText( 'format', $this->contentFormat );
1146
1147        try {
1148            $handler = $this->contentHandlerFactory->getContentHandler( $this->contentModel );
1149        } catch ( MWUnknownContentModelException $e ) {
1150            throw new ErrorPageError(
1151                'editpage-invalidcontentmodel-title',
1152                'editpage-invalidcontentmodel-text',
1153                [ wfEscapeWikiText( $this->contentModel ) ]
1154            );
1155        }
1156
1157        if ( !$handler->isSupportedFormat( $this->contentFormat ) ) {
1158            throw new ErrorPageError(
1159                'editpage-notsupportedcontentformat-title',
1160                'editpage-notsupportedcontentformat-text',
1161                [
1162                    wfEscapeWikiText( $this->contentFormat ),
1163                    wfEscapeWikiText( ContentHandler::getLocalizedName( $this->contentModel ) )
1164                ]
1165            );
1166        }
1167
1168        // Allow extensions to modify form data
1169        $this->getHookRunner()->onEditPage__importFormData( $this, $request );
1170    }
1171
1172    /**
1173     * @param WebRequest $request
1174     */
1175    private function importFormDataPosted( WebRequest $request ): void {
1176        # These fields need to be checked for encoding.
1177        # Also remove trailing whitespace, but don't remove _initial_
1178        # whitespace from the text boxes. This may be significant formatting.
1179        $this->textbox1 = rtrim( $request->getText( 'wpTextbox1' ) );
1180        if ( !$request->getCheck( 'wpTextbox2' ) ) {
1181            // Skip this if wpTextbox2 has input, it indicates that we came
1182            // from a conflict page with raw page text, not a custom form
1183            // modified by subclasses
1184            $textbox1 = $this->importContentFormData( $request );
1185            if ( $textbox1 !== null ) {
1186                $this->textbox1 = $textbox1;
1187            }
1188        }
1189
1190        $this->unicodeCheck = $request->getText( 'wpUnicodeCheck' );
1191
1192        if ( $this->section === 'new' ) {
1193            # Allow setting sectiontitle different from the edit summary.
1194            # Note that wpSectionTitle is not yet a part of the actual edit form, as wpSummary is
1195            # currently doing double duty as both edit summary and section title. Right now this
1196            # is just to allow API edits to work around this limitation, but this should be
1197            # incorporated into the actual edit form when EditPage is rewritten (T20654, T28312).
1198            if ( $request->getCheck( 'wpSectionTitle' ) ) {
1199                $this->sectiontitle = $request->getText( 'wpSectionTitle' );
1200                if ( $request->getCheck( 'wpSummary' ) ) {
1201                    $this->summary = $request->getText( 'wpSummary' );
1202                }
1203            } else {
1204                $this->sectiontitle = $request->getText( 'wpSummary' );
1205            }
1206        } else {
1207            $this->sectiontitle = null;
1208            $this->summary = $request->getText( 'wpSummary' );
1209        }
1210
1211        # If the summary consists of a heading, e.g. '==Foobar==', extract the title from the
1212        # header syntax, e.g. 'Foobar'. This is mainly an issue when we are using wpSummary for
1213        # section titles. (T3600)
1214        # It is weird to modify 'sectiontitle', even when it is provided when using the API, but API
1215        # users have come to rely on it: https://github.com/wikimedia-gadgets/twinkle/issues/1625
1216        $this->summary = preg_replace( '/^\s*=+\s*(.*?)\s*=+\s*$/', '$1', $this->summary );
1217        if ( $this->sectiontitle !== null ) {
1218            $this->sectiontitle = preg_replace( '/^\s*=+\s*(.*?)\s*=+\s*$/', '$1', $this->sectiontitle );
1219        }
1220
1221        // @phan-suppress-next-line PhanSuspiciousValueComparison
1222        if ( $this->section === 'new' ) {
1223            $this->setNewSectionSummary();
1224        }
1225
1226        $this->edittime = $request->getVal( 'wpEdittime' );
1227        $this->editRevId = $request->getIntOrNull( 'editRevId' );
1228        $this->starttime = $request->getVal( 'wpStarttime' );
1229
1230        $undidRev = $request->getInt( 'wpUndidRevision' );
1231        if ( $undidRev ) {
1232            $this->undidRev = $undidRev;
1233        }
1234        $undoAfter = $request->getInt( 'wpUndoAfter' );
1235        if ( $undoAfter ) {
1236            $this->undoAfter = $undoAfter;
1237        }
1238
1239        $this->scrolltop = $request->getIntOrNull( 'wpScrolltop' );
1240
1241        if ( $this->textbox1 === '' && !$request->getCheck( 'wpTextbox1' ) ) {
1242            // wpTextbox1 field is missing, possibly due to being "too big"
1243            // according to some filter rules that may have been configured
1244            // for security reasons.
1245            $this->incompleteForm = true;
1246        } else {
1247            // If we receive the last parameter of the request, we can fairly
1248            // claim the POST request has not been truncated.
1249            $this->incompleteForm = !$request->getVal( 'wpUltimateParam' );
1250        }
1251        if ( $this->incompleteForm ) {
1252            # If the form is incomplete, force to preview.
1253            wfDebug( __METHOD__ . ": Form data appears to be incomplete" );
1254            wfDebug( "POST DATA: " . var_export( $request->getPostValues(), true ) );
1255            $this->preview = true;
1256        } else {
1257            $this->preview = $request->getCheck( 'wpPreview' );
1258            $this->diff = $request->getCheck( 'wpDiff' );
1259
1260            // Remember whether a save was requested, so we can indicate
1261            // if we forced preview due to session failure.
1262            $this->mTriedSave = !$this->preview;
1263
1264            if ( $this->tokenOk( $request ) ) {
1265                # Some browsers will not report any submit button
1266                # if the user hits enter in the comment box.
1267                # The unmarked state will be assumed to be a save,
1268                # if the form seems otherwise complete.
1269                wfDebug( __METHOD__ . ": Passed token check." );
1270            } elseif ( $this->diff ) {
1271                # Failed token check, but only requested "Show Changes".
1272                wfDebug( __METHOD__ . ": Failed token check; Show Changes requested." );
1273            } else {
1274                # Page might be a hack attempt posted from
1275                # an external site. Preview instead of saving.
1276                wfDebug( __METHOD__ . ": Failed token check; forcing preview" );
1277                $this->preview = true;
1278            }
1279        }
1280        $this->save = !$this->preview && !$this->diff;
1281        if ( !$this->edittime || !preg_match( '/^\d{14}$/', $this->edittime ) ) {
1282            $this->edittime = null;
1283        }
1284
1285        if ( !$this->starttime || !preg_match( '/^\d{14}$/', $this->starttime ) ) {
1286            $this->starttime = null;
1287        }
1288
1289        $this->recreate = $request->getCheck( 'wpRecreate' );
1290
1291        $user = $this->context->getUser();
1292
1293        $this->minoredit = $request->getCheck( 'wpMinoredit' );
1294        $this->watchthis = $request->getCheck( 'wpWatchthis' );
1295        $expiry = $request->getText( 'wpWatchlistExpiry' );
1296        if ( $this->watchlistExpiryEnabled && $expiry !== '' ) {
1297            // This parsing of the user-posted expiry is done for both preview and saving. This
1298            // is necessary because ApiEditPage uses preview when it saves (yuck!). Note that it
1299            // only works because the unnormalized value is retrieved again below in
1300            // getCheckboxesDefinitionForWatchlist().
1301            $expiry = ExpiryDef::normalizeExpiry( $expiry, TS_ISO_8601 );
1302            if ( $expiry !== false ) {
1303                $this->watchlistExpiry = $expiry;
1304            }
1305        }
1306
1307        # Don't force edit summaries when a user is editing their own user or talk page
1308        if ( ( $this->mTitle->getNamespace() === NS_USER || $this->mTitle->getNamespace() === NS_USER_TALK )
1309            && $this->mTitle->getText() === $user->getName()
1310        ) {
1311            $this->allowBlankSummary = true;
1312        } else {
1313            $this->allowBlankSummary = $request->getBool( 'wpIgnoreBlankSummary' )
1314                || !$this->userOptionsLookup->getOption( $user, 'forceeditsummary' );
1315        }
1316
1317        $this->autoSumm = $request->getText( 'wpAutoSummary' );
1318
1319        $this->allowBlankArticle = $request->getBool( 'wpIgnoreBlankArticle' );
1320        $this->allowSelfRedirect = $request->getBool( 'wpIgnoreSelfRedirect' );
1321
1322        $changeTags = $request->getVal( 'wpChangeTags' );
1323        if ( $changeTags === null || $changeTags === '' ) {
1324            $this->changeTags = [];
1325        } else {
1326            $this->changeTags = array_filter(
1327                array_map(
1328                    'trim',
1329                    explode( ',', $changeTags )
1330                )
1331            );
1332        }
1333    }
1334
1335    /**
1336     * Subpage overridable method for extracting the page content data from the
1337     * posted form to be placed in $this->textbox1, if using customized input
1338     * this method should be overridden and return the page text that will be used
1339     * for saving, preview parsing and so on...
1340     *
1341     * @param WebRequest &$request
1342     * @return string|null
1343     */
1344    protected function importContentFormData( &$request ) {
1345        return null; // Don't do anything, EditPage already extracted wpTextbox1
1346    }
1347
1348    /**
1349     * Initialise form fields in the object
1350     * Called on the first invocation, e.g. when a user clicks an edit link
1351     * @return bool If the requested section is valid
1352     */
1353    private function initialiseForm(): bool {
1354        $this->edittime = $this->page->getTimestamp();
1355        $this->editRevId = $this->page->getLatest();
1356
1357        $dummy = $this->contentHandlerFactory
1358            ->getContentHandler( $this->contentModel )
1359            ->makeEmptyContent();
1360        $content = $this->getContentObject( $dummy ); # TODO: track content object?!
1361        if ( $content === $dummy ) { // Invalid section
1362            $this->noSuchSectionPage();
1363            return false;
1364        }
1365
1366        if ( !$content ) {
1367            $out = $this->context->getOutput();
1368            $this->editFormPageTop .= Html::errorBox(
1369                $out->parseAsInterface( $this->context->msg( 'missing-revision-content',
1370                    $this->oldid,
1371                    Message::plaintextParam( $this->mTitle->getPrefixedText() )
1372                ) )
1373            );
1374        } elseif ( !$this->isSupportedContentModel( $content->getModel() ) ) {
1375            $modelMsg = $this->getContext()->msg( 'content-model-' . $content->getModel() );
1376            $modelName = $modelMsg->exists() ? $modelMsg->text() : $content->getModel();
1377
1378            $out = $this->context->getOutput();
1379            $out->showErrorPage(
1380                'modeleditnotsupported-title',
1381                'modeleditnotsupported-text',
1382                [ $modelName ]
1383            );
1384            return false;
1385        }
1386
1387        $this->textbox1 = $this->toEditText( $content );
1388
1389        $user = $this->context->getUser();
1390        // activate checkboxes if user wants them to be always active
1391        # Sort out the "watch" checkbox
1392        if ( $this->userOptionsLookup->getOption( $user, 'watchdefault' ) ) {
1393            # Watch all edits
1394            $this->watchthis = true;
1395        } elseif ( $this->userOptionsLookup->getOption( $user, 'watchcreations' ) && !$this->mTitle->exists() ) {
1396            # Watch creations
1397            $this->watchthis = true;
1398        } elseif ( $this->watchlistManager->isWatched( $user, $this->mTitle ) ) {
1399            # Already watched
1400            $this->watchthis = true;
1401        }
1402        if ( $this->watchthis && $this->watchlistExpiryEnabled ) {
1403            $watchedItem = $this->watchedItemStore->getWatchedItem( $user, $this->getTitle() );
1404            $this->watchlistExpiry = $watchedItem ? $watchedItem->getExpiry() : null;
1405        }
1406        if ( !$this->isNew && $this->userOptionsLookup->getOption( $user, 'minordefault' ) ) {
1407            $this->minoredit = true;
1408        }
1409        if ( $this->textbox1 === false ) {
1410            return false;
1411        }
1412        return true;
1413    }
1414
1415    /**
1416     * @param Content|null $def_content The default value to return
1417     *
1418     * @return Content|false|null Content on success, $def_content for invalid sections
1419     *
1420     * @since 1.21
1421     */
1422    protected function getContentObject( $def_content = null ) {
1423        $services = MediaWikiServices::getInstance();
1424        $disableAnonTalk = $services->getMainConfig()->get( MainConfigNames::DisableAnonTalk );
1425        $request = $this->context->getRequest();
1426
1427        $content = false;
1428
1429        // For non-existent articles and new sections, use preload text if any.
1430        if ( !$this->mTitle->exists() || $this->section === 'new' ) {
1431            $content = $services->getPreloadedContentBuilder()->getPreloadedContent(
1432                $this->mTitle->toPageIdentity(),
1433                $this->context->getUser(),
1434                $request->getVal( 'preload' ),
1435                $request->getArray( 'preloadparams', [] ),
1436                $request->getVal( 'section' )
1437            );
1438        // For existing pages, get text based on "undo" or section parameters.
1439        } elseif ( $this->section !== '' ) {
1440            // Get section edit text (returns $def_text for invalid sections)
1441            $orig = $this->getOriginalContent( $this->getAuthority() );
1442            $content = $orig ? $orig->getSection( $this->section ) : null;
1443
1444            if ( !$content ) {
1445                $content = $def_content;
1446            }
1447        } else {
1448            $undoafter = $request->getInt( 'undoafter' );
1449            $undo = $request->getInt( 'undo' );
1450
1451            if ( $undo > 0 && $undoafter > 0 ) {
1452                // The use of getRevisionByTitle() is intentional, as allowing access to
1453                // arbitrary revisions on arbitrary pages bypass partial visibility restrictions (T297322).
1454                $undorev = $this->revisionStore->getRevisionByTitle( $this->mTitle, $undo );
1455                $oldrev = $this->revisionStore->getRevisionByTitle( $this->mTitle, $undoafter );
1456                $undoMsg = null;
1457
1458                # Make sure it's the right page,
1459                # the revisions exist and they were not deleted.
1460                # Otherwise, $content will be left as-is.
1461                if ( $undorev !== null && $oldrev !== null &&
1462                    !$undorev->isDeleted( RevisionRecord::DELETED_TEXT ) &&
1463                    !$oldrev->isDeleted( RevisionRecord::DELETED_TEXT )
1464                ) {
1465                    if ( WikiPage::hasDifferencesOutsideMainSlot( $undorev, $oldrev )
1466                        || !$this->isSupportedContentModel(
1467                            $oldrev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel()
1468                        )
1469                    ) {
1470                        // Hack for undo while EditPage can't handle multi-slot editing
1471                        $this->context->getOutput()->redirect( $this->mTitle->getFullURL( [
1472                            'action' => 'mcrundo',
1473                            'undo' => $undo,
1474                            'undoafter' => $undoafter,
1475                        ] ) );
1476                        return false;
1477                    } else {
1478                        $content = $this->getUndoContent( $undorev, $oldrev, $undoMsg );
1479                    }
1480
1481                    if ( $undoMsg === null ) {
1482                        $oldContent = $this->page->getContent( RevisionRecord::RAW );
1483                        $popts = ParserOptions::newFromUserAndLang(
1484                            $this->getUserForPreview(),
1485                            $services->getContentLanguage()
1486                        );
1487                        $contentTransformer = $services->getContentTransformer();
1488                        $newContent = $contentTransformer->preSaveTransform(
1489                            $content, $this->mTitle, $this->getUserForPreview(), $popts
1490                        );
1491
1492                        if ( $newContent->getModel() !== $oldContent->getModel() ) {
1493                            // The undo may change content
1494                            // model if its reverting the top
1495                            // edit. This can result in
1496                            // mismatched content model/format.
1497                            $this->contentModel = $newContent->getModel();
1498                            $oldMainSlot = $oldrev->getSlot(
1499                                SlotRecord::MAIN,
1500                                RevisionRecord::RAW
1501                            );
1502                            $this->contentFormat = $oldMainSlot->getFormat();
1503                            if ( $this->contentFormat === null ) {
1504                                $this->contentFormat = $this->contentHandlerFactory
1505                                    ->getContentHandler( $oldMainSlot->getModel() )
1506                                    ->getDefaultFormat();
1507                            }
1508                        }
1509
1510                        if ( $newContent->equals( $oldContent ) ) {
1511                            # Tell the user that the undo results in no change,
1512                            # i.e. the revisions were already undone.
1513                            $undoMsg = 'nochange';
1514                            $content = false;
1515                        } else {
1516                            # Inform the user of our success and set an automatic edit summary
1517                            $undoMsg = 'success';
1518
1519                            # If we just undid one rev, use an autosummary
1520                            $firstrev = $this->revisionStore->getNextRevision( $oldrev );
1521                            if ( $firstrev && $firstrev->getId() == $undo ) {
1522                                $userText = $undorev->getUser() ?
1523                                    $undorev->getUser()->getName() :
1524                                    '';
1525                                if ( $userText === '' ) {
1526                                    $undoSummary = $this->context->msg(
1527                                        'undo-summary-username-hidden',
1528                                        $undo
1529                                    )->inContentLanguage()->text();
1530                                // Handle external users (imported revisions)
1531                                } elseif ( ExternalUserNames::isExternal( $userText ) ) {
1532                                    $userLinkTitle = ExternalUserNames::getUserLinkTitle( $userText );
1533                                    if ( $userLinkTitle ) {
1534                                        $userLink = $userLinkTitle->getPrefixedText();
1535                                        $undoSummary = $this->context->msg(
1536                                            'undo-summary-import',
1537                                            $undo,
1538                                            $userLink,
1539                                            $userText
1540                                        )->inContentLanguage()->text();
1541                                    } else {
1542                                        $undoSummary = $this->context->msg(
1543                                            'undo-summary-import2',
1544                                            $undo,
1545                                            $userText
1546                                        )->inContentLanguage()->text();
1547                                    }
1548                                } else {
1549                                    $undoIsAnon =
1550                                        !$undorev->getUser() ||
1551                                        !$undorev->getUser()->isRegistered();
1552                                    $undoMessage = ( $undoIsAnon && $disableAnonTalk ) ?
1553                                        'undo-summary-anon' :
1554                                        'undo-summary';
1555                                    $undoSummary = $this->context->msg(
1556                                        $undoMessage,
1557                                        $undo,
1558                                        $userText
1559                                    )->inContentLanguage()->text();
1560                                }
1561                                if ( $this->summary === '' ) {
1562                                    $this->summary = $undoSummary;
1563                                } else {
1564                                    $this->summary = $undoSummary . $this->context->msg( 'colon-separator' )
1565                                        ->inContentLanguage()->text() . $this->summary;
1566                                }
1567                            }
1568                            $this->undidRev = $undo;
1569                            $this->undoAfter = $undoafter;
1570                            $this->formtype = 'diff';
1571                        }
1572                    }
1573                } else {
1574                    // Failed basic checks.
1575                    // Older revisions may have been removed since the link
1576                    // was created, or we may simply have got bogus input.
1577                    $undoMsg = 'norev';
1578                }
1579
1580                $out = $this->context->getOutput();
1581                // Messages: undo-success, undo-failure, undo-main-slot-only, undo-norev,
1582                // undo-nochange.
1583                $class = ( $undoMsg === 'success' ? '' : 'error ' ) . "mw-undo-{$undoMsg}";
1584                $this->editFormPageTop .= Html::rawElement(
1585                    'div',
1586                    [ 'class' => $class ],
1587                    $out->parseAsInterface(
1588                        $this->context->msg( 'undo-' . $undoMsg )->plain()
1589                    )
1590                );
1591            }
1592
1593            if ( $content === false ) {
1594                $content = $this->getOriginalContent( $this->getAuthority() );
1595            }
1596        }
1597
1598        return $content;
1599    }
1600
1601    /**
1602     * Returns the result of a three-way merge when undoing changes.
1603     *
1604     * @param RevisionRecord $undoRev Newest revision being undone. Corresponds to `undo`
1605     *        URL parameter.
1606     * @param RevisionRecord $oldRev Revision that is being restored. Corresponds to
1607     *        `undoafter` URL parameter.
1608     * @param ?string &$error If false is returned, this will be set to "norev"
1609     *   if the revision failed to load, or "failure" if the content handler
1610     *   failed to merge the required changes.
1611     *
1612     * @return Content|false
1613     */
1614    private function getUndoContent( RevisionRecord $undoRev, RevisionRecord $oldRev, &$error ) {
1615        $handler = $this->contentHandlerFactory
1616            ->getContentHandler( $undoRev->getSlot(
1617                SlotRecord::MAIN,
1618                RevisionRecord::RAW
1619            )->getModel() );
1620        $currentContent = $this->page->getRevisionRecord()
1621            ->getContent( SlotRecord::MAIN );
1622        $undoContent = $undoRev->getContent( SlotRecord::MAIN );
1623        $undoAfterContent = $oldRev->getContent( SlotRecord::MAIN );
1624        $undoIsLatest = $this->page->getRevisionRecord()->getId() === $undoRev->getId();
1625        if ( $currentContent === null
1626            || $undoContent === null
1627            || $undoAfterContent === null
1628        ) {
1629            $error = 'norev';
1630            return false;
1631        }
1632
1633        $content = $handler->getUndoContent(
1634            $currentContent,
1635            $undoContent,
1636            $undoAfterContent,
1637            $undoIsLatest
1638        );
1639        if ( $content === false ) {
1640            $error = 'failure';
1641        }
1642        return $content;
1643    }
1644
1645    /**
1646     * Get the content of the wanted revision, without section extraction.
1647     *
1648     * The result of this function can be used to compare user's input with
1649     * section replaced in its context (using WikiPage::replaceSectionAtRev())
1650     * to the original text of the edit.
1651     *
1652     * This differs from Article::getContent() that when a missing revision is
1653     * encountered the result will be null and not the
1654     * 'missing-revision' message.
1655     *
1656     * @param Authority $performer to get the revision for
1657     * @return Content|null
1658     */
1659    private function getOriginalContent( Authority $performer ): ?Content {
1660        if ( $this->section === 'new' ) {
1661            return $this->getCurrentContent();
1662        }
1663        $revRecord = $this->mArticle->fetchRevisionRecord();
1664        if ( $revRecord === null ) {
1665            return $this->contentHandlerFactory
1666                ->getContentHandler( $this->contentModel )
1667                ->makeEmptyContent();
1668        }
1669        return $revRecord->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $performer );
1670    }
1671
1672    /**
1673     * Get the edit's parent revision ID
1674     *
1675     * The "parent" revision is the ancestor that should be recorded in this
1676     * page's revision history.  It is either the revision ID of the in-memory
1677     * article content, or in the case of a 3-way merge in order to rebase
1678     * across a recoverable edit conflict, the ID of the newer revision to
1679     * which we have rebased this page.
1680     *
1681     * @return int Revision ID
1682     */
1683    private function getParentRevId() {
1684        if ( $this->parentRevId ) {
1685            return $this->parentRevId;
1686        } else {
1687            return $this->mArticle->getRevIdFetched();
1688        }
1689    }
1690
1691    /**
1692     * Get the current content of the page. This is basically similar to
1693     * WikiPage::getContent( RevisionRecord::RAW ) except that when the page doesn't
1694     * exist an empty content object is returned instead of null.
1695     *
1696     * @since 1.21
1697     * @return Content
1698     */
1699    protected function getCurrentContent() {
1700        $revRecord = $this->page->getRevisionRecord();
1701        $content = $revRecord ? $revRecord->getContent(
1702            SlotRecord::MAIN,
1703            RevisionRecord::RAW
1704        ) : null;
1705
1706        if ( $content === null ) {
1707            return $this->contentHandlerFactory
1708                ->getContentHandler( $this->contentModel )
1709                ->makeEmptyContent();
1710        }
1711
1712        return $content;
1713    }
1714
1715    /**
1716     * Make sure the form isn't faking a user's credentials.
1717     *
1718     * @param WebRequest $request
1719     * @return bool
1720     */
1721    private function tokenOk( WebRequest $request ): bool {
1722        $token = $request->getVal( 'wpEditToken' );
1723        $user = $this->context->getUser();
1724        $this->mTokenOk = $user->matchEditToken( $token );
1725        return $this->mTokenOk;
1726    }
1727
1728    /**
1729     * Sets post-edit cookie indicating the user just saved a particular revision.
1730     *
1731     * This uses a temporary cookie for each revision ID so separate saves will never
1732     * interfere with each other.
1733     *
1734     * Article::view deletes the cookie on server-side after the redirect and
1735     * converts the value to the global JavaScript variable wgPostEdit.
1736     *
1737     * If the variable were set on the server, it would be cached, which is unwanted
1738     * since the post-edit state should only apply to the load right after the save.
1739     *
1740     * @param int $statusValue The status value (to check for new article status)
1741     */
1742    private function setPostEditCookie( int $statusValue ): void {
1743        $revisionId = $this->page->getLatest();
1744        $postEditKey = self::POST_EDIT_COOKIE_KEY_PREFIX . $revisionId;
1745
1746        $val = 'saved';
1747        if ( $statusValue === self::AS_SUCCESS_NEW_ARTICLE ) {
1748            $val = 'created';
1749        } elseif ( $this->oldid ) {
1750            $val = 'restored';
1751        }
1752        if ( $this->tempUserCreateDone ) {
1753            $val .= '+tempuser';
1754        }
1755
1756        $response = $this->context->getRequest()->response();
1757        $response->setCookie( $postEditKey, $val, time() + self::POST_EDIT_COOKIE_DURATION );
1758    }
1759
1760    /**
1761     * Attempt submission
1762     * @param array|false &$resultDetails See docs for $result in internalAttemptSave @phan-output-reference
1763     * @throws UserBlockedError|ReadOnlyError|ThrottledError|PermissionsError
1764     * @return Status
1765     */
1766    public function attemptSave( &$resultDetails = false ) {
1767        // Allow bots to exempt some edits from bot flagging
1768        $markAsBot = $this->markAsBot
1769            && $this->getAuthority()->isAllowed( 'bot' );
1770
1771        // Allow trusted users to mark some edits as minor
1772        $markAsMinor = $this->minoredit && !$this->isNew
1773            && $this->getAuthority()->isAllowed( 'minoredit' );
1774
1775        $status = $this->internalAttemptSave( $resultDetails, $markAsBot, $markAsMinor );
1776
1777        $this->getHookRunner()->onEditPage__attemptSave_after( $this, $status, $resultDetails );
1778
1779        return $status;
1780    }
1781
1782    /**
1783     * Log when a page was successfully saved after the edit conflict view
1784     */
1785    private function incrementResolvedConflicts(): void {
1786        if ( $this->context->getRequest()->getText( 'mode' ) !== 'conflict' ) {
1787            return;
1788        }
1789
1790        $this->getEditConflictHelper()->incrementResolvedStats( $this->context->getUser() );
1791    }
1792
1793    /**
1794     * Handle status, such as after attempt save
1795     *
1796     * @param Status $status
1797     * @param array|false $resultDetails
1798     *
1799     * @throws ErrorPageError
1800     * @return bool False, if output is done, true if rest of the form should be displayed
1801     */
1802    private function handleStatus( Status $status, $resultDetails ): bool {
1803        $statusValue = is_int( $status->value ) ? $status->value : 0;
1804
1805        /**
1806         * @todo FIXME: once the interface for internalAttemptSave() is made
1807         *   nicer, this should use the message in $status
1808         */
1809        if ( $statusValue === self::AS_SUCCESS_UPDATE
1810            || $statusValue === self::AS_SUCCESS_NEW_ARTICLE
1811        ) {
1812            $this->incrementResolvedConflicts();
1813
1814            $this->didSave = true;
1815            if ( !$resultDetails['nullEdit'] ) {
1816                $this->setPostEditCookie( $statusValue );
1817            }
1818        }
1819
1820        $out = $this->context->getOutput();
1821
1822        // "wpExtraQueryRedirect" is a hidden input to modify
1823        // after save URL and is not used by actual edit form
1824        $request = $this->context->getRequest();
1825        $extraQueryRedirect = $request->getVal( 'wpExtraQueryRedirect' );
1826
1827        switch ( $statusValue ) {
1828            case self::AS_HOOK_ERROR_EXPECTED:
1829            case self::AS_CONTENT_TOO_BIG:
1830            case self::AS_ARTICLE_WAS_DELETED:
1831            case self::AS_CONFLICT_DETECTED:
1832            case self::AS_SUMMARY_NEEDED:
1833            case self::AS_TEXTBOX_EMPTY:
1834            case self::AS_MAX_ARTICLE_SIZE_EXCEEDED:
1835            case self::AS_END:
1836            case self::AS_BLANK_ARTICLE:
1837            case self::AS_SELF_REDIRECT:
1838            case self::AS_REVISION_WAS_DELETED:
1839                return true;
1840
1841            case self::AS_HOOK_ERROR:
1842                return false;
1843
1844            case self::AS_PARSE_ERROR:
1845            case self::AS_UNICODE_NOT_SUPPORTED:
1846            case self::AS_UNABLE_TO_ACQUIRE_TEMP_ACCOUNT:
1847                $out->wrapWikiTextAsInterface( 'error',
1848                    $status->getWikiText( false, false, $this->context->getLanguage() )
1849                );
1850                return true;
1851
1852            case self::AS_SUCCESS_NEW_ARTICLE:
1853                $queryParts = [];
1854                if ( $resultDetails['redirect'] ) {
1855                    $queryParts[] = 'redirect=no';
1856                }
1857                if ( $extraQueryRedirect ) {
1858                    $queryParts[] = $extraQueryRedirect;
1859                }
1860                $anchor = $resultDetails['sectionanchor'] ?? '';
1861                $this->doPostEditRedirect( implode( '&', $queryParts ), $anchor );
1862                return false;
1863
1864            case self::AS_SUCCESS_UPDATE:
1865                $extraQuery = '';
1866                $sectionanchor = $resultDetails['sectionanchor'];
1867                // Give extensions a chance to modify URL query on update
1868                $this->getHookRunner()->onArticleUpdateBeforeRedirect( $this->mArticle,
1869                    $sectionanchor, $extraQuery );
1870
1871                $queryParts = [];
1872                if ( $resultDetails['redirect'] ) {
1873                    $queryParts[] = 'redirect=no';
1874                }
1875                if ( $extraQuery ) {
1876                    $queryParts[] = $extraQuery;
1877                }
1878                if ( $extraQueryRedirect ) {
1879                    $queryParts[] = $extraQueryRedirect;
1880                }
1881                $this->doPostEditRedirect( implode( '&', $queryParts ), $sectionanchor );
1882                return false;
1883
1884            case self::AS_SPAM_ERROR:
1885                $this->spamPageWithContent( $resultDetails['spam'] ?? false );
1886                return false;
1887
1888            case self::AS_BLOCKED_PAGE_FOR_USER:
1889                throw new UserBlockedError(
1890                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
1891                    $this->context->getUser()->getBlock(),
1892                    $this->context->getUser(),
1893                    $this->context->getLanguage(),
1894                    $request->getIP()
1895                );
1896
1897            case self::AS_IMAGE_REDIRECT_ANON:
1898            case self::AS_IMAGE_REDIRECT_LOGGED:
1899                throw new PermissionsError( 'upload' );
1900
1901            case self::AS_READ_ONLY_PAGE_ANON:
1902            case self::AS_READ_ONLY_PAGE_LOGGED:
1903                throw new PermissionsError( 'edit' );
1904
1905            case self::AS_READ_ONLY_PAGE:
1906                throw new ReadOnlyError;
1907
1908            case self::AS_RATE_LIMITED:
1909                $out->wrapWikiTextAsInterface( 'error',
1910                    wfMessage( 'actionthrottledtext' )->plain()
1911                );
1912                return true;
1913
1914            case self::AS_NO_CREATE_PERMISSION:
1915                $permission = $this->mTitle->isTalkPage() ? 'createtalk' : 'createpage';
1916                throw new PermissionsError( $permission );
1917
1918            case self::AS_NO_CHANGE_CONTENT_MODEL:
1919                throw new PermissionsError( 'editcontentmodel' );
1920
1921            default:
1922                // We don't recognize $statusValue. The only way that can happen
1923                // is if an extension hook aborted from inside ArticleSave.
1924                // Render the status object into $this->hookError
1925                // FIXME this sucks, we should just use the Status object throughout
1926                $this->hookError = Html::errorBox(
1927                    "\n" . $status->getWikiText( false, false, $this->context->getLanguage() )
1928                );
1929                return true;
1930        }
1931    }
1932
1933    /**
1934     * Emit the post-save redirect. The URL is modifiable with a hook.
1935     *
1936     * @param string $query
1937     * @param string $anchor
1938     * @return void
1939     */
1940    private function doPostEditRedirect( $query, $anchor ) {
1941        $out = $this->context->getOutput();
1942        $url = $this->mTitle->getFullURL( $query ) . $anchor;
1943        $user = $this->getUserForSave();
1944        // If the temporary account was created in this request,
1945        // or if the temporary account has zero edits (implying
1946        // that the account was created during a failed edit
1947        // attempt in a previous request), perform the top-level
1948        // redirect to ensure the account is attached.
1949        // Note that the temp user could already have performed
1950        // the top-level redirect if this a first edit on
1951        // a wiki that is not the user's home wiki.
1952        $shouldRedirectForTempUser = $this->tempUserCreateDone ||
1953            $user->isTemp() && $user->getEditCount() === 0;
1954        if ( $shouldRedirectForTempUser ) {
1955            $this->getHookRunner()->onTempUserCreatedRedirect(
1956                $this->context->getRequest()->getSession(),
1957                $user,
1958                $this->mTitle->getPrefixedDBkey(),
1959                $query,
1960                $anchor,
1961                $url
1962            );
1963        }
1964        $out->redirect( $url );
1965    }
1966
1967    /**
1968     * Set the edit summary and link anchor to be used for a new section.
1969     */
1970    private function setNewSectionSummary(): void {
1971        Assert::precondition( $this->section === 'new', 'This method can only be called for new sections' );
1972        Assert::precondition( $this->sectiontitle !== null, 'This method can only be called for new sections' );
1973
1974        $services = MediaWikiServices::getInstance();
1975        $parser = $services->getParser();
1976        $textFormatter = $services->getMessageFormatterFactory()->getTextFormatter(
1977            $services->getContentLanguage()->getCode()
1978        );
1979
1980        if ( $this->sectiontitle !== '' ) {
1981            $this->newSectionAnchor = $this->guessSectionName( $this->sectiontitle );
1982            // If no edit summary was specified, create one automatically from the section
1983            // title and have it link to the new section. Otherwise, respect the summary as
1984            // passed.
1985            if ( $this->summary === '' ) {
1986                $messageValue = MessageValue::new( 'newsectionsummary' )
1987                    ->plaintextParams( $parser->stripSectionName( $this->sectiontitle ) );
1988                $this->summary = $textFormatter->format( $messageValue );
1989            }
1990        } else {
1991            $this->newSectionAnchor = '';
1992        }
1993    }
1994
1995    /**
1996     * Attempt submission (no UI)
1997     *
1998     * @param array &$result Array to add statuses to, currently with the
1999     *   possible keys:
2000     *   - spam (string): Spam string from content if any spam is detected by
2001     *     matchSpamRegex.
2002     *   - sectionanchor (string): Section anchor for a section save.
2003     *   - nullEdit (bool): Set if doUserEditContent is OK.  True if null edit,
2004     *     false otherwise.
2005     *   - redirect (bool): Set if doUserEditContent is OK. True if resulting
2006     *     revision is a redirect.
2007     * @param bool $markAsBot True if edit is being made under the bot right
2008     *     and the bot wishes the edit to be marked as such.
2009     * @param bool $markAsMinor True if edit should be marked as minor.
2010     *
2011     * @return Status Status object, possibly with a message, but always with
2012     *   one of the AS_* constants in $status->value,
2013     *
2014     * @todo FIXME: This interface is TERRIBLE, but hard to get rid of due to
2015     *   various error display idiosyncrasies. There are also lots of cases
2016     *   where error metadata is set in the object and retrieved later instead
2017     *   of being returned, e.g. AS_CONTENT_TOO_BIG and
2018     *   AS_BLOCKED_PAGE_FOR_USER. All that stuff needs to be cleaned up some
2019     * time.
2020     */
2021    public function internalAttemptSave( &$result, $markAsBot = false, $markAsMinor = false ) {
2022        // If an attempt to acquire a temporary name failed, don't attempt to do anything else.
2023        if ( $this->unableToAcquireTempName ) {
2024            $status = Status::newFatal( 'temp-user-unable-to-acquire' );
2025            $status->value = self::AS_UNABLE_TO_ACQUIRE_TEMP_ACCOUNT;
2026            return $status;
2027        }
2028        // Auto-create the temporary account user, if the feature is enabled.
2029        // We create the account before any constraint checks or edit hooks fire, to ensure
2030        // that we have an actor and user account that can be used for any logs generated
2031        // by the edit attempt, and to ensure continuity in the user experience (if a constraint
2032        // denies an edit to a logged-out user, that history should be associated with the
2033        // eventually successful account creation)
2034        $tempAccountStatus = $this->createTempUser();
2035        if ( !$tempAccountStatus->isOK() ) {
2036            return $tempAccountStatus;
2037        }
2038        if ( $tempAccountStatus instanceof CreateStatus ) {
2039            $result['savedTempUser'] = $tempAccountStatus->getUser();
2040        }
2041
2042        $useNPPatrol = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::UseNPPatrol );
2043        $useRCPatrol = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::UseRCPatrol );
2044        if ( !$this->getHookRunner()->onEditPage__attemptSave( $this ) ) {
2045            wfDebug( "Hook 'EditPage::attemptSave' aborted article saving" );
2046            $status = Status::newFatal( 'hookaborted' );
2047            $status->value = self::AS_HOOK_ERROR;
2048            return $status;
2049        }
2050
2051        if ( !$this->getHookRunner()->onEditFilter( $this, $this->textbox1, $this->section,
2052            $this->hookError, $this->summary )
2053        ) {
2054            # Error messages etc. could be handled within the hook...
2055            $status = Status::newFatal( 'hookaborted' );
2056            $status->value = self::AS_HOOK_ERROR;
2057            return $status;
2058        } elseif ( $this->hookError ) {
2059            # ...or the hook could be expecting us to produce an error
2060            $status = Status::newFatal( 'hookaborted' );
2061            $status->value = self::AS_HOOK_ERROR_EXPECTED;
2062            return $status;
2063        }
2064
2065        try {
2066            # Construct Content object
2067            $textbox_content = $this->toEditContent( $this->textbox1 );
2068        } catch ( MWContentSerializationException $ex ) {
2069            $status = Status::newFatal(
2070                'content-failed-to-parse',
2071                $this->contentModel,
2072                $this->contentFormat,
2073                $ex->getMessage()
2074            );
2075            $status->value = self::AS_PARSE_ERROR;
2076            return $status;
2077        }
2078
2079        $this->contentLength = strlen( $this->textbox1 );
2080
2081        $requestUser = $this->context->getUser();
2082        $authority = $this->getAuthority();
2083        $pstUser = $this->getUserForPreview();
2084
2085        $changingContentModel = false;
2086        if ( $this->contentModel !== $this->mTitle->getContentModel() ) {
2087            $changingContentModel = true;
2088            $oldContentModel = $this->mTitle->getContentModel();
2089        }
2090
2091        // BEGINNING OF MIGRATION TO EDITCONSTRAINT SYSTEM (see T157658)
2092        /** @var EditConstraintFactory $constraintFactory */
2093        $constraintFactory = MediaWikiServices::getInstance()->getService( '_EditConstraintFactory' );
2094        $constraintRunner = new EditConstraintRunner();
2095
2096        // UnicodeConstraint: ensure that `$this->unicodeCheck` is the correct unicode
2097        $constraintRunner->addConstraint(
2098            new UnicodeConstraint( $this->unicodeCheck )
2099        );
2100
2101        // SimpleAntiSpamConstraint: ensure that the context request does not have
2102        // `wpAntispam` set
2103        // Use $user since there is no permissions aspect
2104        $constraintRunner->addConstraint(
2105            $constraintFactory->newSimpleAntiSpamConstraint(
2106                $this->context->getRequest()->getText( 'wpAntispam' ),
2107                $requestUser,
2108                $this->mTitle
2109            )
2110        );
2111
2112        // SpamRegexConstraint: ensure that the summary and text don't match the spam regex
2113        $constraintRunner->addConstraint(
2114            $constraintFactory->newSpamRegexConstraint(
2115                $this->summary,
2116                $this->sectiontitle,
2117                $this->textbox1,
2118                $this->context->getRequest()->getIP(),
2119                $this->mTitle
2120            )
2121        );
2122        $constraintRunner->addConstraint(
2123            new ImageRedirectConstraint(
2124                $textbox_content,
2125                $this->mTitle,
2126                $authority
2127            )
2128        );
2129        $constraintRunner->addConstraint(
2130            $constraintFactory->newUserBlockConstraint( $this->mTitle, $requestUser )
2131        );
2132        $constraintRunner->addConstraint(
2133            new ContentModelChangeConstraint(
2134                $authority,
2135                $this->mTitle,
2136                $this->contentModel
2137            )
2138        );
2139
2140        $constraintRunner->addConstraint(
2141            $constraintFactory->newReadOnlyConstraint()
2142        );
2143        $constraintRunner->addConstraint(
2144            $constraintFactory->newUserRateLimitConstraint(
2145                $requestUser->toRateLimitSubject(),
2146                $this->mTitle->getContentModel(),
2147                $this->contentModel
2148            )
2149        );
2150        $constraintRunner->addConstraint(
2151            // Same constraint is used to check size before and after merging the
2152            // edits, which use different failure codes
2153            $constraintFactory->newPageSizeConstraint(
2154                $this->contentLength,
2155                PageSizeConstraint::BEFORE_MERGE
2156            )
2157        );
2158        $constraintRunner->addConstraint(
2159            new ChangeTagsConstraint( $authority, $this->changeTags )
2160        );
2161
2162        // If the article has been deleted while editing, don't save it without
2163        // confirmation
2164        $constraintRunner->addConstraint(
2165            new AccidentalRecreationConstraint(
2166                $this->wasDeletedSinceLastEdit(),
2167                $this->recreate
2168            )
2169        );
2170
2171        // Load the page data from the primary DB. If anything changes in the meantime,
2172        // we detect it by using page_latest like a token in a 1 try compare-and-swap.
2173        $this->page->loadPageData( IDBAccessObject::READ_LATEST );
2174        $new = !$this->page->exists();
2175
2176        // We do this last, as some of the other constraints are more specific
2177        $constraintRunner->addConstraint(
2178            $constraintFactory->newEditRightConstraint( $this->getUserForPermissions(), $this->mTitle, $new )
2179        );
2180
2181        // Check the constraints
2182        if ( !$constraintRunner->checkConstraints() ) {
2183            $failed = $constraintRunner->getFailedConstraint();
2184
2185            // Need to check SpamRegexConstraint here, to avoid needing to pass
2186            // $result by reference again
2187            if ( $failed instanceof SpamRegexConstraint ) {
2188                $result['spam'] = $failed->getMatch();
2189            } else {
2190                $this->handleFailedConstraint( $failed );
2191            }
2192
2193            return Status::wrap( $failed->getLegacyStatus() );
2194        }
2195        // END OF MIGRATION TO EDITCONSTRAINT SYSTEM (continued below)
2196
2197        $flags = EDIT_AUTOSUMMARY |
2198            ( $new ? EDIT_NEW : EDIT_UPDATE ) |
2199            ( $markAsMinor ? EDIT_MINOR : 0 ) |
2200            ( $markAsBot ? EDIT_FORCE_BOT : 0 );
2201
2202        if ( $new ) {
2203            $content = $textbox_content;
2204
2205            $result['sectionanchor'] = '';
2206            if ( $this->section === 'new' ) {
2207                if ( $this->sectiontitle !== null ) {
2208                    // Insert the section title above the content.
2209                    $content = $content->addSectionHeader( $this->sectiontitle );
2210                }
2211                $result['sectionanchor'] = $this->newSectionAnchor;
2212            }
2213
2214            $pageUpdater = $this->page->newPageUpdater( $pstUser )
2215                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
2216                ->setContent( SlotRecord::MAIN, $content );
2217            $pageUpdater->prepareUpdate( $flags );
2218
2219            // BEGINNING OF MIGRATION TO EDITCONSTRAINT SYSTEM (see T157658)
2220            // Create a new runner to avoid rechecking the prior constraints, use the same factory
2221            $constraintRunner = new EditConstraintRunner();
2222
2223            // Don't save a new page if it's blank or if it's a MediaWiki:
2224            // message with content equivalent to default (allow empty pages
2225            // in this case to disable messages, see T52124)
2226            $constraintRunner->addConstraint(
2227                new DefaultTextConstraint(
2228                    $this->mTitle,
2229                    $this->allowBlankArticle,
2230                    $this->textbox1
2231                )
2232            );
2233
2234            $constraintRunner->addConstraint(
2235                $constraintFactory->newEditFilterMergedContentHookConstraint(
2236                    $content,
2237                    $this->context,
2238                    $this->summary,
2239                    $markAsMinor,
2240                    $this->context->getLanguage(),
2241                    $pstUser
2242                )
2243            );
2244
2245            // Check the constraints
2246            if ( !$constraintRunner->checkConstraints() ) {
2247                $failed = $constraintRunner->getFailedConstraint();
2248                $this->handleFailedConstraint( $failed );
2249                return Status::wrap( $failed->getLegacyStatus() );
2250            }
2251            // END OF MIGRATION TO EDITCONSTRAINT SYSTEM (continued below)
2252        } else { # not $new
2253
2254            # Article exists. Check for edit conflict.
2255
2256            $timestamp = $this->page->getTimestamp();
2257            $latest = $this->page->getLatest();
2258
2259            wfDebug( "timestamp: {$timestamp}, edittime: {$this->edittime}" );
2260            wfDebug( "revision: {$latest}, editRevId: {$this->editRevId}" );
2261
2262            $editConflictLogger = LoggerFactory::getInstance( 'EditConflict' );
2263            // An edit conflict is detected if the current revision is different from the
2264            // revision that was current when editing was initiated on the client.
2265            // This is checked based on the timestamp and revision ID.
2266            // TODO: the timestamp based check can probably go away now.
2267            if ( ( $this->edittime !== null && $this->edittime != $timestamp )
2268                || ( $this->editRevId !== null && $this->editRevId != $latest )
2269            ) {
2270                $this->isConflict = true;
2271                if ( $this->section === 'new' ) {
2272                    if ( $this->page->getUserText() === $requestUser->getName() &&
2273                        $this->page->getComment() === $this->summary
2274                    ) {
2275                        // Probably a duplicate submission of a new comment.
2276                        // This can happen when CDN resends a request after
2277                        // a timeout but the first one actually went through.
2278                        $editConflictLogger->debug(
2279                            'Duplicate new section submission; trigger edit conflict!'
2280                        );
2281                    } else {
2282                        // New comment; suppress conflict.
2283                        $this->isConflict = false;
2284                        $editConflictLogger->debug( 'Conflict suppressed; new section' );
2285                    }
2286                } elseif ( $this->section === ''
2287                    && $this->edittime
2288                    && $this->revisionStore->userWasLastToEdit(
2289                        $this->connectionProvider->getPrimaryDatabase(),
2290                        $this->mTitle->getArticleID(),
2291                        $requestUser->getId(),
2292                        $this->edittime
2293                    )
2294                ) {
2295                    # Suppress edit conflict with self, except for section edits where merging is required.
2296                    $editConflictLogger->debug( 'Suppressing edit conflict, same user.' );
2297                    $this->isConflict = false;
2298                }
2299            }
2300
2301            if ( $this->isConflict ) {
2302                $editConflictLogger->debug(
2303                    'Conflict! Getting section {section} for time {editTime}'
2304                    . ' (id {editRevId}, article time {timestamp})',
2305                    [
2306                        'section' => $this->section,
2307                        'editTime' => $this->edittime,
2308                        'editRevId' => $this->editRevId,
2309                        'timestamp' => $timestamp,
2310                    ]
2311                );
2312                // @TODO: replaceSectionAtRev() with base ID (not prior current) for ?oldid=X case
2313                // ...or disable section editing for non-current revisions (not exposed anyway).
2314                if ( $this->editRevId !== null ) {
2315                    $content = $this->page->replaceSectionAtRev(
2316                        $this->section,
2317                        $textbox_content,
2318                        $this->sectiontitle,
2319                        $this->editRevId
2320                    );
2321                } else {
2322                    $content = $this->page->replaceSectionContent(
2323                        $this->section,
2324                        $textbox_content,
2325                        $this->sectiontitle,
2326                        $this->edittime
2327                    );
2328                }
2329            } else {
2330                $editConflictLogger->debug(
2331                    'Getting section {section}',
2332                    [ 'section' => $this->section ]
2333                );
2334                $content = $this->page->replaceSectionAtRev(
2335                    $this->section,
2336                    $textbox_content,
2337                    $this->sectiontitle
2338                );
2339            }
2340
2341            if ( $content === null ) {
2342                $editConflictLogger->debug( 'Activating conflict; section replace failed.' );
2343                $this->isConflict = true;
2344                $content = $textbox_content; // do not try to merge here!
2345            } elseif ( $this->isConflict ) {
2346                // Attempt merge
2347                $mergedChange = $this->mergeChangesIntoContent( $content );
2348                if ( $mergedChange !== false ) {
2349                    // Successful merge! Maybe we should tell the user the good news?
2350                    $content = $mergedChange[0];
2351                    $this->parentRevId = $mergedChange[1];
2352                    $this->isConflict = false;
2353                    $editConflictLogger->debug( 'Suppressing edit conflict, successful merge.' );
2354                } else {
2355                    $this->section = '';
2356                    $this->textbox1 = ( $content instanceof TextContent ) ? $content->getText() : '';
2357                    $editConflictLogger->debug( 'Keeping edit conflict, failed merge.' );
2358                }
2359            }
2360
2361            if ( $this->isConflict ) {
2362                return Status::newGood( self::AS_CONFLICT_DETECTED )->setOK( false );
2363            }
2364
2365            $pageUpdater = $this->page->newPageUpdater( $pstUser )
2366                ->setContent( SlotRecord::MAIN, $content );
2367            $pageUpdater->prepareUpdate( $flags );
2368
2369            // BEGINNING OF MIGRATION TO EDITCONSTRAINT SYSTEM (see T157658)
2370            // Create a new runner to avoid rechecking the prior constraints, use the same factory
2371            $constraintRunner = new EditConstraintRunner();
2372            $constraintRunner->addConstraint(
2373                $constraintFactory->newEditFilterMergedContentHookConstraint(
2374                    $content,
2375                    $this->context,
2376                    $this->summary,
2377                    $markAsMinor,
2378                    $this->context->getLanguage(),
2379                    $pstUser
2380                )
2381            );
2382
2383            if ( $this->section === 'new' ) {
2384                $constraintRunner->addConstraint(
2385                    new NewSectionMissingSubjectConstraint(
2386                        $this->sectiontitle,
2387                        $this->allowBlankSummary
2388                    )
2389                );
2390                $constraintRunner->addConstraint(
2391                    new MissingCommentConstraint( $this->textbox1 )
2392                );
2393            } else {
2394                $originalContent = $this->getOriginalContent( $authority );
2395
2396                if ( $originalContent === null ) {
2397                    // T301947: User loses access to revision after loading
2398                    // The error message, rev-deleted-text-permission, is not really in use currently.
2399                    // It's added for completeness and in case any code path wants to know the error.
2400                    $status = Status::newFatal( 'rev-deleted-text-permission' );
2401                    $status->value = self::AS_REVISION_WAS_DELETED;
2402                    return $status;
2403                }
2404
2405                $constraintRunner->addConstraint(
2406                    new AutoSummaryMissingSummaryConstraint(
2407                        $this->summary,
2408                        $this->autoSumm,
2409                        $this->allowBlankSummary,
2410                        $content,
2411                        $originalContent
2412                    )
2413                );
2414            }
2415            // Check the constraints
2416            if ( !$constraintRunner->checkConstraints() ) {
2417                $failed = $constraintRunner->getFailedConstraint();
2418                $this->handleFailedConstraint( $failed );
2419                return Status::wrap( $failed->getLegacyStatus() );
2420            }
2421            // END OF MIGRATION TO EDITCONSTRAINT SYSTEM (continued below)
2422
2423            # All's well
2424            $sectionAnchor = '';
2425            if ( $this->section === 'new' ) {
2426                $sectionAnchor = $this->newSectionAnchor;
2427            } elseif ( $this->section !== '' ) {
2428                # Try to get a section anchor from the section source, redirect
2429                # to edited section if header found.
2430                # XXX: Might be better to integrate this into WikiPage::replaceSectionAtRev
2431                # for duplicate heading checking and maybe parsing.
2432                $hasmatch = preg_match( "/^ *([=]{1,6})(.*?)(\\1) *\\n/i", $this->textbox1, $matches );
2433                # We can't deal with anchors, includes, html etc in the header for now,
2434                # headline would need to be parsed to improve this.
2435                if ( $hasmatch && $matches[2] !== '' ) {
2436                    $sectionAnchor = $this->guessSectionName( $matches[2] );
2437                }
2438            }
2439            $result['sectionanchor'] = $sectionAnchor;
2440
2441            // Save errors may fall down to the edit form, but we've now
2442            // merged the section into full text. Clear the section field
2443            // so that later submission of conflict forms won't try to
2444            // replace that into a duplicated mess.
2445            $this->textbox1 = $this->toEditText( $content );
2446            $this->section = '';
2447        }
2448
2449        // Check for length errors again now that the section is merged in
2450        $this->contentLength = strlen( $this->toEditText( $content ) );
2451
2452        // BEGINNING OF MIGRATION TO EDITCONSTRAINT SYSTEM (see T157658)
2453        // Create a new runner to avoid rechecking the prior constraints, use the same factory
2454        $constraintRunner = new EditConstraintRunner();
2455        $constraintRunner->addConstraint(
2456            new SelfRedirectConstraint(
2457                $this->allowSelfRedirect,
2458                $content,
2459                $this->getCurrentContent(),
2460                $this->getTitle()
2461            )
2462        );
2463        $constraintRunner->addConstraint(
2464            // Same constraint is used to check size before and after merging the
2465            // edits, which use different failure codes
2466            $constraintFactory->newPageSizeConstraint(
2467                $this->contentLength,
2468                PageSizeConstraint::AFTER_MERGE
2469            )
2470        );
2471        // Check the constraints
2472        if ( !$constraintRunner->checkConstraints() ) {
2473            $failed = $constraintRunner->getFailedConstraint();
2474            $this->handleFailedConstraint( $failed );
2475            return Status::wrap( $failed->getLegacyStatus() );
2476        }
2477        // END OF MIGRATION TO EDITCONSTRAINT SYSTEM
2478
2479        if ( $this->undidRev && $this->isUndoClean( $content ) ) {
2480            // As the user can change the edit's content before saving, we only mark
2481            // "clean" undos as reverts. This is to avoid abuse by marking irrelevant
2482            // edits as undos.
2483            $pageUpdater
2484                ->setOriginalRevisionId( $this->undoAfter ?: false )
2485                ->markAsRevert(
2486                    EditResult::REVERT_UNDO,
2487                    $this->undidRev,
2488                    $this->undoAfter ?: null
2489                );
2490        }
2491
2492        $needsPatrol = $useRCPatrol || ( $useNPPatrol && !$this->page->exists() );
2493        if ( $needsPatrol && $authority->authorizeWrite( 'autopatrol', $this->getTitle() ) ) {
2494            $pageUpdater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
2495        }
2496
2497        $pageUpdater
2498            ->addTags( $this->changeTags )
2499            ->saveRevision(
2500                CommentStoreComment::newUnsavedComment( trim( $this->summary ) ),
2501                $flags
2502            );
2503        $doEditStatus = $pageUpdater->getStatus();
2504
2505        if ( !$doEditStatus->isOK() ) {
2506            // Failure from doEdit()
2507            // Show the edit conflict page for certain recognized errors from doEdit(),
2508            // but don't show it for errors from extension hooks
2509            if (
2510                $doEditStatus->failedBecausePageMissing() ||
2511                $doEditStatus->failedBecausePageExists() ||
2512                $doEditStatus->failedBecauseOfConflict()
2513            ) {
2514                $this->isConflict = true;
2515                // Destroys data doEdit() put in $status->value but who cares
2516                $doEditStatus->value = self::AS_END;
2517            }
2518            return $doEditStatus;
2519        }
2520
2521        $result['nullEdit'] = !$doEditStatus->wasRevisionCreated();
2522        if ( $result['nullEdit'] ) {
2523            // We didn't know if it was a null edit until now, so bump the rate limit now
2524            $limitSubject = $requestUser->toRateLimitSubject();
2525            MediaWikiServices::getInstance()->getRateLimiter()->limit( $limitSubject, 'linkpurge' );
2526        }
2527        $result['redirect'] = $content->isRedirect();
2528
2529        $this->updateWatchlist();
2530
2531        // If the content model changed, add a log entry
2532        if ( $changingContentModel ) {
2533            $this->addContentModelChangeLogEntry(
2534                $this->getUserForSave(),
2535                // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
2536                // $oldContentModel is set when $changingContentModel is true
2537                $new ? false : $oldContentModel,
2538                $this->contentModel,
2539                $this->summary
2540            );
2541        }
2542
2543        // Instead of carrying the same status object throughout, it is created right
2544        // when it is returned, either at an earlier point due to an error or here
2545        // due to a successful edit.
2546        $statusCode = ( $new ? self::AS_SUCCESS_NEW_ARTICLE : self::AS_SUCCESS_UPDATE );
2547        return Status::newGood( $statusCode );
2548    }
2549
2550    /**
2551     * Apply the specific updates needed for the EditPage fields based on which constraint
2552     * failed, rather than interspersing this logic throughout internalAttemptSave at
2553     * each of the points the constraints are checked. Eventually, this will act on the
2554     * result from the backend.
2555     *
2556     * @param IEditConstraint $failed
2557     */
2558    private function handleFailedConstraint( IEditConstraint $failed ): void {
2559        if ( $failed instanceof PageSizeConstraint ) {
2560            // Error will be displayed by showEditForm()
2561            $this->tooBig = true;
2562        } elseif ( $failed instanceof UserBlockConstraint ) {
2563            // Auto-block user's IP if the account was "hard" blocked
2564            if ( !MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) {
2565                $this->context->getUser()->spreadAnyEditBlock();
2566            }
2567        } elseif ( $failed instanceof DefaultTextConstraint ) {
2568            $this->blankArticle = true;
2569        } elseif ( $failed instanceof EditFilterMergedContentHookConstraint ) {
2570            $this->hookError = $failed->getHookError();
2571        } elseif (
2572            $failed instanceof AutoSummaryMissingSummaryConstraint ||
2573            $failed instanceof NewSectionMissingSubjectConstraint
2574        ) {
2575            $this->missingSummary = true;
2576        } elseif ( $failed instanceof MissingCommentConstraint ) {
2577            $this->missingComment = true;
2578        } elseif ( $failed instanceof SelfRedirectConstraint ) {
2579            $this->selfRedirect = true;
2580        }
2581    }
2582
2583    /**
2584     * Does checks and compares the automatically generated undo content with the
2585     * one that was submitted by the user. If they match, the undo is considered "clean".
2586     * Otherwise there is no guarantee if anything was reverted at all, as the user could
2587     * even swap out entire content.
2588     *
2589     * @param Content $content
2590     *
2591     * @return bool
2592     */
2593    private function isUndoClean( Content $content ): bool {
2594        // Check whether the undo was "clean", that is the user has not modified
2595        // the automatically generated content.
2596        $undoRev = $this->revisionStore->getRevisionById( $this->undidRev );
2597        if ( $undoRev === null ) {
2598            return false;
2599        }
2600
2601        if ( $this->undoAfter ) {
2602            $oldRev = $this->revisionStore->getRevisionById( $this->undoAfter );
2603        } else {
2604            $oldRev = $this->revisionStore->getPreviousRevision( $undoRev );
2605        }
2606
2607        if ( $oldRev === null ||
2608            $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
2609            $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
2610        ) {
2611            return false;
2612        }
2613
2614        $undoContent = $this->getUndoContent( $undoRev, $oldRev, $undoError );
2615        if ( !$undoContent ) {
2616            return false;
2617        }
2618
2619        // Do a pre-save transform on the retrieved undo content
2620        $services = MediaWikiServices::getInstance();
2621        $contentLanguage = $services->getContentLanguage();
2622        $user = $this->getUserForPreview();
2623        $parserOptions = ParserOptions::newFromUserAndLang( $user, $contentLanguage );
2624        $contentTransformer = $services->getContentTransformer();
2625        $undoContent = $contentTransformer->preSaveTransform( $undoContent, $this->mTitle, $user, $parserOptions );
2626
2627        if ( $undoContent->equals( $content ) ) {
2628            return true;
2629        }
2630        return false;
2631    }
2632
2633    /**
2634     * @param UserIdentity $user
2635     * @param string|false $oldModel false if the page is being newly created
2636     * @param string $newModel
2637     * @param string $reason
2638     */
2639    private function addContentModelChangeLogEntry( UserIdentity $user, $oldModel, $newModel, $reason = "" ): void {
2640        $new = $oldModel === false;
2641        $log = new ManualLogEntry( 'contentmodel', $new ? 'new' : 'change' );
2642        $log->setPerformer( $user );
2643        $log->setTarget( $this->mTitle );
2644        $log->setComment( is_string( $reason ) ? $reason : "" );
2645        $log->setParameters( [
2646            '4::oldmodel' => $oldModel,
2647            '5::newmodel' => $newModel
2648        ] );
2649        $logid = $log->insert();
2650        $log->publish( $logid );
2651    }
2652
2653    /**
2654     * Register the change of watch status
2655     */
2656    private function updateWatchlist(): void {
2657        if ( $this->tempUserCreateActive ) {
2658            return;
2659        }
2660        $user = $this->getUserForSave();
2661        if ( !$user->isNamed() ) {
2662            return;
2663        }
2664
2665        $title = $this->mTitle;
2666        $watch = $this->watchthis;
2667        $watchlistExpiry = $this->watchlistExpiry;
2668
2669        // This can't run as a DeferredUpdate due to a possible race condition
2670        // when the post-edit redirect happens if the pendingUpdates queue is
2671        // too large to finish in time (T259564)
2672        $this->watchlistManager->setWatch( $watch, $user, $title, $watchlistExpiry );
2673
2674        $this->watchedItemStore->maybeEnqueueWatchlistExpiryJob();
2675    }
2676
2677    /**
2678     * Attempts to do 3-way merge of edit content with a base revision
2679     * and current content, in case of edit conflict, in whichever way appropriate
2680     * for the content type.
2681     *
2682     * @param Content $editContent
2683     *
2684     * @return array|false either `false` or an array of the new Content and the
2685     *   updated parent revision id
2686     */
2687    private function mergeChangesIntoContent( Content $editContent ) {
2688        // This is the revision that was current at the time editing was initiated on the client,
2689        // even if the edit was based on an old revision.
2690        $baseRevRecord = $this->getExpectedParentRevision();
2691        $baseContent = $baseRevRecord ?
2692            $baseRevRecord->getContent( SlotRecord::MAIN ) :
2693            null;
2694
2695        if ( $baseContent === null ) {
2696            return false;
2697        } elseif ( $baseRevRecord->isCurrent() ) {
2698            // Impossible to have a conflict when the user just edited the latest revision. This can
2699            // happen e.g. when $wgDiff3 is badly configured.
2700            return [ $editContent, $baseRevRecord->getId() ];
2701        }
2702
2703        // The current state, we want to merge updates into it
2704        $currentRevisionRecord = $this->revisionStore->getRevisionByTitle(
2705            $this->mTitle,
2706            0,
2707            IDBAccessObject::READ_LATEST
2708        );
2709        $currentContent = $currentRevisionRecord
2710            ? $currentRevisionRecord->getContent( SlotRecord::MAIN )
2711            : null;
2712
2713        if ( $currentContent === null ) {
2714            return false;
2715        }
2716
2717        $mergedContent = $this->contentHandlerFactory
2718            ->getContentHandler( $baseContent->getModel() )
2719            ->merge3( $baseContent, $editContent, $currentContent );
2720
2721        if ( $mergedContent ) {
2722            // Also need to update parentRevId to what we just merged.
2723            return [ $mergedContent, $currentRevisionRecord->getId() ];
2724        }
2725
2726        return false;
2727    }
2728
2729    /**
2730     * Returns the RevisionRecord corresponding to the revision that was current at the time
2731     * editing was initiated on the client even if the edit was based on an old revision
2732     *
2733     * @since 1.35
2734     * @return RevisionRecord|null Current revision when editing was initiated on the client
2735     */
2736    public function getExpectedParentRevision() {
2737        if ( $this->mExpectedParentRevision === false ) {
2738            $revRecord = null;
2739            if ( $this->editRevId ) {
2740                $revRecord = $this->revisionStore->getRevisionById(
2741                    $this->editRevId,
2742                    IDBAccessObject::READ_LATEST
2743                );
2744            } elseif ( $this->edittime ) {
2745                $revRecord = $this->revisionStore->getRevisionByTimestamp(
2746                    $this->getTitle(),
2747                    $this->edittime,
2748                    IDBAccessObject::READ_LATEST
2749                );
2750            }
2751            $this->mExpectedParentRevision = $revRecord;
2752        }
2753        return $this->mExpectedParentRevision;
2754    }
2755
2756    public function setHeaders() {
2757        $out = $this->context->getOutput();
2758
2759        $out->addModules( 'mediawiki.action.edit' );
2760        $out->addModuleStyles( 'mediawiki.action.edit.styles' );
2761        $out->addModuleStyles( 'mediawiki.editfont.styles' );
2762        $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
2763
2764        $user = $this->context->getUser();
2765
2766        if ( $this->userOptionsLookup->getOption( $user, 'uselivepreview' ) ) {
2767            $out->addModules( 'mediawiki.action.edit.preview' );
2768        }
2769
2770        if ( $this->userOptionsLookup->getOption( $user, 'useeditwarning' ) ) {
2771            $out->addModules( 'mediawiki.action.edit.editWarning' );
2772        }
2773
2774        if ( $this->context->getConfig()->get( MainConfigNames::EnableEditRecovery )
2775            && $this->userOptionsLookup->getOption( $user, 'editrecovery' )
2776        ) {
2777            $wasPosted = $this->getContext()->getRequest()->getMethod() === 'POST';
2778            $out->addJsConfigVars( 'wgEditRecoveryWasPosted', $wasPosted );
2779            $out->addModules( 'mediawiki.editRecovery.edit' );
2780        }
2781
2782        # Enabled article-related sidebar, toplinks, etc.
2783        $out->setArticleRelated( true );
2784
2785        $contextTitle = $this->getContextTitle();
2786        if ( $this->isConflict ) {
2787            $msg = 'editconflict';
2788        } elseif ( $contextTitle->exists() && $this->section != '' ) {
2789            $msg = $this->section === 'new' ? 'editingcomment' : 'editingsection';
2790        } else {
2791            $msg = $contextTitle->exists()
2792                || ( $contextTitle->getNamespace() === NS_MEDIAWIKI
2793                    && $contextTitle->getDefaultMessageText() !== false
2794                )
2795                ? 'editing'
2796                : 'creating';
2797        }
2798
2799        # Use the title defined by DISPLAYTITLE magic word when present
2800        # NOTE: getDisplayTitle() returns HTML while getPrefixedText() returns plain text.
2801        #       Escape ::getPrefixedText() so that we have HTML in all cases,
2802        #       and pass as a "raw" parameter to ::setPageTitleMsg().
2803        $displayTitle = isset( $this->mParserOutput ) ? $this->mParserOutput->getDisplayTitle() : false;
2804        if ( $displayTitle === false ) {
2805            $displayTitle = htmlspecialchars(
2806                $contextTitle->getPrefixedText(), ENT_QUOTES, 'UTF-8', false
2807            );
2808        } else {
2809            $out->setDisplayTitle( $displayTitle );
2810        }
2811
2812        // Enclose the title with an element. This is used on live preview to update the
2813        // preview of the display title.
2814        $displayTitle = Html::rawElement( 'span', [ 'id' => 'firstHeadingTitle' ], $displayTitle );
2815
2816        $out->setPageTitleMsg( $this->context->msg( $msg )->rawParams( $displayTitle ) );
2817
2818        $config = $this->context->getConfig();
2819
2820        # Transmit the name of the message to JavaScript. This was added for live preview.
2821        # Live preview doesn't use this anymore. The variable is still transmitted because
2822        # Edit Recovery and user scripts use it.
2823        $out->addJsConfigVars( [
2824            'wgEditMessage' => $msg,
2825        ] );
2826
2827        // Add whether to use 'save' or 'publish' messages to JavaScript for post-edit, other
2828        // editors, etc.
2829        $out->addJsConfigVars(
2830            'wgEditSubmitButtonLabelPublish',
2831            $config->get( MainConfigNames::EditSubmitButtonLabelPublish )
2832        );
2833    }
2834
2835    /**
2836     * Show all applicable editing introductions
2837     */
2838    private function showIntro(): void {
2839        $services = MediaWikiServices::getInstance();
2840
2841        // Hardcoded list of notices that are suppressable for historical reasons.
2842        // This feature was originally added for LiquidThreads, to avoid showing non-essential messages
2843        // when commenting in a thread, but some messages were included (or excluded) by mistake before
2844        // its implementation was moved to one place, and this list doesn't make a lot of sense.
2845        // TODO: Remove the suppressIntro feature from EditPage, and invent a better way for extensions
2846        // to skip individual intro messages.
2847        $skip = $this->suppressIntro ? [
2848            'editintro',
2849            'code-editing-intro',
2850            'sharedupload-desc-create',
2851            'sharedupload-desc-edit',
2852            'userpage-userdoesnotexist',
2853            'blocked-notice-logextract',
2854            'newarticletext',
2855            'newarticletextanon',
2856            'recreate-moveddeleted-warn',
2857        ] : [];
2858
2859        $messages = $services->getIntroMessageBuilder()->getIntroMessages(
2860            IntroMessageBuilder::MORE_FRAMES,
2861            $skip,
2862            $this->context,
2863            $this->mTitle->toPageIdentity(),
2864            $this->mArticle->fetchRevisionRecord(),
2865            $this->context->getUser(),
2866            $this->context->getRequest()->getVal( 'editintro' ),
2867            wfArrayToCgi(
2868                array_diff_key(
2869                    $this->context->getRequest()->getValues(),
2870                    [ 'title' => true, 'returnto' => true, 'returntoquery' => true ]
2871                )
2872            ),
2873            !$this->firsttime,
2874            $this->section !== '' ? $this->section : null
2875        );
2876
2877        foreach ( $messages as $message ) {
2878            $this->context->getOutput()->addHTML( $message );
2879        }
2880    }
2881
2882    /**
2883     * Gets an editable textual representation of $content.
2884     * The textual representation can be turned by into a Content object by the
2885     * toEditContent() method.
2886     *
2887     * If $content is null or false or a string, $content is returned unchanged.
2888     *
2889     * If the given Content object is not of a type that can be edited using
2890     * the text base EditPage, an exception will be raised. Set
2891     * $this->allowNonTextContent to true to allow editing of non-textual
2892     * content.
2893     *
2894     * @param Content|null|false|string $content
2895     * @return string The editable text form of the content.
2896     *
2897     * @throws MWException If $content is not an instance of TextContent and
2898     *   $this->allowNonTextContent is not true.
2899     */
2900    private function toEditText( $content ) {
2901        if ( $content === null || $content === false ) {
2902            return '';
2903        }
2904        if ( is_string( $content ) ) {
2905            return $content;
2906        }
2907
2908        if ( !$this->isSupportedContentModel( $content->getModel() ) ) {
2909            throw new MWException( 'This content model is not supported: ' . $content->getModel() );
2910        }
2911
2912        return $content->serialize( $this->contentFormat );
2913    }
2914
2915    /**
2916     * Turns the given text into a Content object by unserializing it.
2917     *
2918     * If the resulting Content object is not of a type that can be edited using
2919     * the text base EditPage, an exception will be raised. Set
2920     * $this->allowNonTextContent to true to allow editing of non-textual
2921     * content.
2922     *
2923     * @param string|null|false $text Text to unserialize
2924     * @return Content|false|null The content object created from $text. If $text was false
2925     *   or null, then false or null will be returned instead.
2926     *
2927     * @throws MWException If unserializing the text results in a Content
2928     *   object that is not an instance of TextContent and
2929     *   $this->allowNonTextContent is not true.
2930     */
2931    protected function toEditContent( $text ) {
2932        if ( $text === false || $text === null ) {
2933            return $text;
2934        }
2935
2936        $content = ContentHandler::makeContent( $text, $this->getTitle(),
2937            $this->contentModel, $this->contentFormat );
2938
2939        if ( !$this->isSupportedContentModel( $content->getModel() ) ) {
2940            throw new MWException( 'This content model is not supported: ' . $content->getModel() );
2941        }
2942
2943        return $content;
2944    }
2945
2946    /**
2947     * Send the edit form and related headers to OutputPage
2948     */
2949    public function showEditForm() {
2950        # need to parse the preview early so that we know which templates are used,
2951        # otherwise users with "show preview after edit box" will get a blank list
2952        # we parse this near the beginning so that setHeaders can do the title
2953        # setting work instead of leaving it in getPreviewText
2954        $previewOutput = '';
2955        if ( $this->formtype === 'preview' ) {
2956            $previewOutput = $this->getPreviewText();
2957        }
2958
2959        $out = $this->context->getOutput();
2960
2961        // FlaggedRevs depends on running this hook before adding edit notices in showIntro() (T337637)
2962        $this->getHookRunner()->onEditPage__showEditForm_initial( $this, $out );
2963
2964        $this->setHeaders();
2965
2966        // Show applicable editing introductions
2967        $this->showIntro();
2968
2969        if ( !$this->isConflict &&
2970            $this->section !== '' &&
2971            !$this->isSectionEditSupported()
2972        ) {
2973            // We use $this->section to much before this and getVal('wgSection') directly in other places
2974            // at this point we can't reset $this->section to '' to fallback to non-section editing.
2975            // Someone is welcome to try refactoring though
2976            $out->showErrorPage( 'sectioneditnotsupported-title', 'sectioneditnotsupported-text' );
2977            return;
2978        }
2979
2980        $this->showHeader();
2981
2982        $out->addHTML( $this->editFormPageTop );
2983
2984        $user = $this->context->getUser();
2985        if ( $this->userOptionsLookup->getOption( $user, 'previewontop' ) ) {
2986            $this->displayPreviewArea( $previewOutput, true );
2987        }
2988
2989        $out->addHTML( $this->editFormTextTop );
2990
2991        if ( $this->formtype !== 'save' && $this->wasDeletedSinceLastEdit() ) {
2992            $out->addHTML( Html::errorBox(
2993                $out->msg( 'deletedwhileediting' )->parse(),
2994                '',
2995                'mw-deleted-while-editing'
2996            ) );
2997        }
2998
2999        // @todo add EditForm plugin interface and use it here!
3000        //       search for textarea1 and textarea2, and allow EditForm to override all uses.
3001        $out->addHTML( Html::openElement(
3002            'form',
3003            [
3004                'class' => 'mw-editform',
3005                'id' => self::EDITFORM_ID,
3006                'name' => self::EDITFORM_ID,
3007                'method' => 'post',
3008                'action' => $this->getActionURL( $this->getContextTitle() ),
3009                'enctype' => 'multipart/form-data',
3010                'data-mw-editform-type' => $this->formtype
3011            ]
3012        ) );
3013
3014        // Add a check for Unicode support
3015        $out->addHTML( Html::hidden( 'wpUnicodeCheck', self::UNICODE_CHECK ) );
3016
3017        // Add an empty field to trip up spambots
3018        $out->addHTML(
3019            Html::openElement( 'div', [ 'id' => 'antispam-container', 'style' => 'display: none;' ] )
3020            . Html::rawElement(
3021                'label',
3022                [ 'for' => 'wpAntispam' ],
3023                $this->context->msg( 'simpleantispam-label' )->parse()
3024            )
3025            . Html::element(
3026                'input',
3027                [
3028                    'type' => 'text',
3029                    'name' => 'wpAntispam',
3030                    'id' => 'wpAntispam',
3031                    'value' => ''
3032                ]
3033            )
3034            . Html::closeElement( 'div' )
3035        );
3036
3037        $this->getHookRunner()->onEditPage__showEditForm_fields( $this, $out );
3038
3039        // Put these up at the top to ensure they aren't lost on early form submission
3040        $this->showFormBeforeText();
3041
3042        if ( $this->formtype === 'save' && $this->wasDeletedSinceLastEdit() ) {
3043            $username = $this->lastDelete->actor_name;
3044            $comment = $this->commentStore->getComment( 'log_comment', $this->lastDelete )->text;
3045
3046            // It is better to not parse the comment at all than to have templates expanded in the middle
3047            // TODO: can the label be moved outside of the div so that wrapWikiMsg could be used?
3048            $key = $comment === ''
3049                ? 'confirmrecreate-noreason'
3050                : 'confirmrecreate';
3051            $out->addHTML( Html::rawElement(
3052                'div',
3053                [ 'class' => 'mw-confirm-recreate' ],
3054                $this->context->msg( $key )
3055                    ->params( $username )
3056                    ->plaintextParams( $comment )
3057                    ->parse() .
3058                    Html::rawElement(
3059                        'div',
3060                        [],
3061                        Html::check(
3062                            'wpRecreate',
3063                            false,
3064                            [ 'title' => Linker::titleAttrib( 'recreate' ), 'tabindex' => 1, 'id' => 'wpRecreate' ]
3065                        )
3066                        . "\u{00A0}" .
3067                        Html::label(
3068                            $this->context->msg( 'recreate' )->text(),
3069                            'wpRecreate',
3070                            [ 'title' => Linker::titleAttrib( 'recreate' ) ]
3071                        )
3072                    )
3073            ) );
3074        }
3075
3076        # When the summary is hidden, also hide them on preview/show changes
3077        if ( $this->nosummary ) {
3078            $out->addHTML( Html::hidden( 'nosummary', true ) );
3079        }
3080
3081        # If a blank edit summary was previously provided, and the appropriate
3082        # user preference is active, pass a hidden tag as wpIgnoreBlankSummary. This will stop the
3083        # user being bounced back more than once in the event that a summary
3084        # is not required.
3085        # ####
3086        # For a bit more sophisticated detection of blank summaries, hash the
3087        # automatic one and pass that in the hidden field wpAutoSummary.
3088        if (
3089            $this->missingSummary ||
3090            // @phan-suppress-next-line PhanSuspiciousValueComparison
3091            ( $this->section === 'new' && $this->nosummary ) ||
3092            $this->allowBlankSummary
3093        ) {
3094            $out->addHTML( Html::hidden( 'wpIgnoreBlankSummary', true ) );
3095        }
3096
3097        if ( $this->undidRev ) {
3098            $out->addHTML( Html::hidden( 'wpUndidRevision', $this->undidRev ) );
3099        }
3100        if ( $this->undoAfter ) {
3101            $out->addHTML( Html::hidden( 'wpUndoAfter', $this->undoAfter ) );
3102        }
3103
3104        if ( $this->selfRedirect ) {
3105            $out->addHTML( Html::hidden( 'wpIgnoreSelfRedirect', true ) );
3106        }
3107
3108        $autosumm = $this->autoSumm !== '' ? $this->autoSumm : md5( $this->summary );
3109        $out->addHTML( Html::hidden( 'wpAutoSummary', $autosumm ) );
3110
3111        $out->addHTML( Html::hidden( 'oldid', $this->oldid ) );
3112        $out->addHTML( Html::hidden( 'parentRevId', $this->getParentRevId() ) );
3113
3114        $out->addHTML( Html::hidden( 'format', $this->contentFormat ) );
3115        $out->addHTML( Html::hidden( 'model', $this->contentModel ) );
3116
3117        $out->enableOOUI();
3118
3119        if ( $this->section === 'new' ) {
3120            $this->showSummaryInput( true );
3121            $out->addHTML( $this->getSummaryPreview( true ) );
3122        }
3123
3124        $out->addHTML( $this->editFormTextBeforeContent );
3125        if ( $this->isConflict ) {
3126            $currentText = $this->toEditText( $this->getCurrentContent() );
3127
3128            $editConflictHelper = $this->getEditConflictHelper();
3129            $editConflictHelper->setTextboxes( $this->textbox1, $currentText );
3130            $editConflictHelper->setContentModel( $this->contentModel );
3131            $editConflictHelper->setContentFormat( $this->contentFormat );
3132            $out->addHTML( $editConflictHelper->getEditFormHtmlBeforeContent() );
3133
3134            $this->textbox2 = $this->textbox1;
3135            $this->textbox1 = $currentText;
3136        }
3137
3138        if ( !$this->mTitle->isUserConfigPage() ) {
3139            $out->addHTML( self::getEditToolbar() );
3140        }
3141
3142        if ( $this->blankArticle ) {
3143            $out->addHTML( Html::hidden( 'wpIgnoreBlankArticle', true ) );
3144        }
3145
3146        if ( $this->isConflict ) {
3147            // In an edit conflict bypass the overridable content form method
3148            // and fallback to the raw wpTextbox1 since editconflicts can't be
3149            // resolved between page source edits and custom ui edits using the
3150            // custom edit ui.
3151            $conflictTextBoxAttribs = [];
3152            if ( $this->wasDeletedSinceLastEdit() ) {
3153                $conflictTextBoxAttribs['style'] = 'display:none;';
3154            } elseif ( $this->isOldRev ) {
3155                $conflictTextBoxAttribs['class'] = 'mw-textarea-oldrev';
3156            }
3157
3158            // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
3159            // $editConflictHelper is declard, when isConflict is true
3160            $out->addHTML( $editConflictHelper->getEditConflictMainTextBox( $conflictTextBoxAttribs ) );
3161            // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
3162            // $editConflictHelper is declard, when isConflict is true
3163            $out->addHTML( $editConflictHelper->getEditFormHtmlAfterContent() );
3164        } else {
3165            $this->showContentForm();
3166        }
3167
3168        $out->addHTML( $this->editFormTextAfterContent );
3169
3170        $this->showStandardInputs();
3171
3172        $this->showFormAfterText();
3173
3174        $this->showTosSummary();
3175
3176        $this->showEditTools();
3177
3178        $out->addHTML( $this->editFormTextAfterTools . "\n" );
3179
3180        $out->addHTML( $this->makeTemplatesOnThisPageList( $this->getTemplates() ) );
3181
3182        $out->addHTML( Html::rawElement( 'div', [ 'class' => 'hiddencats' ],
3183            Linker::formatHiddenCategories( $this->page->getHiddenCategories() ) ) );
3184
3185        $out->addHTML( Html::rawElement( 'div', [ 'class' => 'limitreport' ],
3186            self::getPreviewLimitReport( $this->mParserOutput ) ) );
3187
3188        $out->addModules( 'mediawiki.action.edit.collapsibleFooter' );
3189
3190        if ( $this->isConflict ) {
3191            try {
3192                $this->showConflict();
3193            } catch ( MWContentSerializationException $ex ) {
3194                // this can't really happen, but be nice if it does.
3195                $msg = $this->context->msg(
3196                    'content-failed-to-parse',
3197                    $this->contentModel,
3198                    $this->contentFormat,
3199                    $ex->getMessage()
3200                );
3201                $out->wrapWikiTextAsInterface( 'error', $msg->plain() );
3202            }
3203        }
3204
3205        // Set a hidden field so JS knows what edit form mode we are in
3206        if ( $this->isConflict ) {
3207            $mode = 'conflict';
3208        } elseif ( $this->preview ) {
3209            $mode = 'preview';
3210        } elseif ( $this->diff ) {
3211            $mode = 'diff';
3212        } else {
3213            $mode = 'text';
3214        }
3215        $out->addHTML( Html::hidden( 'mode', $mode, [ 'id' => 'mw-edit-mode' ] ) );
3216
3217        // Marker for detecting truncated form data.  This must be the last
3218        // parameter sent in order to be of use, so do not move me.
3219        $out->addHTML( Html::hidden( 'wpUltimateParam', true ) );
3220        $out->addHTML( $this->editFormTextBottom . "\n</form>\n" );
3221
3222        if ( !$this->userOptionsLookup->getOption( $user, 'previewontop' ) ) {
3223            $this->displayPreviewArea( $previewOutput, false );
3224        }
3225    }
3226
3227    /**
3228     * Wrapper around TemplatesOnThisPageFormatter to make
3229     * a "templates on this page" list.
3230     *
3231     * @param PageIdentity[] $templates
3232     * @return string HTML
3233     */
3234    public function makeTemplatesOnThisPageList( array $templates ) {
3235        $templateListFormatter = new TemplatesOnThisPageFormatter(
3236            $this->context,
3237            $this->linkRenderer,
3238            $this->linkBatchFactory,
3239            $this->restrictionStore
3240        );
3241
3242        // preview if preview, else section if section, else false
3243        $type = false;
3244        if ( $this->preview ) {
3245            $type = 'preview';
3246        } elseif ( $this->section !== '' ) {
3247            $type = 'section';
3248        }
3249
3250        return Html::rawElement( 'div', [ 'class' => 'templatesUsed' ],
3251            $templateListFormatter->format( $templates, $type )
3252        );
3253    }
3254
3255    /**
3256     * Extract the section title from current section text, if any.
3257     *
3258     * @param string $text
3259     * @return string|false
3260     */
3261    private static function extractSectionTitle( $text ) {
3262        if ( preg_match( "/^(=+)(.+)\\1\\s*(\n|$)/i", $text, $matches ) ) {
3263            return MediaWikiServices::getInstance()->getParser()
3264                ->stripSectionName( trim( $matches[2] ) );
3265        } else {
3266            return false;
3267        }
3268    }
3269
3270    private function showHeader(): void {
3271        $out = $this->context->getOutput();
3272        $user = $this->context->getUser();
3273        if ( $this->isConflict ) {
3274            $this->addExplainConflictHeader();
3275            $this->editRevId = $this->page->getLatest();
3276        } else {
3277            if ( $this->section !== '' && $this->section !== 'new' && $this->summary === '' &&
3278                !$this->preview && !$this->diff
3279            ) {
3280                $sectionTitle = self::extractSectionTitle( $this->textbox1 ); // FIXME: use Content object
3281                if ( $sectionTitle !== false ) {
3282                    $this->summary = "/* $sectionTitle */ ";
3283                }
3284            }
3285
3286            $buttonLabel = $this->context->msg( $this->getSubmitButtonLabel() )->text();
3287
3288            if ( $this->missingComment ) {
3289                $out->wrapWikiMsg( "<div id='mw-missingcommenttext'>\n$1\n</div>", 'missingcommenttext' );
3290            }
3291
3292            if ( $this->missingSummary && $this->section !== 'new' ) {
3293                $out->wrapWikiMsg(
3294                    "<div id='mw-missingsummary'>\n$1\n</div>",
3295                    [ 'missingsummary', $buttonLabel ]
3296                );
3297            }
3298
3299            if ( $this->missingSummary && $this->section === 'new' ) {
3300                $out->wrapWikiMsg(
3301                    "<div id='mw-missingcommentheader'>\n$1\n</div>",
3302                    [ 'missingcommentheader', $buttonLabel ]
3303                );
3304            }
3305
3306            if ( $this->blankArticle ) {
3307                $out->wrapWikiMsg(
3308                    "<div id='mw-blankarticle'>\n$1\n</div>",
3309                    [ 'blankarticle', $buttonLabel ]
3310                );
3311            }
3312
3313            if ( $this->selfRedirect ) {
3314                $out->wrapWikiMsg(
3315                    "<div id='mw-selfredirect'>\n$1\n</div>",
3316                    [ 'selfredirect', $buttonLabel ]
3317                );
3318            }
3319
3320            if ( $this->hookError !== '' ) {
3321                $out->addWikiTextAsInterface( $this->hookError );
3322            }
3323
3324            if ( $this->section != 'new' ) {
3325                $revRecord = $this->mArticle->fetchRevisionRecord();
3326                if ( $revRecord && $revRecord instanceof RevisionStoreRecord ) {
3327                    // Let sysop know that this will make private content public if saved
3328
3329                    if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $user ) ) {
3330                        $out->addHTML(
3331                            Html::warningBox(
3332                                $out->msg( 'rev-deleted-text-permission', $this->mTitle->getPrefixedDBkey() )->parse(),
3333                                'plainlinks'
3334                            )
3335                        );
3336                    } elseif ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
3337                        $out->addHTML(
3338                            Html::warningBox(
3339                                // title used in wikilinks, should not contain whitespaces
3340                                $out->msg( 'rev-deleted-text-view', $this->mTitle->getPrefixedDBkey() )->parse(),
3341                                'plainlinks'
3342                            )
3343                        );
3344                    }
3345
3346                    if ( !$revRecord->isCurrent() ) {
3347                        $this->mArticle->setOldSubtitle( $revRecord->getId() );
3348                        $this->isOldRev = true;
3349                    }
3350                } elseif ( $this->mTitle->exists() ) {
3351                    // Something went wrong
3352
3353                    $out->addHTML(
3354                        Html::errorBox(
3355                            $out->msg( 'missing-revision', $this->oldid )->parse()
3356                        )
3357                    );
3358                }
3359            }
3360        }
3361
3362        $this->addLongPageWarningHeader();
3363    }
3364
3365    /**
3366     * Helper function for summary input functions, which returns the necessary
3367     * attributes for the input.
3368     *
3369     * @param array $inputAttrs Array of attrs to use on the input
3370     * @return array
3371     */
3372    private function getSummaryInputAttributes( array $inputAttrs ): array {
3373        // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
3374        // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
3375        // Unicode codepoints.
3376        return $inputAttrs + [
3377            'id' => 'wpSummary',
3378            'name' => 'wpSummary',
3379            'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
3380            'tabindex' => 1,
3381            'size' => 60,
3382            'spellcheck' => 'true',
3383        ];
3384    }
3385
3386    /**
3387     * Builds a standard summary input with a label.
3388     *
3389     * @param string $summary The value of the summary input
3390     * @param string $labelText The html to place inside the label
3391     * @param array $inputAttrs Array of attrs to use on the input
3392     *
3393     * @return OOUI\FieldLayout OOUI FieldLayout with Label and Input
3394     */
3395    private function getSummaryInputWidget( $summary, string $labelText, array $inputAttrs ): FieldLayout {
3396        $inputAttrs = OOUI\Element::configFromHtmlAttributes(
3397            $this->getSummaryInputAttributes( $inputAttrs )
3398        );
3399        $inputAttrs += [
3400            'title' => Linker::titleAttrib( 'summary' ),
3401            'accessKey' => Linker::accesskey( 'summary' ),
3402        ];
3403
3404        // For compatibility with old scripts and extensions, we want the legacy 'id' on the `<input>`
3405        $inputAttrs['inputId'] = $inputAttrs['id'];
3406        $inputAttrs['id'] = 'wpSummaryWidget';
3407
3408        return new OOUI\FieldLayout(
3409            new OOUI\TextInputWidget( [
3410                'value' => $summary,
3411                'infusable' => true,
3412            ] + $inputAttrs ),
3413            [
3414                'label' => new OOUI\HtmlSnippet( $labelText ),
3415                'align' => 'top',
3416                'id' => 'wpSummaryLabel',
3417                'classes' => [ $this->missingSummary ? 'mw-summarymissed' : 'mw-summary' ],
3418            ]
3419        );
3420    }
3421
3422    /**
3423     * @param bool $isSubjectPreview True if this is the section subject/title
3424     *   up top, or false if this is the comment summary
3425     *   down below the textarea
3426     */
3427    private function showSummaryInput( bool $isSubjectPreview ): void {
3428        # Add a class if 'missingsummary' is triggered to allow styling of the summary line
3429        $summaryClass = $this->missingSummary ? 'mw-summarymissed' : 'mw-summary';
3430        if ( $isSubjectPreview ) {
3431            if ( $this->nosummary ) {
3432                return;
3433            }
3434        } elseif ( !$this->mShowSummaryField ) {
3435            return;
3436        }
3437
3438        $labelText = $this->context->msg( $isSubjectPreview ? 'subject' : 'summary' )->parse();
3439        $this->context->getOutput()->addHTML(
3440            $this->getSummaryInputWidget(
3441                $isSubjectPreview ? $this->sectiontitle : $this->summary,
3442                $labelText,
3443                [ 'class' => $summaryClass ]
3444            )
3445        );
3446    }
3447
3448    /**
3449     * @param bool $isSubjectPreview True if this is the section subject/title
3450     *   up top, or false if this is the comment summary
3451     *   down below the textarea
3452     * @return string
3453     */
3454    private function getSummaryPreview( bool $isSubjectPreview ): string {
3455        // avoid spaces in preview, gets always trimmed on save
3456        $summary = trim( $this->summary );
3457        if ( $summary === '' || ( !$this->preview && !$this->diff ) ) {
3458            return "";
3459        }
3460
3461        $commentFormatter = MediaWikiServices::getInstance()->getCommentFormatter();
3462        $summary = $this->context->msg( 'summary-preview' )->parse()
3463            . $commentFormatter->formatBlock( $summary, $this->mTitle, $isSubjectPreview );
3464        return Html::rawElement( 'div', [ 'class' => 'mw-summary-preview' ], $summary );
3465    }
3466
3467    private function showFormBeforeText(): void {
3468        $out = $this->context->getOutput();
3469        $out->addHTML( Html::hidden( 'wpSection', $this->section ) );
3470        $out->addHTML( Html::hidden( 'wpStarttime', $this->starttime ) );
3471        $out->addHTML( Html::hidden( 'wpEdittime', $this->edittime ) );
3472        $out->addHTML( Html::hidden( 'editRevId', $this->editRevId ) );
3473        $out->addHTML( Html::hidden( 'wpScrolltop', $this->scrolltop, [ 'id' => 'wpScrolltop' ] ) );
3474    }
3475
3476    protected function showFormAfterText() {
3477        /**
3478         * To make it harder for someone to slip a user a page
3479         * which submits an edit form to the wiki without their
3480         * knowledge, a random token is associated with the login
3481         * session. If it's not passed back with the submission,
3482         * we won't save the page, or render user JavaScript and
3483         * CSS previews.
3484         *
3485         * For anon editors, who may not have a session, we just
3486         * include the constant suffix to prevent editing from
3487         * broken text-mangling proxies.
3488         */
3489        $this->context->getOutput()->addHTML(
3490            "\n" .
3491            Html::hidden( "wpEditToken", $this->context->getUser()->getEditToken() ) .
3492            "\n"
3493        );
3494    }
3495
3496    /**
3497     * Subpage overridable method for printing the form for page content editing
3498     * By default this simply outputs wpTextbox1
3499     * Subclasses can override this to provide a custom UI for editing;
3500     * be it a form, or simply wpTextbox1 with a modified content that will be
3501     * reverse modified when extracted from the post data.
3502     * Note that this is basically the inverse for importContentFormData
3503     */
3504    protected function showContentForm() {
3505        $this->showTextbox1();
3506    }
3507
3508    private function showTextbox1(): void {
3509        if ( $this->formtype === 'save' && $this->wasDeletedSinceLastEdit() ) {
3510            $attribs = [ 'style' => 'display:none;' ];
3511        } else {
3512            $builder = new TextboxBuilder();
3513            $classes = $builder->getTextboxProtectionCSSClasses( $this->getTitle() );
3514
3515            # Is an old revision being edited?
3516            if ( $this->isOldRev ) {
3517                $classes[] = 'mw-textarea-oldrev';
3518            }
3519
3520            $attribs = [
3521                'aria-label' => $this->context->msg( 'edit-textarea-aria-label' )->text(),
3522                'tabindex' => 1
3523            ];
3524
3525            $attribs = $builder->mergeClassesIntoAttributes( $classes, $attribs );
3526        }
3527
3528        $this->showTextbox(
3529            $this->textbox1,
3530            'wpTextbox1',
3531            $attribs
3532        );
3533    }
3534
3535    protected function showTextbox( $text, $name, $customAttribs = [] ) {
3536        $builder = new TextboxBuilder();
3537        $attribs = $builder->buildTextboxAttribs(
3538            $name,
3539            $customAttribs,
3540            $this->context->getUser(),
3541            $this->mTitle
3542        );
3543
3544        $this->context->getOutput()->addHTML(
3545            Html::textarea( $name, $builder->addNewLineAtEnd( $text ), $attribs )
3546        );
3547    }
3548
3549    private function displayPreviewArea( string $previewOutput, bool $isOnTop ): void {
3550        $attribs = [ 'id' => 'wikiPreview' ];
3551        if ( $isOnTop ) {
3552            $attribs['class'] = 'ontop';
3553        }
3554        if ( $this->formtype !== 'preview' ) {
3555            $attribs['style'] = 'display: none;';
3556        }
3557
3558        $out = $this->context->getOutput();
3559        $out->addHTML( Html::openElement( 'div', $attribs ) );
3560
3561        if ( $this->formtype === 'preview' ) {
3562            $this->showPreview( $previewOutput );
3563        }
3564
3565        $out->addHTML( '</div>' );
3566
3567        if ( $this->formtype === 'diff' ) {
3568            try {
3569                $this->showDiff();
3570            } catch ( MWContentSerializationException $ex ) {
3571                $msg = $this->context->msg(
3572                    'content-failed-to-parse',
3573                    $this->contentModel,
3574                    $this->contentFormat,
3575                    $ex->getMessage()
3576                );
3577                $out->wrapWikiTextAsInterface( 'error', $msg->plain() );
3578            }
3579        }
3580    }
3581
3582    /**
3583     * Append preview output to OutputPage.
3584     * Includes category rendering if this is a category page.
3585     *
3586     * @param string $text The HTML to be output for the preview.
3587     */
3588    private function showPreview( string $text ): void {
3589        if ( $this->mArticle instanceof CategoryPage ) {
3590            $this->mArticle->openShowCategory();
3591        }
3592        # This hook seems slightly odd here, but makes things more
3593        # consistent for extensions.
3594        $out = $this->context->getOutput();
3595        $this->getHookRunner()->onOutputPageBeforeHTML( $out, $text );
3596        $out->addHTML( $text );
3597        if ( $this->mArticle instanceof CategoryPage ) {
3598            $this->mArticle->closeShowCategory();
3599        }
3600    }
3601
3602    /**
3603     * Get a diff between the current contents of the edit box and the
3604     * version of the page we're editing from.
3605     *
3606     * If this is a section edit, we'll replace the section as for final
3607     * save and then make a comparison.
3608     */
3609    public function showDiff() {
3610        $oldtitlemsg = 'currentrev';
3611        # if message does not exist, show diff against the preloaded default
3612        if ( $this->mTitle->getNamespace() === NS_MEDIAWIKI && !$this->mTitle->exists() ) {
3613            $oldtext = $this->mTitle->getDefaultMessageText();
3614            if ( $oldtext !== false ) {
3615                $oldtitlemsg = 'defaultmessagetext';
3616                $oldContent = $this->toEditContent( $oldtext );
3617            } else {
3618                $oldContent = null;
3619            }
3620        } else {
3621            $oldContent = $this->getCurrentContent();
3622        }
3623
3624        $textboxContent = $this->toEditContent( $this->textbox1 );
3625        if ( $this->editRevId !== null ) {
3626            $newContent = $this->page->replaceSectionAtRev(
3627                $this->section, $textboxContent, $this->sectiontitle, $this->editRevId
3628            );
3629        } else {
3630            $newContent = $this->page->replaceSectionContent(
3631                $this->section, $textboxContent, $this->sectiontitle, $this->edittime
3632            );
3633        }
3634
3635        if ( $newContent ) {
3636            $this->getHookRunner()->onEditPageGetDiffContent( $this, $newContent );
3637
3638            $user = $this->getUserForPreview();
3639            $popts = ParserOptions::newFromUserAndLang( $user,
3640                MediaWikiServices::getInstance()->getContentLanguage() );
3641            $services = MediaWikiServices::getInstance();
3642            $contentTransformer = $services->getContentTransformer();
3643            $newContent = $contentTransformer->preSaveTransform( $newContent, $this->mTitle, $user, $popts );
3644        }
3645
3646        if ( ( $oldContent && !$oldContent->isEmpty() ) || ( $newContent && !$newContent->isEmpty() ) ) {
3647            $oldtitle = $this->context->msg( $oldtitlemsg )->parse();
3648            $newtitle = $this->context->msg( 'yourtext' )->parse();
3649
3650            if ( !$oldContent ) {
3651                $oldContent = $newContent->getContentHandler()->makeEmptyContent();
3652            }
3653
3654            if ( !$newContent ) {
3655                $newContent = $oldContent->getContentHandler()->makeEmptyContent();
3656            }
3657
3658            $de = $oldContent->getContentHandler()->createDifferenceEngine( $this->context );
3659            $de->setContent( $oldContent, $newContent );
3660
3661            $difftext = $de->getDiff( $oldtitle, $newtitle );
3662            $de->showDiffStyle();
3663        } else {
3664            $difftext = '';
3665        }
3666
3667        $this->context->getOutput()->addHTML( Html::rawElement( 'div', [ 'id' => 'wikiDiff' ], $difftext ) );
3668    }
3669
3670    /**
3671     * Give a chance for site and per-namespace customizations of
3672     * terms of service summary link that might exist separately
3673     * from the copyright notice.
3674     *
3675     * This will display between the save button and the edit tools,
3676     * so should remain short!
3677     */
3678    private function showTosSummary(): void {
3679        $msgKey = 'editpage-tos-summary';
3680        $this->getHookRunner()->onEditPageTosSummary( $this->mTitle, $msgKey );
3681        $msg = $this->context->msg( $msgKey );
3682        if ( !$msg->isDisabled() ) {
3683            $this->context->getOutput()->addHTML( Html::rawElement(
3684                'div',
3685                [ 'class' => 'mw-tos-summary' ],
3686                $msg->parseAsBlock()
3687            ) );
3688        }
3689    }
3690
3691    /**
3692     * Inserts optional text shown below edit and upload forms. Can be used to offer special
3693     * characters not present on most keyboards for copying/pasting.
3694     */
3695    private function showEditTools(): void {
3696        $this->context->getOutput()->addHTML( Html::rawElement(
3697            'div',
3698            [ 'class' => 'mw-editTools' ],
3699            $this->context->msg( 'edittools' )->inContentLanguage()->parse()
3700        ) );
3701    }
3702
3703    /**
3704     * Get the copyright warning.
3705     *
3706     * @param PageReference $page
3707     * @param string $format Output format, valid values are any function of a Message object
3708     *   (e.g. 'parse', 'plain')
3709     * @param MessageLocalizer $localizer
3710     * @return string
3711     */
3712    public static function getCopyrightWarning( PageReference $page, string $format, MessageLocalizer $localizer ) {
3713        $services = MediaWikiServices::getInstance();
3714        $rightsText = $services->getMainConfig()->get( MainConfigNames::RightsText );
3715        if ( $rightsText ) {
3716            $copywarnMsg = [ 'copyrightwarning',
3717                '[[' . $localizer->msg( 'copyrightpage' )->inContentLanguage()->text() . ']]',
3718                $rightsText ];
3719        } else {
3720            $copywarnMsg = [ 'copyrightwarning2',
3721                '[[' . $localizer->msg( 'copyrightpage' )->inContentLanguage()->text() . ']]' ];
3722        }
3723        // Allow for site and per-namespace customization of contribution/copyright notice.
3724        $title = Title::newFromPageReference( $page );
3725        ( new HookRunner( $services->getHookContainer() ) )->onEditPageCopyrightWarning( $title, $copywarnMsg );
3726        if ( !$copywarnMsg ) {
3727            return '';
3728        }
3729
3730        $msg = $localizer->msg( ...$copywarnMsg )->page( $page );
3731        return Html::rawElement( 'div', [ 'id' => 'editpage-copywarn' ], $msg->$format() );
3732    }
3733
3734    /**
3735     * Get the Limit report for page previews
3736     *
3737     * @since 1.22
3738     * @param ParserOutput|null $output ParserOutput object from the parse
3739     * @return string HTML
3740     */
3741    public static function getPreviewLimitReport( ParserOutput $output = null ) {
3742        if ( !$output || !$output->getLimitReportData() ) {
3743            return '';
3744        }
3745
3746        $limitReport = Html::rawElement( 'div', [ 'class' => 'mw-limitReportExplanation' ],
3747            wfMessage( 'limitreport-title' )->parseAsBlock()
3748        );
3749
3750        // Show/hide animation doesn't work correctly on a table, so wrap it in a div.
3751        $limitReport .= Html::openElement( 'div', [ 'class' => 'preview-limit-report-wrapper' ] );
3752
3753        $limitReport .= Html::openElement( 'table', [
3754            'class' => 'preview-limit-report wikitable'
3755        ] ) .
3756            Html::openElement( 'tbody' );
3757
3758        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
3759        foreach ( $output->getLimitReportData() as $key => $value ) {
3760            if ( $hookRunner->onParserLimitReportFormat( $key, $value, $limitReport, true, true ) ) {
3761                $keyMsg = wfMessage( $key );
3762                $valueMsg = wfMessage( [ "$key-value-html", "$key-value" ] );
3763                if ( !$valueMsg->exists() ) {
3764                    // This is formatted raw, not as localized number.
3765                    // If you want the parameter formatted as a number,
3766                    // define the `$key-value` message.
3767                    $valueMsg = ( new RawMessage( '$1' ) )->params( $value );
3768                } else {
3769                    // If you define the `$key-value` or `$key-value-html`
3770                    // message then the argument *must* be numeric.
3771                    $valueMsg = $valueMsg->numParams( $value );
3772                }
3773                if ( !$keyMsg->isDisabled() && !$valueMsg->isDisabled() ) {
3774                    $limitReport .= Html::openElement( 'tr' ) .
3775                        Html::rawElement( 'th', [], $keyMsg->parse() ) .
3776                        Html::rawElement( 'td', [], $valueMsg->parse() ) .
3777                        Html::closeElement( 'tr' );
3778                }
3779            }
3780        }
3781
3782        $limitReport .= Html::closeElement( 'tbody' ) .
3783            Html::closeElement( 'table' ) .
3784            Html::closeElement( 'div' );
3785
3786        return $limitReport;
3787    }
3788
3789    protected function showStandardInputs( &$tabindex = 2 ) {
3790        $out = $this->context->getOutput();
3791        $out->addHTML( "<div class='editOptions'>\n" );
3792
3793        if ( $this->section !== 'new' ) {
3794            $this->showSummaryInput( false );
3795            $out->addHTML( $this->getSummaryPreview( false ) );
3796        }
3797
3798        // When previewing, override the selected dropdown option to select whatever was posted
3799        // (if it's a valid option) rather than the current value for watchlistExpiry.
3800        // See also above in $this->importFormData().
3801        $expiryFromRequest = null;
3802        if ( $this->preview || $this->diff || $this->isConflict ) {
3803            $expiryFromRequest = $this->getContext()->getRequest()->getText( 'wpWatchlistExpiry' );
3804        }
3805
3806        $checkboxes = $this->getCheckboxesWidget(
3807            $tabindex,
3808            [ 'minor' => $this->minoredit, 'watch' => $this->watchthis, 'wpWatchlistExpiry' => $expiryFromRequest ]
3809        );
3810        $checkboxesHTML = new OOUI\HorizontalLayout( [ 'items' => array_values( $checkboxes ) ] );
3811
3812        $out->addHTML( "<div class='editCheckboxes'>" . $checkboxesHTML . "</div>\n" );
3813
3814        // Show copyright warning.
3815        $out->addHTML( self::getCopyrightWarning( $this->mTitle, 'parse', $this->context ) );
3816        $out->addHTML( $this->editFormTextAfterWarn );
3817
3818        $out->addHTML( "<div class='editButtons'>\n" );
3819        $out->addHTML( implode( "\n", $this->getEditButtons( $tabindex ) ) . "\n" );
3820
3821        $cancel = $this->getCancelLink( $tabindex++ );
3822
3823        $edithelp = $this->getHelpLink() .
3824            $this->context->msg( 'word-separator' )->escaped() .
3825            $this->context->msg( 'newwindow' )->parse();
3826
3827        $out->addHTML( "    <span class='cancelLink'>{$cancel}</span>\n" );
3828        $out->addHTML( "    <span class='editHelp'>{$edithelp}</span>\n" );
3829        $out->addHTML( "</div><!-- editButtons -->\n" );
3830
3831        $this->getHookRunner()->onEditPage__showStandardInputs_options( $this, $out, $tabindex );
3832
3833        $out->addHTML( "</div><!-- editOptions -->\n" );
3834    }
3835
3836    /**
3837     * Show an edit conflict. textbox1 is already shown in showEditForm().
3838     * If you want to use another entry point to this function, be careful.
3839     */
3840    private function showConflict(): void {
3841        $out = $this->context->getOutput();
3842        if ( $this->getHookRunner()->onEditPageBeforeConflictDiff( $this, $out ) ) {
3843            $this->incrementConflictStats();
3844
3845            $this->getEditConflictHelper()->showEditFormTextAfterFooters();
3846        }
3847    }
3848
3849    private function incrementConflictStats(): void {
3850        $this->getEditConflictHelper()->incrementConflictStats( $this->context->getUser() );
3851    }
3852
3853    /**
3854     * @return string
3855     */
3856    private function getHelpLink(): string {
3857        $message = $this->context->msg( 'edithelppage' )->inContentLanguage()->text();
3858        $editHelpUrl = Skin::makeInternalOrExternalUrl( $message );
3859        return Html::element( 'a', [
3860            'href' => $editHelpUrl,
3861            'target' => 'helpwindow'
3862        ], $this->context->msg( 'edithelp' )->text() );
3863    }
3864
3865    /**
3866     * @param int $tabindex Current tabindex
3867     * @return ButtonWidget
3868     */
3869    private function getCancelLink( int $tabindex ): ButtonWidget {
3870        $cancelParams = [];
3871        if ( !$this->isConflict && $this->oldid > 0 ) {
3872            $cancelParams['oldid'] = $this->oldid;
3873        } elseif ( $this->getContextTitle()->isRedirect() ) {
3874            $cancelParams['redirect'] = 'no';
3875        }
3876
3877        return new OOUI\ButtonWidget( [
3878            'id' => 'mw-editform-cancel',
3879            'tabIndex' => $tabindex,
3880            'href' => $this->getContextTitle()->getLinkURL( $cancelParams ),
3881            'label' => new OOUI\HtmlSnippet( $this->context->msg( 'cancel' )->parse() ),
3882            'framed' => false,
3883            'infusable' => true,
3884            'flags' => 'destructive',
3885        ] );
3886    }
3887
3888    /**
3889     * Returns the URL to use in the form's action attribute.
3890     * This is used by EditPage subclasses when simply customizing the action
3891     * variable in the constructor is not enough. This can be used when the
3892     * EditPage lives inside of a Special page rather than a custom page action.
3893     *
3894     * @param Title $title Title object for which is being edited (where we go to for &action= links)
3895     * @return string
3896     */
3897    protected function getActionURL( Title $title ) {
3898        return $title->getLocalURL( [ 'action' => $this->action ] );
3899    }
3900
3901    /**
3902     * Check if a page was deleted while the user was editing it, before submit.
3903     * Note that we rely on the logging table, which hasn't been always there,
3904     * but that doesn't matter, because this only applies to brand new
3905     * deletes.
3906     * @return bool
3907     */
3908    private function wasDeletedSinceLastEdit(): bool {
3909        if ( $this->deletedSinceEdit !== null ) {
3910            return $this->deletedSinceEdit;
3911        }
3912
3913        $this->deletedSinceEdit = false;
3914
3915        if ( !$this->mTitle->exists() && $this->mTitle->hasDeletedEdits() ) {
3916            $this->lastDelete = $this->getLastDelete();
3917            if ( $this->lastDelete ) {
3918                $deleteTime = wfTimestamp( TS_MW, $this->lastDelete->log_timestamp );
3919                if ( $deleteTime > $this->starttime ) {
3920                    $this->deletedSinceEdit = true;
3921                }
3922            }
3923        }
3924
3925        return $this->deletedSinceEdit;
3926    }
3927
3928    /**
3929     * Get the last log record of this page being deleted, if ever.  This is
3930     * used to detect whether a delete occurred during editing.
3931     * @return stdClass|null
3932     */
3933    private function getLastDelete(): ?stdClass {
3934        $dbr = $this->connectionProvider->getReplicaDatabase();
3935        $commentQuery = $this->commentStore->getJoin( 'log_comment' );
3936        $data = $dbr->selectRow(
3937            array_merge( [ 'logging' ], $commentQuery['tables'], [ 'actor' ] ),
3938            [
3939                'log_type',
3940                'log_action',
3941                'log_timestamp',
3942                'log_namespace',
3943                'log_title',
3944                'log_params',
3945                'log_deleted',
3946                'actor_name'
3947            ] + $commentQuery['fields'],
3948            [
3949                'log_namespace' => $this->mTitle->getNamespace(),
3950                'log_title' => $this->mTitle->getDBkey(),
3951                'log_type' => 'delete',
3952                'log_action' => 'delete',
3953            ],
3954            __METHOD__,
3955            [ 'ORDER BY' => [ 'log_timestamp DESC', 'log_id DESC' ] ],
3956            [
3957                'actor' => [ 'JOIN', 'actor_id=log_actor' ],
3958            ] + $commentQuery['joins']
3959        );
3960        // Quick paranoid permission checks...
3961        if ( $data !== false ) {
3962            if ( $data->log_deleted & LogPage::DELETED_USER ) {
3963                $data->actor_name = $this->context->msg( 'rev-deleted-user' )->escaped();
3964            }
3965
3966            if ( $data->log_deleted & LogPage::DELETED_COMMENT ) {
3967                $data->log_comment_text = $this->context->msg( 'rev-deleted-comment' )->escaped();
3968                $data->log_comment_data = null;
3969            }
3970        }
3971
3972        return $data ?: null;
3973    }
3974
3975    /**
3976     * Get the rendered text for previewing.
3977     * @throws MWException
3978     * @return string
3979     */
3980    public function getPreviewText() {
3981        $out = $this->context->getOutput();
3982        $config = $this->context->getConfig();
3983
3984        if ( $config->get( MainConfigNames::RawHtml ) && !$this->mTokenOk ) {
3985            // Could be an offsite preview attempt. This is very unsafe if
3986            // HTML is enabled, as it could be an attack.
3987            $parsedNote = '';
3988            if ( $this->textbox1 !== '' ) {
3989                // Do not put big scary notice, if previewing the empty
3990                // string, which happens when you initially edit
3991                // a category page, due to automatic preview-on-open.
3992                $parsedNote = Html::rawElement( 'div', [ 'class' => 'previewnote' ],
3993                    $out->parseAsInterface(
3994                        $this->context->msg( 'session_fail_preview_html' )->plain()
3995                    ) );
3996            }
3997            $this->incrementEditFailureStats( 'session_loss' );
3998            return $parsedNote;
3999        }
4000
4001        $note = '';
4002
4003        try {
4004            $content = $this->toEditContent( $this->textbox1 );
4005
4006            $previewHTML = '';
4007            if ( !$this->getHookRunner()->onAlternateEditPreview(
4008                $this, $content, $previewHTML, $this->mParserOutput )
4009            ) {
4010                return $previewHTML;
4011            }
4012
4013            # provide a anchor link to the editform
4014            $continueEditing = '<span class="mw-continue-editing">' .
4015                '[[#' . self::EDITFORM_ID . '|' .
4016                $this->context->getLanguage()->getArrow() . ' ' .
4017                $this->context->msg( 'continue-editing' )->text() . ']]</span>';
4018            if ( $this->mTriedSave && !$this->mTokenOk ) {
4019                $note = $this->context->msg( 'session_fail_preview' )->plain();
4020                $this->incrementEditFailureStats( 'session_loss' );
4021            } elseif ( $this->incompleteForm ) {
4022                $note = $this->context->msg( 'edit_form_incomplete' )->plain();
4023                if ( $this->mTriedSave ) {
4024                    $this->incrementEditFailureStats( 'incomplete_form' );
4025                }
4026            } else {
4027                $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
4028            }
4029
4030            # don't parse non-wikitext pages, show message about preview
4031            if ( $this->mTitle->isUserConfigPage() || $this->mTitle->isSiteConfigPage() ) {
4032                if ( $this->mTitle->isUserConfigPage() ) {
4033                    $level = 'user';
4034                } elseif ( $this->mTitle->isSiteConfigPage() ) {
4035                    $level = 'site';
4036                } else {
4037                    $level = false;
4038                }
4039
4040                if ( $content->getModel() === CONTENT_MODEL_CSS ) {
4041                    $format = 'css';
4042                    if ( $level === 'user' && !$config->get( MainConfigNames::AllowUserCss ) ) {
4043                        $format = false;
4044                    }
4045                } elseif ( $content->getModel() === CONTENT_MODEL_JSON ) {
4046                    $format = 'json';
4047                    if ( $level === 'user' /* No comparable 'AllowUserJson' */ ) {
4048                        $format = false;
4049                    }
4050                } elseif ( $content->getModel() === CONTENT_MODEL_JAVASCRIPT ) {
4051                    $format = 'js';
4052                    if ( $level === 'user' && !$config->get( MainConfigNames::AllowUserJs ) ) {
4053                        $format = false;
4054                    }
4055                } else {
4056                    $format = false;
4057                }
4058
4059                # Used messages to make sure grep find them:
4060                # Messages: usercsspreview, userjsonpreview, userjspreview,
4061                #   sitecsspreview, sitejsonpreview, sitejspreview
4062                if ( $level && $format ) {
4063                    $note = "<div id='mw-{$level}{$format}preview'>" .
4064                        $this->context->msg( "{$level}{$format}preview" )->plain() .
4065                        ' ' . $continueEditing . "</div>";
4066                }
4067            }
4068
4069            if ( $this->section === "new" ) {
4070                $content = $content->addSectionHeader( $this->sectiontitle );
4071            }
4072
4073            // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
4074            $this->getHookRunner()->onEditPageGetPreviewContent( $this, $content );
4075
4076            $parserResult = $this->doPreviewParse( $content );
4077            $parserOutput = $parserResult['parserOutput'];
4078            $previewHTML = $parserResult['html'];
4079            $this->mParserOutput = $parserOutput;
4080            $out->addParserOutputMetadata( $parserOutput );
4081            if ( $out->userCanPreview() ) {
4082                $out->addContentOverride( $this->getTitle(), $content );
4083            }
4084
4085            if ( count( $parserOutput->getWarnings() ) ) {
4086                $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
4087            }
4088
4089        } catch ( MWContentSerializationException $ex ) {
4090            $m = $this->context->msg(
4091                'content-failed-to-parse',
4092                $this->contentModel,
4093                $this->contentFormat,
4094                $ex->getMessage()
4095            );
4096            $note .= "\n\n" . $m->plain(); # gets parsed down below
4097            $previewHTML = '';
4098        }
4099
4100        if ( $this->isConflict ) {
4101            $conflict = Html::warningBox(
4102                $this->context->msg( 'previewconflict' )->escaped(),
4103                'mw-previewconflict'
4104            );
4105        } else {
4106            $conflict = '';
4107        }
4108
4109        $previewhead = Html::rawElement(
4110            'div', [ 'class' => 'previewnote' ],
4111            Html::rawElement(
4112                'h2', [ 'id' => 'mw-previewheader' ],
4113                $this->context->msg( 'preview' )->escaped()
4114            ) .
4115            Html::warningBox(
4116                $out->parseAsInterface( $note )
4117            ) . $conflict
4118        );
4119
4120        return $previewhead . $previewHTML . $this->previewTextAfterContent;
4121    }
4122
4123    private function incrementEditFailureStats( string $failureType ): void {
4124        MediaWikiServices::getInstance()->getStatsFactory()
4125            ->getCounter( 'edit_failure_total' )
4126            ->setLabel( 'cause', $failureType )
4127            ->setLabel( 'namespace', 'n/a' )
4128            ->setLabel( 'user_bucket', 'n/a' )
4129            ->copyToStatsdAt( 'edit.failures.' . $failureType )
4130            ->increment();
4131    }
4132
4133    /**
4134     * Get parser options for a preview
4135     * @return ParserOptions
4136     */
4137    protected function getPreviewParserOptions() {
4138        $parserOptions = $this->page->makeParserOptions( $this->context );
4139        $parserOptions->setRenderReason( 'page-preview' );
4140        $parserOptions->setIsPreview( true );
4141        $parserOptions->setIsSectionPreview( $this->section !== null && $this->section !== '' );
4142
4143        // XXX: we could call $parserOptions->setCurrentRevisionRecordCallback here to force the
4144        // current revision to be null during PST, until setupFakeRevision is called on
4145        // the ParserOptions. Currently, we rely on Parser::getRevisionRecordObject() to ignore
4146        // existing revisions in preview mode.
4147
4148        return $parserOptions;
4149    }
4150
4151    /**
4152     * Parse the page for a preview. Subclasses may override this class, in order
4153     * to parse with different options, or to otherwise modify the preview HTML.
4154     *
4155     * @param Content $content The page content
4156     * @return array with keys:
4157     *   - parserOutput: The ParserOutput object
4158     *   - html: The HTML to be displayed
4159     */
4160    protected function doPreviewParse( Content $content ) {
4161        $user = $this->getUserForPreview();
4162        $parserOptions = $this->getPreviewParserOptions();
4163
4164        // NOTE: preSaveTransform doesn't have a fake revision to operate on.
4165        // Parser::getRevisionRecordObject() will return null in preview mode,
4166        // causing the context user to be used for {{subst:REVISIONUSER}}.
4167        // XXX: Alternatively, we could also call setupFakeRevision()
4168        // before PST with $content.
4169        $services = MediaWikiServices::getInstance();
4170        $contentTransformer = $services->getContentTransformer();
4171        $contentRenderer = $services->getContentRenderer();
4172        $pstContent = $contentTransformer->preSaveTransform( $content, $this->mTitle, $user, $parserOptions );
4173        $parserOutput = $contentRenderer->getParserOutput( $pstContent, $this->mTitle, null, $parserOptions );
4174        $out = $this->context->getOutput();
4175        $skin = $out->getSkin();
4176        $skinOptions = $skin->getOptions();
4177        return [
4178            'parserOutput' => $parserOutput,
4179            'html' => $parserOutput->getText( [
4180                'userLang' => $skin->getLanguage(),
4181                'injectTOC' => $skinOptions['toc'],
4182                'enableSectionEditLinks' => false,
4183                'includeDebugInfo' => true,
4184            ] )
4185        ];
4186    }
4187
4188    /**
4189     * @return Title[]
4190     */
4191    public function getTemplates() {
4192        if ( $this->preview || $this->section !== '' ) {
4193            $templates = [];
4194            if ( !isset( $this->mParserOutput ) ) {
4195                return $templates;
4196            }
4197            foreach ( $this->mParserOutput->getTemplates() as $ns => $template ) {
4198                foreach ( $template as $dbk => $_ ) {
4199                    $templates[] = Title::makeTitle( $ns, $dbk );
4200                }
4201            }
4202            return $templates;
4203        } else {
4204            return $this->mTitle->getTemplateLinksFrom();
4205        }
4206    }
4207
4208    /**
4209     * Allow extensions to provide a toolbar.
4210     *
4211     * @return string|null
4212     */
4213    public static function getEditToolbar() {
4214        $startingToolbar = '<div id="toolbar"></div>';
4215        $toolbar = $startingToolbar;
4216
4217        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
4218        if ( !$hookRunner->onEditPageBeforeEditToolbar( $toolbar ) ) {
4219            return null;
4220        }
4221        // Don't add a pointless `<div>` to the page unless a hook caller populated it
4222        return ( $toolbar === $startingToolbar ) ? null : $toolbar;
4223    }
4224
4225    /**
4226     * Return an array of field definitions. Despite the name, not only checkboxes are supported.
4227     *
4228     * Array keys correspond to the `<input>` 'name' attribute to use for each field.
4229     *
4230     * Array values are associative arrays with the following keys:
4231     *  - 'label-message' (required): message for label text
4232     *  - 'id' (required): 'id' attribute for the `<input>`
4233     *  - 'default' (required): default checkedness (true or false)
4234     *  - 'title-message' (optional): used to generate 'title' attribute for the `<label>`
4235     *  - 'tooltip' (optional): used to generate 'title' and 'accesskey' attributes
4236     *    from messages like 'tooltip-foo', 'accesskey-foo'
4237     *  - 'label-id' (optional): 'id' attribute for the `<label>`
4238     *  - 'legacy-name' (optional): short name for backwards-compatibility
4239     *  - 'class' (optional): PHP class name of the OOUI widget to use. Defaults to
4240     *    CheckboxInputWidget.
4241     *  - 'options' (optional): options to use for DropdownInputWidget,
4242     *    ComboBoxInputWidget, etc. following the structure as given in the documentation for those
4243     *    classes.
4244     *  - 'value-attr' (optional): name of the widget config option for the "current value" of the
4245     *    widget. Defaults to 'selected'; for some widget types it should be 'value'.
4246     * @param array<string,mixed> $values Map of field names (matching the 'legacy-name') to current field values.
4247     *   For checkboxes, the value is a bool that indicates the checked status of the checkbox.
4248     * @return array[]
4249     */
4250    public function getCheckboxesDefinition( $values ) {
4251        $checkboxes = [];
4252
4253        $user = $this->context->getUser();
4254        // don't show the minor edit checkbox if it's a new page or section
4255        if ( !$this->isNew && $this->permManager->userHasRight( $user, 'minoredit' ) ) {
4256            $checkboxes['wpMinoredit'] = [
4257                'id' => 'wpMinoredit',
4258                'label-message' => 'minoredit',
4259                // Uses messages: tooltip-minoredit, accesskey-minoredit
4260                'tooltip' => 'minoredit',
4261                'label-id' => 'mw-editpage-minoredit',
4262                'legacy-name' => 'minor',
4263                'default' => $values['minor'],
4264            ];
4265        }
4266
4267        if ( $user->isNamed() ) {
4268            $checkboxes = array_merge(
4269                $checkboxes,
4270                $this->getCheckboxesDefinitionForWatchlist( $values['watch'], $values['wpWatchlistExpiry'] ?? null )
4271            );
4272        }
4273
4274        $this->getHookRunner()->onEditPageGetCheckboxesDefinition( $this, $checkboxes );
4275
4276        return $checkboxes;
4277    }
4278
4279    /**
4280     * Get the watchthis and watchlistExpiry form field definitions.
4281     *
4282     * @param bool $watch
4283     * @param string $watchexpiry
4284     * @return array[]
4285     */
4286    private function getCheckboxesDefinitionForWatchlist( $watch, $watchexpiry ): array {
4287        $fieldDefs = [
4288            'wpWatchthis' => [
4289                'id' => 'wpWatchthis',
4290                'label-message' => 'watchthis',
4291                // Uses messages: tooltip-watch, accesskey-watch
4292                'tooltip' => 'watch',
4293                'label-id' => 'mw-editpage-watch',
4294                'legacy-name' => 'watch',
4295                'default' => $watch,
4296            ]
4297        ];
4298        if ( $this->watchlistExpiryEnabled ) {
4299            $watchedItem = $this->watchedItemStore->getWatchedItem( $this->getContext()->getUser(), $this->getTitle() );
4300            $expiryOptions = WatchAction::getExpiryOptions( $this->getContext(), $watchedItem );
4301            if ( $watchexpiry && in_array( $watchexpiry, $expiryOptions['options'] ) ) {
4302                $expiryOptions['default'] = $watchexpiry;
4303            }
4304            // Reformat the options to match what DropdownInputWidget wants.
4305            $options = [];
4306            foreach ( $expiryOptions['options'] as $label => $value ) {
4307                $options[] = [ 'data' => $value, 'label' => $label ];
4308            }
4309            $fieldDefs['wpWatchlistExpiry'] = [
4310                'id' => 'wpWatchlistExpiry',
4311                'label-message' => 'confirm-watch-label',
4312                // Uses messages: tooltip-watchlist-expiry, accesskey-watchlist-expiry
4313                'tooltip' => 'watchlist-expiry',
4314                'label-id' => 'mw-editpage-watchlist-expiry',
4315                'default' => $expiryOptions['default'],
4316                'value-attr' => 'value',
4317                'class' => DropdownInputWidget::class,
4318                'options' => $options,
4319                'invisibleLabel' => true,
4320            ];
4321        }
4322        return $fieldDefs;
4323    }
4324
4325    /**
4326     * Returns an array of fields for the edit form, including 'minor' and 'watch' checkboxes and
4327     * any other added by extensions. Despite the name, not only checkboxes are supported.
4328     *
4329     * @param int &$tabindex Current tabindex
4330     * @param array<string,mixed> $values Map of field names to current field values.
4331     *   For checkboxes, the value is a bool that indicates the checked status of the checkbox.
4332     * @return \OOUI\Element[] Associative array of string keys to \OOUI\Widget or \OOUI\Layout
4333     *  instances
4334     */
4335    public function getCheckboxesWidget( &$tabindex, $values ) {
4336        $checkboxes = [];
4337        $checkboxesDef = $this->getCheckboxesDefinition( $values );
4338
4339        foreach ( $checkboxesDef as $name => $options ) {
4340            $legacyName = $options['legacy-name'] ?? $name;
4341
4342            $title = null;
4343            $accesskey = null;
4344            if ( isset( $options['tooltip'] ) ) {
4345                $accesskey = $this->context->msg( "accesskey-{$options['tooltip']}" )->text();
4346                $title = Linker::titleAttrib( $options['tooltip'] );
4347            }
4348            if ( isset( $options['title-message'] ) ) {
4349                $title = $this->context->msg( $options['title-message'] )->text();
4350            }
4351            // Allow checkbox definitions to set their own class and value-attribute names.
4352            // See $this->getCheckboxesDefinition() for details.
4353            $className = $options['class'] ?? CheckboxInputWidget::class;
4354            $valueAttr = $options['value-attr'] ?? 'selected';
4355            $checkboxes[ $legacyName ] = new FieldLayout(
4356                new $className( [
4357                    'tabIndex' => ++$tabindex,
4358                    'accessKey' => $accesskey,
4359                    'id' => $options['id'] . 'Widget',
4360                    'inputId' => $options['id'],
4361                    'name' => $name,
4362                    $valueAttr => $options['default'],
4363                    'infusable' => true,
4364                    'options' => $options['options'] ?? null,
4365                ] ),
4366                [
4367                    'align' => 'inline',
4368                    'label' => new OOUI\HtmlSnippet( $this->context->msg( $options['label-message'] )->parse() ),
4369                    'title' => $title,
4370                    'id' => $options['label-id'] ?? null,
4371                    'invisibleLabel' => $options['invisibleLabel'] ?? null,
4372                ]
4373            );
4374        }
4375
4376        return $checkboxes;
4377    }
4378
4379    /**
4380     * Get the message key of the label for the button to save the page
4381     *
4382     * @return string
4383     */
4384    private function getSubmitButtonLabel(): string {
4385        $labelAsPublish =
4386            $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
4387
4388        // Can't use $this->isNew as that's also true if we're adding a new section to an extant page
4389        $newPage = !$this->mTitle->exists();
4390
4391        if ( $labelAsPublish ) {
4392            $buttonLabelKey = $newPage ? 'publishpage' : 'publishchanges';
4393        } else {
4394            $buttonLabelKey = $newPage ? 'savearticle' : 'savechanges';
4395        }
4396
4397        return $buttonLabelKey;
4398    }
4399
4400    /**
4401     * Returns an array of html code of the following buttons:
4402     * save, diff and preview
4403     *
4404     * @param int &$tabindex Current tabindex
4405     *
4406     * @return string[] Strings or objects with a __toString() implementation. Usually an array of
4407     *  {@see ButtonInputWidget}, but EditPageBeforeEditButtons hook handlers might inject something
4408     *  else.
4409     */
4410    public function getEditButtons( &$tabindex ) {
4411        $buttons = [];
4412
4413        $labelAsPublish =
4414            $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
4415
4416        $buttonLabel = $this->context->msg( $this->getSubmitButtonLabel() )->text();
4417        $buttonTooltip = $labelAsPublish ? 'publish' : 'save';
4418
4419        $buttons['save'] = new OOUI\ButtonInputWidget( [
4420            'name' => 'wpSave',
4421            'tabIndex' => ++$tabindex,
4422            'id' => 'wpSaveWidget',
4423            'inputId' => 'wpSave',
4424            // Support: IE 6 â€“ Use <input>, otherwise it can't distinguish which button was clicked
4425            'useInputTag' => true,
4426            'flags' => [ 'progressive', 'primary' ],
4427            'label' => $buttonLabel,
4428            'infusable' => true,
4429            'type' => 'submit',
4430            // Messages used: tooltip-save, tooltip-publish
4431            'title' => Linker::titleAttrib( $buttonTooltip ),
4432            // Messages used: accesskey-save, accesskey-publish
4433            'accessKey' => Linker::accesskey( $buttonTooltip ),
4434        ] );
4435
4436        $buttons['preview'] = new OOUI\ButtonInputWidget( [
4437            'name' => 'wpPreview',
4438            'tabIndex' => ++$tabindex,
4439            'id' => 'wpPreviewWidget',
4440            'inputId' => 'wpPreview',
4441            // Support: IE 6 â€“ Use <input>, otherwise it can't distinguish which button was clicked
4442            'useInputTag' => true,
4443            'label' => $this->context->msg( 'showpreview' )->text(),
4444            'infusable' => true,
4445            'type' => 'submit',
4446            // Allow previewing even when the form is in invalid state (T343585)
4447            'formNoValidate' => true,
4448            // Message used: tooltip-preview
4449            'title' => Linker::titleAttrib( 'preview' ),
4450            // Message used: accesskey-preview
4451            'accessKey' => Linker::accesskey( 'preview' ),
4452        ] );
4453
4454        $buttons['diff'] = new OOUI\ButtonInputWidget( [
4455            'name' => 'wpDiff',
4456            'tabIndex' => ++$tabindex,
4457            'id' => 'wpDiffWidget',
4458            'inputId' => 'wpDiff',
4459            // Support: IE 6 â€“ Use <input>, otherwise it can't distinguish which button was clicked
4460            'useInputTag' => true,
4461            'label' => $this->context->msg( 'showdiff' )->text(),
4462            'infusable' => true,
4463            'type' => 'submit',
4464            // Allow previewing even when the form is in invalid state (T343585)
4465            'formNoValidate' => true,
4466            // Message used: tooltip-diff
4467            'title' => Linker::titleAttrib( 'diff' ),
4468            // Message used: accesskey-diff
4469            'accessKey' => Linker::accesskey( 'diff' ),
4470        ] );
4471
4472        $this->getHookRunner()->onEditPageBeforeEditButtons( $this, $buttons, $tabindex );
4473
4474        return $buttons;
4475    }
4476
4477    /**
4478     * Creates a basic error page which informs the user that
4479     * they have attempted to edit a nonexistent section.
4480     */
4481    private function noSuchSectionPage(): void {
4482        $out = $this->context->getOutput();
4483        $out->prepareErrorPage();
4484        $out->setPageTitleMsg( $this->context->msg( 'nosuchsectiontitle' ) );
4485
4486        $res = $this->context->msg( 'nosuchsectiontext', $this->section )->parseAsBlock();
4487
4488        $this->getHookRunner()->onEditPageNoSuchSection( $this, $res );
4489        $out->addHTML( $res );
4490
4491        $out->returnToMain( false, $this->mTitle );
4492    }
4493
4494    /**
4495     * Show "your edit contains spam" page with your diff and text
4496     *
4497     * @param string|array|false $match Text (or array of texts) which triggered one or more filters
4498     */
4499    public function spamPageWithContent( $match = false ) {
4500        $this->textbox2 = $this->textbox1;
4501
4502        $out = $this->context->getOutput();
4503        $out->prepareErrorPage();
4504        $out->setPageTitleMsg( $this->context->msg( 'spamprotectiontitle' ) );
4505
4506        $spamText = $this->context->msg( 'spamprotectiontext' )->parseAsBlock();
4507
4508        if ( $match ) {
4509            if ( is_array( $match ) ) {
4510                $matchText = $this->context->getLanguage()->listToText( array_map( 'wfEscapeWikiText', $match ) );
4511            } else {
4512                $matchText = wfEscapeWikiText( $match );
4513            }
4514
4515            $spamText .= $this->context->msg( 'spamprotectionmatch' )
4516                ->params( $matchText )
4517                ->parseAsBlock();
4518        }
4519        $out->addHTML( Html::rawElement(
4520            'div',
4521            [ 'id' => 'spamprotected' ],
4522            $spamText
4523        ) );
4524
4525        $out->wrapWikiMsg( '<h2>$1</h2>', "yourdiff" );
4526        $this->showDiff();
4527
4528        $out->wrapWikiMsg( '<h2>$1</h2>', "yourtext" );
4529        $this->showTextbox( $this->textbox2, 'wpTextbox2', [ 'tabindex' => 6, 'readonly' ] );
4530
4531        $out->addReturnTo( $this->getContextTitle(), [ 'action' => 'edit' ] );
4532    }
4533
4534    private function addLongPageWarningHeader(): void {
4535        if ( $this->contentLength === false ) {
4536            $this->contentLength = strlen( $this->textbox1 );
4537        }
4538
4539        $out = $this->context->getOutput();
4540        $maxArticleSize = $this->context->getConfig()->get( MainConfigNames::MaxArticleSize );
4541        if ( $this->tooBig || $this->contentLength > $maxArticleSize * 1024 ) {
4542            $lang = $this->context->getLanguage();
4543            $out->wrapWikiMsg( "<div class='error' id='mw-edit-longpageerror'>\n$1\n</div>",
4544                [
4545                    'longpageerror',
4546                    $lang->formatNum( round( $this->contentLength / 1024, 3 ) ),
4547                    $lang->formatNum( $maxArticleSize )
4548                ]
4549            );
4550        } else {
4551            $longPageHint = $this->context->msg( 'longpage-hint' );
4552            if ( !$longPageHint->isDisabled() ) {
4553                $msgText = trim( $longPageHint->sizeParams( $this->contentLength )
4554                    ->params( $this->contentLength ) // Keep this unformatted for math inside message
4555                    ->text() );
4556                if ( $msgText !== '' && $msgText !== '-' ) {
4557                    $out->addWikiTextAsInterface( "<div id='mw-edit-longpage-hint'>\n$msgText\n</div>" );
4558                }
4559            }
4560        }
4561    }
4562
4563    private function addExplainConflictHeader(): void {
4564        $this->context->getOutput()->addHTML(
4565            $this->getEditConflictHelper()->getExplainHeader()
4566        );
4567    }
4568
4569    /**
4570     * Turns section name wikitext into anchors for use in HTTP redirects. Various
4571     * versions of Microsoft browsers misinterpret fragment encoding of Location: headers
4572     * resulting in mojibake in address bar. Redirect them to legacy section IDs,
4573     * if possible. All the other browsers get HTML5 if the wiki is configured for it, to
4574     * spread the new style links more efficiently.
4575     *
4576     * @param string $text
4577     * @return string
4578     */
4579    private function guessSectionName( $text ): string {
4580        // Detect Microsoft browsers
4581        $userAgent = $this->context->getRequest()->getHeader( 'User-Agent' );
4582        $parser = MediaWikiServices::getInstance()->getParser();
4583        if ( $userAgent && preg_match( '/MSIE|Edge/', $userAgent ) ) {
4584            // ...and redirect them to legacy encoding, if available
4585            return $parser->guessLegacySectionNameFromWikiText( $text );
4586        }
4587        // Meanwhile, real browsers get real anchors
4588        $name = $parser->guessSectionNameFromWikiText( $text );
4589        // With one little caveat: per T216029, fragments in HTTP redirects need to be urlencoded,
4590        // otherwise Chrome double-escapes the rest of the URL.
4591        return '#' . urlencode( mb_substr( $name, 1 ) );
4592    }
4593
4594    /**
4595     * @param callable $factory Factory function to create a {@see TextConflictHelper}
4596     * @since 1.31
4597     */
4598    public function setEditConflictHelperFactory( callable $factory ) {
4599        Assert::precondition( !$this->editConflictHelperFactory,
4600            'Can only have one extension that resolves edit conflicts' );
4601        $this->editConflictHelperFactory = $factory;
4602    }
4603
4604    private function getEditConflictHelper(): TextConflictHelper {
4605        if ( !$this->editConflictHelper ) {
4606            $label = $this->getSubmitButtonLabel();
4607            if ( $this->editConflictHelperFactory ) {
4608                $this->editConflictHelper = ( $this->editConflictHelperFactory )( $label );
4609            } else {
4610                $this->editConflictHelper = new TextConflictHelper(
4611                    $this->getTitle(),
4612                    $this->getContext()->getOutput(),
4613                    MediaWikiServices::getInstance()->getStatsFactory(),
4614                    $label,
4615                    MediaWikiServices::getInstance()->getContentHandlerFactory()
4616                );
4617            }
4618        }
4619        return $this->editConflictHelper;
4620    }
4621}
4622
4623/** @deprecated class alias since 1.40 */
4624class_alias( EditPage::class, 'EditPage' );