Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 169
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryContentTranslationSuggestions
0.00% covered (danger)
0.00%
0 / 169
0.00% covered (danger)
0.00%
0 / 10
1190
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 executeGenerator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 run
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
210
 getOngoingTranslations
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 getExistingTitles
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
56
 filterSuggestions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 removeInvalidSuggestions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
2
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
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
9namespace ContentTranslation\ActionApi;
10
11use ContentTranslation\Service\UserService;
12use ContentTranslation\SiteMapper;
13use ContentTranslation\Store\TranslationStore;
14use ContentTranslation\SuggestionListManager;
15use MediaWiki\Api\ApiBase;
16use MediaWiki\Api\ApiPageSet;
17use MediaWiki\Api\ApiQuery;
18use MediaWiki\Api\ApiQueryGeneratorBase;
19use MediaWiki\Deferred\DeferredUpdates;
20use MediaWiki\Json\FormatJson;
21use MediaWiki\MediaWikiServices;
22use Wikimedia\ParamValidator\ParamValidator;
23use Wikimedia\ParamValidator\TypeDef\IntegerDef;
24
25/**
26 * Api module for querying translation suggestions.
27 */
28class 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}