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