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