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