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