Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
34.92% |
22 / 63 |
|
43.75% |
7 / 16 |
CRAP | |
0.00% |
0 / 1 |
NewcomerTasksUserOptionsLookup | |
34.92% |
22 / 63 |
|
43.75% |
7 / 16 |
295.88 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getTaskTypeFilter | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getTopics | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getTopicsMatchMode | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
getTopicFilterWithoutFallback | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
areLinkRecommendationsEnabled | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
areImageRecommendationsEnabled | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
areSectionImageRecommendationsEnabled | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
filterTaskTypes | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
getDefaultTaskTypes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getConversionMap | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
convertTaskTypes | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
filterNonExistentTaskTypes | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getJsonListOption | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
getStringOption | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
shouldUserSeeAllTaskTypes | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\NewcomerTasks; |
4 | |
5 | use GrowthExperiments\ExperimentUserManager; |
6 | use GrowthExperiments\HomepageModules\SuggestedEdits; |
7 | use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader; |
8 | use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchStrategy\SearchStrategy; |
9 | use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler; |
10 | use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskTypeHandler; |
11 | use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskTypeHandler; |
12 | use MediaWiki\Config\Config; |
13 | use MediaWiki\User\Options\UserOptionsLookup; |
14 | use MediaWiki\User\UserIdentity; |
15 | |
16 | /** |
17 | * Retrieves user settings related to newcomer tasks. |
18 | */ |
19 | class NewcomerTasksUserOptionsLookup { |
20 | |
21 | /** |
22 | * This property isn't used, but we want to preserve the ability to run A/B tests where |
23 | * user options depend on the user's experiment group. |
24 | * @var ExperimentUserManager |
25 | */ |
26 | private $experimentUserManager; |
27 | |
28 | /** @var UserOptionsLookup */ |
29 | private $userOptionsLookup; |
30 | |
31 | /** @var Config */ |
32 | private $config; |
33 | |
34 | /** @var ConfigurationLoader */ |
35 | private $configurationLoader; |
36 | |
37 | /** |
38 | * @param ExperimentUserManager $experimentUserManager |
39 | * @param UserOptionsLookup $userOptionsLookup |
40 | * @param Config $config |
41 | * @param ConfigurationLoader $configurationLoader |
42 | */ |
43 | public function __construct( |
44 | ExperimentUserManager $experimentUserManager, |
45 | UserOptionsLookup $userOptionsLookup, |
46 | Config $config, |
47 | ConfigurationLoader $configurationLoader |
48 | ) { |
49 | $this->experimentUserManager = $experimentUserManager; |
50 | $this->userOptionsLookup = $userOptionsLookup; |
51 | $this->config = $config; |
52 | $this->configurationLoader = $configurationLoader; |
53 | } |
54 | |
55 | /** |
56 | * Get user's task types given their preferences and community configuration. |
57 | * @param UserIdentity $user |
58 | * @return string[] A list of task type IDs, or the default task types when the user |
59 | * has no preference set. |
60 | * @see \GrowthExperiments\NewcomerTasks\TaskType\TaskType::getId() |
61 | */ |
62 | public function getTaskTypeFilter( UserIdentity $user ): array { |
63 | $taskTypes = $this->getJsonListOption( $user, SuggestedEdits::TASKTYPES_PREF ); |
64 | // Filter out invalid task types for the user and use defaults based on user options. |
65 | if ( !$taskTypes ) { |
66 | $taskTypes = $this->getDefaultTaskTypes( $user ); |
67 | } |
68 | return $this->filterNonExistentTaskTypes( $this->convertTaskTypes( $taskTypes, $user ) ); |
69 | } |
70 | |
71 | /** |
72 | * Get the given user's topic preferences. |
73 | * @param UserIdentity $user |
74 | * @return string[] A list of topic IDs, or an empty array when the user has |
75 | * no preference set (This is method is meant to be compatible with TaskSuggester |
76 | * which takes an empty array as "no filtering".) |
77 | * @see \GrowthExperiments\NewcomerTasks\Topic\Topic::getId() |
78 | */ |
79 | public function getTopics( UserIdentity $user ): array { |
80 | return $this->getTopicFilterWithoutFallback( $user ) ?? []; |
81 | } |
82 | |
83 | /** |
84 | * Get the given user's topic mode preference. |
85 | * @param UserIdentity $user |
86 | * @return string A string representing the topic match mode. |
87 | * One of ( 'AND', 'OR'). |
88 | * @see SearchStrategy::TOPIC_MATCH_MODES |
89 | */ |
90 | public function getTopicsMatchMode( UserIdentity $user ): string { |
91 | $matchMode = $this->getStringOption( $user, SuggestedEdits::TOPICS_MATCH_MODE_PREF ) ?? |
92 | SearchStrategy::TOPIC_MATCH_MODE_OR; |
93 | if ( $matchMode === SearchStrategy::TOPIC_MATCH_MODE_AND && |
94 | !$this->config->get( 'GETopicsMatchModeEnabled' ) ) { |
95 | $matchMode = SearchStrategy::TOPIC_MATCH_MODE_OR; |
96 | } |
97 | return $matchMode; |
98 | } |
99 | |
100 | /** |
101 | * Get the given user's topic preferences without a fallback to empty array. |
102 | * @param UserIdentity $user |
103 | * @return string[]|null A list of topic IDs, an empty array when the user has |
104 | * no preference set, or null if preference wasn't set or is invalid. |
105 | */ |
106 | public function getTopicFilterWithoutFallback( UserIdentity $user ): ?array { |
107 | return $this->getJsonListOption( $user, SuggestedEdits::getTopicFiltersPref( $this->config ) ); |
108 | } |
109 | |
110 | /** |
111 | * Check if link recommendations are enabled. When true, the link-recommendation task type |
112 | * should be made available to the user and the links task type hidden. |
113 | * @return bool |
114 | */ |
115 | public function areLinkRecommendationsEnabled(): bool { |
116 | return $this->config->get( 'GENewcomerTasksLinkRecommendationsEnabled' ) |
117 | && $this->config->get( 'GELinkRecommendationsFrontendEnabled' ) |
118 | && array_key_exists( LinkRecommendationTaskTypeHandler::TASK_TYPE_ID, |
119 | $this->configurationLoader->getTaskTypes() ); |
120 | } |
121 | |
122 | /** |
123 | * Check if image recommendations are enabled. When true, the image-recommendation task type |
124 | * should be made available to the user. |
125 | * @return bool |
126 | */ |
127 | public function areImageRecommendationsEnabled(): bool { |
128 | return $this->config->get( 'GENewcomerTasksImageRecommendationsEnabled' ) |
129 | && array_key_exists( ImageRecommendationTaskTypeHandler::TASK_TYPE_ID, |
130 | $this->configurationLoader->getTaskTypes() ); |
131 | } |
132 | |
133 | /** |
134 | * Check if section-level image recommendations are enabled. When true, the |
135 | * section-image-recommendation task type should be made available to the user. |
136 | * @param UserIdentity $user |
137 | * @return bool |
138 | */ |
139 | public function areSectionImageRecommendationsEnabled( UserIdentity $user ): bool { |
140 | return $this->config->get( 'GENewcomerTasksSectionImageRecommendationsEnabled' ) |
141 | && array_key_exists( SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID, |
142 | $this->configurationLoader->getTaskTypes() ); |
143 | } |
144 | |
145 | /** |
146 | * Remove task types which the user is not supposed to see, given the link recommendation |
147 | * configuration and community configuration. |
148 | * @param string[] $taskTypes Task types IDs. |
149 | * @param UserIdentity $user |
150 | * @return string[] Filtered task types IDs. Array keys are not preserved. |
151 | */ |
152 | public function filterTaskTypes( array $taskTypes, UserIdentity $user ): array { |
153 | $conversionMap = $this->getConversionMap( $user ); |
154 | $taskTypes = array_filter( $taskTypes, |
155 | fn ( $taskTypeId ) => !array_key_exists( $taskTypeId, $conversionMap ) |
156 | ); |
157 | return $this->filterNonExistentTaskTypes( $taskTypes ); |
158 | } |
159 | |
160 | /** |
161 | * Get default task types when the user has no stored preference. |
162 | * @param UserIdentity $user |
163 | * @return string[] |
164 | */ |
165 | private function getDefaultTaskTypes( UserIdentity $user ): array { |
166 | // This doesn't do anything useful right now, but we want to preserve the ability |
167 | // to determine the default task types dynamically for A/B testing. |
168 | return SuggestedEdits::DEFAULT_TASK_TYPES; |
169 | } |
170 | |
171 | /** |
172 | * Get mapping of task types which the user is not supposed to see to a similar task type |
173 | * or false (meaning nothing should be shown instead). |
174 | * Identical to TaskTypesAbFilter.getConversionMap(). |
175 | * @param UserIdentity $user |
176 | * @return array A map of old task type ID => new task type ID or false. |
177 | * @phan-return array<string,string|false> |
178 | */ |
179 | private function getConversionMap( UserIdentity $user ): array { |
180 | $map = []; |
181 | if ( $this->areLinkRecommendationsEnabled() ) { |
182 | $map += [ 'links' => LinkRecommendationTaskTypeHandler::TASK_TYPE_ID ]; |
183 | } else { |
184 | $map += [ LinkRecommendationTaskTypeHandler::TASK_TYPE_ID => 'links' ]; |
185 | } |
186 | if ( !$this->areImageRecommendationsEnabled() ) { |
187 | $map += [ ImageRecommendationTaskTypeHandler::TASK_TYPE_ID => false ]; |
188 | } |
189 | if ( !$this->areSectionImageRecommendationsEnabled( $user ) ) { |
190 | $map += [ SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID => false ]; |
191 | } |
192 | return $map; |
193 | } |
194 | |
195 | /** |
196 | * Convert task types which the user is not supposed to see, given the link recommendation |
197 | * configuration, to the closest task type available to them. |
198 | * @param string[] $taskTypes Task types IDs. |
199 | * @param UserIdentity $user |
200 | * @return string[] Converted task types IDs. Array keys are not preserved. |
201 | */ |
202 | private function convertTaskTypes( array $taskTypes, UserIdentity $user ): array { |
203 | $map = $this->getConversionMap( $user ); |
204 | $taskTypes = array_map( static function ( string $taskType ) use ( $map ) { |
205 | return $map[$taskType] ?? $taskType; |
206 | }, $taskTypes ); |
207 | return array_unique( array_filter( $taskTypes ) ); |
208 | } |
209 | |
210 | /** |
211 | * Remove task types which have been disabled via community configuration. |
212 | * @param string[] $taskTypesToFilter Task types IDs. |
213 | * @return string[] Filtered task types IDs. |
214 | */ |
215 | private function filterNonExistentTaskTypes( array $taskTypesToFilter ) { |
216 | $allTaskTypes = $this->configurationLoader->getTaskTypes(); |
217 | return array_values( array_filter( $taskTypesToFilter, |
218 | static function ( $taskTypeId ) use ( $allTaskTypes ) { |
219 | return array_key_exists( $taskTypeId, $allTaskTypes ); |
220 | } |
221 | ) ); |
222 | } |
223 | |
224 | /** |
225 | * Read a user preference that is a list of strings. |
226 | * @param UserIdentity $user |
227 | * @param string $pref |
228 | * @return array|null User preferences as a list of strings, or null of the preference was |
229 | * missing or invalid. |
230 | */ |
231 | private function getJsonListOption( UserIdentity $user, string $pref ) { |
232 | $stored = $this->userOptionsLookup->getOption( $user, $pref ); |
233 | if ( $stored ) { |
234 | $stored = json_decode( $stored, true ); |
235 | } |
236 | // sanity check |
237 | if ( !is_array( $stored ) || array_filter( $stored, 'is_string' ) !== $stored ) { |
238 | return null; |
239 | } |
240 | return $stored; |
241 | } |
242 | |
243 | /** |
244 | * Read a user preference that is a string. |
245 | * @param UserIdentity $user |
246 | * @param string $pref |
247 | * @return string|null User preference as a string, or null if the preference is invalid |
248 | */ |
249 | private function getStringOption( UserIdentity $user, string $pref ) { |
250 | $stored = $this->userOptionsLookup->getOption( $user, $pref ); |
251 | |
252 | if ( !is_string( $stored ) ) { |
253 | return null; |
254 | } |
255 | return $stored; |
256 | } |
257 | |
258 | /** |
259 | * Check if the user is an internal pseudo-user who should see all task types. |
260 | * @param UserIdentity $user |
261 | * @return bool |
262 | */ |
263 | private function shouldUserSeeAllTaskTypes( UserIdentity $user ) { |
264 | return $user->getId() === 0 && $user->getName() === SuggestionsInfo::USER; |
265 | } |
266 | |
267 | } |