Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 173 |
|
0.00% |
0 / 10 |
CRAP | |
0.00% |
0 / 1 |
ApiQueryContentTranslationSuggestions | |
0.00% |
0 / 173 |
|
0.00% |
0 / 10 |
1260 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
executeGenerator | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
run | |
0.00% |
0 / 75 |
|
0.00% |
0 / 1 |
210 | |||
getOngoingTranslations | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
42 | |||
getExistingTitles | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
72 | |||
filterSuggestions | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
removeInvalidSuggestions | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getAllowedParams | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
2 | |||
getExamplesMessages | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * Api module for querying translation suggestions. |
4 | * |
5 | * @copyright See AUTHORS.txt |
6 | * @license GPL-2.0-or-later |
7 | */ |
8 | |
9 | namespace ContentTranslation\ActionApi; |
10 | |
11 | use ContentTranslation\Service\UserService; |
12 | use ContentTranslation\SiteMapper; |
13 | use ContentTranslation\Store\TranslationStore; |
14 | use ContentTranslation\SuggestionListManager; |
15 | use MediaWiki\Api\ApiBase; |
16 | use MediaWiki\Api\ApiPageSet; |
17 | use MediaWiki\Api\ApiQuery; |
18 | use MediaWiki\Api\ApiQueryGeneratorBase; |
19 | use MediaWiki\Deferred\DeferredUpdates; |
20 | use MediaWiki\Json\FormatJson; |
21 | use MediaWiki\MediaWikiServices; |
22 | use Wikimedia\ParamValidator\ParamValidator; |
23 | use Wikimedia\ParamValidator\TypeDef\IntegerDef; |
24 | |
25 | /** |
26 | * Api module for querying translation suggestions. |
27 | */ |
28 | class ApiQueryContentTranslationSuggestions extends ApiQueryGeneratorBase { |
29 | private UserService $userService; |
30 | private TranslationStore $translationStore; |
31 | |
32 | public function __construct( |
33 | ApiQuery $query, |
34 | string $moduleName, |
35 | UserService $userService, |
36 | TranslationStore $translationStore |
37 | ) { |
38 | parent::__construct( $query, $moduleName ); |
39 | $this->userService = $userService; |
40 | $this->translationStore = $translationStore; |
41 | } |
42 | |
43 | public function execute() { |
44 | $this->run(); |
45 | } |
46 | |
47 | /** @inheritDoc */ |
48 | public function executeGenerator( $resultPageSet ) { |
49 | $this->run( $resultPageSet ); |
50 | } |
51 | |
52 | /** |
53 | * @param ApiPageSet|null $resultPageSet |
54 | */ |
55 | private function run( $resultPageSet = null ) { |
56 | $config = $this->getConfig(); |
57 | if ( !$config->get( 'ContentTranslationEnableSuggestions' ) ) { |
58 | $this->dieWithError( 'apierror-cx-suggestionsdisabled', 'suggestionsdisabled' ); |
59 | } |
60 | |
61 | $params = $this->extractRequestParams(); |
62 | $result = $this->getResult(); |
63 | $user = $this->getUser(); |
64 | |
65 | if ( !$user->isRegistered() ) { |
66 | $this->dieWithError( 'apierror-cx-mustbeloggedin-get-suggestions', 'notloggedin' ); |
67 | } |
68 | |
69 | $from = $params['from'] ?? ''; |
70 | $to = $params['to'] ?? ''; |
71 | |
72 | if ( $from !== '' && $from === $to ) { |
73 | $this->dieWithError( 'apierror-cx-samelanguages', 'invalidparam' ); |
74 | } |
75 | $translatorUserId = $this->userService->getGlobalUserId( $user ); |
76 | $manager = new SuggestionListManager(); |
77 | |
78 | // Get personalized suggestions. |
79 | // We do not want to send personalized suggestions in paginated results |
80 | // other than the first page. Hence checking offset. |
81 | if ( $params['listid'] !== null ) { |
82 | $list = $manager->getListById( $params['listid'] ); |
83 | if ( $list === null ) { |
84 | $this->dieWithError( |
85 | [ 'apierror-badparameter', $this->encodeParamName( 'listid' ) ], |
86 | 'invalidparam' |
87 | ); |
88 | } |
89 | |
90 | $suggestions = $manager->getSuggestionsInList( |
91 | $list->getId(), |
92 | $from, |
93 | $to, |
94 | $params['limit'], |
95 | $params['offset'], |
96 | $params['seed'] |
97 | ); |
98 | $data = [ |
99 | 'lists' => [ $list ], |
100 | 'suggestions' => $suggestions, |
101 | ]; |
102 | } else { |
103 | $personalizedSuggestions = $manager->getFavoriteSuggestions( $translatorUserId ); |
104 | |
105 | $data = $personalizedSuggestions; |
106 | |
107 | if ( $from !== '' && $to !== '' ) { |
108 | // Get non-personalized suggestions |
109 | $publicSuggestions = $manager->getPublicSuggestions( |
110 | $from, |
111 | $to, |
112 | $params['limit'], |
113 | $params['offset'], |
114 | $params['seed'] |
115 | ); |
116 | // Merge the personal lists to public lists. There won't be duplicates |
117 | // because the list of lists is an associative array with listId as a key. |
118 | $data['lists'] = array_merge( $data['lists'], $publicSuggestions['lists'] ); |
119 | $data['suggestions'] = array_merge( $data['suggestions'], $publicSuggestions['suggestions'] ); |
120 | |
121 | } |
122 | } |
123 | |
124 | $lists = []; |
125 | $suggestions = $data['suggestions']; |
126 | |
127 | if ( count( $suggestions ) ) { |
128 | // Find the titles to filter out from suggestions. |
129 | $ongoingTranslations = $this->getOngoingTranslations( $suggestions ); |
130 | $existingTitles = $this->getExistingTitles( $suggestions ); |
131 | $discardedSuggestions = $manager->getDiscardedSuggestions( $translatorUserId, $from, $to ); |
132 | $suggestions = $this->filterSuggestions( |
133 | $suggestions, |
134 | array_merge( $existingTitles, $ongoingTranslations, $discardedSuggestions ) |
135 | ); |
136 | |
137 | // Remove the Suggestions that are no longer valid. |
138 | $this->removeInvalidSuggestions( $from, $existingTitles ); |
139 | } |
140 | |
141 | foreach ( $data['lists'] as $list ) { |
142 | $lists[$list->getId()] = [ |
143 | 'displayName' => $list->getDisplayNameMessage( $this->getContext() )->text(), |
144 | 'name' => $list->getName(), |
145 | 'type' => $list->getType(), |
146 | 'suggestions' => [], |
147 | ]; |
148 | foreach ( $suggestions as $suggestion ) { |
149 | if ( $list->getId() !== $suggestion->getListId() ) { |
150 | continue; |
151 | } |
152 | $lists[$suggestion->getListId()]['suggestions'][] = [ |
153 | 'title' => $suggestion->getTitle()->getPrefixedText(), |
154 | 'sourceLanguage' => $suggestion->getSourceLanguage(), |
155 | 'targetLanguage' => $suggestion->getTargetLanguage(), |
156 | 'listId' => $suggestion->getListId(), |
157 | ]; |
158 | } |
159 | } |
160 | |
161 | if ( count( $suggestions ) ) { |
162 | $this->setContinueEnumParameter( 'offset', $params['limit'] + $params['offset'] ); |
163 | } |
164 | $result->addValue( [ 'query', $this->getModuleName() ], 'lists', $lists ); |
165 | } |
166 | |
167 | /** |
168 | * TODO: This is misnamed. TranslationStore::findTranslationsByTitles returns all translations with any status. |
169 | * |
170 | * @param array $suggestions |
171 | * @return array |
172 | */ |
173 | private function getOngoingTranslations( array $suggestions ) { |
174 | $titles = []; |
175 | $params = $this->extractRequestParams(); |
176 | $sourceLanguage = $params['from']; |
177 | $targetLanguage = $params['to']; |
178 | |
179 | // translations inside "cx_translations" table requires "translation_source_language" |
180 | // and "translation_target_language" to be NOT null. So if the given source language |
181 | // or target language is empty, no translation will be found. In these cases, skip |
182 | // the SQL query at all. |
183 | if ( !count( $suggestions ) || $sourceLanguage === null || $targetLanguage === null ) { |
184 | return $titles; |
185 | } |
186 | |
187 | $ongoingTranslationTitles = []; |
188 | foreach ( $suggestions as $suggestion ) { |
189 | $titles[] = $suggestion->getTitle()->getPrefixedText(); |
190 | } |
191 | $translations = $this->translationStore->findTranslationsByTitles( |
192 | $titles, |
193 | $sourceLanguage, |
194 | $targetLanguage |
195 | ); |
196 | foreach ( $translations as $translation ) { |
197 | // $translation['sourceTitle'] is prefixed title with spaces |
198 | $ongoingTranslationTitles[] = $translation->translation['sourceTitle']; |
199 | } |
200 | return $ongoingTranslationTitles; |
201 | } |
202 | |
203 | private function getExistingTitles( array $suggestions ): array { |
204 | $titles = []; |
205 | if ( !count( $suggestions ) ) { |
206 | return $titles; |
207 | } |
208 | |
209 | $params = $this->extractRequestParams(); |
210 | $sourceLanguage = $params['from']; |
211 | $targetLanguage = $params['to']; |
212 | $existingTitles = []; |
213 | foreach ( $suggestions as $suggestion ) { |
214 | $titles[] = $suggestion->getTitle()->getPrefixedText(); |
215 | } |
216 | $params = [ |
217 | 'action' => 'query', |
218 | 'format' => 'json', |
219 | 'titles' => implode( '|', $titles ), |
220 | 'prop' => 'langlinks', |
221 | 'lllimit' => $params['limit'], |
222 | 'lllang' => SiteMapper::getDomainCode( $targetLanguage ), |
223 | 'redirects' => true |
224 | ]; |
225 | $apiUrl = SiteMapper::getApiURL( $sourceLanguage, $params ); |
226 | $json = MediaWikiServices::getInstance()->getHttpRequestFactory() |
227 | ->get( $apiUrl, [], __METHOD__ ); |
228 | if ( $json === null ) { |
229 | // Something wrong with the response. |
230 | // TODO: Should we throw exception? |
231 | return $existingTitles; |
232 | } |
233 | $response = FormatJson::decode( $json, true ); |
234 | if ( !isset( $response['query'] ) || !isset( $response['query']['pages'] ) ) { |
235 | // Something wrong with the response. |
236 | // TODO: Should we throw exception? |
237 | return $existingTitles; |
238 | } |
239 | |
240 | $pages = $response['query']['pages']; |
241 | foreach ( $pages as $page ) { |
242 | if ( isset( $page['langlinks'] ) ) { |
243 | // API returns titles in PrefixedText format |
244 | $existingTitles[] = $page['title']; |
245 | } |
246 | } |
247 | |
248 | return $existingTitles; |
249 | } |
250 | |
251 | private function filterSuggestions( array $suggestions, array $titlesToFilter ): array { |
252 | return array_filter( $suggestions, |
253 | static function ( $suggestion ) use( $titlesToFilter ) { |
254 | return !in_array( |
255 | $suggestion->getTitle()->getPrefixedText(), |
256 | $titlesToFilter |
257 | ); |
258 | } |
259 | ); |
260 | } |
261 | |
262 | private function removeInvalidSuggestions( string $sourceLanguage, array $existingTitles ) { |
263 | DeferredUpdates::addCallableUpdate( static function () use ( $sourceLanguage, $existingTitles ) { |
264 | // Remove the already existing links from cx_suggestion table |
265 | $manager = new SuggestionListManager(); |
266 | $manager->removeTitles( $sourceLanguage, $existingTitles ); |
267 | } ); |
268 | } |
269 | |
270 | /** @inheritDoc */ |
271 | public function getAllowedParams() { |
272 | $allowedParams = [ |
273 | 'from' => [ |
274 | ParamValidator::PARAM_TYPE => 'string', |
275 | ParamValidator::PARAM_REQUIRED => false, |
276 | ParamValidator::PARAM_DEFAULT => '', |
277 | ], |
278 | 'to' => [ |
279 | ParamValidator::PARAM_TYPE => 'string', |
280 | ParamValidator::PARAM_REQUIRED => false, |
281 | ParamValidator::PARAM_DEFAULT => '', |
282 | ], |
283 | 'listid' => [ |
284 | ParamValidator::PARAM_TYPE => 'string', |
285 | ], |
286 | 'limit' => [ |
287 | ParamValidator::PARAM_DEFAULT => 10, |
288 | ParamValidator::PARAM_TYPE => 'limit', |
289 | IntegerDef::PARAM_MIN => 1, |
290 | IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1, |
291 | IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2 |
292 | ], |
293 | 'offset' => [ |
294 | ParamValidator::PARAM_TYPE => 'string', |
295 | ], |
296 | 'seed' => [ |
297 | ParamValidator::PARAM_TYPE => 'integer', |
298 | ], |
299 | ]; |
300 | return $allowedParams; |
301 | } |
302 | |
303 | /** @inheritDoc */ |
304 | protected function getExamplesMessages() { |
305 | return [ |
306 | 'action=query&list=contenttranslationsuggestions&from=en&to=es' => |
307 | 'apihelp-query+contenttranslationsuggestions-example-1', |
308 | ]; |
309 | } |
310 | } |