Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.00% covered (warning)
75.00%
135 / 180
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ManageMessageGroupsActionApi
75.00% covered (warning)
75.00%
135 / 180
28.57% covered (danger)
28.57%
2 / 7
58.39
0.00% covered (danger)
0.00%
0 / 1
 execute
60.71% covered (warning)
60.71%
34 / 56
0.00% covered (danger)
0.00%
0 / 1
16.06
 handleRename
75.00% covered (warning)
75.00%
39 / 52
0.00% covered (danger)
0.00%
0 / 1
11.56
 handleSourceRename
80.00% covered (warning)
80.00%
24 / 30
0.00% covered (danger)
0.00%
0 / 1
7.39
 handleNew
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
7.60
 getAllowedParams
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\MessageGroupProcessing;
5
6use ApiBase;
7use Exception;
8use FormatJson;
9use MediaWiki\Extension\Translate\MessageSync\MessageSourceChange;
10use MediaWiki\Extension\Translate\Synchronization\MessageChangeStorage;
11use MediaWiki\Extension\Translate\Utilities\StringComparators\SimpleStringComparator;
12use MediaWiki\Extension\Translate\Utilities\Utilities;
13use MediaWiki\Title\Title;
14use MessageGroup;
15use Wikimedia\ParamValidator\ParamValidator;
16
17/**
18 * API module for managing message group changes.
19 * Marks message as a rename of another message or as a new message.
20 * Updates the cdb file.
21 * @author Abijeet Patro
22 * @since 2019.10
23 * @license GPL-2.0-or-later
24 * @ingroup API TranslateAPI
25 */
26class ManageMessageGroupsActionApi extends ApiBase {
27    private const RIGHT = 'translate-manage';
28
29    public function execute(): void {
30        $this->checkUserRightsAny( self::RIGHT );
31        $params = $this->extractRequestParams();
32
33        $groupId = $params['groupId'];
34        $op = $params['operation'];
35        $msgKey = $params['messageKey'];
36        $name = $params['changesetName'] ?? MessageChangeStorage::DEFAULT_NAME;
37        $changesetModifiedTime = $params['changesetModified'];
38        $keyToRename = null;
39
40        if ( !MessageChangeStorage::isValidCdbName( $name ) ) {
41            $this->dieWithError(
42                [ 'apierror-translate-invalid-changeset-name', wfEscapeWikiText( $name ) ],
43                'invalidchangeset'
44            );
45        }
46        $cdbPath = MessageChangeStorage::getCdbPath( $name );
47
48        if ( !MessageChangeStorage::isModifiedSince( $cdbPath, $changesetModifiedTime ) ) {
49            // Changeset file has been modified since the time the page was generated.
50            $this->dieWithError( [ 'apierror-translate-changeset-modified' ] );
51        }
52
53        if ( $op === 'rename' ) {
54            if ( !isset( $params['renameMessageKey'] ) ) {
55                $this->dieWithError( [ 'apierror-missingparam', 'renameMessageKey' ] );
56            }
57            $keyToRename = $params['renameMessageKey'];
58        }
59
60        $sourceChanges = MessageChangeStorage::getGroupChanges( $cdbPath, $groupId );
61        if ( $sourceChanges->getAllModifications() === [] ) {
62            $this->dieWithError( [ 'apierror-translate-smg-nochanges' ] );
63        }
64
65        $group = MessageGroups::getGroup( $groupId );
66        if ( $group === null ) {
67            $this->dieWithError( 'apierror-translate-invalidgroup', 'invalidgroup' );
68        }
69
70        try {
71            if ( $op === 'rename' ) {
72                $this->handleRename(
73                    $group, $sourceChanges, $msgKey, $keyToRename, $group->getSourceLanguage()
74                );
75            } elseif ( $op === 'new' ) {
76                $this->handleNew( $sourceChanges, $msgKey, $group->getSourceLanguage() );
77            } else {
78                $this->dieWithError(
79                    [ 'apierror-translate-invalid-operation', wfEscapeWikiText( $op ),
80                        wfEscapeWikiText( implode( '/', [ 'new', 'rename' ] ) ) ],
81                    'invalidoperation'
82                );
83            }
84        } catch ( Exception $ex ) {
85            // Log necessary parameters and rethrow.
86            $data = [
87                'op' => $op,
88                'newMsgKey' => $msgKey,
89                'msgKey' => $keyToRename,
90                'groupId' => $group->getId(),
91                'group' => $group->getLabel(),
92                'groupSourceLang' => $group->getSourceLanguage(),
93                'exception' => $ex
94            ];
95
96            error_log(
97                "Error while running: ManageMessageGroupsActionApi::execute. Inputs: \n" .
98                FormatJson::encode( $data, true )
99            );
100
101            throw $ex;
102        }
103
104        // Write the source changes back to file.
105        MessageChangeStorage::writeGroupChanges( $sourceChanges, $groupId, $cdbPath );
106
107        $this->getResult()->addValue( null, $this->getModuleName(), [
108            'success' => 1
109        ] );
110    }
111
112    /** Handles rename requests */
113    protected function handleRename(
114        MessageGroup $group,
115        MessageSourceChange $sourceChanges,
116        string $msgKey,
117        string $keyToRename,
118        string $sourceLanguage
119    ): void {
120        $languages = $sourceChanges->getLanguages();
121
122        foreach ( $languages as $code ) {
123            $msgState = $renameMsgState = null;
124
125            $isSourceLang = $sourceLanguage === $code;
126            if ( $isSourceLang ) {
127                $this->handleSourceRename( $sourceChanges, $code, $msgKey, $keyToRename );
128                continue;
129            }
130
131            // Check for changes with the new key, then with the old key.
132            // If there are no changes, we won't find anything at all, and
133            // can skip this languageCode.
134            $msg = $sourceChanges->findMessage( $code, $msgKey, [
135                MessageSourceChange::ADDITION,
136                MessageSourceChange::RENAME
137            ], $msgState );
138
139            // This case will arise if the message key has been changed in the source
140            // language, but has not been modified in this language code.
141            // NOTE: We are also searching under deletions. This means that if the source
142            // language key is renamed, but one of the non source language keys is removed,
143            // renaming it will not remove the translation, but only rename it. This
144            // scenario is highly unlikely though.
145            $msg ??= $sourceChanges->findMessage( $code, $keyToRename, [
146                MessageSourceChange::DELETION,
147                MessageSourceChange::CHANGE,
148                MessageSourceChange::RENAME
149            ], $msgState );
150
151            if ( $msg === null ) {
152                continue;
153            }
154
155            // Check for the renamed message in the rename list, and deleted list.
156            $renameMsg = $sourceChanges->findMessage(
157                $code,
158                $keyToRename,
159                [ MessageSourceChange::RENAME, MessageSourceChange::DELETION ],
160                $renameMsgState
161            );
162
163            // content / msg will not be present if the message was deleted from the wiki or
164            // was for some reason unavailable during processing incoming changes. We're going
165            // to try and load it here again from the database. Very rare chance of this happening.
166            if ( $renameMsg === null || !isset( $renameMsg['content'] ) ) {
167                $title = Title::newFromText(
168                    Utilities::title( $keyToRename, $code, $group->getNamespace() ),
169                    $group->getNamespace()
170                );
171
172                $renameContent = Utilities::getContentForTitle( $title, true ) ?? '';
173
174                $renameMsg = [
175                    'key' => $keyToRename,
176                    'content' => $renameContent
177                ];
178
179                // If the message was found in changes, this will be set, otherwise set it
180                // to none
181                if ( $renameMsgState === null ) {
182                    $renameMsgState = MessageSourceChange::NONE;
183                }
184            }
185
186            // Remove previous states
187            if ( $msgState === MessageSourceChange::RENAME ) {
188                $msgState = $sourceChanges->breakRename( $code, $msg['key'] );
189            } else {
190                $sourceChanges->removeBasedOnType( $code, [ $msg['key'] ], $msgState );
191            }
192
193            if ( $renameMsgState === MessageSourceChange::RENAME ) {
194                $renameMsgState = $sourceChanges->breakRename( $code, $renameMsg['key'] );
195            } elseif ( $renameMsgState !== MessageSourceChange::NONE ) {
196                $sourceChanges->removeBasedOnType( $code, [ $keyToRename ], $renameMsgState );
197            }
198
199            // This is done in case the key has not been renamed in the non-source language.
200            $msg['key'] = $msgKey;
201
202            // Add as rename
203            $stringComparator = new SimpleStringComparator();
204            $similarity = $stringComparator->getSimilarity(
205                $msg['content'],
206                $renameMsg['content']
207            );
208            $sourceChanges->addRename( $code, $msg, $renameMsg, $similarity );
209            $sourceChanges->setRenameState( $code, $msgKey, $msgState );
210            $sourceChanges->setRenameState( $code, $keyToRename, $renameMsgState );
211        }
212    }
213
214    protected function handleSourceRename(
215        MessageSourceChange $sourceChanges,
216        string $code,
217        string $msgKey,
218        string $keyToRename
219    ): void {
220        $msgState = $renameMsgState = null;
221
222        $msg = $sourceChanges->findMessage(
223            $code, $msgKey, [ MessageSourceChange::ADDITION, MessageSourceChange::RENAME ], $msgState
224        );
225
226        $renameMsg = $sourceChanges->findMessage(
227            $code,
228            $keyToRename,
229            [ MessageSourceChange::DELETION, MessageSourceChange::RENAME ],
230            $renameMsgState
231        );
232
233        if ( $msg === null || $renameMsg === null ) {
234            $this->dieWithError( 'apierror-translate-rename-key-invalid' );
235        }
236
237        if ( $msgState === MessageSourceChange::RENAME ) {
238            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
239            $msgState = $sourceChanges->breakRename( $code, $msg['key'] );
240        }
241
242        if ( $renameMsgState === MessageSourceChange::RENAME ) {
243            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
244            $renameMsgState = $sourceChanges->breakRename( $code, $renameMsg['key'] );
245        }
246
247        // Ensure that one of them is an ADDITION, and one is DELETION
248        if ( $msgState !== MessageSourceChange::ADDITION ||
249            $renameMsgState !== MessageSourceChange::DELETION ) {
250            $this->dieWithError( [
251                'apierror-translate-rename-state-invalid',
252                wfEscapeWikiText( $msgState ), wfEscapeWikiText( $renameMsgState )
253            ] );
254        }
255
256        // Remove previous states
257        $sourceChanges->removeAdditions( $code, [ $msgKey ] );
258        $sourceChanges->removeDeletions( $code, [ $keyToRename ] );
259
260        // Add as rename
261        $stringComparator = new SimpleStringComparator();
262        $similarity = $stringComparator->getSimilarity(
263            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
264            $msg['content'],
265            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
266            $renameMsg['content']
267        );
268        $sourceChanges->addRename( $code, $msg, $renameMsg, $similarity );
269    }
270
271    /** Handles add message as new request */
272    protected function handleNew(
273        MessageSourceChange $sourceChanges,
274        string $msgKey,
275        string $sourceLang
276    ): void {
277        $msgState = null;
278        $languages = $sourceChanges->getLanguages();
279
280        foreach ( $languages as $code ) {
281            $msg = $sourceChanges->findMessage(
282                $code, $msgKey, [ MessageSourceChange::RENAME ], $msgState
283            );
284
285            if ( $code === $sourceLang && $msg === null ) {
286                $this->dieWithError( 'apierror-translate-addition-key-invalid' );
287            }
288
289            if ( $code === $sourceLang && $msgState !== MessageSourceChange::RENAME ) {
290                $this->dieWithError( 'apierror-translate-rename-msg-new' );
291            }
292
293            // For any other language, it's possible for the message to be not found.
294            if ( $msg === null ) {
295                continue;
296            }
297
298            // breakRename will add the message back to its previous state, nothing more to do
299            $sourceChanges->breakRename( $code, $msg['key'] );
300        }
301    }
302
303    protected function getAllowedParams(): array {
304        return [
305            'groupId' => [
306                ParamValidator::PARAM_TYPE => 'string',
307                ParamValidator::PARAM_REQUIRED => true,
308            ],
309            'renameMessageKey' => [
310                ParamValidator::PARAM_TYPE => 'string',
311                ParamValidator::PARAM_REQUIRED => false,
312            ],
313            'messageKey' => [
314                ParamValidator::PARAM_TYPE => 'string',
315                ParamValidator::PARAM_REQUIRED => true,
316            ],
317            'operation' => [
318                ParamValidator::PARAM_TYPE => [ 'rename', 'new' ],
319                ParamValidator::PARAM_ISMULTI => false,
320                ParamValidator::PARAM_REQUIRED => true,
321            ],
322            'changesetName' => [
323                ParamValidator::PARAM_TYPE => 'string',
324                ParamValidator::PARAM_DEFAULT => MessageChangeStorage::DEFAULT_NAME
325            ],
326            'changesetModified' => [
327                ParamValidator::PARAM_TYPE => 'integer',
328                ParamValidator::PARAM_REQUIRED => true,
329            ]
330        ];
331    }
332
333    public function isInternal(): bool {
334        return true;
335    }
336
337    public function needsToken(): string {
338        return 'csrf';
339    }
340}