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