Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.42% covered (warning)
75.42%
135 / 179
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ManageMessageGroupsActionApi
75.42% covered (warning)
75.42%
135 / 179
28.57% covered (danger)
28.57%
2 / 7
55.25
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
76.47% covered (warning)
76.47%
39 / 51
0.00% covered (danger)
0.00%
0 / 1
10.06
 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 Exception;
7use MediaWiki\Api\ApiBase;
8use MediaWiki\Extension\Translate\MessageSync\MessageSourceChange;
9use MediaWiki\Extension\Translate\Synchronization\MessageChangeStorage;
10use MediaWiki\Extension\Translate\Utilities\StringComparators\SimpleStringComparator;
11use MediaWiki\Extension\Translate\Utilities\Utilities;
12use MediaWiki\Json\FormatJson;
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                $renameMsgState ??= MessageSourceChange::NONE;
182            }
183
184            // Remove previous states
185            if ( $msgState === MessageSourceChange::RENAME ) {
186                $msgState = $sourceChanges->breakRename( $code, $msg['key'] );
187            } else {
188                $sourceChanges->removeBasedOnType( $code, [ $msg['key'] ], $msgState );
189            }
190
191            if ( $renameMsgState === MessageSourceChange::RENAME ) {
192                $renameMsgState = $sourceChanges->breakRename( $code, $renameMsg['key'] );
193            } elseif ( $renameMsgState !== MessageSourceChange::NONE ) {
194                $sourceChanges->removeBasedOnType( $code, [ $keyToRename ], $renameMsgState );
195            }
196
197            // This is done in case the key has not been renamed in the non-source language.
198            $msg['key'] = $msgKey;
199
200            // Add as rename
201            $stringComparator = new SimpleStringComparator();
202            $similarity = $stringComparator->getSimilarity(
203                $msg['content'],
204                $renameMsg['content']
205            );
206            $sourceChanges->addRename( $code, $msg, $renameMsg, $similarity );
207            $sourceChanges->setRenameState( $code, $msgKey, $msgState );
208            $sourceChanges->setRenameState( $code, $keyToRename, $renameMsgState );
209        }
210    }
211
212    protected function handleSourceRename(
213        MessageSourceChange $sourceChanges,
214        string $code,
215        string $msgKey,
216        string $keyToRename
217    ): void {
218        $msgState = $renameMsgState = null;
219
220        $msg = $sourceChanges->findMessage(
221            $code, $msgKey, [ MessageSourceChange::ADDITION, MessageSourceChange::RENAME ], $msgState
222        );
223
224        $renameMsg = $sourceChanges->findMessage(
225            $code,
226            $keyToRename,
227            [ MessageSourceChange::DELETION, MessageSourceChange::RENAME ],
228            $renameMsgState
229        );
230
231        if ( $msg === null || $renameMsg === null ) {
232            $this->dieWithError( 'apierror-translate-rename-key-invalid' );
233        }
234
235        if ( $msgState === MessageSourceChange::RENAME ) {
236            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
237            $msgState = $sourceChanges->breakRename( $code, $msg['key'] );
238        }
239
240        if ( $renameMsgState === MessageSourceChange::RENAME ) {
241            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
242            $renameMsgState = $sourceChanges->breakRename( $code, $renameMsg['key'] );
243        }
244
245        // Ensure that one of them is an ADDITION, and one is DELETION
246        if ( $msgState !== MessageSourceChange::ADDITION ||
247            $renameMsgState !== MessageSourceChange::DELETION ) {
248            $this->dieWithError( [
249                'apierror-translate-rename-state-invalid',
250                wfEscapeWikiText( $msgState ), wfEscapeWikiText( $renameMsgState )
251            ] );
252        }
253
254        // Remove previous states
255        $sourceChanges->removeAdditions( $code, [ $msgKey ] );
256        $sourceChanges->removeDeletions( $code, [ $keyToRename ] );
257
258        // Add as rename
259        $stringComparator = new SimpleStringComparator();
260        $similarity = $stringComparator->getSimilarity(
261            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
262            $msg['content'],
263            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable T240141
264            $renameMsg['content']
265        );
266        $sourceChanges->addRename( $code, $msg, $renameMsg, $similarity );
267    }
268
269    /** Handles add message as new request */
270    protected function handleNew(
271        MessageSourceChange $sourceChanges,
272        string $msgKey,
273        string $sourceLang
274    ): void {
275        $msgState = null;
276        $languages = $sourceChanges->getLanguages();
277
278        foreach ( $languages as $code ) {
279            $msg = $sourceChanges->findMessage(
280                $code, $msgKey, [ MessageSourceChange::RENAME ], $msgState
281            );
282
283            if ( $code === $sourceLang && $msg === null ) {
284                $this->dieWithError( 'apierror-translate-addition-key-invalid' );
285            }
286
287            if ( $code === $sourceLang && $msgState !== MessageSourceChange::RENAME ) {
288                $this->dieWithError( 'apierror-translate-rename-msg-new' );
289            }
290
291            // For any other language, it's possible for the message to be not found.
292            if ( $msg === null ) {
293                continue;
294            }
295
296            // breakRename will add the message back to its previous state, nothing more to do
297            $sourceChanges->breakRename( $code, $msg['key'] );
298        }
299    }
300
301    protected function getAllowedParams(): array {
302        return [
303            'groupId' => [
304                ParamValidator::PARAM_TYPE => 'string',
305                ParamValidator::PARAM_REQUIRED => true,
306            ],
307            'renameMessageKey' => [
308                ParamValidator::PARAM_TYPE => 'string',
309                ParamValidator::PARAM_REQUIRED => false,
310            ],
311            'messageKey' => [
312                ParamValidator::PARAM_TYPE => 'string',
313                ParamValidator::PARAM_REQUIRED => true,
314            ],
315            'operation' => [
316                ParamValidator::PARAM_TYPE => [ 'rename', 'new' ],
317                ParamValidator::PARAM_ISMULTI => false,
318                ParamValidator::PARAM_REQUIRED => true,
319            ],
320            'changesetName' => [
321                ParamValidator::PARAM_TYPE => 'string',
322                ParamValidator::PARAM_DEFAULT => MessageChangeStorage::DEFAULT_NAME
323            ],
324            'changesetModified' => [
325                ParamValidator::PARAM_TYPE => 'integer',
326                ParamValidator::PARAM_REQUIRED => true,
327            ]
328        ];
329    }
330
331    public function isInternal(): bool {
332        return true;
333    }
334
335    public function needsToken(): string {
336        return 'csrf';
337    }
338}