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 ApiBase;
12use ApiPageSet;
13use ApiQuery;
14use ApiQueryGeneratorBase;
15use ContentTranslation\Service\UserService;
16use ContentTranslation\SiteMapper;
17use ContentTranslation\Store\TranslationStore;
18use ContentTranslation\SuggestionListManager;
19use DeferredUpdates;
20use 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    /**
33     * @param ApiQuery $query
34     * @param string $moduleName
35     * @param UserService $userService
36     * @param TranslationStore $translationStore
37     */
38    public function __construct( $query, $moduleName, UserService $userService, TranslationStore $translationStore ) {
39        parent::__construct( $query, $moduleName );
40        $this->userService = $userService;
41        $this->translationStore = $translationStore;
42    }
43
44    public function execute() {
45        $this->run();
46    }
47
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 ) {
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        $response = FormatJson::decode( $json, true );
229        if ( !isset( $response['query'] ) || !isset( $response['query']['pages'] ) ) {
230            // Something wrong with response. Should we throw exception?
231            return $existingTitles;
232        }
233
234        $pages = $response['query']['pages'];
235        foreach ( $pages as $page ) {
236            if ( isset( $page['langlinks'] ) ) {
237                // API returns titles in PrefixedText format
238                $existingTitles[] = $page['title'];
239            }
240        }
241
242        return $existingTitles;
243    }
244
245    private function filterSuggestions( array $suggestions, array $titlesToFilter ) {
246        return array_filter( $suggestions,
247            static function ( $suggestion ) use( $titlesToFilter ) {
248                return !in_array(
249                    $suggestion->getTitle()->getPrefixedText(),
250                    $titlesToFilter
251                );
252            }
253        );
254    }
255
256    private function removeInvalidSuggestions( $sourceLanguage, array $existingTitles ) {
257        DeferredUpdates::addCallableUpdate( static function () use ( $sourceLanguage, $existingTitles ) {
258            // Remove the already existing links from cx_suggestion table
259            $manager = new SuggestionListManager();
260            $manager->removeTitles( $sourceLanguage, $existingTitles );
261        } );
262    }
263
264    public function getAllowedParams() {
265        $allowedParams = [
266            'from' => [
267                ParamValidator::PARAM_TYPE => 'string',
268                ParamValidator::PARAM_REQUIRED => false,
269            ],
270            'to' => [
271                ParamValidator::PARAM_TYPE => 'string',
272                ParamValidator::PARAM_REQUIRED => false,
273            ],
274            'listid' => [
275                ParamValidator::PARAM_TYPE => 'string',
276            ],
277            'limit' => [
278                ParamValidator::PARAM_DEFAULT => 10,
279                ParamValidator::PARAM_TYPE => 'limit',
280                IntegerDef::PARAM_MIN => 1,
281                IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
282                IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
283            ],
284            'offset' => [
285                ParamValidator::PARAM_TYPE => 'string',
286            ],
287            'seed' => [
288                ParamValidator::PARAM_TYPE => 'integer',
289            ],
290        ];
291        return $allowedParams;
292    }
293
294    protected function getExamplesMessages() {
295        return [
296            'action=query&list=contenttranslationsuggestions&from=en&to=es' =>
297                'apihelp-query+contenttranslationsuggestions-example-1',
298        ];
299    }
300}