Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 190
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AggregateGroupsActionApi
0.00% covered (danger)
0.00%
0 / 190
0.00% covered (danger)
0.00%
0 / 9
2450
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 / 138
0.00% covered (danger)
0.00%
0 / 1
1332
 getGroupsWithDifferentLanguage
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 generateAggregateGroupId
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isWriteMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 needsToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 25
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
 getAllPages
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\MessageGroupProcessing;
5
6use AggregateMessageGroup;
7use ApiBase;
8use ApiMain;
9use JobQueueGroup;
10use ManualLogEntry;
11use MediaWiki\Extension\Translate\MessageProcessing\MessageGroupMetadata;
12use MediaWiki\Logger\LoggerFactory;
13use MediaWiki\Title\Title;
14use MessageIndexRebuildJob;
15use Wikimedia\ParamValidator\ParamValidator;
16use WikiPageMessageGroup;
17
18/**
19 * API module for managing aggregate message groups
20 * Only supports aggregate message groups defined inside the wiki.
21 * Aggregate message group defined in YAML configuration cannot be altered.
22 * @author Santhosh Thottingal
23 * @author Niklas Laxström
24 * @copyright Copyright © 2012-2013, Santhosh Thottingal
25 * @license GPL-2.0-or-later
26 * @ingroup API TranslateAPI
27 */
28class AggregateGroupsActionApi extends ApiBase {
29    private JobQueueGroup $jobQueueGroup;
30    protected static string $right = 'translate-manage';
31    private const NO_LANGUAGE_CODE = '-';
32    private MessageGroupMetadata $messageGroupMetadata;
33
34    public function __construct(
35        ApiMain $main,
36        string $action,
37        JobQueueGroup $jobQueueGroup,
38        MessageGroupMetadata $messageGroupMetadata
39    ) {
40        parent::__construct( $main, $action );
41        $this->jobQueueGroup = $jobQueueGroup;
42        $this->messageGroupMetadata = $messageGroupMetadata;
43    }
44
45    public function execute(): void {
46        $this->checkUserRightsAny( self::$right );
47        $block = $this->getUser()->getBlock();
48        if ( $block && $block->isSitewide() ) {
49            $this->dieBlocked( $block );
50        }
51
52        $params = $this->extractRequestParams();
53        $action = $params['do'];
54        $output = [];
55        if ( $action === 'associate' || $action === 'dissociate' ) {
56            // Group is mandatory only for these two actions
57            if ( !isset( $params['group'] ) ) {
58                $this->dieWithError( [ 'apierror-missingparam', 'group' ] );
59            }
60            if ( !isset( $params['aggregategroup'] ) ) {
61                $this->dieWithError( [ 'apierror-missingparam', 'aggregategroup' ] );
62            }
63            $aggregateGroup = $params['aggregategroup'];
64            $subgroups = $this->messageGroupMetadata->getSubgroups( $aggregateGroup );
65            if ( $subgroups === null ) {
66                // For a newly created aggregate group, it may contain no subgroups, but null
67                // means the group does not exist or something has gone wrong.
68
69                $this->dieWithError( 'apierror-translate-invalidaggregategroup', 'invalidaggregategroup' );
70            }
71
72            $subgroupId = $params['group'];
73            $group = MessageGroups::getGroup( $subgroupId );
74
75            // Add or remove from the list
76            if ( $action === 'associate' ) {
77                if ( !$group instanceof WikiPageMessageGroup ) {
78                    $this->dieWithError( 'apierror-translate-invalidgroup', 'invalidgroup' );
79                }
80
81                $messageGroupLanguage = $group->getSourceLanguage();
82                $aggregateGroupLanguage = $this->messageGroupMetadata->get( $aggregateGroup, 'sourcelanguagecode' );
83                // If source language is not set, user shouldn't be prevented from associating a message group
84                if ( $aggregateGroupLanguage !== false && $messageGroupLanguage !== $aggregateGroupLanguage ) {
85                    $this->dieWithError( [
86                        'apierror-translate-grouplanguagemismatch',
87                        $messageGroupLanguage,
88                        $aggregateGroupLanguage
89                    ] );
90                }
91                $subgroups[] = $subgroupId;
92                $subgroups = array_unique( $subgroups );
93            } elseif ( $action === 'dissociate' ) {
94                // Allow removal of non-existing groups
95                $subgroups = array_flip( $subgroups );
96                unset( $subgroups[$subgroupId] );
97                $subgroups = array_flip( $subgroups );
98            }
99
100            $this->messageGroupMetadata->setSubgroups( $aggregateGroup, $subgroups );
101
102            $logParams = [
103                'aggregategroup' => $this->messageGroupMetadata->get( $aggregateGroup, 'name' ),
104                'aggregategroup-id' => $aggregateGroup,
105            ];
106
107            /* Note that to allow removing no longer existing groups from
108             * aggregate message groups, the message group object $group
109             * might not always be available. In this case we need to fake
110             * some title. */
111            $title = $group instanceof WikiPageMessageGroup ?
112                $group->getTitle() :
113                Title::newFromText( "Special:Translate/$subgroupId" );
114
115            $entry = new ManualLogEntry( 'pagetranslation', $action );
116            $entry->setPerformer( $this->getUser() );
117            $entry->setTarget( $title );
118            // @todo
119            // $entry->setComment( $comment );
120            $entry->setParameters( $logParams );
121
122            $logid = $entry->insert();
123            $entry->publish( $logid );
124        } elseif ( $action === 'remove' ) {
125            if ( !isset( $params['aggregategroup'] ) ) {
126                $this->dieWithError( [ 'apierror-missingparam', 'aggregategroup' ] );
127            }
128
129            $aggregateGroupId = $params['aggregategroup'];
130            $group = MessageGroups::getGroup( $aggregateGroupId );
131            if ( !( $group instanceof AggregateMessageGroup ) ) {
132                $this->dieWithError(
133                    'apierror-translate-invalidaggregategroupname',
134                    'invalidaggregategroupname'
135                );
136            }
137
138            $this->messageGroupMetadata->deleteGroup( $params['aggregategroup'] );
139            $logger = LoggerFactory::getInstance( 'Translate' );
140            $logger->info(
141                'Aggregate group {groupId} has been deleted.',
142                [ 'groupId' => $aggregateGroupId ]
143            );
144        } elseif ( $action === 'add' ) {
145            if ( !isset( $params['groupname'] ) ) {
146                $this->dieWithError( [ 'apierror-missingparam', 'groupname' ] );
147            }
148            $name = trim( $params['groupname'] );
149            if ( strlen( $name ) === 0 ) {
150                $this->dieWithError(
151                    'apierror-translate-invalidaggregategroupname',
152                    'invalidaggregategroupname'
153                );
154            }
155
156            if ( !isset( $params['groupdescription'] ) ) {
157                $this->dieWithError( [ 'apierror-missingparam', 'groupdescription' ] );
158            }
159            $desc = trim( $params['groupdescription'] );
160
161            $aggregateGroupId = self::generateAggregateGroupId( $name );
162
163            // Throw error if group already exists
164            $nameExists = MessageGroups::labelExists( $name );
165            if ( $nameExists ) {
166                $this->dieWithError( 'apierror-translate-duplicateaggregategroup', 'duplicateaggregategroup' );
167            }
168
169            // ID already exists: Generate a new ID by adding a number to it.
170            $idExists = MessageGroups::getGroup( $aggregateGroupId );
171            if ( $idExists ) {
172                $i = 1;
173                do {
174                    $tempId = $aggregateGroupId . '-' . $i;
175                    $idExists = MessageGroups::getGroup( $tempId );
176                    $i++;
177                } while ( $idExists );
178                $aggregateGroupId = $tempId;
179            }
180            $sourceLanguageCode = trim( $params['groupsourcelanguagecode'] );
181
182            $this->messageGroupMetadata->set( $aggregateGroupId, 'name', $name );
183            $this->messageGroupMetadata->set( $aggregateGroupId, 'description', $desc );
184            if ( $sourceLanguageCode !== self::NO_LANGUAGE_CODE ) {
185                $this->messageGroupMetadata->set( $aggregateGroupId, 'sourcelanguagecode', $sourceLanguageCode );
186            }
187            $this->messageGroupMetadata->setSubgroups( $aggregateGroupId, [] );
188
189            // Once new aggregate group added, we need to show all the pages that can be added to that.
190            $output['groups'] = self::getAllPages();
191            $output['aggregategroupId'] = $aggregateGroupId;
192            // @todo Logging
193        } elseif ( $action === 'update' ) {
194            if ( !isset( $params['groupname'] ) ) {
195                $this->dieWithError( [ 'apierror-missingparam', 'groupname' ] );
196            }
197            $name = trim( $params['groupname'] );
198            if ( strlen( $name ) === 0 ) {
199                $this->dieWithError(
200                    'apierror-translate-invalidaggregategroupname',
201                    'invalidaggregategroupname'
202                );
203            }
204            $desc = trim( $params['groupdescription'] );
205            $aggregateGroupId = $params['aggregategroup'];
206            $newLanguageCode = trim( $params['groupsourcelanguagecode'] );
207
208            $oldName = $this->messageGroupMetadata->get( $aggregateGroupId, 'name' );
209            $oldDesc = $this->messageGroupMetadata->get( $aggregateGroupId, 'description' );
210            $currentLanguageCode = $this->messageGroupMetadata->get( $aggregateGroupId, 'sourcelanguagecode' );
211
212            if ( $newLanguageCode !== self::NO_LANGUAGE_CODE && $newLanguageCode !== $currentLanguageCode ) {
213                $groupsWithDifferentLanguage =
214                    $this->getGroupsWithDifferentLanguage( $aggregateGroupId, $newLanguageCode );
215
216                if ( count( $groupsWithDifferentLanguage ) ) {
217                    $this->dieWithError( [
218                        'apierror-translate-messagegroup-aggregategrouplanguagemismatch',
219                        implode( ', ', $groupsWithDifferentLanguage ),
220                        $newLanguageCode,
221                        count( $groupsWithDifferentLanguage )
222                    ] );
223                }
224            }
225
226            // Error if the label exists already
227            $exists = MessageGroups::labelExists( $name );
228            if ( $exists && $oldName !== $name ) {
229                $this->dieWithError( 'apierror-translate-duplicateaggregategroup', 'duplicateaggregategroup' );
230            }
231
232            if (
233                $oldName === $name
234                && $oldDesc === $desc
235                && $newLanguageCode === $currentLanguageCode
236            ) {
237                $this->dieWithError( 'apierror-translate-invalidupdate', 'invalidupdate' );
238            }
239            $this->messageGroupMetadata->set( $aggregateGroupId, 'name', $name );
240            $this->messageGroupMetadata->set( $aggregateGroupId, 'description', $desc );
241            if ( $newLanguageCode === self::NO_LANGUAGE_CODE ) {
242                $this->messageGroupMetadata->clearMetadata( $aggregateGroupId, [ 'sourcelanguagecode' ] );
243            } else {
244                $this->messageGroupMetadata->set( $aggregateGroupId, 'sourcelanguagecode', $newLanguageCode );
245            }
246        }
247
248        // If we got this far, nothing has failed
249        $output['result'] = 'ok';
250        $this->getResult()->addValue( null, $this->getModuleName(), $output );
251        // Cache needs to be cleared after any changes to groups
252        MessageGroups::singleton()->recache();
253        $this->jobQueueGroup->push( MessageIndexRebuildJob::newJob() );
254    }
255
256    /**
257     * Aggregate groups have an explicit source language that should match with
258     * the associated message group source language. Thus, we check which of the subgroups
259     * of an aggregate group don't have a matching source language.
260     */
261    private function getGroupsWithDifferentLanguage(
262        string $aggregateGroupId,
263        string $sourceLanguageCode
264    ): array {
265        $groupsWithDifferentLanguage = [];
266        $subgroups = $this->messageGroupMetadata->getSubgroups( $aggregateGroupId );
267        foreach ( $subgroups as $group ) {
268            $messageGroup = MessageGroups::getGroup( $group );
269            $messageGroupLanguage = $messageGroup->getSourceLanguage();
270            if ( $messageGroupLanguage !== $sourceLanguageCode ) {
271                $groupsWithDifferentLanguage[] = $messageGroup->getLabel();
272            }
273        }
274
275        return $groupsWithDifferentLanguage;
276    }
277
278    protected function generateAggregateGroupId( string $aggregateGroupName, string $prefix = 'agg-' ): string {
279        // The database field has a maximum limit of 200 bytes
280        if ( strlen( $aggregateGroupName ) + strlen( $prefix ) >= 200 ) {
281            return $prefix . substr( sha1( $aggregateGroupName ), 0, 5 );
282        } else {
283            $pattern = '/[\x00-\x1f\x23\x27\x2c\x2e\x3c\x3e\x5b\x5d\x7b\x7c\x7d\x7f\s]+/i';
284            return $prefix . preg_replace( $pattern, '_', $aggregateGroupName );
285        }
286    }
287
288    public function isWriteMode(): bool {
289        return true;
290    }
291
292    public function needsToken(): string {
293        return 'csrf';
294    }
295
296    protected function getAllowedParams(): array {
297        return [
298            'do' => [
299                ParamValidator::PARAM_TYPE => [ 'associate', 'dissociate', 'remove', 'add', 'update' ],
300                ParamValidator::PARAM_REQUIRED => true,
301            ],
302            'aggregategroup' => [
303                ParamValidator::PARAM_TYPE => 'string',
304            ],
305            'group' => [
306                // Not providing list of values, to allow dissociation of unknown groups
307                ParamValidator::PARAM_TYPE => 'string',
308            ],
309            'groupname' => [
310                ParamValidator::PARAM_TYPE => 'string',
311            ],
312            'groupdescription' => [
313                ParamValidator::PARAM_TYPE => 'string',
314            ],
315            'groupsourcelanguagecode' => [
316                ParamValidator::PARAM_TYPE => 'string',
317            ],
318            'token' => [
319                ParamValidator::PARAM_TYPE => 'string',
320                ParamValidator::PARAM_REQUIRED => true,
321            ],
322        ];
323    }
324
325    protected function getExamplesMessages(): array {
326        return [
327            'action=aggregategroups&do=associate&group=groupId&aggregategroup=aggregateGroupId'
328                => 'apihelp-aggregategroups-example-1',
329        ];
330    }
331
332    public static function getAllPages(): array {
333        $groups = MessageGroups::getAllGroups();
334        $pages = [];
335        foreach ( $groups as $group ) {
336            if ( $group instanceof WikiPageMessageGroup ) {
337                $pages[$group->getId()] = $group->getTitle()->getPrefixedText();
338            }
339        }
340
341        return $pages;
342    }
343}