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