Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 173
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 / 173
0.00% covered (danger)
0.00%
0 / 10
1260
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 / 31
0.00% covered (danger)
0.00%
0 / 1
72
 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 / 29
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    /** @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}