Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
7.57% covered (danger)
7.57%
55 / 727
9.86% covered (danger)
9.86%
7 / 71
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
7.57% covered (danger)
7.57%
55 / 727
9.86% covered (danger)
9.86%
7 / 71
46112.05
0.00% covered (danger)
0.00%
0 / 1
 registerExtension
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onResourceLoaderRegisterModules
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 getAbuseFilter
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 initFlowExtension
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 resetFlowExtension
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onChangesListInitRows
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 onChangesListInsertArticleLink
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onOldChangesListRecentChangesLine
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processRecentChangesLine
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
56
 onEnhancedChangesList__getLogText
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 onEnhancedChangesListModifyLineData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onEnhancedChangesListModifyBlockLineData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 modifyChangesListLine
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 isFlow
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onSpecialCheckUserGetLinksFromRow
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 onCheckUserFormatRow
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 getReplacementRowItems
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 onSkinTemplateNavigation__Universal
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
72
 onArticle__MissingArticleConditions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 onUserGetReservedNames
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 onResourceLoaderGetConfigVars
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onDeletedContributionsLineEnding
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 onContributionsLineEnding
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onApiFeedContributions__feedItem
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 getContributionsQuery
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 onDeletedContribsPager__reallyDoQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onContribsPager__reallyDoQuery
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onAbuseFilterBuilder
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 onAbuseFilterDeprecatedVariables
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onAbuseFilterComputeVariable
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onArticleEditUpdateNewTalk
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isTalkpageManagerUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onInfoAction
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 onCheckUserInsertChangesRow
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 onIRCLineURL
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
5.02
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 handleWatchArticle
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 onWatchArticle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onUnwatchArticle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onMovePageIsValidMove
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 onMovePageCheckPermissions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 onTitleSquidURLs
11.76% covered (danger)
11.76%
2 / 17
0.00% covered (danger)
0.00%
0 / 1
14.99
 onWatchlistEditorBuildRemoveLine
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 onWatchlistEditorBeforeFormRender
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 onUserMergeAccountFields
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onMergeAccountFromTo
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onIsLiquidThreadsPage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 onCategoryViewer__doCategoryQuery
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onCategoryViewer__generateLink
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 getTopicDeletionError
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleConfirmDelete
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onArticleDelete
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onArticleDeleteComplete
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 onRevisionUndeleted
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 onArticleUndelete
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onTitleMoveStarting
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 onPageMoveCompleting
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onShowMissingArticle
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 onSearchableNamespaces
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isBetaFeatureAvailable
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 onGetBetaFeaturePreferences
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 onSaveUserOptions
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 onImportHandleToplevelXMLTag
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
210
 onNukeGetNewPages
0.00% covered (danger)
0.00%
0 / 95
0.00% covered (danger)
0.00%
0 / 1
420
 onNukeDeletePage
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
42
 onChangesListSpecialPageQuery
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 onGetUserPermissionsErrors
42.86% covered (danger)
42.86%
6 / 14
0.00% covered (danger)
0.00%
0 / 1
24.11
 getTermsOfUseMessages
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getTermsOfUseMessagesParsed
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTermsOfUseMessagesVersion
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace Flow;
4
5use Exception;
6use Flow\Collection\PostCollection;
7use Flow\Data\Listener\RecentChangesListener;
8use Flow\Exception\FlowException;
9use Flow\Exception\InvalidInputException;
10use Flow\Exception\PermissionException;
11use Flow\Formatter\CheckUserQuery;
12use Flow\Hooks\HookRunner;
13use Flow\Import\OptInController;
14use Flow\Model\UUID;
15use Flow\SpamFilter\AbuseFilter;
16use MediaWiki\Api\Hook\ApiFeedContributions__feedItemHook;
17use MediaWiki\CheckUser\CheckUser\Pagers\AbstractCheckUserPager;
18use MediaWiki\Config\Config;
19use MediaWiki\Content\Content;
20use MediaWiki\Context\IContextSource;
21use MediaWiki\Context\RequestContext;
22use MediaWiki\Deferred\DeferredUpdates;
23use MediaWiki\Exception\MWException;
24use MediaWiki\Exception\MWExceptionHandler;
25use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
26use MediaWiki\Extension\BetaFeatures\BetaFeatures;
27use MediaWiki\Extension\GuidedTour\GuidedTourLauncher;
28use MediaWiki\Feed\FeedItem;
29use MediaWiki\Hook\CategoryViewer__doCategoryQueryHook;
30use MediaWiki\Hook\CategoryViewer__generateLinkHook;
31use MediaWiki\Hook\ChangesListInitRowsHook;
32use MediaWiki\Hook\ChangesListInsertArticleLinkHook;
33use MediaWiki\Hook\ContribsPager__reallyDoQueryHook;
34use MediaWiki\Hook\ContributionsLineEndingHook;
35use MediaWiki\Hook\DeletedContribsPager__reallyDoQueryHook;
36use MediaWiki\Hook\DeletedContributionsLineEndingHook;
37use MediaWiki\Hook\EnhancedChangesList__getLogTextHook;
38use MediaWiki\Hook\EnhancedChangesListModifyBlockLineDataHook;
39use MediaWiki\Hook\EnhancedChangesListModifyLineDataHook;
40use MediaWiki\Hook\ImportHandleToplevelXMLTagHook;
41use MediaWiki\Hook\InfoActionHook;
42use MediaWiki\Hook\IRCLineURLHook;
43use MediaWiki\Hook\MovePageCheckPermissionsHook;
44use MediaWiki\Hook\MovePageIsValidMoveHook;
45use MediaWiki\Hook\OldChangesListRecentChangesLineHook;
46use MediaWiki\Hook\PageMoveCompletingHook;
47use MediaWiki\Hook\SkinTemplateNavigation__UniversalHook;
48use MediaWiki\Hook\TitleMoveStartingHook;
49use MediaWiki\Hook\TitleSquidURLsHook;
50use MediaWiki\Hook\UnwatchArticleHook;
51use MediaWiki\Hook\WatchArticleHook;
52use MediaWiki\Hook\WatchlistEditorBeforeFormRenderHook;
53use MediaWiki\Hook\WatchlistEditorBuildRemoveLineHook;
54use MediaWiki\Html\FormOptions;
55use MediaWiki\Html\Html;
56use MediaWiki\Logging\LogEntry;
57use MediaWiki\MediaWikiServices;
58use MediaWiki\Message\Message;
59use MediaWiki\Output\Hook\BeforePageDisplayHook;
60use MediaWiki\Output\OutputPage;
61use MediaWiki\Page\Article;
62use MediaWiki\Page\Hook\Article__MissingArticleConditionsHook;
63use MediaWiki\Page\Hook\ArticleConfirmDeleteHook;
64use MediaWiki\Page\Hook\ArticleDeleteCompleteHook;
65use MediaWiki\Page\Hook\ArticleDeleteHook;
66use MediaWiki\Page\Hook\ArticleUndeleteHook;
67use MediaWiki\Page\Hook\RevisionUndeletedHook;
68use MediaWiki\Page\Hook\ShowMissingArticleHook;
69use MediaWiki\Page\WikiPage;
70use MediaWiki\Pager\ContribsPager;
71use MediaWiki\Pager\DeletedContribsPager;
72use MediaWiki\Pager\IndexPager;
73use MediaWiki\Parser\ParserOptions;
74use MediaWiki\Permissions\Hook\GetUserPermissionsErrorsHook;
75use MediaWiki\Preferences\Hook\GetPreferencesHook;
76use MediaWiki\RecentChanges\ChangesList;
77use MediaWiki\RecentChanges\EnhancedChangesList;
78use MediaWiki\RecentChanges\OldChangesList;
79use MediaWiki\RecentChanges\RecentChange;
80use MediaWiki\Registration\ExtensionRegistry;
81use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
82use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook;
83use MediaWiki\ResourceLoader\ResourceLoader;
84use MediaWiki\Revision\RevisionRecord;
85use MediaWiki\Revision\SlotRecord;
86use MediaWiki\Search\Hook\SearchableNamespacesHook;
87use MediaWiki\Skin\Skin;
88use MediaWiki\Skin\SkinTemplate;
89use MediaWiki\SpecialPage\Hook\ChangesListSpecialPageQueryHook;
90use MediaWiki\Status\Status;
91use MediaWiki\Storage\Hook\ArticleEditUpdateNewTalkHook;
92use MediaWiki\Title\Title;
93use MediaWiki\User\Hook\UserGetReservedNamesHook;
94use MediaWiki\User\Options\Hook\SaveUserOptionsHook;
95use MediaWiki\User\User;
96use MediaWiki\User\UserIdentity;
97use MediaWiki\WikiMap\WikiMap;
98use MessageLocalizer;
99use stdClass;
100use WikiImporter;
101use Wikimedia\Rdbms\SelectQueryBuilder;
102use XMLReader;
103
104class Hooks implements
105    ResourceLoaderRegisterModulesHook,
106    BeforePageDisplayHook,
107    GetPreferencesHook,
108    OldChangesListRecentChangesLineHook,
109    ChangesListInsertArticleLinkHook,
110    ChangesListInitRowsHook,
111    EnhancedChangesList__getLogTextHook,
112    EnhancedChangesListModifyLineDataHook,
113    EnhancedChangesListModifyBlockLineDataHook,
114    ChangesListSpecialPageQueryHook,
115    SkinTemplateNavigation__UniversalHook,
116    Article__MissingArticleConditionsHook,
117    UserGetReservedNamesHook,
118    ResourceLoaderGetConfigVarsHook,
119    ContribsPager__reallyDoQueryHook,
120    DeletedContribsPager__reallyDoQueryHook,
121    ContributionsLineEndingHook,
122    DeletedContributionsLineEndingHook,
123    ApiFeedContributions__feedItemHook,
124    ArticleEditUpdateNewTalkHook,
125    InfoActionHook,
126    IRCLineURLHook,
127    ShowMissingArticleHook,
128    WatchArticleHook,
129    UnwatchArticleHook,
130    MovePageCheckPermissionsHook,
131    MovePageIsValidMoveHook,
132    TitleMoveStartingHook,
133    PageMoveCompletingHook,
134    TitleSquidURLsHook,
135    WatchlistEditorBuildRemoveLineHook,
136    WatchlistEditorBeforeFormRenderHook,
137    CategoryViewer__doCategoryQueryHook,
138    CategoryViewer__generateLinkHook,
139    ArticleConfirmDeleteHook,
140    ArticleDeleteHook,
141    ArticleDeleteCompleteHook,
142    RevisionUndeletedHook,
143    ArticleUndeleteHook,
144    SearchableNamespacesHook,
145    ImportHandleToplevelXMLTagHook,
146    SaveUserOptionsHook,
147    GetUserPermissionsErrorsHook
148{
149    /**
150     * @var AbuseFilter|null Initialized during extension initialization
151     */
152    protected static $abuseFilter;
153
154    public static function registerExtension() {
155        require_once dirname( __DIR__ ) . '/defines.php';
156    }
157
158    public function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ): void {
159        // Register a dummy supportCheck module in case VE isn't loaded, as we attempt
160        // to load this module unconditionally on load.
161        if ( !$resourceLoader->isModuleRegistered( 'ext.visualEditor.supportCheck' ) ) {
162            $resourceLoader->register( 'ext.visualEditor.supportCheck', [] );
163        }
164
165        if ( ExtensionRegistry::getInstance()->isLoaded( 'GuidedTour' ) ) {
166            $resourceLoader->register( 'ext.guidedTour.tour.flowOptIn', [
167                'localBasePath' => dirname( __DIR__ ) . '/modules',
168                'remoteExtPath' => 'Flow/modules',
169                'scripts' => 'tours/flowOptIn.js',
170                'styles' => 'tours/flowOptIn.less',
171                'messages' => [
172                    "flow-guidedtour-optin-welcome",
173                    "flow-guidedtour-optin-welcome-description",
174                    "flow-guidedtour-optin-find-old-conversations",
175                    "flow-guidedtour-optin-find-old-conversations-description",
176                    "flow-guidedtour-optin-feedback",
177                    "flow-guidedtour-optin-feedback-description"
178                ],
179                'dependencies' => 'ext.guidedTour',
180            ] );
181        }
182    }
183
184    public function onBeforePageDisplay( $out, $skin ): void {
185        $title = $skin->getTitle();
186
187        // Register guided tour if needed
188        if (
189            // Check that the cookie for Flow opt-in tour exists
190            $out->getRequest()->getCookie( 'Flow_optIn_guidedTour' ) &&
191            // Check that the user is on their own talk page
192            $out->getUser()->getTalkPage()->equals( $title ) &&
193            // Check that we are on a flow board
194            $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD &&
195            // Check that guided tour exists
196            ExtensionRegistry::getInstance()->isLoaded( 'GuidedTour' )
197        ) {
198            // Activate tour
199            GuidedTourLauncher::launchTourByCookie( 'flowOptIn', 'newTopic' );
200
201            // Destroy Flow cookie
202            $out->getRequest()->response()->setCookie( 'Flow_optIn_guidedTour', '', time() - 3600 );
203        }
204    }
205
206    /**
207     * Initialized during extension initialization rather than
208     * in container so that non-flow pages don't load the container.
209     *
210     * @return AbuseFilter
211     */
212    public static function getAbuseFilter() {
213        if ( self::$abuseFilter === null ) {
214            global $wgFlowAbuseFilterGroup,
215                $wgFlowAbuseFilterEmergencyDisableThreshold,
216                $wgFlowAbuseFilterEmergencyDisableCount,
217                $wgFlowAbuseFilterEmergencyDisableAge;
218
219            self::$abuseFilter = new AbuseFilter( $wgFlowAbuseFilterGroup );
220            self::$abuseFilter->setup( [
221                'threshold' => $wgFlowAbuseFilterEmergencyDisableThreshold,
222                'count' => $wgFlowAbuseFilterEmergencyDisableCount,
223                'age' => $wgFlowAbuseFilterEmergencyDisableAge,
224            ] );
225        }
226        return self::$abuseFilter;
227    }
228
229    /**
230     * Initialize Flow extension with necessary data, this function is invoked
231     * from $wgExtensionFunctions
232     */
233    public static function initFlowExtension() {
234        global $wgFlowAbuseFilterGroup;
235
236        // necessary to provide flow options in abuse filter on-wiki pages
237        if ( $wgFlowAbuseFilterGroup ) {
238            self::getAbuseFilter();
239        }
240    }
241
242    /**
243     * Reset anything that happened in self::initFlowExtension for
244     * unit tests
245     */
246    public static function resetFlowExtension() {
247        self::$abuseFilter = null;
248    }
249
250    /**
251     * Loads RecentChanges list metadata into a temporary cache for later use.
252     *
253     * @param ChangesList $changesList
254     * @param array $rows
255     */
256    public function onChangesListInitRows( $changesList, $rows ) {
257        if ( !( $changesList instanceof OldChangesList || $changesList instanceof EnhancedChangesList ) ) {
258            return;
259        }
260
261        set_error_handler( new RecoverableErrorHandler, -1 );
262        try {
263            /** @var Formatter\ChangesListQuery $query */
264            $query = Container::get( 'query.changeslist' );
265            $query->loadMetadataBatch(
266                $rows,
267                $changesList->isWatchlist()
268            );
269        } catch ( Exception $e ) {
270            MWExceptionHandler::logException( $e );
271        } finally {
272            restore_error_handler();
273        }
274    }
275
276    /**
277     * Updates the given Flow topic line in an enhanced changes list (grouped RecentChanges).
278     *
279     * @param ChangesList $changesList
280     * @param string &$articlelink
281     * @param string &$s
282     * @param RecentChange $rc
283     * @param bool $unpatrolled
284     * @param bool $isWatchlist
285     * @return bool
286     */
287    public function onChangesListInsertArticleLink(
288        $changesList,
289        &$articlelink,
290        &$s,
291        $rc,
292        $unpatrolled,
293        $isWatchlist
294    ) {
295        if ( !( $changesList instanceof EnhancedChangesList ) ) {
296            // This method is only to update EnhancedChangesList.
297            // onOldChangesListRecentChangesLine allows updating OldChangesList,
298            // and supports adding wrapper classes.
299            return true;
300        }
301        $classes = null; // avoid pass-by-reference error
302        return self::processRecentChangesLine( $changesList, $articlelink, $rc, $classes, true );
303    }
304
305    /**
306     * Updates a Flow line in the old changes list (standard RecentChanges).
307     *
308     * @param ChangesList $changesList
309     * @param string &$s
310     * @param RecentChange $rc
311     * @param array &$classes
312     * @param array &$attribs
313     * @return bool
314     */
315    public function onOldChangesListRecentChangesLine(
316        $changesList,
317        &$s,
318        $rc,
319        &$classes,
320        &$attribs
321    ) {
322        return self::processRecentChangesLine( $changesList, $s, $rc, $classes );
323    }
324
325    /**
326     * Does the actual work for onOldChangesListRecentChangesLine and
327     * onChangesListInsertArticleLink hooks. Either updates an entire
328     * line with meta info (old changes), or simply updates the link to
329     * the topic (enhanced).
330     *
331     * @param ChangesList $changesList
332     * @param string &$s
333     * @param RecentChange $rc
334     * @param array|null &$classes
335     * @param bool $topicOnly
336     * @return bool
337     */
338    protected static function processRecentChangesLine(
339        ChangesList $changesList,
340        &$s,
341        RecentChange $rc,
342        &$classes = null,
343        $topicOnly = false
344    ) {
345        if ( $rc->getAttribute( 'rc_source' ) !== RecentChangesListener::SRC_FLOW ) {
346            return true;
347        }
348
349        set_error_handler( new RecoverableErrorHandler, -1 );
350        try {
351            /** @var Formatter\ChangesListQuery $query */
352            $query = Container::get( 'query.changeslist' );
353
354            $row = $query->getResult( $changesList, $rc, $changesList->isWatchlist() );
355            if ( $row === false ) {
356                restore_error_handler();
357                return false;
358            }
359
360            /** @var Formatter\ChangesListFormatter $formatter */
361            $formatter = Container::get( 'formatter.changeslist' );
362            $line = $formatter->format( $row, $changesList, $topicOnly );
363        } catch ( PermissionException ) {
364            // It is expected that some rows won't be formatted because the current user
365            // doesn't have permission to see some of the data they contain.
366            return false;
367        } catch ( Exception $e ) {
368            wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting rc ' .
369                $rc->getAttribute( 'rc_id' ) . ' ' . $e );
370            MWExceptionHandler::logException( $e );
371            return false;
372        } finally {
373            restore_error_handler();
374        }
375
376        if ( $line === false ) {
377            return false;
378        }
379
380        if ( is_array( $classes ) ) {
381            // Add the flow class to <li>
382            $classes[] = 'flow-recentchanges-line';
383            $classes[] = 'mw-changeslist-src-mw-edit';
384        }
385        // Update the line markup
386        $s = $line;
387
388        return true;
389    }
390
391    /**
392     * Alter the enhanced RC links: (n changes | history)
393     * The default diff links are incorrect!
394     *
395     * @param EnhancedChangesList $changesList
396     * @param array &$links
397     * @param RecentChange[] $block
398     * @return bool
399     */
400    public function onEnhancedChangesList__getLogText( $changesList, &$links, $block ) {
401        $rc = $block[0];
402
403        // quit if non-flow
404        // FIXME: It could be that $rc is a non-Flow change (e.g. Wikidata), but $block still
405        // contains Flow changes. In that case we should probably process those?
406        if ( !self::isFlow( $rc ) ) {
407            return true;
408        }
409
410        set_error_handler( new RecoverableErrorHandler, -1 );
411        try {
412            /** @var Formatter\ChangesListQuery $query */
413            $query = Container::get( 'query.changeslist' );
414
415            $row = $query->getResult( $changesList, $rc, $changesList->isWatchlist() );
416            if ( $row === false ) {
417                restore_error_handler();
418                return false;
419            }
420
421            /** @var Formatter\ChangesListFormatter $formatter */
422            $formatter = Container::get( 'formatter.changeslist' );
423            $logTextLinks = $formatter->getLogTextLinks( $row, $changesList, $block, $links );
424        } catch ( Exception $e ) {
425            wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting rc logtext ' .
426                $rc->getAttribute( 'rc_id' ) . ' ' . $e );
427            MWExceptionHandler::logException( $e );
428            return false;
429        } finally {
430            restore_error_handler();
431        }
432
433        if ( $logTextLinks === false ) {
434            return false;
435        }
436
437        $links = $logTextLinks;
438        return true;
439    }
440
441    /**
442     * @param EnhancedChangesList $changesList
443     * @param array &$data
444     * @param RecentChange[] $block
445     * @param RecentChange $rc
446     * @param string[] &$classes
447     * @param string[] &$attribs
448     * @return bool
449     */
450    public function onEnhancedChangesListModifyLineData( $changesList, &$data, $block, $rc, &$classes, &$attribs ) {
451        return static::modifyChangesListLine( $changesList, $data, $rc, $classes );
452    }
453
454    /**
455     * @param EnhancedChangesList $changesList
456     * @param array &$data
457     * @param RecentChange $rc
458     * @return bool
459     */
460    public function onEnhancedChangesListModifyBlockLineData( $changesList, &$data, $rc ) {
461        $classes = [];
462        return static::modifyChangesListLine( $changesList, $data, $rc, $classes );
463    }
464
465    /**
466     * @param ChangesList $changesList
467     * @param array &$data
468     * @param RecentChange $rc
469     * @param string[] &$classes
470     * @return bool
471     */
472    private static function modifyChangesListLine( $changesList, &$data, $rc, &$classes ) {
473        // quit if non-flow
474        if ( !self::isFlow( $rc ) ) {
475            return true;
476        }
477
478        $query = Container::get( 'query.changeslist' );
479        $row = $query->getResult( $changesList, $rc, $changesList->isWatchlist() );
480        if ( $row === false ) {
481            return false;
482        }
483
484        /** @var Formatter\ChangesListFormatter $formatter */
485        $formatter = Container::get( 'formatter.changeslist' );
486        try {
487            $data['timestampLink'] = $formatter->getTimestampLink( $row, $changesList );
488            $data['recentChangesFlags'] = array_merge(
489                $data['recentChangesFlags'],
490                $formatter->getFlags( $row, $changesList )
491            );
492            $classes[] = 'mw-changeslist-src-mw-edit';
493        } catch ( PermissionException ) {
494            return false;
495        }
496
497        return true;
498    }
499
500    /**
501     * Checks if the given recent change entry is from Flow
502     * @param RecentChange $rc
503     * @return bool
504     */
505    private static function isFlow( $rc ) {
506        return $rc->getAttribute( 'rc_source' ) === RecentChangesListener::SRC_FLOW;
507    }
508
509    public static function onSpecialCheckUserGetLinksFromRow( AbstractCheckUserPager $pager, $row, &$links ) {
510        // TODO: Replace accesses to $row properties with the prefix "cuc_" to
511        // remove the need for this aliasing.
512        if ( isset( $row->type ) ) {
513            $row->cuc_type = $row->type;
514        }
515        if ( isset( $row->comment_text ) ) {
516            $row->cuc_comment_text = $row->comment_text;
517            $row->cuc_comment_data = $row->comment_data;
518        }
519
520        if ( $row->cuc_type != RC_FLOW ) {
521            return;
522        }
523
524        $replacement = self::getReplacementRowItems( $pager->getContext(), $row );
525
526        if ( $replacement === null ) {
527            // some sort of failure, but this is a RC_FLOW so blank out hist/diff links
528            // which aren't correct
529            $links['history'] = '';
530            $links['diff'] = '';
531        } else {
532            $links = $replacement;
533        }
534    }
535
536    public static function onCheckUserFormatRow( IContextSource $context, $row, &$rowItems ) {
537        // TODO: Replace accesses to $row properties with the prefix "cuc_" to
538        // remove the need for this aliasing.
539        if ( isset( $row->type ) ) {
540            $row->cuc_type = $row->type;
541        }
542        if ( isset( $row->comment_text ) ) {
543            $row->cuc_comment_text = $row->comment_text;
544            $row->cuc_comment_data = $row->comment_data;
545        }
546
547        if ( $row->cuc_type != RC_FLOW ) {
548            return;
549        }
550
551        $replacement = self::getReplacementRowItems( $context, $row );
552
553        // These links are incorrect for Flow
554        $rowItems['links']['diffLink'] = '';
555        $rowItems['links']['historyLink'] = '';
556
557        if ( $replacement !== null ) {
558            array_unshift( $rowItems['links'], $replacement['links'] );
559            $rowItems['info']['titleLink'] = $replacement['title'];
560        }
561    }
562
563    /**
564     * @param IContextSource $context
565     * @param stdClass $row
566     * @return array|null
567     */
568    private static function getReplacementRowItems( IContextSource $context, $row ): ?array {
569        set_error_handler( new RecoverableErrorHandler, -1 );
570        $replacement = null;
571        try {
572            /** @var CheckUserQuery $query */
573            $query = Container::get( 'query.checkuser' );
574            // @todo: create hook to allow batch-loading this data, instead of doing piecemeal like this
575            $query->loadMetadataBatch( [ $row ] );
576            $row = $query->getResult( $row );
577            if ( $row !== false ) {
578                /** @var Formatter\CheckUserFormatter $formatter */
579                $formatter = Container::get( 'formatter.checkuser' );
580                $replacement = $formatter->format( $row, $context );
581            }
582        } catch ( Exception $e ) {
583            wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting cu ' . json_encode( $row ) . ' ' . $e );
584            MWExceptionHandler::logException( $e );
585        } finally {
586            restore_error_handler();
587        }
588
589        return $replacement;
590    }
591
592    /**
593     * Regular talk page "Create source" and "Add topic" links are quite useless
594     * in the context of Flow boards. Let's get rid of them.
595     *
596     * @param SkinTemplate $template
597     * @param array &$links
598     */
599    public function onSkinTemplateNavigation__Universal( $template, &$links ): void {
600        global $wgFlowCoreActionWhitelist,
601            $wgMFPageActions;
602
603        $title = $template->getTitle();
604
605        // if Flow is enabled on this talk page, overrule talk page red link
606        if ( $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
607            // Turn off page actions in MobileFrontend.
608            // FIXME: Find more elegant standard way of doing this.
609            $wgMFPageActions = [];
610
611            // watch star & delete links are inside the topic itself
612            if ( $title->getNamespace() === NS_TOPIC ) {
613                unset( $links['actions']['watch'] );
614                unset( $links['actions']['unwatch'] );
615                unset( $links['actions']['delete'] );
616            }
617
618            // hide all views unless whitelisted
619            foreach ( $links['views'] as $action => $data ) {
620                if ( !in_array( $action, $wgFlowCoreActionWhitelist ) ) {
621                    unset( $links['views'][$action] );
622                }
623            }
624
625            // hide all actions unless whitelisted
626            foreach ( $links['actions'] as $action => $data ) {
627                if ( !in_array( $action, $wgFlowCoreActionWhitelist ) ) {
628                    unset( $links['actions'][$action] );
629                }
630            }
631
632            if ( isset( $links['namespaces']['topic_talk'] ) ) {
633                // hide discussion page in Topic namespace(which is already discussion)
634                unset( $links['namespaces']['topic_talk'] );
635                unset( $links['associated-pages']['topic_talk'] );
636                // hide protection (topic protection is done via moderation)
637                unset( $links['actions']['protect'] );
638                // topic pages are also not movable
639                unset( $links['actions']['move'] );
640            }
641        }
642    }
643
644    /**
645     * When a (talk) page does not exist, one of the checks being performed is
646     * to see if the page had once existed but was removed. In doing so, the
647     * deletion & move log is checked.
648     *
649     * In theory, a Flow board could overtake a non-existing talk page. If that
650     * board is later removed, this will be run to see if a message can be
651     * displayed to inform the user if the page has been deleted/moved.
652     *
653     * Since, in Flow, we also write (topic, post, ...) deletion to the deletion
654     * log, we don't want those to appear, since they're not actually actions
655     * related to that talk page (rather: they were actions on the board)
656     *
657     * @param array &$conds Array of conditions
658     * @param array $logTypes Array of log types
659     */
660    public function onArticle__MissingArticleConditions( &$conds, $logTypes ) {
661        global $wgLogActionsHandlers;
662        /** @var FlowActions $actions */
663        $actions = Container::get( 'flow_actions' );
664
665        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
666
667        foreach ( $actions->getActions() as $action ) {
668            foreach ( $logTypes as $logType ) {
669                // Check if Flow actions are defined for the requested log types
670                // and make sure they're ignored.
671                if ( isset( $wgLogActionsHandlers["$logType/flow-$action"] ) ) {
672                    $conds[] = $dbr->expr( 'log_action', '!=', "flow-$action" );
673                }
674            }
675        }
676    }
677
678    /**
679     * Make sure no user can register a flow-*-usertext username, to avoid
680     * confusion with a real user when we print e.g. "Suppressed" instead of a
681     * username. Additionally reserve the username used to add a revision on
682     * taking over a page.
683     *
684     * @param array &$names
685     */
686    public function onUserGetReservedNames( &$names ) {
687        $permissions = Model\AbstractRevision::$perms;
688        foreach ( $permissions as $permission ) {
689            $names[] = "msg:flow-$permission-usertext";
690        }
691
692        // Reserve the bot account we use during content model changes & LQT conversion
693        $names[] = FLOW_TALK_PAGE_MANAGER_USER;
694    }
695
696    /**
697     * Static variables that do not vary by request; delivered through startup module
698     * @param array &$vars
699     * @param string $skin
700     * @param Config $config
701     */
702    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
703        global $wgFlowAjaxTimeout;
704
705        $vars['wgFlowMaxTopicLength'] = Model\PostRevision::MAX_TOPIC_LENGTH;
706        $vars['wgFlowMentionTemplate'] = wfMessage( 'flow-ve-mention-template-title' )->inContentLanguage()->plain();
707        $vars['wgFlowAjaxTimeout'] = $wgFlowAjaxTimeout;
708    }
709
710    /**
711     * Intercept contribution entries and format those belonging to Flow
712     *
713     * @param IContextSource $pager
714     * @param string &$ret The HTML line
715     * @param stdClass $row The data for this line
716     * @param array &$classes the classes to add to the surrounding <li>
717     * @param array &$attribs
718     * @return bool
719     */
720    public function onDeletedContributionsLineEnding( $pager, &$ret, $row, &$classes, &$attribs ) {
721        if ( !$row instanceof Formatter\FormatterRow ) {
722            return true;
723        }
724
725        set_error_handler( new RecoverableErrorHandler, -1 );
726        try {
727            /** @var Formatter\ContributionsFormatter $formatter */
728            $formatter = Container::get( 'formatter.contributions' );
729            $line = $formatter->format( $row, $pager );
730        } catch ( PermissionException ) {
731            $line = false;
732        } catch ( Exception $e ) {
733            wfDebugLog( 'Flow', __METHOD__ . ': Failed formatting contribution ' .
734                json_encode( $row ) . ': ' . $e->getMessage() );
735            MWExceptionHandler::logException( $e );
736            $line = false;
737        } finally {
738            restore_error_handler();
739        }
740
741        if ( $line === false ) {
742            return false;
743        }
744
745        $classes[] = 'mw-flow-contribution';
746        $ret = $line;
747
748        // If we output one or more lines of contributions entries we also need to include
749        // the javascript that hooks into moderation actions.
750        $pager->getOutput()->addModules( [ 'ext.flow.contributions' ] );
751        $pager->getOutput()->addModuleStyles( [ 'ext.flow.contributions.styles' ] );
752
753        return true;
754    }
755
756    /**
757     * Intercept contribution entries and format those belonging to Flow
758     *
759     * @param IContextSource $pager
760     * @param string &$ret The HTML line
761     * @param stdClass $row The data for this line
762     * @param array &$classes the classes to add to the surrounding <li>
763     * @param array &$attribs
764     * @return bool
765     */
766    public function onContributionsLineEnding( $pager, &$ret, $row, &$classes, &$attribs ) {
767        return static::onDeletedContributionsLineEnding( $pager, $ret, $row, $classes, $attribs );
768    }
769
770    /**
771     * Convert flow contributions entries into FeedItem instances
772     * for ApiFeedContributions
773     *
774     * @param object $row Single row of data from ContribsPager
775     * @param IContextSource $ctx The context to creat the feed item within
776     * @param FeedItem|null &$feedItem Return value holder for created feed item.
777     * @return bool
778     */
779    public function onApiFeedContributions__feedItem( $row, $ctx, &$feedItem ) {
780        if ( !$row instanceof Formatter\FormatterRow ) {
781            return true;
782        }
783
784        set_error_handler( new RecoverableErrorHandler, -1 );
785        try {
786            /** @var Formatter\FeedItemFormatter $formatter */
787            $formatter = Container::get( 'formatter.contributions.feeditem' );
788            $result = $formatter->format( $row, $ctx );
789        } catch ( PermissionException ) {
790            return false;
791        } catch ( Exception $e ) {
792            wfDebugLog( 'Flow', __METHOD__ . ': Failed formatting contribution ' .
793                json_encode( $row ) . ': ' . $e->getMessage() );
794            MWExceptionHandler::logException( $e );
795            return false;
796        } finally {
797            restore_error_handler();
798        }
799
800        if ( $result instanceof FeedItem ) {
801            $feedItem = $result;
802            return true;
803        } else {
804            // If we failed to render a flow row, cancel it. This could be
805            // either permissions or bugs.
806            return false;
807        }
808    }
809
810    /**
811     * Gets Flow contributions for contributions-related special pages
812     *
813     * @see onDeletedContributionsQuery
814     * @see onContributionsQuery
815     *
816     * @param array &$data
817     * @param IndexPager $pager
818     * @param string $offset
819     * @param int $limit
820     * @param bool $descending
821     * @param array $rangeOffsets Query range, in the format of [ startOffset, endOffset ]
822     * @return bool
823     */
824    private static function getContributionsQuery( &$data, $pager, $offset, $limit, $descending, $rangeOffsets = [] ) {
825        if (
826            !( $pager instanceof ContribsPager ) &&
827            !( $pager instanceof DeletedContribsPager )
828        ) {
829            return false;
830        }
831
832        set_error_handler( new RecoverableErrorHandler, -1 );
833        try {
834            /** @var Formatter\ContributionsQuery $query */
835            $query = Container::get( 'query.contributions' );
836            $results = $query->getResults( $pager, $offset, $limit, $descending, $rangeOffsets );
837        } catch ( Exception $e ) {
838            wfDebugLog( 'Flow', __METHOD__ . ': Failed contributions query' );
839            MWExceptionHandler::logException( $e );
840            $results = false;
841        } finally {
842            restore_error_handler();
843        }
844
845        if ( $results === false ) {
846            return false;
847        }
848
849        $data[] = $results;
850
851        return true;
852    }
853
854    /**
855     * Adds Flow contributions to the DeletedContributions special page
856     *
857     * @inheritDoc
858     */
859    public function onDeletedContribsPager__reallyDoQuery( &$data, $pager, $offset, $limit, $descending ) {
860        return self::getContributionsQuery( $data, $pager, $offset, $limit, $descending, [ $pager->getEndOffset() ] );
861    }
862
863    /**
864     * Adds Flow contributions to the Contributions special page
865     *
866     * @inheritDoc
867     */
868    public function onContribsPager__reallyDoQuery( &$data, $pager, $offset, $limit, $descending ) {
869        // Flow has nothing to do with the tag filter, so ignore tag searches
870        if ( $pager->getTagFilter() != false ) {
871            return true;
872        }
873
874        return static::getContributionsQuery( $data, $pager, $offset, $limit, $descending, $pager->getRangeOffsets() );
875    }
876
877    /**
878     * Define and add descriptions for board-related variables
879     * @param array &$realValues
880     */
881    public static function onAbuseFilterBuilder( &$realValues ) {
882        $realValues['vars'] += [
883            'board_id' => 'board-id',
884            'board_namespace' => 'board-namespace',
885            'board_title' => 'board-title',
886            'board_prefixedtitle' => 'board-prefixedtitle',
887        ];
888    }
889
890    /**
891     * Add our deprecated variables
892     * @param array &$deprecatedVars
893     */
894    public static function onAbuseFilterDeprecatedVariables( &$deprecatedVars ) {
895        $deprecatedVars += [
896            'board_articleid' => 'board_id',
897            'board_text' => 'board_title',
898            'board_prefixedtext' => 'board_prefixedtitle',
899        ];
900    }
901
902    /**
903     * Adds lazy-load methods for AbstractRevision objects.
904     *
905     * @param string $method Method to generate the variable
906     * @param VariableHolder $vars
907     * @param array $parameters Parameters with data to compute the value
908     * @param mixed &$result Result of the computation
909     * @return bool
910     */
911    public static function onAbuseFilterComputeVariable(
912        $method,
913        VariableHolder $vars,
914        $parameters,
915        &$result
916    ) {
917        // fetch all lazy-load methods
918        $methods = self::getAbuseFilter()->lazyLoadMethods();
919
920        // method isn't known here
921        if ( !isset( $methods[$method] ) ) {
922            return true;
923        }
924
925        // fetch variable result from lazy-load method
926        $result = $methods[$method]( $vars, $parameters );
927        return false;
928    }
929
930    /**
931     * Suppress the 'You have new messages!' indication when a change to a
932     * user talk page is done by the talk page manager user.
933     *
934     * @param WikiPage $page
935     * @param User $recipient
936     * @return bool
937     */
938    public function onArticleEditUpdateNewTalk( $page, $recipient ) {
939        $user = User::newFromId( $page->getUser( RevisionRecord::RAW ) );
940
941        if ( self::isTalkpageManagerUser( $user ) ) {
942            return false;
943        }
944
945        return true;
946    }
947
948    /**
949     * @param UserIdentity $user
950     * @return bool
951     */
952    public static function isTalkpageManagerUser( UserIdentity $user ) {
953        return $user->getName() === FLOW_TALK_PAGE_MANAGER_USER;
954    }
955
956    public function onInfoAction( $ctx, &$pageinfo ) {
957        if ( $ctx->getTitle()->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
958            return;
959        }
960
961        // All of the info in this section is wrong for Flow pages,
962        // so we'll just remove it.
963        unset( $pageinfo['header-edits'] );
964
965        // These keys are wrong on Flow pages, so we'll remove them
966        static $badMessageKeys = [ 'pageinfo-length' ];
967
968        foreach ( $pageinfo['header-basic'] as $num => $val ) {
969            if ( $val[0] instanceof Message && in_array( $val[0]->getKey(), $badMessageKeys ) ) {
970                unset( $pageinfo['header-basic'][$num] );
971            }
972        }
973    }
974
975    /**
976     * Provide detail about the Flow edit for checkusers using Special:CheckUser / Special:Investigate
977     *
978     * @param string &$ip
979     * @param string|false &$xff
980     * @param array &$row The row to be inserted (before defaults are applied)
981     * @param UserIdentity $user
982     * @param ?RecentChange $rc If triggered by a RecentChange, then this is the associated
983     *   RecentChange object. Null if not triggered by a RecentChange.
984     */
985    public static function onCheckUserInsertChangesRow(
986        string &$ip,
987        &$xff,
988        array &$row,
989        UserIdentity $user,
990        ?RecentChange $rc
991    ) {
992        if ( $rc === null || $rc->getAttribute( 'rc_source' ) !== RecentChangesListener::SRC_FLOW ) {
993            return;
994        }
995
996        $params = unserialize( $rc->getAttribute( 'rc_params' ) );
997        $change = $params['flow-workflow-change'];
998
999        // don't forget to increase the version number when data format changes
1000        $comment = CheckUserQuery::VERSION_PREFIX;
1001        $comment .= ',' . $change['action'];
1002        $comment .= ',' . $change['workflow'];
1003        $comment .= ',' . $change['revision'];
1004
1005        $row['cuc_comment'] = $comment;
1006    }
1007
1008    public function onIRCLineURL( &$url, &$query, $rc ) {
1009        if ( $rc->getAttribute( 'rc_source' ) !== RecentChangesListener::SRC_FLOW ) {
1010            return;
1011        }
1012
1013        set_error_handler( new RecoverableErrorHandler, -1 );
1014        $result = null;
1015        try {
1016            /** @var Formatter\IRCLineUrlFormatter $formatter */
1017            $formatter = Container::get( 'formatter.irclineurl' );
1018            $result = $formatter->format( $rc );
1019        } catch ( Exception $e ) {
1020            $result = null;
1021            wfDebugLog( 'Flow', __METHOD__ . ': Failed formatting rc ' .
1022                $rc->getAttribute( 'rc_id' ) . ': ' . $e->getMessage() );
1023            MWExceptionHandler::logException( $e );
1024        } finally {
1025            restore_error_handler();
1026        }
1027
1028        if ( $result !== null ) {
1029            $url = $result;
1030            $query = '';
1031        }
1032    }
1033
1034    /**
1035     * Add topiclist sortby to preferences.
1036     *
1037     * @param User $user
1038     * @param array &$preferences
1039     */
1040    public function onGetPreferences( $user, &$preferences ) {
1041        $preferences['flow-topiclist-sortby'] = [
1042            'type' => 'api',
1043        ];
1044
1045        $preferences['flow-editor'] = [
1046            'type' => 'api'
1047        ];
1048
1049        $preferences['flow-side-rail-state'] = [
1050            'type' => 'api'
1051        ];
1052
1053        if ( ExtensionRegistry::getInstance()->isLoaded( 'VisualEditor' ) ) {
1054            $preferences['flow-visualeditor'] = [
1055                'type' => 'toggle',
1056                'label-message' => 'flow-preference-visualeditor',
1057                'section' => 'editing/editor',
1058            ];
1059        }
1060    }
1061
1062    /**
1063     * @param UserIdentity $user
1064     * @param WikiPage $page
1065     * @param Status &$status
1066     * @return bool
1067     */
1068    public static function handleWatchArticle( $user, WikiPage $page, &$status ) {
1069        $title = $page->getTitle();
1070        if ( $title->getNamespace() == NS_TOPIC ) {
1071            // @todo - use !$title->exists()?
1072            /** @var Data\ManagerGroup $storage */
1073            $storage = Container::get( 'storage' );
1074            $found = $storage->find(
1075                'PostRevision',
1076                [ 'rev_type_id' => strtolower( $title->getDBkey() ) ],
1077                [ 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 ]
1078            );
1079            if ( !$found ) {
1080                return false;
1081            }
1082            $post = reset( $found );
1083            if ( !$post->isTopicTitle() ) {
1084                return false;
1085            }
1086        }
1087        return true;
1088    }
1089
1090    /**
1091     * Don't watch a non-existing flow topic
1092     *
1093     * @param UserIdentity $user
1094     * @param WikiPage $page
1095     * @param Status &$status
1096     * @param string|null $expiry
1097     * @return bool
1098     */
1099    public function onWatchArticle( $user, $page, &$status, $expiry ) {
1100        return self::handleWatchArticle( $user, $page, $status );
1101    }
1102
1103    /**
1104     * Don't unwatch a non-existing flow topic
1105     *
1106     * @param UserIdentity $user
1107     * @param WikiPage $page
1108     * @param Status &$status
1109     * @return bool
1110     */
1111    public function onUnwatchArticle( $user, $page, &$status ) {
1112        return self::handleWatchArticle( $user, $page, $status );
1113    }
1114
1115    /**
1116     * Checks whether this is a valid move technically.  MovePageIsValidMove should not
1117     * be affected by the specific user, or user permissions.
1118     *
1119     * Those are handled in onMovePageCheckPermissions, called later.
1120     *
1121     * @param Title $oldTitle
1122     * @param Title $newTitle
1123     * @param Status $status Status to update with any technical issues
1124     *
1125     * @return bool true to continue, false to abort the hook
1126     */
1127    public function onMovePageIsValidMove( $oldTitle, $newTitle, $status ) {
1128        // We only care about moving Flow boards, and *not* moving Flow topics
1129        // (but both are CONTENT_MODEL_FLOW_BOARD)
1130        if ( $oldTitle->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
1131            return true;
1132        }
1133
1134        // Pages within the Topic namespace are not movable
1135        // This is also enforced by the namespace configuration in extension.json.
1136        if ( $oldTitle->getNamespace() === NS_TOPIC ) {
1137            $status->fatal( 'flow-error-move-topic' );
1138            return false;
1139        }
1140
1141        /** @var OccupationController $occupationController */
1142        $occupationController = MediaWikiServices::getInstance()->getService( 'FlowTalkpageManager' );
1143        $flowStatus = $occupationController->checkIfCreationIsPossible( $newTitle, /*mustNotExist*/ true );
1144        $status->merge( $flowStatus );
1145
1146        return true;
1147    }
1148
1149    /**
1150     * Checks whether user has permission to move the board.
1151     *
1152     * Technical restrictions are handled in onMovePageIsValidMove, called earlier.
1153     *
1154     * @param Title $oldTitle
1155     * @param Title $newTitle
1156     * @param User $user User doing the move
1157     * @param string $reason Reason for the move
1158     * @param Status $status Status updated with any permissions issue
1159     */
1160    public function onMovePageCheckPermissions(
1161        $oldTitle,
1162        $newTitle,
1163        $user,
1164        $reason,
1165        $status
1166    ) {
1167        // Only affect moves if the source has Flow content model
1168        if ( $oldTitle->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
1169            return;
1170        }
1171
1172        /** @var OccupationController $occupationController */
1173        $occupationController = MediaWikiServices::getInstance()->getService( 'FlowTalkpageManager' );
1174        $permissionStatus = $occupationController->checkIfUserHasPermission(
1175            $newTitle,
1176            $user
1177        );
1178        $status->merge( $permissionStatus );
1179    }
1180
1181    /**
1182     * @param Title $title
1183     * @param string[] &$urls
1184     */
1185    public function onTitleSquidURLs( $title, &$urls ) {
1186        if ( $title->getNamespace() !== NS_TOPIC ) {
1187            return;
1188        }
1189        try {
1190            $uuid = WorkflowLoaderFactory::uuidFromTitle( $title );
1191        } catch ( InvalidInputException ) {
1192            wfDebugLog( 'Flow', __METHOD__ . ': Invalid title ' . $title->getPrefixedText() );
1193            return;
1194        }
1195        /** @var Data\ManagerGroup $storage */
1196        $storage = Container::get( 'storage' );
1197        $workflow = $storage->get( 'Workflow', $uuid );
1198        if ( !$workflow instanceof Model\Workflow ) {
1199            wfDebugLog( 'Flow', __METHOD__ . ': Title for non-existent Workflow ' .
1200                $title->getPrefixedText() );
1201            return;
1202        }
1203
1204        $htmlCache = MediaWikiServices::getInstance()->getHtmlCacheUpdater();
1205        $urls = array_merge(
1206            $urls,
1207            $htmlCache->getUrls( $workflow->getOwnerTitle() )
1208        );
1209    }
1210
1211    /**
1212     * @param array &$tools Extra links
1213     * @param Title $title
1214     * @param bool $redirect Whether the page is a redirect
1215     * @param Skin $skin
1216     * @param string &$link
1217     */
1218    public function onWatchlistEditorBuildRemoveLine(
1219        &$tools,
1220        $title,
1221        $redirect,
1222        $skin,
1223        &$link
1224    ) {
1225        if ( $title->getNamespace() !== NS_TOPIC ) {
1226            // Leave all non Flow topics alone!
1227            return;
1228        }
1229
1230        /*
1231         * Link to talk page is no applicable for Flow topics
1232         * Note that key 'talk' doesn't exist prior to
1233         * https://gerrit.wikimedia.org/r/#/c/156522/, so on old MW's, the link
1234         * to talk page will still be present.
1235         */
1236        unset( $tools['talk'] );
1237
1238        if ( !$link ) {
1239            /*
1240             * https://gerrit.wikimedia.org/r/#/c/156118/ adds argument $link.
1241             * Prior to that patch, it was impossible to change the link, so
1242             * let's quit early if it doesn't exist.
1243             */
1244            return;
1245        }
1246
1247        try {
1248            // Find the title text of this specific topic
1249            $uuid = WorkflowLoaderFactory::uuidFromTitle( $title );
1250            $collection = PostCollection::newFromId( $uuid );
1251            $revision = $collection->getLastRevision();
1252        } catch ( Exception ) {
1253            wfWarn( __METHOD__ . ': Failed to locate revision for: ' . $title->getDBkey() );
1254            return;
1255        }
1256
1257        $content = $revision->getContent( 'topic-title-plaintext' );
1258        $link = MediaWikiServices::getInstance()->getLinkRenderer()->makeLink( $title, $content );
1259    }
1260
1261    /**
1262     * @param array &$watchlistInfo Watchlisted pages
1263     */
1264    public function onWatchlistEditorBeforeFormRender( &$watchlistInfo ) {
1265        if ( !isset( $watchlistInfo[NS_TOPIC] ) ) {
1266            // No topics watchlisted
1267            return;
1268        }
1269
1270        $ids = array_keys( $watchlistInfo[NS_TOPIC] );
1271
1272        // build array of queries to be executed all at once
1273        $queries = [];
1274        foreach ( $ids as $id ) {
1275            try {
1276                $uuid = WorkflowLoaderFactory::uuidFromTitlePair( NS_TOPIC, $id );
1277                $queries[] = [ 'rev_type_id' => $uuid ];
1278            } catch ( Exception ) {
1279                // invalid id
1280                unset( $watchlistInfo[NS_TOPIC][$id] );
1281            }
1282        }
1283
1284        /** @var Data\ManagerGroup $storage */
1285        $storage = Container::get( 'storage' );
1286
1287        /*
1288         * Now, finally find all requested topics - this will be stored in
1289         * local cache so subsequent calls (in onWatchlistEditorBuildRemoveLine)
1290         * will just find these in memory, instead of doing a bunch of network
1291         * requests.
1292         */
1293        $storage->findMulti(
1294            'PostRevision',
1295            $queries,
1296            [ 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 ]
1297        );
1298    }
1299
1300    /**
1301     * For integration with the UserMerge extension. Provides the database and
1302     * sets of table/column pairs to update user id's within.
1303     *
1304     * @param array &$updateFields
1305     */
1306    public static function onUserMergeAccountFields( &$updateFields ) {
1307        /** @var Data\Utils\UserMerger $merger */
1308        $merger = Container::get( 'user_merger' );
1309        foreach ( $merger->getAccountFields() as $row ) {
1310            $updateFields[] = $row;
1311        }
1312    }
1313
1314    /**
1315     * Finalize the merge by purging any cached value that contained $oldUser
1316     * @param User &$oldUser
1317     * @param User &$newUser
1318     */
1319    public static function onMergeAccountFromTo( User &$oldUser, User &$newUser ) {
1320        /** @var Data\Utils\UserMerger $merger */
1321        $merger = Container::get( 'user_merger' );
1322        $merger->finalizeMerge( $oldUser->getId(), $newUser->getId() );
1323    }
1324
1325    /**
1326     * Gives precedence to Flow over LQT.
1327     * @param Title $title
1328     * @param bool &$isLqtPage
1329     */
1330    public static function onIsLiquidThreadsPage( Title $title, &$isLqtPage ) {
1331        if ( $isLqtPage && $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
1332            $isLqtPage = false;
1333        }
1334    }
1335
1336    public function onCategoryViewer__doCategoryQuery( $type, $res ) {
1337        if ( $type !== 'page' ) {
1338            return;
1339        }
1340
1341        /** @var Formatter\CategoryViewerQuery $query */
1342        $query = Container::get( 'query.categoryviewer' );
1343        $query->loadMetadataBatch( $res );
1344    }
1345
1346    public function onCategoryViewer__generateLink( $type, $title, $html, &$link ) {
1347        if ( $type !== 'page' || $title->getNamespace() !== NS_TOPIC ) {
1348            return;
1349        }
1350        $uuid = UUID::create( strtolower( $title->getDBkey() ) );
1351        if ( !$uuid ) {
1352            return;
1353        }
1354        /** @var Formatter\CategoryViewerQuery $query */
1355        $query = Container::get( 'query.categoryviewer' );
1356        $row = $query->getResult( $uuid );
1357        /** @var Formatter\CategoryViewerFormatter $formatter */
1358        $formatter = Container::get( 'formatter.categoryviewer' );
1359        $result = $formatter->format( $row );
1360        if ( $result ) {
1361            $link = $result;
1362        }
1363    }
1364
1365    /**
1366     * Gets error HTML for attempted NS_TOPIC deletion using core interface
1367     *
1368     * @param Title $title Topic title they are attempting to delete
1369     * @return string Error html
1370     */
1371    protected static function getTopicDeletionError( Title $title ) {
1372        $error = wfMessage( 'flow-error-core-topic-deletion', $title->getFullURL() )->parse();
1373        $wrappedError = Html::rawElement( 'span', [
1374            'class' => 'plainlinks',
1375        ], $error );
1376        return $wrappedError;
1377    }
1378
1379    // This should block them from wasting their time filling the form, but it won't
1380    // without a core change.  However, it does show the message.
1381
1382    /**
1383     * Shows an error message when the user visits the deletion form if the page is in
1384     * the Topic namespace.
1385     *
1386     * @param Article $article Page the user requested to delete
1387     * @param OutputPage $output Output page
1388     * @param string &$reason Pre-filled reason given for deletion (note, this could
1389     *   be used to customize this for boards and/or topics later)
1390     * @return bool False if it is a Topic; otherwise, true
1391     */
1392    public function onArticleConfirmDelete( $article, $output, &$reason ) {
1393        $title = $article->getTitle();
1394        if ( $title->inNamespace( NS_TOPIC ) ) {
1395            $output->addHTML( self::getTopicDeletionError( $title ) );
1396            return false;
1397        }
1398
1399        return true;
1400    }
1401
1402    /**
1403     * Blocks topics from being deleted using the core deletion process, since it
1404     * doesn't work.
1405     *
1406     * @param WikiPage $article Page the user requested to delete
1407     * @param User $user User who requested to delete the article
1408     * @param string &$reason Reason given for deletion
1409     * @param string &$error Error explaining why we are not allowing the deletion
1410     * @param Status &$status
1411     * @param bool $suppress
1412     * @return bool False if it is a Topic (to block it); otherwise, true
1413     */
1414    public function onArticleDelete( WikiPage $article, User $user, &$reason, &$error, Status &$status, $suppress ) {
1415        $title = $article->getTitle();
1416        if ( $title->inNamespace( NS_TOPIC ) ) {
1417            $error = self::getTopicDeletionError( $title );
1418            return false;
1419        }
1420
1421        return true;
1422    }
1423
1424    /**
1425     * Evicts topics from Squid/Varnish when the board is deleted.
1426     * We do permission checks for this scenario, but since the topic isn't deleted
1427     * at the core level, we need to evict it from Varnish ourselves.
1428     *
1429     * @param WikiPage $article Deleted article
1430     * @param User $user User that deleted article
1431     * @param string $reason Reason given
1432     * @param int $articleId Article ID of deleted article
1433     * @param Content|null $content Content that was deleted, or null on error
1434     * @param LogEntry $logEntry Log entry for deletion
1435     * @param int $archivedRevisionCount
1436     */
1437    public function onArticleDeleteComplete(
1438        $article,
1439        $user,
1440        $reason,
1441        $articleId,
1442        $content,
1443        $logEntry,
1444        $archivedRevisionCount
1445    ) {
1446        $title = $article->getTitle();
1447
1448        // Topics use the same content model, but can't be deleted at the core
1449        // level currently.
1450        if ( $content !== null &&
1451            $title->getNamespace() !== NS_TOPIC &&
1452            $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
1453            $storage = Container::get( 'storage' );
1454
1455            DeferredUpdates::addCallableUpdate( static function () use ( $storage, $articleId ) {
1456                /** @var Model\Workflow[] $workflows */
1457                $workflows = $storage->find( 'Workflow', [
1458                    'workflow_wiki' => WikiMap::getCurrentWikiId(),
1459                    'workflow_page_id' => $articleId,
1460                ] );
1461                if ( !$workflows ) {
1462                    return;
1463                }
1464
1465                $topicTitles = [];
1466                foreach ( $workflows as $workflow ) {
1467                    if ( $workflow->getType() === 'topic' ) {
1468                        $topicTitles[] = $workflow->getArticleTitle();
1469                    }
1470                }
1471
1472                $hcu = MediaWikiServices::getInstance()->getHtmlCacheUpdater();
1473                $hcu->purgeTitleUrls( $topicTitles, $hcu::PURGE_INTENT_TXROUND_REFLECTED );
1474            } );
1475        }
1476    }
1477
1478    /**
1479     * @param RevisionRecord $revisionRecord Revision just undeleted
1480     * @param ?int $oldPageId Old page ID stored with that revision when it was in the archive table
1481     */
1482    public function onRevisionUndeleted( $revisionRecord, $oldPageId ) {
1483        $contentModel = $revisionRecord->getSlot(
1484            SlotRecord::MAIN, RevisionRecord::RAW
1485        )->getModel();
1486        if ( $contentModel === CONTENT_MODEL_FLOW_BOARD ) {
1487            // complete hack to make sure that when the page is saved to new
1488            // location and rendered it doesn't throw an error about the wrong title
1489            Container::get( 'factory.loader.workflow' )->pageMoveInProgress();
1490
1491            $title = Title::newFromPageIdentity( $revisionRecord->getPage() );
1492
1493            // Reassociate the Flow board associated with this undeleted revision.
1494            /** @var BoardMover $boardMover */
1495            $boardMover = Container::get( 'board_mover' );
1496            $boardMover->move( intval( $oldPageId ), $title );
1497        }
1498    }
1499
1500    /**
1501     * @param Title $title Title corresponding to the article restored
1502     * @param bool $create Whether or not the restoration caused the page to be created (i.e. it didn't exist before).
1503     * @param string $comment The comment associated with the undeletion.
1504     * @param int $oldPageId ID of page previously deleted (from archive table)
1505     * @param array $restoredPages
1506     */
1507    public function onArticleUndelete( $title, $create, $comment, $oldPageId, $restoredPages ) {
1508        // Avoid CI errors when other ArticleUndelete implementations are present, see: T356704
1509        if ( defined( 'MW_PHPUNIT_TEST' ) ) {
1510            return;
1511        }
1512        $boardMover = Container::get( 'board_mover' );
1513        $boardMover->commit();
1514    }
1515
1516    /**
1517     * Occurs at the beginning of the MovePage process (just after the startAtomic).
1518     *
1519     * Perhaps ContentModel should be extended to be notified about moves explicitly.
1520     * @param Title $oldTitle
1521     * @param Title $newTitle
1522     * @param User $user
1523     */
1524    public function onTitleMoveStarting( $oldTitle, $newTitle, $user ) {
1525        if ( $oldTitle->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
1526            // $newTitle doesn't yet exist, but after the move it'll still have
1527            // the same ID $oldTitle used to have
1528            // Since we don't want to wait until after the page has been moved
1529            // to start preparing relevant Flow moves, I'll make it reflect the
1530            // correct ID already
1531            $bogusTitle = clone $newTitle;
1532            $bogusTitle->resetArticleID( $oldTitle->getArticleID() );
1533
1534            // This is only safe because we have called
1535            // checkIfCreationIsPossible and (usually) checkIfUserHasPermission.
1536            Container::get( 'occupation_controller' )->forceAllowCreation( $bogusTitle );
1537            // complete hack to make sure that when the page is saved to new
1538            // location and rendered it doesn't throw an error about the wrong title
1539            Container::get( 'factory.loader.workflow' )->pageMoveInProgress();
1540            // open a database transaction and prepare everything for the move, but
1541            // don't commit yet. That is done below in self::onPageMoveCompleting
1542            $boardMover = Container::get( 'board_mover' );
1543            $boardMover->move( $oldTitle->getArticleID(), $bogusTitle );
1544        }
1545    }
1546
1547    public function onPageMoveCompleting(
1548        $oldTitle,
1549        $newTitle,
1550        $user,
1551        $pageid,
1552        $redirid,
1553        $reason,
1554        $revisionRecord
1555    ) {
1556        $newTitle = Title::newFromLinkTarget( $newTitle );
1557        if ( $newTitle->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
1558            Container::get( 'board_mover' )->commit();
1559        }
1560    }
1561
1562    public function onShowMissingArticle( $article ) {
1563        if ( $article->getPage()->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
1564            return true;
1565        }
1566
1567        if ( $article->getTitle()->getNamespace() === NS_TOPIC ) {
1568            // @todo pretty message about invalid workflow
1569            throw new FlowException( 'Non-existent topic' );
1570        }
1571
1572        $services = MediaWikiServices::getInstance();
1573        $emptyContent = $services->getContentHandlerFactory()
1574            ->getContentHandler( CONTENT_MODEL_FLOW_BOARD )->makeEmptyContent();
1575        $contentRenderer = $services->getContentRenderer();
1576        $parserOptions = ParserOptions::newFromAnon();
1577        $parserOutput = $contentRenderer->getParserOutput( $emptyContent, $article->getTitle(), null, $parserOptions );
1578        $article->getContext()->getOutput()->addParserOutput( $parserOutput, $parserOptions );
1579
1580        return false;
1581    }
1582
1583    /**
1584     * Excludes NS_TOPIC from the list of searchable namespaces
1585     *
1586     * @param array &$namespaces Associative array mapping namespace index
1587     *  to name
1588     */
1589    public function onSearchableNamespaces( &$namespaces ) {
1590        unset( $namespaces[NS_TOPIC] );
1591    }
1592
1593    /**
1594     * @return bool
1595     */
1596    private static function isBetaFeatureAvailable() {
1597        if ( !ExtensionRegistry::getInstance()->isLoaded( 'BetaFeatures' ) ) {
1598            return false;
1599        }
1600
1601        $config = RequestContext::getMain()->getConfig();
1602        $betaFeaturesAllowList = $config->get( 'BetaFeaturesAllowList' );
1603
1604        return $config->get( 'FlowEnableOptInBetaFeature' )
1605            && (
1606                !is_array( $betaFeaturesAllowList )
1607                || in_array( BETA_FEATURE_FLOW_USER_TALK_PAGE, $betaFeaturesAllowList )
1608            );
1609    }
1610
1611    /**
1612     * @param User $user
1613     * @param array &$prefs
1614     */
1615    public function onGetBetaFeaturePreferences( $user, &$prefs ) {
1616        global $wgExtensionAssetsPath;
1617
1618        if ( !self::isBetaFeatureAvailable() ) {
1619            return;
1620        }
1621        // Do not allow users to opt-in for Flow as preliminary sunset step
1622        if ( !BetaFeatures::isFeatureEnabled( $user, BETA_FEATURE_FLOW_USER_TALK_PAGE ) ) {
1623            return;
1624        }
1625
1626        $prefs[BETA_FEATURE_FLOW_USER_TALK_PAGE] = [
1627            // The first two are message keys
1628            'label-message' => 'flow-talk-page-beta-feature-message',
1629            'desc-message' => 'flow-talk-page-beta-feature-description',
1630            'screenshot' => [
1631                'ltr' => "$wgExtensionAssetsPath/Flow/images/betafeature-flow-ltr.svg",
1632                'rtl' => "$wgExtensionAssetsPath/Flow/images/betafeature-flow-rtl.svg",
1633            ],
1634            'info-link' => 'https://www.mediawiki.org/wiki/Flow',
1635            'discussion-link' => 'https://www.mediawiki.org/wiki/Talk:Flow',
1636            'exempt-from-auto-enrollment' => true,
1637        ];
1638    }
1639
1640    /**
1641     * @param UserIdentity $user
1642     * @param array &$modifiedOptions
1643     * @param array $originalOptions
1644     */
1645    public function onSaveUserOptions( UserIdentity $user, array &$modifiedOptions, array $originalOptions ) {
1646        if ( !self::isBetaFeatureAvailable() ) {
1647            return;
1648        }
1649
1650        // Short circuit, it's fine because this beta feature is exempted from auto-enroll.
1651        if ( !array_key_exists( BETA_FEATURE_FLOW_USER_TALK_PAGE, $modifiedOptions ) ) {
1652            return;
1653        }
1654
1655        $before = BetaFeatures::isFeatureEnabled( $user, BETA_FEATURE_FLOW_USER_TALK_PAGE, $originalOptions );
1656        $after = BetaFeatures::isFeatureEnabled( $user, BETA_FEATURE_FLOW_USER_TALK_PAGE );
1657        $action = null;
1658
1659        $optInController = Container::get( 'controller.opt_in' );
1660        $user = MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $user );
1661        if ( !$before && $after ) {
1662            $action = OptInController::ENABLE;
1663            // Check if the user had a flow board
1664            if ( !$optInController->hasFlowBoardArchive( $user ) ) {
1665                // Enable the guided tour by setting the cookie
1666                RequestContext::getMain()->getRequest()->response()->setCookie( 'Flow_optIn_guidedTour', '1' );
1667            }
1668        } elseif ( $before && !$after ) {
1669            $action = OptInController::DISABLE;
1670        }
1671
1672        if ( $action ) {
1673            $optInController->initiateChange( $action, $user->getTalkPage(), $user );
1674        }
1675    }
1676
1677    /**
1678     * @param WikiImporter $importer
1679     * @return bool
1680     */
1681    public function onImportHandleToplevelXMLTag( $importer ) {
1682        // only init Flow's importer once, then re-use it
1683        static $flowImporter = null;
1684        if ( $flowImporter === null ) {
1685            // importer can be dry-run (= parse, but don't store), but we can only
1686            // derive that from mPageOutCallback. I'll set a new value (which will
1687            // return the existing value) to see if it's in dry-run mode (= null)
1688            $callback = $importer->setPageOutCallback( null );
1689            // restore previous mPageOutCallback value
1690            $importer->setPageOutCallback( $callback );
1691
1692            $flowImporter = new Dump\Importer( $importer );
1693            if ( $callback !== null ) {
1694                // not in dry-run mode
1695                $flowImporter->setStorage( Container::get( 'storage' ) );
1696            }
1697        }
1698
1699        $reader = $importer->getReader();
1700        $tag = $reader->localName;
1701        $type = $reader->nodeType;
1702
1703        if ( $tag == 'board' ) {
1704            if ( $type === XMLReader::ELEMENT ) {
1705                $flowImporter->handleBoard();
1706            }
1707            return false;
1708        } elseif ( $tag == 'description' ) {
1709            if ( $type === XMLReader::ELEMENT ) {
1710                $flowImporter->handleHeader();
1711            }
1712            return false;
1713        } elseif ( $tag == 'topic' ) {
1714            if ( $type === XMLReader::ELEMENT ) {
1715                $flowImporter->handleTopic();
1716            }
1717            return false;
1718        } elseif ( $tag == 'post' ) {
1719            if ( $type === XMLReader::ELEMENT ) {
1720                $flowImporter->handlePost();
1721            }
1722            return false;
1723        } elseif ( $tag == 'summary' ) {
1724            if ( $type === XMLReader::ELEMENT ) {
1725                $flowImporter->handleSummary();
1726            }
1727            return false;
1728        } elseif ( $tag == 'children' ) {
1729            return false;
1730        }
1731
1732        return true;
1733    }
1734
1735    public static function onNukeGetNewPages( $username, $pattern, $namespace, $limit, &$pages ) {
1736        if ( $namespace && $namespace !== NS_TOPIC ) {
1737            // not interested in any Topics
1738            return true;
1739        }
1740
1741        // Remove any pre-existing Topic pages.
1742        // They are coming from the recentchanges table.
1743        // The most likely case is that the filters were not applied correctly.
1744        $pages = array_filter( $pages, static function ( $entry ) {
1745            /** @var Title $title */
1746            $title = $entry[0];
1747            return $title->getNamespace() !== NS_TOPIC;
1748        } );
1749
1750        if ( $pattern ) {
1751            // pattern is not supported
1752            return true;
1753        }
1754
1755        if ( !MediaWikiServices::getInstance()->getPermissionManager()
1756            ->userHasRight( RequestContext::getMain()->getUser(), 'flow-delete' )
1757        ) {
1758            // there's no point adding topics since the current user won't be allowed to delete them
1759            return true;
1760        }
1761
1762        // how many revisions are we allowed to retrieve now
1763        $newLimit = $limit - count( $pages );
1764
1765        // we can't add anything
1766        if ( $newLimit < 1 ) {
1767            return true;
1768        }
1769
1770        /** @var DbFactory $dbFactory */
1771        $dbFactory = Container::get( 'db.factory' );
1772        $dbr = $dbFactory->getDB( DB_REPLICA );
1773
1774        // if a username is specified, search only for that user
1775        $userWhere = [];
1776        if ( $username ) {
1777            $user = User::newFromName( $username );
1778            if ( $user && $user->isRegistered() ) {
1779                $userWhere = [ 'tree_orig_user_id' => $user->getId() ];
1780            } else {
1781                $userWhere = [ 'tree_orig_user_ip' => $username ];
1782            }
1783        }
1784
1785        // limit results to the range of RC
1786        global $wgRCMaxAge;
1787        $rcTimeLimit = UUID::getComparisonUUID( strtotime( "-$wgRCMaxAge seconds" ) );
1788
1789        // get the latest revision id for each topic
1790        $result = $dbr->newSelectQueryBuilder()
1791            ->select( [
1792                'revId' => 'MAX(r.rev_id)',
1793                'userIp' => "tree_orig_user_ip",
1794                'userId' => "tree_orig_user_id",
1795            ] )
1796            ->from( 'flow_revision', 'r' )
1797            ->join( 'flow_tree_revision', null, 'r.rev_type_id=tree_rev_descendant_id' )
1798            ->join( 'flow_workflow', null, 'r.rev_type_id=workflow_id' )
1799            ->where( [
1800                'tree_parent_id' => null,
1801                'r.rev_type' => 'post',
1802                'workflow_wiki' => WikiMap::getCurrentWikiId(),
1803                $dbr->expr( 'workflow_id', '>', $rcTimeLimit->getBinary() )
1804            ] )
1805            ->andWhere( $userWhere )
1806            ->groupBy( [ 'r.rev_type_id', 'tree_orig_user_ip' ] )
1807            ->caller( __METHOD__ )
1808            ->fetchResultSet();
1809
1810        if ( $result->numRows() < 1 ) {
1811            return true;
1812        }
1813
1814        $revIds = [];
1815        foreach ( $result as $r ) {
1816            $revIds[$r->revId] = [ 'userIp' => $r->userIp, 'userId' => $r->userId, 'name' => false ];
1817        }
1818
1819        // get non-moderated revisions (but include hidden ones for T180607)
1820        $result = $dbr->newSelectQueryBuilder()
1821            ->select( [
1822                'topicId' => 'rev_type_id',
1823                'revId' => 'rev_id'
1824            ] )
1825            ->from( 'flow_revision' )
1826            ->where( [
1827                'rev_mod_state' => [ '', 'hide' ],
1828                'rev_id' => array_keys( $revIds )
1829            ] )
1830            ->limit( $newLimit )
1831            ->orderBy( 'rev_type_id', SelectQueryBuilder::SORT_DESC )
1832            ->caller( __METHOD__ )
1833            ->fetchResultSet();
1834
1835        // all topics previously found appear to be moderated
1836        if ( $result->numRows() < 1 ) {
1837            return true;
1838        }
1839
1840        // keep only the relevant topics in [topicId => userInfo] format
1841        $limitedRevIds = [];
1842        foreach ( $result as $r ) {
1843            $limitedRevIds[$r->topicId] = $revIds[$r->revId];
1844        }
1845
1846        // fill usernames if no $username filter was specified
1847        if ( !$username ) {
1848            $userIds = array_column( array_values( $limitedRevIds ), 'userId' );
1849            $userIds = array_filter( $userIds );
1850
1851            $userMap = [];
1852            if ( $userIds ) {
1853                $wikiDbr = $dbFactory->getWikiDB( DB_REPLICA );
1854                $result = $wikiDbr->newSelectQueryBuilder()
1855                    ->select( [ 'user_id', 'user_name' ] )
1856                    ->from( 'user' )
1857                    ->where( [ 'user_id' => array_values( $userIds ) ] )
1858                    ->caller( __METHOD__ )
1859                    ->fetchResultSet();
1860                foreach ( $result as $r ) {
1861                    $userMap[$r->user_id] = $r->user_name;
1862                }
1863            }
1864
1865            // set name in userInfo structure
1866            foreach ( $limitedRevIds as $topicId => &$userInfo ) {
1867                if ( $userInfo['userIp'] ) {
1868                    $userInfo['name'] = $userInfo['userIp'];
1869                } elseif ( $userInfo['userId'] ) {
1870                    $userInfo['name'] = $userMap[$userInfo['userId']];
1871                } else {
1872                    $userInfo['name'] = false;
1873                    $topicIdAlpha = UUID::create( $topicId )->getAlphadecimal();
1874                    wfLogWarning( __METHOD__ . ": Cannot find user information for topic {$topicIdAlpha}" );
1875                }
1876            }
1877        }
1878        unset( $userInfo );
1879
1880        // add results to the list of pages to nuke
1881        foreach ( $limitedRevIds as $topicId => $userInfo ) {
1882            $pages[] = [
1883                Title::makeTitle( NS_TOPIC, UUID::create( $topicId )->getAlphadecimal() ),
1884                $userInfo['name']
1885            ];
1886        }
1887
1888        return true;
1889    }
1890
1891    public static function onNukeDeletePage( Title $title, $reason, &$deletionResult ) {
1892        if ( $title->getNamespace() !== NS_TOPIC ) {
1893            // we don't handle it
1894            return true;
1895        }
1896
1897        $action = 'moderate-topic';
1898        $params = [
1899            'topic' => [
1900                'moderationState' => 'delete',
1901                'reason' => $reason,
1902                'page' => $title->getPrefixedText()
1903            ],
1904        ];
1905
1906        /** @var WorkflowLoaderFactory $factory */
1907        $factory = Container::get( 'factory.loader.workflow' );
1908
1909        $workflowId = WorkflowLoaderFactory::uuidFromTitle( $title );
1910        /** @var WorkflowLoader $loader */
1911        $loader = $factory->createWorkflowLoader( $title, $workflowId );
1912
1913        $blocks = $loader->getBlocks();
1914
1915        $blocksToCommit = $loader->handleSubmit(
1916            RequestContext::getMain(),
1917            $action,
1918            $params
1919        );
1920
1921        $result = true;
1922        $errors = [];
1923        foreach ( $blocks as $block ) {
1924            if ( $block->hasErrors() ) {
1925                $result = false;
1926                $errorKeys = $block->getErrors();
1927                foreach ( $errorKeys as $errorKey ) {
1928                    $errors[] = $block->getErrorMessage( $errorKey );
1929                }
1930            }
1931        }
1932
1933        if ( $result ) {
1934            $loader->commit( $blocksToCommit );
1935            $deletionResult = true;
1936        } else {
1937            $deletionResult = false;
1938            $msg = "Failed to delete {$title->getPrefixedText()}. Errors: " . implode( '. ', $errors );
1939            wfLogWarning( $msg );
1940        }
1941
1942        // we've handled the deletion, abort the hook
1943        return false;
1944    }
1945
1946    /**
1947     * Filter out all Flow changes when hidepageedits=1
1948     *
1949     * @param string $name
1950     * @param array &$tables
1951     * @param array &$fields
1952     * @param array &$conds
1953     * @param array &$query_options
1954     * @param array &$join_conds
1955     * @param FormOptions $opts
1956     */
1957    public function onChangesListSpecialPageQuery(
1958        $name, &$tables, &$fields, &$conds,
1959        &$query_options, &$join_conds, $opts
1960    ) {
1961        try {
1962            $hidePageEdits = $opts->getValue( 'hidepageedits' );
1963        } catch ( MWException ) {
1964            // If not set, assume they should be hidden.
1965            $hidePageEdits = true;
1966        }
1967        if ( $hidePageEdits ) {
1968            $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1969            $conds[] = $dbr->expr( 'rc_source', '!=', RecentChangesListener::SRC_FLOW );
1970        }
1971    }
1972
1973    /**
1974     * @inheritDoc
1975     * @return false|void
1976     */
1977    public function onGetUserPermissionsErrors( $title, $user, $action, &$result ) {
1978        global $wgFlowReadOnly;
1979
1980        $tempUserConfig = MediaWikiServices::getInstance()->getTempUserConfig();
1981        // Flow has no support for temp accounts. If temp accounts are
1982        // known on the wiki, don't let anonymous users edit, and
1983        // don't let temporary users edit either.
1984        if (
1985            $tempUserConfig->isKnown() && !$user->isNamed() &&
1986            $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD &&
1987            $action !== 'read'
1988        ) {
1989            $result = 'flow-error-protected-readonly';
1990            return false;
1991        }
1992
1993        if ( !$wgFlowReadOnly ) {
1994            return;
1995        }
1996
1997        // Deny all actions related to Flow pages, and deny all flow-create-board actions,
1998        // but allow read and delete/undelete
1999        $allowedActions = [ 'read', 'delete', 'undelete' ];
2000        if (
2001            $action === 'flow-create-board' ||
2002            (
2003                $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD &&
2004                !in_array( $action, $allowedActions )
2005            )
2006        ) {
2007            $result = 'flow-error-protected-readonly';
2008            return false;
2009        }
2010    }
2011
2012    /**
2013     * Return information about terms-of-use messages.
2014     *
2015     * @param MessageLocalizer $context
2016     * @param Config $config
2017     * @return array Map from internal name to array of parameters for MessageLocalizer::msg()
2018     * @phan-return non-empty-array[]
2019     */
2020    private static function getTermsOfUseMessages(
2021        MessageLocalizer $context, Config $config
2022    ): array {
2023        $messages = [
2024            'new-topic' => [ 'flow-terms-of-use-new-topic' ],
2025            'reply' => [ 'flow-terms-of-use-reply' ],
2026            'edit' => [ 'flow-terms-of-use-edit' ],
2027            'summarize' => [ 'flow-terms-of-use-summarize' ],
2028            'lock-topic' => [ 'flow-terms-of-use-lock-topic' ],
2029            'unlock-topic' => [ 'flow-terms-of-use-unlock-topic' ],
2030        ];
2031
2032        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
2033        $hookRunner->onFlowTermsOfUseMessages( $messages, $context, $config );
2034
2035        return $messages;
2036    }
2037
2038    /**
2039     * Return parsed terms-of-use messages, for use in a ResourceLoader module.
2040     *
2041     * @param MessageLocalizer $context
2042     * @param Config $config
2043     * @return array
2044     */
2045    public static function getTermsOfUseMessagesParsed(
2046        MessageLocalizer $context, Config $config
2047    ): array {
2048        $messages = self::getTermsOfUseMessages( $context, $config );
2049        foreach ( $messages as &$msg ) {
2050            $msg = $context->msg( ...$msg )->parse();
2051        }
2052        return $messages;
2053    }
2054
2055    /**
2056     * Return information about terms-of-use messages, for use in a ResourceLoader module as
2057     * 'versionCallback'. This is to avoid calling the parser from version invalidation code.
2058     *
2059     * @param MessageLocalizer $context
2060     * @param Config $config
2061     * @return array
2062     */
2063    public static function getTermsOfUseMessagesVersion(
2064        MessageLocalizer $context, Config $config
2065    ): array {
2066        $messages = self::getTermsOfUseMessages( $context, $config );
2067        foreach ( $messages as &$msg ) {
2068            $message = $context->msg( ...$msg );
2069            $msg = [
2070                // Include the text of the message, in case the canonical translation changes
2071                $message->plain(),
2072                // Include the page touched time, in case the on-wiki override is invalidated
2073                Title::makeTitle( NS_MEDIAWIKI, ucfirst( $message->getKey() ) )->getTouched(),
2074            ];
2075        }
2076        return $messages;
2077    }
2078}