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