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