Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.02% covered (danger)
18.02%
20 / 111
10.00% covered (danger)
10.00%
1 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractDataConfigurationLoader
18.02% covered (danger)
18.02%
20 / 111
10.00% covered (danger)
10.00%
1 / 10
1110.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 disableTaskType
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 loadTaskTypesConfig
n/a
0 / 0
n/a
0 / 0
0
 loadTopicsConfig
n/a
0 / 0
n/a
0 / 0
0
 loadInfoboxTemplates
n/a
0 / 0
n/a
0 / 0
0
 loadTaskTypes
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 loadTopics
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getDisabledTaskTypes
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 parseTaskTypesFromConfig
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 parseTopicsFromConfig
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
342
 setCampaignConfigCallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCampaignTopics
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isDisabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GrowthExperiments\NewcomerTasks\ConfigurationLoader;
4
5use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
6use GrowthExperiments\NewcomerTasks\TaskType\TaskTypeHandlerRegistry;
7use GrowthExperiments\NewcomerTasks\TaskType\TemplateBasedTaskTypeHandler;
8use GrowthExperiments\NewcomerTasks\Topic\CampaignTopic;
9use GrowthExperiments\NewcomerTasks\Topic\MorelikeBasedTopic;
10use GrowthExperiments\NewcomerTasks\Topic\OresBasedTopic;
11use GrowthExperiments\NewcomerTasks\Topic\Topic;
12use InvalidArgumentException;
13use LogicException;
14use MediaWiki\Title\TitleValue;
15use Message;
16use StatusValue;
17
18/**
19 * Load configuration from a local or remote .json wiki page.
20 * For syntax see
21 * https://cs.wikipedia.org/wiki/MediaWiki:NewcomerTasks.json
22 * https://cs.wikipedia.org/wiki/MediaWiki:NewcomerTopics.json
23 * https://www.mediawiki.org/wiki/MediaWiki:NewcomerTopicsOres.json
24 */
25abstract class AbstractDataConfigurationLoader implements ConfigurationLoader {
26
27    use ConfigurationLoaderTrait;
28
29    /** @var string Use the configuration for OresBasedTopic topics. */
30    public const CONFIGURATION_TYPE_ORES = 'ores';
31    /** @var string Use the configuration for MorelikeBasedTopic topics. */
32    public const CONFIGURATION_TYPE_MORELIKE = 'morelike';
33
34    private const VALID_TOPIC_TYPES = [
35        self::CONFIGURATION_TYPE_ORES,
36        self::CONFIGURATION_TYPE_MORELIKE,
37    ];
38
39    /** @var TaskTypeHandlerRegistry */
40    private TaskTypeHandlerRegistry $taskTypeHandlerRegistry;
41
42    /** @var ConfigurationValidator */
43    private ConfigurationValidator $configurationValidator;
44
45    /** @var array */
46    private array $disabledTaskTypeIds = [];
47
48    /** @var TaskType[]|StatusValue|null Cached task type set (or an error). */
49    private $taskTypes;
50
51    /** @var Topic[]|StatusValue|null Cached topic set (or an error). */
52    private $topics;
53
54    /**
55     * @var string One of the PageConfigurationLoader::CONFIGURATION_TYPE constants.
56     */
57    private string $topicType;
58
59    /** @var ?callable */
60    private $campaignConfigCallback;
61
62    /** @var TaskType[] */
63    private array $disabledTaskTypes;
64
65    /**
66     * @param ConfigurationValidator $configurationValidator
67     * @param TaskTypeHandlerRegistry $taskTypeHandlerRegistry
68     * @param string $topicType One of the PageConfigurationLoader::CONFIGURATION_TYPE constants.
69     */
70    public function __construct(
71        ConfigurationValidator $configurationValidator,
72        TaskTypeHandlerRegistry $taskTypeHandlerRegistry,
73        string $topicType
74    ) {
75        $this->configurationValidator = $configurationValidator;
76        $this->taskTypeHandlerRegistry = $taskTypeHandlerRegistry;
77        $this->topicType = $topicType;
78
79        if ( !in_array( $this->topicType, self::VALID_TOPIC_TYPES, true ) ) {
80            throw new InvalidArgumentException( 'Invalid topic type ' . $this->topicType );
81        }
82    }
83
84    /**
85     * Hide the existence of the given task type. Must be called before task types are loaded.
86     * This is equivalent to setting the 'disabled' field in community configuration.
87     * @param string $taskTypeId
88     */
89    public function disableTaskType( string $taskTypeId ): void {
90        if ( $this->taskTypes !== null ) {
91            throw new LogicException( __METHOD__ . ' must be called before task types are loaded' );
92        }
93        if ( !in_array( $taskTypeId, $this->disabledTaskTypeIds, true ) ) {
94            $this->disabledTaskTypeIds[] = $taskTypeId;
95        }
96    }
97
98    /**
99     * @return array|StatusValue
100     */
101    abstract protected function loadTaskTypesConfig();
102
103    /**
104     * @return array|StatusValue
105     */
106    abstract protected function loadTopicsConfig();
107
108    /**
109     * @return array|StatusValue
110     */
111    abstract public function loadInfoboxTemplates();
112
113    /** @inheritDoc */
114    public function loadTaskTypes() {
115        if ( $this->taskTypes !== null ) {
116            return $this->taskTypes;
117        }
118
119        $config = $this->loadTaskTypesConfig();
120        if ( $config instanceof StatusValue ) {
121            $allTaskTypes = $config;
122        } else {
123            $allTaskTypes = $this->parseTaskTypesFromConfig( $config );
124        }
125
126        if ( !$allTaskTypes instanceof StatusValue ) {
127            $taskTypes = array_filter( $allTaskTypes,
128                fn ( TaskType $taskType ) => !$this->isDisabled( $taskType ) );
129            $disabledTaskTypes = array_filter( $allTaskTypes,
130                fn ( TaskType $taskType ) => $this->isDisabled( $taskType ) );
131        } else {
132            $taskTypes = $allTaskTypes;
133            $disabledTaskTypes = [];
134        }
135
136        $this->taskTypes = $taskTypes;
137        $this->disabledTaskTypes = array_combine( array_map( static function ( TaskType $taskType ) {
138            return $taskType->getId();
139        }, $disabledTaskTypes ), $disabledTaskTypes );
140        return $this->taskTypes;
141    }
142
143    /** @inheritDoc */
144    public function loadTopics() {
145        if ( $this->topics !== null ) {
146            return $this->topics;
147        }
148
149        $config = $this->loadTopicsConfig();
150        if ( $config instanceof StatusValue ) {
151            $topics = $config;
152        } else {
153            $topics = $this->parseTopicsFromConfig( $config );
154        }
155
156        $this->topics = $topics;
157        return $topics;
158    }
159
160    /** @inheritDoc */
161    public function getDisabledTaskTypes(): array {
162        // @phan-suppress-next-line PhanRedundantCondition
163        if ( !isset( $this->disabledTaskTypes ) ) {
164            $this->loadTaskTypes();
165        }
166        return $this->disabledTaskTypes;
167    }
168
169    /**
170     * Like loadTaskTypes() but without caching.
171     * @param mixed $config A JSON value.
172     * @return TaskType[]|StatusValue
173     */
174    private function parseTaskTypesFromConfig( $config ) {
175        $status = StatusValue::newGood();
176        $taskTypes = [];
177        if ( !is_array( $config ) || array_filter( $config, 'is_array' ) !== $config ) {
178            return StatusValue::newFatal(
179                'growthexperiments-homepage-suggestededits-config-wrongstructure' );
180        }
181        foreach ( $config as $taskTypeId => $taskTypeData ) {
182            // Fall back to legacy handler if not specified.
183            $handlerId = $taskTypeData['type'] ?? TemplateBasedTaskTypeHandler::ID;
184            if ( !$this->taskTypeHandlerRegistry->has( $handlerId ) ) {
185                $status->fatal( 'growthexperiments-homepage-suggestededits-config-invalidhandlerid',
186                    $taskTypeId, $handlerId, Message::listParam(
187                        $this->taskTypeHandlerRegistry->getKnownIds(), 'comma' ) );
188                continue;
189            }
190            $taskTypeHandler = $this->taskTypeHandlerRegistry->get( $handlerId );
191            $status->merge( $taskTypeHandler->validateTaskTypeConfiguration( $taskTypeId, $taskTypeData ) );
192
193            if ( $status->isGood() ) {
194                $taskType = $taskTypeHandler->createTaskType( $taskTypeId, $taskTypeData );
195                $status->merge( $taskTypeHandler->validateTaskTypeObject( $taskType ) );
196                $taskTypes[] = $taskType;
197                if ( !empty( $taskTypeData['disabled'] ) ) {
198                    $this->disableTaskType( $taskTypeId );
199                }
200            }
201        }
202        return $status->isGood() ? $taskTypes : $status;
203    }
204
205    /**
206     * Like loadTopics() but without caching.
207     * @param mixed $config A JSON value.
208     * @return TaskType[]|StatusValue
209     */
210    private function parseTopicsFromConfig( $config ) {
211        $status = StatusValue::newGood();
212        $topics = [];
213        if ( !is_array( $config ) || array_filter( $config, 'is_array' ) !== $config ) {
214            return StatusValue::newFatal(
215                'growthexperiments-homepage-suggestededits-config-wrongstructure' );
216        }
217
218        $groups = [];
219        if ( $this->topicType === self::CONFIGURATION_TYPE_ORES ) {
220            if ( !isset( $config['topics'] ) || !isset( $config['groups'] ) ) {
221                return StatusValue::newFatal(
222                    'growthexperiments-homepage-suggestededits-config-wrongstructure' );
223            }
224            $groups = $config['groups'];
225            $config = $config['topics'];
226        }
227
228        foreach ( $config as $topicId => $topicConfiguration ) {
229            $status->merge( $this->configurationValidator->validateIdentifier( $topicId ) );
230            $requiredFields = [
231                self::CONFIGURATION_TYPE_ORES => [ 'group', 'oresTopics' ],
232                self::CONFIGURATION_TYPE_MORELIKE => [ 'label', 'titles' ],
233            ][$this->topicType];
234            foreach ( $requiredFields as $field ) {
235                if ( !isset( $topicConfiguration[$field] ) ) {
236                    $status->fatal( 'growthexperiments-homepage-suggestededits-config-missingfield',
237                        'titles', $topicId );
238                }
239            }
240
241            if ( !$status->isGood() ) {
242                // don't try to load if the config data format was invalid
243                continue;
244            }
245
246            if ( $this->topicType === self::CONFIGURATION_TYPE_ORES ) {
247                '@phan-var array{group:string,oresTopics:string[]} $topicConfiguration';
248                $oresTopics = [];
249                foreach ( $topicConfiguration['oresTopics'] as $oresTopic ) {
250                    $oresTopics[] = (string)$oresTopic;
251                }
252                $topic = new OresBasedTopic( $topicId, $topicConfiguration['group'], $oresTopics );
253                $status->merge( $this->configurationValidator->validateTopicMessages( $topic ) );
254            } elseif ( $this->topicType === self::CONFIGURATION_TYPE_MORELIKE ) {
255                '@phan-var array{label:string,titles:string[]} $topicConfiguration';
256                $linkTargets = [];
257                foreach ( $topicConfiguration['titles'] as $title ) {
258                    $linkTargets[] = new TitleValue( NS_MAIN, $title );
259                }
260                $topic = new MorelikeBasedTopic( $topicId, $linkTargets );
261                $topic->setName( $topicConfiguration['label'] );
262            } else {
263                throw new LogicException( 'Impossible but this makes phan happy.' );
264            }
265            $topics[] = $topic;
266        }
267
268        if ( $this->topicType === self::CONFIGURATION_TYPE_ORES && $status->isGood() ) {
269            $this->configurationValidator->sortTopics( $topics, $groups );
270        }
271
272        // FIXME T301030 remove when campaign is done.
273        $campaignTopics = array_map( static function ( $topic ) {
274            return new CampaignTopic( $topic[ 'id' ], $topic[ 'searchExpression' ] );
275        }, $this->getCampaignTopics() );
276        if ( count( $campaignTopics ) ) {
277            array_unshift( $topics, ...$campaignTopics );
278        }
279
280        return $status->isGood() ? $topics : $status;
281    }
282
283    /**
284     * Set the callback used to retrieve CampaignConfig, used to show campaign-specific topics
285     *
286     * @param callable $callback
287     */
288    public function setCampaignConfigCallback( callable $callback ) {
289        $this->campaignConfigCallback = $callback;
290    }
291
292    /**
293     * Get campaign-specific topics
294     *
295     * @return array
296     */
297    private function getCampaignTopics(): array {
298        if ( is_callable( $this->campaignConfigCallback ) ) {
299            $getCampaignConfig = $this->campaignConfigCallback;
300            return $getCampaignConfig()->getCampaignTopics();
301        }
302        return [];
303    }
304
305    /**
306     * @param TaskType $taskType
307     * @return bool
308     */
309    private function isDisabled( TaskType $taskType ) {
310        return in_array( $taskType->getId(), $this->disabledTaskTypeIds, true );
311    }
312
313}