Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.91% covered (danger)
1.91%
14 / 733
3.57% covered (danger)
3.57%
1 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialEditGrowthConfig
1.91% covered (danger)
1.91%
14 / 733
3.57% covered (danger)
3.57%
1 / 28
10912.44
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 setConfigPage
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 isWikiConfigEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 userCanExecute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 displayRestrictionError
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 requiresWrite
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMessagePrefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDisplayFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 preHtml
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 alterForm
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
72
 getRawDescriptors
0.00% covered (danger)
0.00%
0 / 339
0.00% covered (danger)
0.00%
0 / 1
132
 getValueGeConfig
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getNewcomerTasksConfig
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 getPrefixAndName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFormFields
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 1
306
 normalizeSuggestedEditsIntroLinks
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 normalizeHelpPanelLinks
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 preprocessSubmittedData
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
110
 normalizeSuggestedEditsConfig
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
132
 getDefaultDataForEnabledTaskTypes
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 normalizeTitleList
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 onSubmit
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 onSuccess
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getFeedbackHtml
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GrowthExperiments\Specials;
4
5use GrowthExperiments\Config\GrowthExperimentsMultiConfig;
6use GrowthExperiments\Config\Validation\GrowthConfigValidation;
7use GrowthExperiments\Config\Validation\NewcomerTasksValidator;
8use GrowthExperiments\Config\WikiPageConfigLoader;
9use GrowthExperiments\Config\WikiPageConfigWriterFactory;
10use GrowthExperiments\EventLogging\SpecialEditGrowthConfigLogger;
11use GrowthExperiments\HomepageModules\Banner;
12use GrowthExperiments\LevelingUp\LevelingUpManager;
13use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskType;
14use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler;
15use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskType;
16use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskTypeHandler;
17use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskType;
18use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskTypeHandler;
19use MediaWiki\Html\Html;
20use MediaWiki\HTMLForm\HTMLForm;
21use MediaWiki\Message\Message;
22use MediaWiki\Page\PageProps;
23use MediaWiki\Revision\RevisionLookup;
24use MediaWiki\SpecialPage\FormSpecialPage;
25use MediaWiki\Status\Status;
26use MediaWiki\Title\Title;
27use MediaWiki\Title\TitleFactory;
28use MediaWiki\User\User;
29use MediaWiki\Utils\MWTimestamp;
30use OOUI\ButtonWidget;
31use OOUI\IconWidget;
32use PermissionsError;
33use Wikimedia\Assert\Assert;
34use Wikimedia\Rdbms\IDatabase;
35use Wikimedia\Rdbms\ILoadBalancer;
36use Wikimedia\Rdbms\ReadOnlyMode;
37
38class SpecialEditGrowthConfig extends FormSpecialPage {
39    /** @var string Right required to write */
40    public const REQUIRED_RIGHT_TO_WRITE = 'editinterface';
41
42    /** @var string[] */
43    private const SUGGESTED_EDITS_INTRO_LINKS = [ 'create', 'image' ];
44
45    /** @var string[] Keys that will be present in $configPages */
46    private const CONFIG_PAGES_KEYS = [ 'geconfig', 'newcomertasks' ];
47
48    private TitleFactory $titleFactory;
49    private RevisionLookup $revisionLookup;
50    private PageProps $pageProps;
51    private ILoadBalancer $loadBalancer;
52    private ReadOnlyMode $readOnlyMode;
53    private WikiPageConfigLoader $configLoader;
54    private WikiPageConfigWriterFactory $configWriterFactory;
55    private GrowthExperimentsMultiConfig $growthWikiConfig;
56    private SpecialEditGrowthConfigLogger $eventLogger;
57    private ?string $errorMsgKey = null;
58
59    /**
60     * @var Title[]
61     *
62     * All keys listed in CONFIG_PAGES_KEYS will be present,
63     * unless $errorMsgKey is not null (in which case the special page
64     * short-circuits anyway).
65     */
66    private array $configPages = [];
67    private bool $userCanWrite;
68    private ?array $newcomerTasksConfig = null;
69
70    /**
71     * @param TitleFactory $titleFactory
72     * @param RevisionLookup $revisionLookup
73     * @param PageProps $pageProps
74     * @param ILoadBalancer $loadBalancer
75     * @param ReadOnlyMode $readOnlyMode
76     * @param WikiPageConfigLoader $configLoader
77     * @param WikiPageConfigWriterFactory $configWriterFactory
78     * @param GrowthExperimentsMultiConfig $growthWikiConfig
79     */
80    public function __construct(
81        TitleFactory $titleFactory,
82        RevisionLookup $revisionLookup,
83        PageProps $pageProps,
84        ILoadBalancer $loadBalancer,
85        ReadOnlyMode $readOnlyMode,
86        WikiPageConfigLoader $configLoader,
87        WikiPageConfigWriterFactory $configWriterFactory,
88        GrowthExperimentsMultiConfig $growthWikiConfig
89    ) {
90        parent::__construct( 'EditGrowthConfig' );
91
92        $this->titleFactory = $titleFactory;
93        $this->revisionLookup = $revisionLookup;
94        $this->pageProps = $pageProps;
95        $this->loadBalancer = $loadBalancer;
96        $this->readOnlyMode = $readOnlyMode;
97        $this->configLoader = $configLoader;
98        $this->configWriterFactory = $configWriterFactory;
99        $this->growthWikiConfig = $growthWikiConfig;
100
101        $this->eventLogger = new SpecialEditGrowthConfigLogger();
102    }
103
104    /** @inheritDoc */
105    protected function getGroupName() {
106        return 'growth-tools';
107    }
108
109    /**
110     * @param string|null $par
111     */
112    public function execute( $par ) {
113        $this->getOutput()->enableOOUI();
114        $this->addHelpLink( 'Growth/Community configuration' );
115
116        $config = $this->getConfig();
117        $this->setConfigPage(
118            'geconfig',
119            $config->get( 'GEWikiConfigPageTitle' )
120        );
121        $this->setConfigPage(
122            'newcomertasks',
123            $config->get( 'GENewcomerTasksConfigTitle' )
124        );
125
126        $this->userCanWrite = $this->getAuthority()->isAllowed( self::REQUIRED_RIGHT_TO_WRITE );
127
128        parent::execute( $par );
129
130        $this->eventLogger->logAction( SpecialEditGrowthConfigLogger::ACTION_VIEW, $this->getAuthority() );
131    }
132
133    /**
134     * Register a config page
135     *
136     * This validates the config page has proper content model
137     * and that it can be used to store config.
138     *
139     * @param string $key One of keys listed in CONFIG_PAGES_KEYS
140     * @param string $configPage
141     */
142    private function setConfigPage( string $key, string $configPage ): void {
143        Assert::parameter(
144            in_array( $key, self::CONFIG_PAGES_KEYS ),
145            '$key',
146            'must be one of keys listed in SpecialEditGrowthConfig::CONFIG_PAGES_KEYS'
147        );
148
149        $configTitle = $this->titleFactory->newFromText( $configPage );
150        if (
151            $configTitle === null ||
152            !$configTitle->hasContentModel( CONTENT_MODEL_JSON )
153        ) {
154            $this->errorMsgKey = 'growthexperiments-edit-config-error-invalid-title';
155            return;
156        }
157
158        $this->configPages[$key] = $configTitle;
159    }
160
161    /**
162     * Determines if wiki config is enabled
163     *
164     * @return bool
165     */
166    private function isWikiConfigEnabled(): bool {
167        return $this->growthWikiConfig->isWikiConfigEnabled();
168    }
169
170    /**
171     * @inheritDoc
172     */
173    public function userCanExecute( User $user ) {
174        // Require both enabled wiki config and user-specific access level to
175        // be able to use the special page.
176        return $this->isWikiConfigEnabled() && parent::userCanExecute( $user );
177    }
178
179    /**
180     * @inheritDoc
181     */
182    public function displayRestrictionError() {
183        if ( !$this->isWikiConfigEnabled() ) {
184            // Wiki config is disabled, display a meaningful restriction error
185            throw new PermissionsError(
186                null,
187                [ 'growthexperiments-edit-config-disabled' ]
188            );
189        }
190
191        // Otherwise, defer to the default logic
192        parent::displayRestrictionError();
193    }
194
195    /**
196     * @inheritDoc
197     */
198    public function requiresWrite() {
199        return false;
200    }
201
202    /**
203     * @inheritDoc
204     */
205    public function doesWrites() {
206        return true;
207    }
208
209    /**
210     * @inheritDoc
211     */
212    protected function getMessagePrefix() {
213        return 'growthexperiments-edit-config';
214    }
215
216    /**
217     * @inheritDoc
218     */
219    public function getDescription() {
220        return $this->msg( 'growthexperiments-edit-config-title' );
221    }
222
223    /**
224     * @inheritDoc
225     */
226    protected function getDisplayFormat() {
227        return 'ooui';
228    }
229
230    /**
231     * @inheritDoc
232     */
233    protected function preHtml() {
234        if ( $this->errorMsgKey !== null ) {
235            return $this->msg( $this->errorMsgKey )->escaped();
236        }
237        return '';
238    }
239
240    /**
241     * Customize the form used
242     *
243     * This:
244     *     * Hides the form if there is an error
245     *     * Displays "last edited by" message
246     *     * Displays an introduction message
247     *
248     * @param HTMLForm $form
249     */
250    protected function alterForm( HTMLForm $form ) {
251        if ( $this->errorMsgKey !== null ) {
252            $form->suppressDefaultSubmit( true );
253            return;
254        }
255
256        if ( $this->userCanWrite ) {
257            $form->addPreHtml( $this->msg(
258                'growthexperiments-edit-config-pretext',
259                Message::listParam( array_map( static function ( Title $title ) {
260                    return '[[' . $title->getPrefixedText() . ']]';
261                }, array_values( $this->configPages ) ) )
262            )->parseAsBlock() );
263            $form->addPreHtml( $this->msg(
264                'growthexperiments-edit-config-pretext-banner',
265                $this->titleFactory->newFromText(
266                    Banner::MESSAGE_KEY,
267                    NS_MEDIAWIKI
268                )->getPrefixedText()
269            )->parseAsBlock() );
270        } else {
271            $form->addPreHtml( $this->msg( 'growthexperiments-edit-config-pretext-unprivileged' ) );
272        }
273
274        // Add last updated data
275        foreach ( $this->configPages as $configType => $configTitle ) {
276            $revision = $this->revisionLookup->getRevisionByTitle( $configTitle );
277            if ( $revision !== null ) {
278                $lastRevisionUser = $revision->getUser();
279                $diffLink = $configTitle->getFullURL( [ 'oldid' => $revision->getId(), 'diff' => 'prev' ] );
280                if ( $lastRevisionUser !== null ) {
281                    $form->addPreHtml( $this->msg(
282                        'growthexperiments-edit-config-last-edit',
283                        $lastRevisionUser->getName(),
284                        MWTimestamp::getInstance( $revision->getTimestamp() )
285                            ->getRelativeTimestamp(),
286                        $configTitle->getPrefixedText(),
287                        $diffLink
288                    )->parseAsBlock() );
289                } else {
290                    $form->addPreHtml( $this->msg(
291                        'growthexperiments-edit-config-last-edit-unknown-user',
292                        MWTimestamp::getInstance( $revision->getTimestamp() )
293                            ->getRelativeTimestamp(),
294                        $configTitle->getPrefixedText(),
295                        $diffLink
296                    )->parseAsBlock() );
297                }
298            }
299        }
300
301        $form->addPreHtml( $this->getFeedbackHtml() );
302
303        if ( !$this->userCanWrite ) {
304            $form->suppressDefaultSubmit( true );
305        } elseif ( $this->readOnlyMode->isReadOnly() ) {
306            $form->suppressDefaultSubmit( true );
307            $form->addPostHtml( $this->msg( 'readonlytext', $this->readOnlyMode->getReason() ) );
308        }
309    }
310
311    private function getRawDescriptors(): array {
312        // Whether the various pages configured as help links etc. must exist.
313        $pagesMustExist = !$this->getConfig()->get( 'GEDeveloperSetup' );
314
315        $descriptors = [
316            // Growth experiments config (stored in MediaWiki:GrowthExperimentsConfig.json)
317            'geconfig-GEHomepageSuggestedEditsIntroLinks-create' => [
318                'type' => 'title',
319                'exists' => $pagesMustExist,
320                'interwiki' => true,
321                'label-message' => 'growthexperiments-edit-config-homepage-intro-links-create',
322                'required' => true,
323                'section' => 'homepage',
324            ],
325            'geconfig-GEHomepageSuggestedEditsIntroLinks-image' => [
326                'type' => 'title',
327                'exists' => $pagesMustExist,
328                'interwiki' => true,
329                'label-message' => 'growthexperiments-edit-config-homepage-intro-links-image',
330                'required' => true,
331                'section' => 'homepage',
332            ],
333            'geconfig-mentorship-description' => [
334                'type' => 'info',
335                'label-message' => 'growthexperiments-edit-config-mentorship-description-structured',
336                'section' => 'mentorship',
337            ],
338            'geconfig-GEMentorshipEnabled' => [
339                'type' => 'radio',
340                'label-message' => 'growthexperiments-edit-config-mentorship-enabled',
341                'options-messages' => [
342                    'growthexperiments-edit-config-mentorship-enabled-true' => 'true',
343                    'growthexperiments-edit-config-mentorship-enabled-false' => 'false',
344                ],
345                'section' => 'mentorship',
346            ],
347            'geconfig-GEMentorshipAutomaticEligibility' => [
348                'type' => 'radio',
349                'label-message' => 'growthexperiments-edit-config-mentorship-automatic-eligibility',
350                'options-messages' => [
351                    'growthexperiments-edit-config-mentorship-automatic-eligibility-true' => 'true',
352                    'growthexperiments-edit-config-mentorship-automatic-eligibility-false' => 'false',
353                ],
354                'section' => 'mentorship',
355            ],
356            'geconfig-GEMentorshipMinimumAge' => [
357                'type' => 'int',
358                'label-message' => 'growthexperiments-edit-config-mentorship-minimum-age',
359                'section' => 'mentorship',
360            ],
361            'geconfig-GEMentorshipMinimumEditcount' => [
362                'type' => 'int',
363                'label-message' => 'growthexperiments-edit-config-mentorship-minimum-editcount',
364                'section' => 'mentorship',
365            ],
366        ];
367
368        if ( $this->getConfig()->get( 'GEPersonalizedPraiseBackendEnabled' ) ) {
369            $descriptors = array_merge( $descriptors, [
370                'geconfig-personalized-praise-description' => [
371                    'type' => 'info',
372                    'label-message' => 'growthexperiments-edit-config-personalized-praise-description',
373                    'section' => 'personalized-praise',
374                ],
375                'geconfig-GEPersonalizedPraiseDefaultNotificationsFrequency' => [
376                    'type' => 'int',
377                    'label-message' => 'growthexperiments-edit-config-personalized-praise-notification-frequency',
378                    'section' => 'personalized-praise',
379                    'help-message' => 'growthexperiments-edit-config-personalized-praise-mentors-can-change',
380                ],
381                'geconfig-GEPersonalizedPraiseMinEdits' => [
382                    'type' => 'int',
383                    'label-message' => 'growthexperiments-edit-config-personalized-praise-min-edits',
384                    'section' => 'personalized-praise',
385                    'help-message' => 'growthexperiments-edit-config-personalized-praise-mentors-can-change',
386                ],
387                'geconfig-GEPersonalizedPraiseDays' => [
388                    'type' => 'int',
389                    'label-message' => 'growthexperiments-edit-config-personalized-praise-days',
390                    'section' => 'personalized-praise',
391                    'help-message' => 'growthexperiments-edit-config-personalized-praise-mentors-can-change',
392                ],
393                'geconfig-GEPersonalizedPraiseMaxEdits' => [
394                    'type' => 'int',
395                    'label-message' => 'growthexperiments-edit-config-personalized-praise-max-edits',
396                    'section' => 'personalized-praise',
397                ],
398            ] );
399        }
400
401        $descriptors = array_merge( $descriptors, [
402            // Description for suggested edits config
403            'newcomertasks-section-description' => [
404                'type' => 'info',
405                'label-message' => 'growthexperiments-edit-config-newcomer-tasks-description',
406                'section' => 'newcomertasks',
407            ],
408        ] );
409
410        $descriptors = array_merge( $descriptors, [
411            'geconfig-GEInfoboxTemplates' => [
412                'type' => 'titlesmultiselect',
413                'exists' => $pagesMustExist,
414                'placeholder' => $this->msg( 'nstab-template' )->text() . ':Infobox',
415                'max' => GrowthConfigValidation::MAX_TEMPLATES_IN_COLLECTION,
416                'label-message' => $this->msg(
417                    'growthexperiments-edit-config-newcomer-tasks-infobox-templates'
418                ),
419                'help' => $this->msg( 'growthexperiments-edit-config-newcomer-tasks-infobox-templates-help' )->parse(),
420                'required' => false,
421                'section' => 'newcomertasks',
422            ]
423        ] );
424
425        // Add fields for suggested edits config (stored in MediaWiki:NewcomerTasks.json)
426        foreach ( $this->getDefaultDataForEnabledTaskTypes() as $taskType => $taskTypeData ) {
427            $isMachineSuggestionTaskType = in_array(
428                $taskType,
429                NewcomerTasksValidator::SUGGESTED_EDITS_MACHINE_SUGGESTIONS_TASK_TYPES
430            );
431            $descriptors["newcomertasks-{$taskType}Info"] = [
432                'type' => 'info',
433                // TODO: It looks nicer to have each task type in its own section, but that's a bigger
434                // reorganization.
435                'default' => '<h3>' . new IconWidget( [ 'icon' => $taskTypeData['icon'] ] ) . '  ' .
436                    $this->msg( "growthexperiments-homepage-suggestededits-tasktype-name-$taskType" )->parse() .
437                    '</h3>',
438                'raw' => true,
439                'section' => 'newcomertasks',
440            ];
441            $descriptors["newcomertasks-{$taskType}Disabled"] = [
442                'type' => 'check',
443                'label-message' => 'growthexperiments-edit-config-newcomer-tasks-disabled',
444                'section' => 'newcomertasks',
445            ];
446            $descriptors["newcomertasks-{$taskType}Templates"] = [
447                'type' => 'titlesmultiselect',
448                'disabled' => $isMachineSuggestionTaskType,
449                'exists' => $pagesMustExist,
450                'namespace' => NS_TEMPLATE,
451                // TODO: This should be relative => true in an ideal world, see T285750 and
452                // T285748 for blockers
453                'relative' => false,
454                'label-message' => $isMachineSuggestionTaskType ?
455                    "growthexperiments-edit-config-newcomer-tasks-machine-suggestions-no-templates" :
456                    "growthexperiments-edit-config-newcomer-tasks-$taskType-templates",
457                'required' => false,
458                'section' => 'newcomertasks'
459            ];
460            $descriptors["newcomertasks-{$taskType}ExcludedTemplates"] = [
461                'type' => 'titlesmultiselect',
462                'exists' => $pagesMustExist,
463                'namespace' => NS_TEMPLATE,
464                // TODO: This should be relative => true in an ideal world, see T285750 and
465                // T285748 for blockers
466                'relative' => false,
467                'label-message' => $this->msg(
468                    "growthexperiments-edit-config-newcomer-tasks-excluded-templates"
469                ),
470                'required' => false,
471                'section' => 'newcomertasks'
472            ];
473            $descriptors["newcomertasks-{$taskType}ExcludedCategories"] = [
474                'type' => 'titlesmultiselect',
475                'exists' => $pagesMustExist,
476                'namespace' => NS_CATEGORY,
477                // TODO: This should be relative => true in an ideal world, see T285750 and
478                // T285748 for blockers
479                'relative' => false,
480                'label-message' => $this->msg(
481                    "growthexperiments-edit-config-newcomer-tasks-excluded-categories"
482                ),
483                'required' => false,
484                'section' => 'newcomertasks'
485            ];
486            $descriptors["newcomertasks-{$taskType}Learnmore"] = [
487                'type' => 'title',
488                'interwiki' => true,
489                'exists' => $pagesMustExist,
490                'label-message' => "growthexperiments-edit-config-newcomer-tasks-$taskType-learnmore",
491                'required' => false,
492                'section' => 'newcomertasks'
493            ];
494
495            if ( $taskType === LinkRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
496                $descriptors["newcomertasks-link-recommendationMaximumLinksToShowPerTask"] = [
497                    'type' => 'int',
498                    'default' => LinkRecommendationTaskType::DEFAULT_SETTINGS[
499                        LinkRecommendationTaskType::FIELD_MAX_LINKS_TO_SHOW_PER_TASK
500                    ],
501                    'min' => LinkRecommendationTaskType::DEFAULT_SETTINGS[
502                        LinkRecommendationTaskType::FIELD_MIN_LINKS_PER_TASK
503                    ],
504                    'max' => LinkRecommendationTaskType::DEFAULT_SETTINGS[
505                        LinkRecommendationTaskType::FIELD_MAX_LINKS_PER_TASK
506                    ],
507                    'label-message' =>
508                        "growthexperiments-edit-config-newcomer-tasks-link-recommendation-maximum-links-to-show",
509                    'required' => false,
510                    'section' => 'newcomertasks'
511                ];
512
513                $descriptors['newcomertasks-link-recommendationMaxTasksPerDay'] = [
514                    'type' => 'int',
515                    'default' => LinkRecommendationTaskType::DEFAULT_SETTINGS[
516                        LinkRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY
517                    ],
518                    'label-message' =>
519                        'growthexperiments-edit-config-newcomer-tasks-link-recommendation-maximum-tasks-per-day',
520                    'required' => false,
521                    'section' => 'newcomertasks'
522                ];
523
524                $descriptors["newcomertasks-link-recommendationExcludedSections"] = [
525                    'type' => 'tagmultiselect',
526                    'allowArbitrary' => true,
527                    // will be converted to string later
528                    'default' => LinkRecommendationTaskType::DEFAULT_SETTINGS[
529                        LinkRecommendationTaskType::FIELD_EXCLUDED_SECTIONS
530                    ],
531                    'label-message' =>
532                        "growthexperiments-edit-config-newcomer-tasks-link-recommendation-excluded-sections",
533                    'help-message' => 'growthexperiments-edit-config-delayed',
534                    'required' => false,
535                    'section' => 'newcomertasks'
536                ];
537            } elseif ( $taskType === ImageRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
538                $descriptors['newcomertasks-image-recommendationMaxTasksPerDay'] = [
539                    'type' => 'int',
540                    'default' => ImageRecommendationTaskType::DEFAULT_SETTINGS[
541                        ImageRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY
542                    ],
543                    'label-message' =>
544                        'growthexperiments-edit-config-newcomer-tasks-image-recommendation-maximum-tasks-per-day',
545                    'required' => false,
546                    'section' => 'newcomertasks'
547                ];
548            } elseif ( $taskType === SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
549                $descriptors['newcomertasks-section-image-recommendationMaxTasksPerDay'] = [
550                    'type' => 'int',
551                    'default' => SectionImageRecommendationTaskType::DEFAULT_SETTINGS[
552                        SectionImageRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY
553                    ],
554                    'label-message' =>
555                        'growthexperiments-edit-config-newcomer-tasks-section-image-'
556                            . 'recommendation-maximum-tasks-per-day',
557                    'required' => false,
558                    'section' => 'newcomertasks'
559                ];
560            }
561        }
562
563        if ( LevelingUpManager::isEnabledForAnyone( $this->getConfig() ) ) {
564            $levelUpDescriptors = [
565                'geconfig-level-up-notifications-description' => [
566                    'type' => 'info',
567                    'label-message' => 'growthexperiments-edit-config-level-up-notifications-description',
568                    'section' => 'level-up-notifications'
569                ],
570                'geconfig-GELevelingUpGetStartedMaxTotalEdits' => [
571                    'type' => 'int',
572                    'label-message' => 'growthexperiments-edit-config-try-suggested-edits-notification-title',
573                    'section' => 'level-up-notifications',
574                    'help-message' => 'growthexperiments-edit-config-try-suggested-edits-notification-description',
575                    'required' => true,
576                    // NOTE: zero is used to disable the notification
577                    'min' => 0,
578                ],
579                'geconfig-GELevelingUpKeepGoingNotificationThresholds-maximum' => [
580                    'type' => 'int',
581                    'label-message' => 'growthexperiments-edit-config-keep-going-notification-title',
582                    'section' => 'level-up-notifications',
583                    'help-message' => 'growthexperiments-edit-config-keep-going-notification-description',
584                    'required' => true,
585                    // NOTE: zero is used to disable the notification
586                    'min' => 0,
587                ]
588            ];
589
590            $descriptors = array_merge( $descriptors, $levelUpDescriptors );
591        }
592
593        $descriptors = array_merge( $descriptors, [
594            'geconfig-help-panel-description' => [
595                'type' => 'info',
596                'label-message' => 'growthexperiments-edit-config-help-panel-description',
597                'section' => 'help-panel',
598            ],
599            'geconfig-GEHelpPanelExcludedNamespaces' => [
600                'type' => 'namespacesmultiselect',
601                'exists' => true,
602                'autocomplete' => false,
603                'label-message' => 'growthexperiments-edit-config-help-panel-disabled-namespaces',
604                'section' => 'help-panel',
605            ],
606            'geconfig-GEHelpPanelReadingModeNamespaces' => [
607                'type' => 'namespacesmultiselect',
608                'exists' => true,
609                'autocomplete' => false,
610                'label-message' => 'growthexperiments-edit-config-help-panel-reading-namespaces',
611                'section' => 'help-panel',
612            ],
613            'geconfig-GEHelpPanelSearchNamespaces' => [
614                'type' => 'namespacesmultiselect',
615                'exists' => true,
616                'autocomplete' => false,
617                'label-message' => 'growthexperiments-edit-config-help-panel-searched-namespaces',
618                'section' => 'help-panel',
619            ],
620            'geconfig-GEHelpPanelAskMentor' => [
621                'type' => 'radio',
622                'label-message' => 'growthexperiments-edit-config-help-panel-ask-mentor',
623                'options-messages' => [
624                    'growthexperiments-edit-config-help-panel-ask-mentor-true' => 'true',
625                    'growthexperiments-edit-config-help-panel-ask-mentor-false' => 'false',
626                ],
627                'section' => 'help-panel',
628            ],
629            'geconfig-GEHelpPanelHelpDeskTitle' => [
630                'type' => 'title',
631                'exists' => $pagesMustExist,
632                'label-message' => 'growthexperiments-edit-config-help-panel-helpdesk-title',
633                'required' => false,
634                'section' => 'help-panel',
635            ],
636            'geconfig-GEHelpPanelHelpDeskPostOnTop' => [
637                'type' => 'radio',
638                'label-message' => 'growthexperiments-edit-config-help-panel-post-on-top',
639                'options-messages' => [
640                    'growthexperiments-edit-config-help-panel-post-on-top-true' => 'true',
641                    'growthexperiments-edit-config-help-panel-post-on-top-false' => 'false',
642                ],
643                'section' => 'help-panel',
644            ],
645            'geconfig-GEHelpPanelLinks-description' => [
646                'type' => 'info',
647                'label-message' => 'growthexperiments-edit-config-help-panel-links-description',
648                'section' => 'help-panel-links',
649            ],
650        ] );
651
652        foreach ( [ 'mos', 'editing', 'images', 'references', 'articlewizard' ] as $position => $type ) {
653            // Messages used here (giving grep a chance to find usages):
654            // * growthexperiments-edit-config-help-panel-links-mos-title
655            // * growthexperiments-edit-config-help-panel-links-mos-label
656            // * growthexperiments-edit-config-help-panel-links-editing-title
657            // * growthexperiments-edit-config-help-panel-links-editing-label
658            // * growthexperiments-edit-config-help-panel-links-images-title
659            // * growthexperiments-edit-config-help-panel-links-images-label
660            // * growthexperiments-edit-config-help-panel-links-references-title
661            // * growthexperiments-edit-config-help-panel-links-references-label
662            // * growthexperiments-edit-config-help-panel-links-articlewizard-title
663            // * growthexperiments-edit-config-help-panel-links-articlewizard-label
664            $descriptors = array_merge( $descriptors, [
665                "geconfig-GEHelpPanelLinks-$position-title" => [
666                    'type' => 'title',
667                    'label-message' => "growthexperiments-edit-config-help-panel-links-$type-title",
668                    'section' => 'help-panel-links',
669                    'required' => false,
670                    'exists' => $pagesMustExist,
671                    'interwiki' => true,
672                ],
673                "geconfig-GEHelpPanelLinks-$position-label" => [
674                    'type' => 'text',
675                    'label-message' => "growthexperiments-edit-config-help-panel-links-$type-label",
676                    'section' => 'help-panel-links',
677                ],
678            ] );
679        }
680
681        $descriptors = array_merge( $descriptors, [
682            'geconfig-GEHelpPanelViewMoreTitle' => [
683                'type' => 'title',
684                'exists' => $pagesMustExist,
685                'label-message' => 'growthexperiments-edit-config-help-panel-view-more',
686                'required' => false,
687                'interwiki' => true,
688                'section' => 'help-panel-links',
689            ],
690        ] );
691
692        if ( !$this->userCanWrite ) {
693            foreach ( $descriptors as $key => $descriptor ) {
694                $descriptors[$key]['disabled'] = true;
695            }
696        }
697
698        return $descriptors;
699    }
700
701    /**
702     * Provide current value for a GrowthExperimentsMultiConfig variable
703     *
704     * @param string $name
705     * @return string|null
706     */
707    private function getValueGeConfig( string $name ): ?string {
708        $default = $this->growthWikiConfig->getWithFlags(
709            $name,
710            GrowthExperimentsMultiConfig::READ_UNCACHED
711        );
712        if ( is_array( $default ) ) {
713            $default = implode( "\n", $default );
714        }
715        if ( is_bool( $default ) ) {
716            $default = $default ? 'true' : 'false';
717        }
718
719        return $default;
720    }
721
722    /**
723     * Get newcomer tasks config. Avoid normal cache, use in-process cache only.
724     *
725     * @return array
726     */
727    private function getNewcomerTasksConfig(): array {
728        if ( $this->newcomerTasksConfig !== null ) {
729            return $this->newcomerTasksConfig;
730        }
731
732        $title = $this->titleFactory->newFromText(
733            $this->getConfig()->get( 'GENewcomerTasksConfigTitle' )
734        );
735        if ( $title === null || !$title->exists() ) {
736            return [];
737        }
738
739        $res = $this->configLoader->load(
740            $title,
741            WikiPageConfigLoader::READ_UNCACHED
742        );
743        if ( !is_array( $res ) ) {
744            // TODO: Maybe log the failure?
745            return [];
746        }
747
748        $this->newcomerTasksConfig = $res;
749        return $res;
750    }
751
752    /**
753     * Get config type from a form field name
754     *
755     * Form field names are always $configType-$configName, where
756     * $configType refers to the config page the variable is set in and
757     * $configName is the variable name.
758     *
759     * @param string $nameRaw
760     * @return string[]
761     */
762    private function getPrefixAndName( string $nameRaw ): array {
763        return explode( '-', $nameRaw, 2 );
764    }
765
766    /**
767     * @inheritDoc
768     */
769    protected function getFormFields() {
770        if ( $this->errorMsgKey !== null ) {
771            // Return an empty array when there is an error
772            return [];
773        }
774
775        $descriptors = $this->getRawDescriptors();
776
777        // Add default values for geconfig variables
778        foreach ( $descriptors as $nameRaw => $descriptor ) {
779            [ $prefix, $name ] = $this->getPrefixAndName( $nameRaw );
780            if ( strpos( $name, '-' ) !== false ) {
781                // Non-standard field, will be handled later in this method
782                continue;
783            }
784
785            if ( $prefix === 'geconfig' ) {
786                $default = $this->getValueGeConfig( $name );
787                if ( $default !== null ) {
788                    $descriptors[$nameRaw]['default'] = $default;
789                }
790            }
791        }
792
793        if ( LevelingUpManager::isEnabledForAnyone( $this->getConfig() ) ) {
794            $descriptors['geconfig-GELevelingUpKeepGoingNotificationThresholds-maximum']['default'] =
795                $this->growthWikiConfig->get( 'GELevelingUpKeepGoingNotificationThresholds' )[1];
796        }
797
798        // Add default values for newcomertasks variables
799        $newcomerTasksConfig = $this->getNewcomerTasksConfig();
800        foreach ( $this->getDefaultDataForEnabledTaskTypes() as $taskType => $group ) {
801            $descriptors["newcomertasks-{$taskType}Disabled"]['default']
802                = !empty( $newcomerTasksConfig[$taskType]['disabled'] );
803            $descriptors["newcomertasks-{$taskType}Templates"]['default'] = implode(
804                "\n",
805                array_map( function ( $rawTitle ) {
806                    return $this->titleFactory
807                        ->newFromTextThrow( $rawTitle, NS_TEMPLATE )
808                        ->getPrefixedText();
809                }, $newcomerTasksConfig[$taskType]['templates'] ?? [] )
810            );
811            $descriptors["newcomertasks-{$taskType}ExcludedTemplates"]['default'] = implode(
812                "\n",
813                array_map( function ( $rawTitle ) {
814                    return $this->titleFactory
815                        ->newFromTextThrow( $rawTitle, NS_TEMPLATE )
816                        ->getPrefixedText();
817                }, $newcomerTasksConfig[$taskType]['excludedTemplates'] ?? [] )
818            );
819            $descriptors["newcomertasks-{$taskType}ExcludedCategories"]['default'] = implode(
820                "\n",
821                array_map( function ( $rawTitle ) {
822                    return $this->titleFactory
823                        ->newFromTextThrow( $rawTitle, NS_CATEGORY )
824                        ->getPrefixedText();
825                }, $newcomerTasksConfig[$taskType]['excludedCategories'] ?? [] )
826            );
827            $descriptors["newcomertasks-{$taskType}Learnmore"]['default'] =
828                $newcomerTasksConfig[$taskType]['learnmore'] ?? '';
829
830            if ( $taskType === LinkRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
831                $maxLinksDescriptorName = "newcomertasks-{$taskType}" .
832                    ucfirst( LinkRecommendationTaskType::FIELD_MAX_LINKS_TO_SHOW_PER_TASK );
833                $descriptors[$maxLinksDescriptorName]['default'] =
834                    $newcomerTasksConfig[$taskType][LinkRecommendationTaskType::FIELD_MAX_LINKS_TO_SHOW_PER_TASK] ??
835                    $descriptors[$maxLinksDescriptorName]['default'];
836                $maxTasksDescriptorName = "newcomertasks-{$taskType}" .
837                    ucfirst( LinkRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY );
838                $descriptors[$maxTasksDescriptorName]['default'] =
839                    $newcomerTasksConfig[$taskType][LinkRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY] ??
840                    $descriptors[$maxTasksDescriptorName]['default'];
841                $descriptors[$maxLinksDescriptorName]['min'] =
842                    $newcomerTasksConfig[$taskType][LinkRecommendationTaskType::FIELD_MIN_LINKS_PER_TASK] ??
843                    $descriptors[$maxLinksDescriptorName]['min'];
844                $descriptors[$maxLinksDescriptorName]['max'] =
845                    $newcomerTasksConfig[$taskType][LinkRecommendationTaskType::FIELD_MAX_LINKS_PER_TASK] ??
846                    $descriptors[$maxLinksDescriptorName]['max'];
847
848                $excludeSectionsDescriptorName = "newcomertasks-{$taskType}" .
849                    ucfirst( LinkRecommendationTaskType::FIELD_EXCLUDED_SECTIONS );
850                $descriptors[$excludeSectionsDescriptorName]['default'] = implode( "\n",
851                    $newcomerTasksConfig[$taskType][LinkRecommendationTaskType::FIELD_EXCLUDED_SECTIONS] ??
852                    $descriptors[$excludeSectionsDescriptorName]['default']
853                );
854
855                // Ugly special-casing: if link-recommendations is soft-disabled, show it so
856                // configuration can be changed (in the future, once the special page supports that)
857                // but warn about it being disabled.
858                if ( $this->getConfig()->get( 'GELinkRecommendationsFrontendEnabled' ) === false ) {
859                    $descriptors["newcomertasks-{$taskType}Disabled"] = [
860                        'type' => 'info',
861                        'default' => new IconWidget( [ 'icon' => 'cancel' ] ) . ' '
862                            . $this->msg( 'growthexperiments-edit-config-newcomer-tasks-disabledinconfig' )->parse(),
863                        'raw' => true,
864                        'section' => 'newcomertasks',
865                    ];
866                }
867            } elseif ( $taskType === ImageRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
868                $maxTasksDescriptorName = "newcomertasks-{$taskType}" .
869                    ucfirst( ImageRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY );
870                $descriptors[$maxTasksDescriptorName]['default'] =
871                    $newcomerTasksConfig[$taskType][ImageRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY] ??
872                    $descriptors[$maxTasksDescriptorName]['default'];
873            } elseif ( $taskType === SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID ) {
874                $maxTasksDescriptorName = "newcomertasks-{$taskType}" .
875                    ucfirst( SectionImageRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY );
876                $descriptors[$maxTasksDescriptorName]['default'] =
877                    $newcomerTasksConfig[$taskType][SectionImageRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY] ??
878                    $descriptors[$maxTasksDescriptorName]['default'];
879            }
880        }
881
882        // Add default values for intro links
883        $linkValues = $this->growthWikiConfig->getWithFlags(
884            'GEHomepageSuggestedEditsIntroLinks',
885            GrowthExperimentsMultiConfig::READ_UNCACHED
886        );
887        foreach ( self::SUGGESTED_EDITS_INTRO_LINKS as $link ) {
888            $descriptors["geconfig-GEHomepageSuggestedEditsIntroLinks-$link"]['default'] =
889                $linkValues[$link];
890        }
891
892        // Add default values for help panel links
893        $helpPanelLinks = $this->growthWikiConfig->get( 'GEHelpPanelLinks' );
894        foreach ( $helpPanelLinks as $i => $link ) {
895            if (
896                isset( $descriptors["geconfig-GEHelpPanelLinks-$i-title"] ) &&
897                isset( $descriptors["geconfig-GEHelpPanelLinks-$i-label"] )
898            ) {
899                $descriptors["geconfig-GEHelpPanelLinks-$i-title"]['default'] = $link['title'];
900                $descriptors["geconfig-GEHelpPanelLinks-$i-label"]['default'] = $link['text'];
901            }
902        }
903
904        // Add edit summary field, if user can write
905        if ( $this->userCanWrite ) {
906            $descriptors['summary'] = [
907                'type' => 'text',
908                'label-message' => 'growthexperiments-edit-config-edit-summary',
909            ];
910        }
911        return $descriptors;
912    }
913
914    private function normalizeSuggestedEditsIntroLinks( array $data ): array {
915        $links = [];
916        foreach ( self::SUGGESTED_EDITS_INTRO_LINKS as $link ) {
917            $links[$link] = $data["geconfig-GEHomepageSuggestedEditsIntroLinks-$link"];
918        }
919        return $links;
920    }
921
922    private function normalizeHelpPanelLinks( array $data ): array {
923        $res = [];
924        // Right now, we support up to 5 help panel links
925        // If you want to change this, don't forget to update
926        // SpecialEditGrowthConfig::getRawDescriptors as well.
927        $supportedHelpPanelLinks = 5;
928        for ( $i = 0; $i < $supportedHelpPanelLinks; $i++ ) {
929            if (
930                $data["geconfig-GEHelpPanelLinks-$i-title"] == '' ||
931                $data["geconfig-GEHelpPanelLinks-$i-label"] == ''
932            ) {
933                continue;
934            }
935
936            $linkId = null;
937            $title = $this->titleFactory->newFromText( $data["geconfig-GEHelpPanelLinks-$i-title"] );
938            if ( $title !== null && $title->exists() && !$title->isExternal() ) {
939                $props = $this->pageProps->getProperties( $title, 'wikibase_item' );
940                $pageId = $title->getId();
941                if ( array_key_exists( $pageId, $props ) ) {
942                    $linkId = $props[$pageId];
943                }
944            }
945            $res[] = [
946                'title' => $data["geconfig-GEHelpPanelLinks-$i-title"],
947                'text' => $data["geconfig-GEHelpPanelLinks-$i-label"],
948                'id' => $linkId ?? $title->getPrefixedDBkey(),
949            ];
950        }
951        return $res;
952    }
953
954    /**
955     * Helper function that preprocesses submitted data
956     *
957     * This function:
958     *   * normalizes namespaces into arrays
959     *   * normalizes string true/false variables to actual booleans
960     *   * ignores "complex fields" (fields having - in their name) and field for edit summary
961     *   * splits variables by config type (one for each config page)
962     *
963     * @param array $data
964     * @return array
965     */
966    private function preprocessSubmittedData( array $data ): array {
967        $dataToSave = [];
968        foreach ( $this->getFormFields() as $nameRaw => $descriptor ) {
969            if ( $nameRaw === 'summary' ) {
970                continue;
971            }
972
973            [ $prefix, $name ] = $this->getPrefixAndName( $nameRaw );
974
975            if ( $descriptor['type'] === 'namespacesmultiselect' ) {
976                if ( $data[$nameRaw] === '' ) {
977                    $data[$nameRaw] = [];
978                } else {
979                    $data[$nameRaw] = array_map(
980                        'intval',
981                        explode( "\n", $data[$nameRaw] )
982                    );
983                }
984            } elseif ( $descriptor['type'] === 'int' ) {
985                $data[$nameRaw] = (int)$data[$nameRaw];
986            }
987
988            // Ignore fields with dashes except for newcomertasks, where task types
989            // can have a dash, e.g. 'link-recommendation'
990            if ( $prefix === 'newcomertasks' || strpos( $name, '-' ) === false ) {
991                $dataToSave[$prefix][$name] = $data[$nameRaw] ?? 'false';
992
993                // Basic normalization
994                if ( $dataToSave[$prefix][$name] === 'true' ) {
995                    $dataToSave[$prefix][$name] = true;
996                } elseif ( $dataToSave[$prefix][$name] === 'false' ) {
997                    $dataToSave[$prefix][$name] = false;
998                }
999            }
1000        }
1001        return $dataToSave;
1002    }
1003
1004    /**
1005     * Normalize configuration used in NewcomerTasks.json config file
1006     *
1007     * This function converts form fields into array that's then stored
1008     * in the JSON file.
1009     *
1010     * @param array $data
1011     * @return array
1012     */
1013    private function normalizeSuggestedEditsConfig( array $data ): array {
1014        $suggestedEditsConfig = $this->getNewcomerTasksConfig()
1015           // If a new task type was added since the on-wiki config page has last been updated,
1016           // we want that task type to be created the next time someone saves the page.
1017            + array_map( static function ( array $taskTypeData ) {
1018                return [
1019                    'disabled' => false,
1020                    'group' => $taskTypeData['difficulty'],
1021                    'templates' => [],
1022                    'excludedTemplates' => [],
1023                    'excludedCategories' => [],
1024                    'type' => $taskTypeData['handler-id'],
1025                ];
1026            }, $this->getDefaultDataForEnabledTaskTypes() );
1027
1028        foreach ( $this->getDefaultDataForEnabledTaskTypes() as $taskType => $taskTypeData ) {
1029            $templates = array_map( static function ( Title $title ) {
1030                return $title->getText();
1031            }, $this->normalizeTitleList( $data["{$taskType}Templates"] ?? null ) );
1032            if ( $templates === [] &&
1033                !in_array( $taskType, NewcomerTasksValidator::SUGGESTED_EDITS_MACHINE_SUGGESTIONS_TASK_TYPES )
1034            ) {
1035                // Do not save template-based tasks with no templates
1036                continue;
1037            }
1038            $excludedTemplates = array_map( static function ( Title $title ) {
1039                return $title->getText();
1040            }, $this->normalizeTitleList( $data["{$taskType}ExcludedTemplates"] ?? null ) );
1041
1042            $excludedCategories = array_map( static function ( Title $title ) {
1043                return $title->getText();
1044            }, $this->normalizeTitleList( $data["{$taskType}ExcludedCategories"] ?? null ) );
1045
1046            $suggestedEditsConfig[$taskType] = [
1047                'disabled' => (bool)$data["{$taskType}Disabled"],
1048                'templates' => $templates,
1049                'excludedTemplates' => $excludedTemplates,
1050                'excludedCategories' => $excludedCategories,
1051                'type' => $taskTypeData['handler-id'],
1052            ] + $suggestedEditsConfig[$taskType];
1053
1054            // Add learnmore link if specified
1055            if ( isset( $data["{$taskType}Learnmore"] ) ) {
1056                $suggestedEditsConfig[$taskType]['learnmore'] = $data["{$taskType}Learnmore"];
1057            } else {
1058                unset( $suggestedEditsConfig[$taskType]['learnmore'] );
1059            }
1060
1061            // link-recommendation specific
1062            if ( isset( $data['link-recommendationMaximumLinksToShowPerTask'] ) ) {
1063                $suggestedEditsConfig['link-recommendation']['maximumLinksToShowPerTask'] =
1064                    $data['link-recommendationMaximumLinksToShowPerTask'];
1065            }
1066            if ( isset( $data['link-recommendationMaxTasksPerDay'] ) ) {
1067                $suggestedEditsConfig['link-recommendation'][
1068                    LinkRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY
1069                ] = $data['link-recommendationMaxTasksPerDay'];
1070            }
1071            if ( isset( $data['link-recommendationExcludedSections'] ) ) {
1072                $suggestedEditsConfig['link-recommendation']['excludedSections'] =
1073                    ( $data['link-recommendationExcludedSections'] === '' )
1074                        ? []
1075                        : explode( "\n", $data['link-recommendationExcludedSections'] );
1076            }
1077
1078            // image-recommendation specific
1079            if ( isset( $data['image-recommendationMaxTasksPerDay'] ) ) {
1080                $suggestedEditsConfig['image-recommendation'][
1081                    ImageRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY
1082                ] = $data['image-recommendationMaxTasksPerDay'];
1083            }
1084            if ( isset( $data['section-image-recommendationMaxTasksPerDay'] ) ) {
1085                $suggestedEditsConfig['section-image-recommendation'][
1086                    SectionImageRecommendationTaskType::FIELD_MAX_TASKS_PER_DAY
1087                ] = $data['section-image-recommendationMaxTasksPerDay'];
1088            }
1089        }
1090
1091        return $suggestedEditsConfig;
1092    }
1093
1094    /**
1095     * Returns the contents of NewcomerTasksValidator::SUGGESTED_EDITS_TASK_TYPES, excluding those
1096     * which have been disabled for this wiki via PHP configuration.
1097     * @return array[]
1098     */
1099    public function getDefaultDataForEnabledTaskTypes(): array {
1100        $preferenceMap = [
1101            LinkRecommendationTaskTypeHandler::TASK_TYPE_ID => 'GENewcomerTasksLinkRecommendationsEnabled',
1102            ImageRecommendationTaskTypeHandler::TASK_TYPE_ID => 'GENewcomerTasksImageRecommendationsEnabled',
1103            SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID =>
1104                'GENewcomerTasksSectionImageRecommendationsEnabled',
1105        ];
1106        return array_filter( NewcomerTasksValidator::SUGGESTED_EDITS_TASK_TYPES,
1107            function ( $taskType ) use ( $preferenceMap ) {
1108                if ( !array_key_exists( $taskType, $preferenceMap ) ) {
1109                    return true;
1110                }
1111                return $this->getConfig()->get( $preferenceMap[$taskType] );
1112            }, ARRAY_FILTER_USE_KEY );
1113    }
1114
1115    /**
1116     * Helper method for normalizeSuggestedEditsConfig()
1117     * @param string|null $list
1118     * @return Title[] List of valid titles
1119     */
1120    private function normalizeTitleList( ?string $list ) {
1121        if ( $list === null || $list === '' ) {
1122            return [];
1123        }
1124        return array_values( array_filter( array_map( function ( string $titleText ) {
1125            $title = $this->titleFactory->newFromText( $titleText );
1126            if ( $title === null ) {
1127                return null;
1128            }
1129            return $title;
1130        }, explode( "\n", $list ) ) ) );
1131    }
1132
1133    /**
1134     * @inheritDoc
1135     */
1136    public function onSubmit( array $data ) {
1137        $this->checkReadOnly();
1138
1139        // DO NOT rely on userCanWrite here, in case its value is wrong for some weird reason
1140        if ( !$this->getAuthority()->isAllowed( self::REQUIRED_RIGHT_TO_WRITE ) ) {
1141            throw new PermissionsError( self::REQUIRED_RIGHT_TO_WRITE );
1142        }
1143
1144        $dataToSave = $this->preprocessSubmittedData( $data );
1145
1146        $geconfigThresholds = $this->growthWikiConfig->get( 'GELevelingUpKeepGoingNotificationThresholds' );
1147        $geconfigThresholds[1] = intval( $data['geconfig-GELevelingUpKeepGoingNotificationThresholds-maximum'] );
1148        $dataToSave['geconfig']['GELevelingUpKeepGoingNotificationThresholds'] = $geconfigThresholds;
1149
1150        // Normalize complex variables
1151        $dataToSave['geconfig']['GEHomepageSuggestedEditsIntroLinks'] =
1152            $this->normalizeSuggestedEditsIntroLinks( $data );
1153        $dataToSave['geconfig']['GEHelpPanelLinks'] = $this->normalizeHelpPanelLinks( $data );
1154        $dataToSave['geconfig']['GEInfoboxTemplates'] = array_map( static function ( Title $title ) {
1155            return $title->getPrefixedText();
1156        }, $this->normalizeTitleList( $data['geconfig-GEInfoboxTemplates'] ?? null ) );
1157
1158        // Normalize suggested edits configuration
1159        $dataToSave['newcomertasks'] = $this->normalizeSuggestedEditsConfig( $dataToSave['newcomertasks'] );
1160
1161        // Start atomic section; we can end up editing multiple pages here,
1162        // with some edits failing and other succeeding. We want to either save everything,
1163        // or nothing.
1164        $dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
1165        $dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
1166
1167        // Actually save the edits
1168        $status = Status::newGood();
1169        foreach ( $dataToSave as $configType => $configData ) {
1170            $configWriter = $this->configWriterFactory
1171                ->newWikiPageConfigWriter( $this->configPages[$configType], $this->getUser() );
1172            $configWriter->setVariables( $configData );
1173            $status->merge( $configWriter->save( $data['summary'] ) );
1174        }
1175
1176        // End atomic section if all edits succeeded, cancel it otherwise
1177        if ( $status->isOK() ) {
1178            $dbw->endAtomic( __METHOD__ );
1179        } else {
1180            $dbw->cancelAtomic( __METHOD__ );
1181        }
1182
1183        $this->eventLogger->logAction( SpecialEditGrowthConfigLogger::ACTION_SAVE, $this->getAuthority() );
1184        return $status;
1185    }
1186
1187    /**
1188     * @inheritDoc
1189     */
1190    public function onSuccess() {
1191        $out = $this->getOutput();
1192
1193        // Add success message
1194        $out->addWikiMsg( 'growthexperiments-edit-config-config-changed' );
1195        $out->addWikiMsg( 'growthexperiments-edit-config-return-to-form' );
1196
1197        // Ask for feedback
1198        $out->addHTML( $this->getFeedbackHtml() );
1199    }
1200
1201    /**
1202     * Add feedback CTA to the output
1203     *
1204     * @return string HTML to add to the output
1205     */
1206    private function getFeedbackHtml(): string {
1207        $this->getOutput()->addModuleStyles( 'oojs-ui.styles.icons-interactions' );
1208        return Html::rawElement( 'div', [], implode( "\n", [
1209            Html::rawElement(
1210                'h3',
1211                [],
1212                $this->msg( 'growthexperiments-edit-config-feedback-headline' )
1213            ),
1214            new ButtonWidget( [
1215                'icon' => 'feedback',
1216                'label' => $this->msg( 'growthexperiments-edit-config-feedback-cta' ),
1217                'href' => 'https://www.mediawiki.org/wiki/Talk:Growth',
1218                'flags' => [ 'primary', 'progressive' ]
1219            ] )
1220        ] ) );
1221    }
1222}