Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 647
0.00% covered (danger)
0.00%
0 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
ManageGroupsSpecialPage
0.00% covered (danger)
0.00%
0 / 647
0.00% covered (danger)
0.00%
0 / 26
21170
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
110
 getLimit
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getLegend
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 showChanges
0.00% covered (danger)
0.00%
0 / 76
0.00% covered (danger)
0.00%
0 / 1
240
 formatChange
0.00% covered (danger)
0.00%
0 / 100
0.00% covered (danger)
0.00%
0 / 1
650
 processSubmit
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 1
240
 changeId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tabify
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 isMessageDefinitionPresent
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 showRenames
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
72
 formatRename
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
56
 getRenameJobParams
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
30
 handleRenameSubmit
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
110
 handleModificationsSubmit
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 createRenameJobs
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 isTitlePresent
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 isRenameMissing
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getProcessingErrorMessage
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getGroupsFromCdb
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 startSync
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
30
 radioLabel
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 sendNotificationsForChangedMessages
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Synchronization;
5
6use Cdb\Reader;
7use DifferenceEngine;
8use Exception;
9use FileBasedMessageGroup;
10use JobQueueGroup;
11use MediaWiki\Cache\LinkBatchFactory;
12use MediaWiki\Content\ContentHandler;
13use MediaWiki\Content\TextContent;
14use MediaWiki\Deferred\DeferredUpdates;
15use MediaWiki\Extension\Translate\LogNames;
16use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
17use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroupSubscription;
18use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
19use MediaWiki\Extension\Translate\MessageLoading\MessageIndex;
20use MediaWiki\Extension\Translate\MessageSync\MessageSourceChange;
21use MediaWiki\Extension\Translate\Utilities\Utilities;
22use MediaWiki\Html\Html;
23use MediaWiki\Language\Language;
24use MediaWiki\Logger\LoggerFactory;
25use MediaWiki\MediaWikiServices;
26use MediaWiki\Output\OutputPage;
27use MediaWiki\Request\WebRequest;
28use MediaWiki\Revision\MutableRevisionRecord;
29use MediaWiki\Revision\RevisionLookup;
30use MediaWiki\Revision\SlotRecord;
31use MediaWiki\SpecialPage\DisabledSpecialPage;
32use MediaWiki\SpecialPage\SpecialPage;
33use MediaWiki\Title\NamespaceInfo;
34use MediaWiki\Title\Title;
35use MessageGroup;
36use OOUI\ButtonInputWidget;
37use PermissionsError;
38use RuntimeException;
39use Skin;
40use UserBlockedError;
41
42/**
43 * Class for special page Special:ManageMessageGroups. On this special page
44 * file based message groups can be managed (FileBasedMessageGroup). This page
45 * allows updating of the file cache, import and fuzzy for source language
46 * messages, as well as import/update of messages in other languages.
47 *
48 * @author Niklas Laxström
49 * @author Siebrand Mazeland
50 * @ingroup SpecialPage TranslateSpecialPage
51 * @license GPL-2.0-or-later
52 */
53class ManageGroupsSpecialPage extends SpecialPage {
54    private const GROUP_SYNC_INFO_WRAPPER_CLASS = 'smg-group-sync-cache-info';
55    private const RIGHT = 'translate-manage';
56    protected DifferenceEngine $diff;
57    /** Name of the import. */
58    private string $name;
59    /** Path to the change cdb file, derived from the name. */
60    protected string $cdb;
61    /** Has the necessary right specified by the RIGHT constant */
62    protected bool $hasRight = false;
63    private Language $contLang;
64    private NamespaceInfo $nsInfo;
65    private RevisionLookup $revLookup;
66    private GroupSynchronizationCache $synchronizationCache;
67    private DisplayGroupSynchronizationInfo $displayGroupSyncInfo;
68    private JobQueueGroup $jobQueueGroup;
69    private MessageIndex $messageIndex;
70    private LinkBatchFactory $linkBatchFactory;
71    private MessageGroupSubscription $messageGroupSubscription;
72
73    public function __construct(
74        Language $contLang,
75        NamespaceInfo $nsInfo,
76        RevisionLookup $revLookup,
77        GroupSynchronizationCache $synchronizationCache,
78        JobQueueGroup $jobQueueGroup,
79        MessageIndex $messageIndex,
80        LinkBatchFactory $linkBatchFactory,
81        MessageGroupSubscription $messageGroupSubscription
82    ) {
83        // Anyone is allowed to see, but actions are restricted
84        parent::__construct( 'ManageMessageGroups' );
85        $this->contLang = $contLang;
86        $this->nsInfo = $nsInfo;
87        $this->revLookup = $revLookup;
88        $this->synchronizationCache = $synchronizationCache;
89        $this->displayGroupSyncInfo = new DisplayGroupSynchronizationInfo( $this, $this->getLinkRenderer() );
90        $this->jobQueueGroup = $jobQueueGroup;
91        $this->messageIndex = $messageIndex;
92        $this->linkBatchFactory = $linkBatchFactory;
93        $this->messageGroupSubscription = $messageGroupSubscription;
94    }
95
96    public function doesWrites() {
97        return true;
98    }
99
100    protected function getGroupName() {
101        return 'translation';
102    }
103
104    public function getDescription() {
105        return $this->msg( 'managemessagegroups' );
106    }
107
108    public function execute( $par ) {
109        $this->setHeaders();
110
111        $out = $this->getOutput();
112        $out->addModuleStyles( [
113            'ext.translate.specialpages.styles',
114            'mediawiki.codex.messagebox.styles',
115        ] );
116        $out->addModules( 'ext.translate.special.managegroups' );
117        $out->addHelpLink( 'Help:Extension:Translate/Group_management' );
118
119        $this->name = $par ?: MessageChangeStorage::DEFAULT_NAME;
120
121        $this->cdb = MessageChangeStorage::getCdbPath( $this->name );
122        if ( !MessageChangeStorage::isValidCdbName( $this->name ) || !file_exists( $this->cdb ) ) {
123            if ( $this->getConfig()->get( 'TranslateGroupSynchronizationCache' ) ) {
124                $out->addHTML(
125                    $this->displayGroupSyncInfo->getGroupsInSyncHtml(
126                        $this->synchronizationCache->getGroupsInSync(),
127                        self::GROUP_SYNC_INFO_WRAPPER_CLASS
128                    )
129                );
130
131                $out->addHTML(
132                    $this->displayGroupSyncInfo->getHtmlForGroupsWithError(
133                        $this->synchronizationCache,
134                        self::GROUP_SYNC_INFO_WRAPPER_CLASS,
135                        $this->getLanguage()
136                    )
137                );
138            }
139
140            // @todo Tell them when changes was last checked/process
141            // or how to initiate recheck.
142            $out->addWikiMsg( 'translate-smg-nochanges' );
143
144            return;
145        }
146
147        $user = $this->getUser();
148        $this->hasRight = $user->isAllowed( self::RIGHT );
149
150        $req = $this->getRequest();
151        if ( !$req->wasPosted() ) {
152            $this->showChanges( $this->getLimit() );
153
154            return;
155        }
156
157        $block = $user->getBlock();
158        if ( $block && $block->isSitewide() ) {
159            throw new UserBlockedError(
160                $block,
161                $user,
162                $this->getLanguage(),
163                $req->getIP()
164            );
165        }
166
167        $csrfTokenSet = $this->getContext()->getCsrfTokenSet();
168        if ( !$this->hasRight || !$csrfTokenSet->matchTokenField( 'token' ) ) {
169            throw new PermissionsError( self::RIGHT );
170        }
171
172        $this->processSubmit();
173    }
174
175    /** How many changes can be shown per page. */
176    protected function getLimit(): int {
177        $limits = [
178            1000, // Default max
179            ini_get( 'max_input_vars' ),
180            ini_get( 'suhosin.post.max_vars' ),
181            ini_get( 'suhosin.request.max_vars' )
182        ];
183        // Ignore things not set
184        $limits = array_filter( $limits );
185        return (int)min( $limits );
186    }
187
188    protected function getLegend(): string {
189        $text = $this->diff->addHeader(
190            '',
191            $this->msg( 'translate-smg-left' )->escaped(),
192            $this->msg( 'translate-smg-right' )->escaped()
193        );
194
195        return Html::rawElement( 'div', [ 'class' => 'mw-translate-smg-header' ], $text );
196    }
197
198    protected function showChanges( int $limit ): void {
199        $diff = new DifferenceEngine( $this->getContext() );
200        $diff->showDiffStyle();
201        $diff->setReducedLineNumbers();
202        $this->diff = $diff;
203
204        $out = $this->getOutput();
205        $out->addHTML(
206            Html::openElement( 'form', [ 'method' => 'post', 'id' => 'smgForm', 'data-name' => $this->name ] ) .
207            Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() ) .
208            Html::hidden( 'changesetModifiedTime',
209                MessageChangeStorage::getLastModifiedTime( $this->cdb ) ) .
210            $this->getLegend()
211        );
212
213        // The above count as three
214        $limit -= 3;
215
216        $groupSyncCacheEnabled = $this->getConfig()->get( 'TranslateGroupSynchronizationCache' );
217        if ( $groupSyncCacheEnabled ) {
218            $out->addHTML(
219                $this->displayGroupSyncInfo->getGroupsInSyncHtml(
220                    $this->synchronizationCache->getGroupsInSync(),
221                    self::GROUP_SYNC_INFO_WRAPPER_CLASS
222                )
223            );
224
225            $out->addHTML(
226                $this->displayGroupSyncInfo->getHtmlForGroupsWithError(
227                    $this->synchronizationCache,
228                    self::GROUP_SYNC_INFO_WRAPPER_CLASS,
229                    $this->getLanguage()
230                )
231            );
232        }
233
234        $reader = Reader::open( $this->cdb );
235        $groups = $this->getGroupsFromCdb( $reader );
236        foreach ( $groups as $id => $group ) {
237            $sourceChanges = MessageSourceChange::loadModifications(
238                Utilities::deserialize( $reader->get( $id ) )
239            );
240            $out->addHTML( Html::element( 'h2', [], $group->getLabel() ) );
241
242            if ( $groupSyncCacheEnabled && $this->synchronizationCache->groupHasErrors( $id ) ) {
243                $out->addHTML(
244                    Html::warningBox( $this->msg( 'translate-smg-group-sync-error-warn' )->escaped(), 'center' )
245                );
246            }
247
248            // Reduce page existence queries to one per group
249            $lb = $this->linkBatchFactory->newLinkBatch();
250            $ns = $group->getNamespace();
251            $isCap = $this->nsInfo->isCapitalized( $ns );
252            $languages = $sourceChanges->getLanguages();
253
254            foreach ( $languages as $language ) {
255                $languageChanges = $sourceChanges->getModificationsForLanguage( $language );
256                foreach ( $languageChanges as $changes ) {
257                    foreach ( $changes as $params ) {
258                        // Constructing title objects is way slower
259                        $key = $params['key'];
260                        if ( $isCap ) {
261                            $key = $this->contLang->ucfirst( $key );
262                        }
263                        $lb->add( $ns, "$key/$language" );
264                    }
265                }
266            }
267            $lb->execute();
268
269            foreach ( $languages as $language ) {
270                // Handle and generate UI for additions, deletions, change
271                $changes = [];
272                $changes[ MessageSourceChange::ADDITION ] = $sourceChanges->getAdditions( $language );
273                $changes[ MessageSourceChange::DELETION ] = $sourceChanges->getDeletions( $language );
274                $changes[ MessageSourceChange::CHANGE ] = $sourceChanges->getChanges( $language );
275
276                foreach ( $changes as $type => $messages ) {
277                    foreach ( $messages as $params ) {
278                        $change = $this->formatChange( $group, $sourceChanges, $language, $type, $params, $limit );
279                        $out->addHTML( $change );
280
281                        if ( $limit <= 0 ) {
282                            // We need to restrict the changes per page per form submission
283                            // limitations as well as performance.
284                            $out->wrapWikiMsg( "<div class=warning>\n$1\n</div>", 'translate-smg-more' );
285                            break 4;
286                        }
287                    }
288                }
289
290                // Handle and generate UI for renames
291                $this->showRenames( $group, $sourceChanges, $out, $language, $limit );
292            }
293        }
294
295        $out->enableOOUI();
296        $button = new ButtonInputWidget( [
297            'type' => 'submit',
298            'label' => $this->msg( 'translate-smg-submit' )->plain(),
299            'disabled' => !$this->hasRight ? 'disabled' : null,
300            'classes' => [ 'mw-translate-smg-submit' ],
301            'title' => !$this->hasRight ? $this->msg( 'translate-smg-notallowed' )->plain() : null,
302            'flags' => [ 'primary', 'progressive' ],
303        ] );
304        $out->addHTML( $button );
305        $out->addHTML( Html::closeElement( 'form' ) );
306    }
307
308    protected function formatChange(
309        MessageGroup $group,
310        MessageSourceChange $changes,
311        string $language,
312        string $type,
313        array $params,
314        int &$limit
315    ): string {
316        $key = $params['key'];
317        $title = Title::makeTitleSafe( $group->getNamespace(), "$key/$language" );
318        $id = self::changeId( $group->getId(), $language, $type, $key );
319        $noticeHtml = '';
320        $isReusedKey = false;
321
322        if ( $title && $type === 'addition' && $title->exists() ) {
323            // The message has for some reason dropped out from cache
324            // or, perhaps it is being reused. In any case treat it
325            // as a change for display, so the admin can see if
326            // action is needed and let the message be processed.
327            // Otherwise, it will end up in the postponed category
328            // forever and will prevent rebuilding the cache, which
329            // leads to many other annoying problems.
330            $type = 'change';
331            $noticeHtml .= Html::warningBox( $this->msg( 'translate-manage-key-reused' )->parse() );
332            $isReusedKey = true;
333        } elseif ( $title && ( $type === 'deletion' || $type === 'change' ) && !$title->exists() ) {
334            // This happens if a message key has been renamed
335            // The change can be ignored.
336            return '';
337        }
338
339        $text = '';
340        $titleLink = $this->getLinkRenderer()->makeLink( $title );
341
342        if ( $type === 'deletion' ) {
343            $revTitle = $this->revLookup->getRevisionByTitle( $title );
344            if ( !$revTitle ) {
345                wfWarn( "[ManageGroupSpecialPage] No revision associated with {$title->getPrefixedText()}" );
346            }
347            $content = $revTitle ? $revTitle->getContent( SlotRecord::MAIN ) : null;
348            $wiki = ( $content instanceof TextContent ) ? $content->getText() : '';
349
350            if ( $wiki === '' ) {
351                $noticeHtml .= Html::warningBox(
352                    $this->msg( 'translate-manage-empty-content' )->parse()
353                );
354            }
355
356            $newRevision = new MutableRevisionRecord( $title );
357            $newRevision->setContent( SlotRecord::MAIN, ContentHandler::makeContent( '', $title ) );
358
359            $this->diff->setRevisions( $revTitle, $newRevision );
360            $text = $this->diff->getDiff( $titleLink, '', $noticeHtml );
361        } elseif ( $type === 'addition' ) {
362            $menu = '';
363            $sourceLanguage = $group->getSourceLanguage();
364            if ( $sourceLanguage === $language ) {
365                if ( $this->hasRight ) {
366                    $menu = Html::rawElement(
367                        'button',
368                        [
369                            'class' => 'smg-rename-actions',
370                            'type' => 'button',
371                            'data-group-id' => $group->getId(),
372                            'data-lang' => $language,
373                            'data-msgkey' => $key,
374                            'data-msgtitle' => $title->getFullText()
375                        ]
376                    );
377                }
378            } elseif ( !self::isMessageDefinitionPresent( $group, $changes, $key ) ) {
379                $noticeHtml .= Html::warningBox(
380                    $this->msg( 'translate-manage-source-message-not-found' )->parse(),
381                    'mw-translate-smg-notice-important'
382                );
383
384                // Automatically ignore messages that don't have a definitions
385                $menu = Html::hidden( "msg/$id", 'ignore', [ 'id' => "i/$id" ] );
386                $limit--;
387            }
388
389            if ( $params['content'] === '' ) {
390                $noticeHtml .= Html::warningBox(
391                    $this->msg( 'translate-manage-empty-content' )->parse()
392                );
393            }
394
395            $oldRevision = new MutableRevisionRecord( $title );
396            $oldRevision->setContent( SlotRecord::MAIN, ContentHandler::makeContent( '', $title ) );
397
398            $newRevision = new MutableRevisionRecord( $title );
399            $newRevision->setContent(
400                SlotRecord::MAIN,
401                ContentHandler::makeContent( (string)$params['content'], $title )
402            );
403
404            $this->diff->setRevisions( $oldRevision, $newRevision );
405            $text = $this->diff->getDiff( '', $titleLink . $menu, $noticeHtml );
406        } elseif ( $type === 'change' ) {
407            $wiki = Utilities::getContentForTitle( $title, true );
408
409            $actions = '';
410            $sourceLanguage = $group->getSourceLanguage();
411
412            // Option to fuzzy is only available for source languages, and should be used
413            // if content has changed.
414            $shouldFuzzy = $sourceLanguage === $language && $wiki !== $params['content'];
415
416            if ( $sourceLanguage === $language ) {
417                $label = $this->msg( 'translate-manage-action-fuzzy' )->text();
418                $actions .= $this->radioLabel( $label, "msg/$id", "fuzzy", $shouldFuzzy );
419            }
420
421            if (
422                $sourceLanguage !== $language &&
423                $isReusedKey &&
424                !self::isMessageDefinitionPresent( $group, $changes, $key )
425            ) {
426                $noticeHtml .= Html::warningBox(
427                    $this->msg( 'translate-manage-source-message-not-found' )->parse(),
428                    'mw-translate-smg-notice-important'
429                );
430
431                // Automatically ignore messages that don't have a definitions
432                $actions .= Html::hidden( "msg/$id", 'ignore', [ 'id' => "i/$id" ] );
433                $limit--;
434            } else {
435                $label = $this->msg( 'translate-manage-action-import' )->text();
436                $actions .= $this->radioLabel( $label, "msg/$id", "import", !$shouldFuzzy );
437
438                $label = $this->msg( 'translate-manage-action-ignore' )->text();
439                $actions .= $this->radioLabel( $label, "msg/$id", "ignore" );
440                $limit--;
441            }
442
443            $oldRevision = new MutableRevisionRecord( $title );
444            $oldRevision->setContent( SlotRecord::MAIN, ContentHandler::makeContent( (string)$wiki, $title ) );
445
446            $newRevision = new MutableRevisionRecord( $title );
447            $newRevision->setContent(
448                SlotRecord::MAIN,
449                ContentHandler::makeContent( (string)$params['content'], $title )
450            );
451
452            $this->diff->setRevisions( $oldRevision, $newRevision );
453            $text .= $this->diff->getDiff( $titleLink, $actions, $noticeHtml );
454        }
455
456        $hidden = Html::hidden( $id, 1 );
457        $limit--;
458        $text .= $hidden;
459        $classes = "mw-translate-smg-change smg-change-$type";
460
461        if ( $limit < 0 ) {
462            // Don't add if one of the fields might get dropped of at submission
463            return '';
464        }
465
466        return Html::rawElement( 'div', [ 'class' => $classes ], $text );
467    }
468
469    protected function processSubmit(): void {
470        $req = $this->getRequest();
471        $out = $this->getOutput();
472        $errorGroups = [];
473
474        $modificationJobs = $renameJobData = [];
475        $lastModifiedTime = intval( $req->getVal( 'changesetModifiedTime' ) );
476
477        if ( !MessageChangeStorage::isModifiedSince( $this->cdb, $lastModifiedTime ) ) {
478            $out->addWikiMsg( 'translate-smg-changeset-modified' );
479            return;
480        }
481
482        $reader = Reader::open( $this->cdb );
483        $groups = $this->getGroupsFromCdb( $reader );
484        $groupSyncCacheEnabled = $this->getConfig()->get( 'TranslateGroupSynchronizationCache' );
485        $postponed = [];
486
487        foreach ( $groups as $groupId => $group ) {
488            try {
489                if ( !$group instanceof FileBasedMessageGroup ) {
490                    throw new RuntimeException( "Expected $groupId to be FileBasedMessageGroup, got "
491                        . get_class( $group )
492                        . " instead."
493                    );
494                }
495                $changes = Utilities::deserialize( $reader->get( $groupId ) );
496                if ( $groupSyncCacheEnabled && $this->synchronizationCache->groupHasErrors( $groupId ) ) {
497                    $postponed[$groupId] = $changes;
498                    continue;
499                }
500
501                $sourceChanges = MessageSourceChange::loadModifications( $changes );
502                $groupModificationJobs = [];
503                $groupRenameJobData = [];
504                $languages = $sourceChanges->getLanguages();
505                foreach ( $languages as $language ) {
506                    // Handle changes, additions, deletions
507                    $this->handleModificationsSubmit(
508                        $group,
509                        $sourceChanges,
510                        $req,
511                        $language,
512                        $postponed,
513                        $groupModificationJobs
514                    );
515
516                    // Handle renames, this might also add modification jobs based on user selection.
517                    $this->handleRenameSubmit(
518                        $group,
519                        $sourceChanges,
520                        $req,
521                        $language,
522                        $postponed,
523                        $groupRenameJobData,
524                        $groupModificationJobs
525                    );
526
527                    if ( !isset( $postponed[$groupId][$language] ) ) {
528                        $group->getMessageGroupCache( $language )->create();
529                    }
530                }
531
532                if ( $groupSyncCacheEnabled && !isset( $postponed[ $groupId ] ) ) {
533                    $this->synchronizationCache->markGroupAsReviewed( $groupId );
534                }
535
536                $modificationJobs[$groupId] = $groupModificationJobs;
537                $renameJobData[$groupId] = $groupRenameJobData;
538            } catch ( Exception $e ) {
539                error_log(
540                    "ManageGroupsSpecialPage: Error in processSubmit. Group: $groupId\n" .
541                    "Exception: $e"
542                );
543
544                $errorGroups[] = $group->getLabel();
545            }
546        }
547        $this->messageGroupSubscription->queueNotificationJob();
548
549        $renameJobs = $this->createRenameJobs( $renameJobData );
550        $this->startSync( $modificationJobs, $renameJobs );
551
552        $reader->close();
553        rename( $this->cdb, $this->cdb . '-' . wfTimestamp() );
554
555        if ( $errorGroups ) {
556            $errorMsg = $this->getProcessingErrorMessage( $errorGroups, count( $groups ) );
557            $out->addHTML(
558                Html::warningBox(
559                    $errorMsg,
560                    'mw-translate-smg-submitted'
561                )
562            );
563        }
564
565        if ( count( $postponed ) ) {
566            $postponedSourceChanges = [];
567            foreach ( $postponed as $groupId => $changes ) {
568                $postponedSourceChanges[$groupId] = MessageSourceChange::loadModifications( $changes );
569            }
570            MessageChangeStorage::writeChanges( $postponedSourceChanges, $this->cdb );
571
572            $this->showChanges( $this->getLimit() );
573        } elseif ( $errorGroups === [] ) {
574            $out->addWikiMsg( 'translate-smg-submitted' );
575        }
576    }
577
578    protected static function changeId(
579        string $groupId,
580        string $language,
581        string $type,
582        string $key
583    ): string {
584        return 'smg/' . substr( sha1( "$groupId/$language/$type/$key" ), 0, 7 );
585    }
586
587    /**
588     * Adds the task-based tabs on Special:Translate and few other special pages.
589     * Hook: SkinTemplateNavigation::Universal
590     */
591    public static function tabify( Skin $skin, array &$tabs ): void {
592        $title = $skin->getTitle();
593        if ( !$title->isSpecialPage() ) {
594            return;
595        }
596        $specialPageFactory = MediaWikiServices::getInstance()->getSpecialPageFactory();
597        [ $alias, ] = $specialPageFactory->resolveAlias( $title->getText() );
598
599        $pagesInGroup = [
600            'ManageMessageGroups' => 'namespaces',
601            'AggregateGroups' => 'namespaces',
602            'SupportedLanguages' => 'views',
603            'TranslationStats' => 'views',
604        ];
605        if ( !isset( $pagesInGroup[$alias] ) ) {
606            return;
607        }
608
609        $tabs['namespaces'] = [];
610        foreach ( $pagesInGroup as $spName => $section ) {
611            $spClass = $specialPageFactory->getPage( $spName );
612
613            if ( $spClass === null || $spClass instanceof DisabledSpecialPage ) {
614                continue; // Page explicitly disabled
615            }
616            $spTitle = $spClass->getPageTitle();
617
618            $tabs[$section][strtolower( $spName )] = [
619                'text' => $spClass->getDescription(),
620                'href' => $spTitle->getLocalURL(),
621                'class' => $alias === $spName ? 'selected' : '',
622            ];
623        }
624    }
625
626    /**
627     * Check if the message definition is present as an incoming addition
628     * OR exists already on the wiki
629     */
630    private static function isMessageDefinitionPresent(
631        MessageGroup $group,
632        MessageSourceChange $changes,
633        string $msgKey
634    ): bool {
635        $sourceLanguage = $group->getSourceLanguage();
636        if ( $changes->findMessage( $sourceLanguage, $msgKey, [ MessageSourceChange::ADDITION ] ) ) {
637            return true;
638        }
639
640        $namespace = $group->getNamespace();
641        $sourceHandle = new MessageHandle( Title::makeTitle( $namespace, $msgKey ) );
642        return $sourceHandle->isValid();
643    }
644
645    private function showRenames(
646        MessageGroup $group,
647        MessageSourceChange $sourceChanges,
648        OutputPage $out,
649        string $language,
650        int &$limit
651    ): void {
652        $changes = $sourceChanges->getRenames( $language );
653        foreach ( $changes as $key => $params ) {
654            // Since we're removing items from the array within the loop add
655            // a check here to ensure that the current key is still set.
656            if ( !isset( $changes[ $key ] ) ) {
657                continue;
658            }
659
660            if ( $group->getSourceLanguage() !== $language &&
661                $sourceChanges->isEqual( $language, $key ) ) {
662                    // This is a translation rename, that does not have any changes.
663                    // We can group this along with the source rename.
664                    continue;
665            }
666
667            // Determine added key, and corresponding removed key.
668            $firstMsg = $params;
669            $secondKey = $sourceChanges->getMatchedKey( $language, $key ) ?? '';
670            $secondMsg = $sourceChanges->getMatchedMessage( $language, $key );
671            if ( $secondMsg === null ) {
672                throw new RuntimeException( "Could not find matched message for $key" );
673            }
674
675            if (
676                $sourceChanges->isPreviousState(
677                    $language,
678                    $key,
679                    [ MessageSourceChange::ADDITION, MessageSourceChange::CHANGE ]
680                )
681            ) {
682                $addedMsg = $firstMsg;
683                $deletedMsg = $secondMsg;
684            } else {
685                $addedMsg = $secondMsg;
686                $deletedMsg = $firstMsg;
687            }
688
689            $change = $this->formatRename(
690                $group,
691                $addedMsg,
692                $deletedMsg,
693                $language,
694                $sourceChanges->isEqual( $language, $key ),
695                $limit
696            );
697            $out->addHTML( $change );
698
699            // no need to process the second key again.
700            unset( $changes[$secondKey] );
701
702            if ( $limit <= 0 ) {
703                // We need to restrict the changes per page per form submission
704                // limitations as well as performance.
705                $out->wrapWikiMsg( "<div class=warning>\n$1\n</div>", 'translate-smg-more' );
706                break;
707            }
708        }
709    }
710
711    private function formatRename(
712        MessageGroup $group,
713        array $addedMsg,
714        array $deletedMsg,
715        string $language,
716        bool $isEqual,
717        int &$limit
718    ): string {
719        $addedKey = $addedMsg['key'];
720        $deletedKey = $deletedMsg['key'];
721        $actions = '';
722
723        $addedTitle = Title::makeTitleSafe( $group->getNamespace(), "$addedKey/$language" );
724        $deletedTitle = Title::makeTitleSafe( $group->getNamespace(), "$deletedKey/$language" );
725        $id = self::changeId( $group->getId(), $language, MessageSourceChange::RENAME, $addedKey );
726
727        $addedTitleLink = $this->getLinkRenderer()->makeLink( $addedTitle );
728        $deletedTitleLink = $this->getLinkRenderer()->makeLink( $deletedTitle );
729
730        $renameSelected = true;
731        if ( $group->getSourceLanguage() === $language ) {
732            if ( !$isEqual ) {
733                $renameSelected = false;
734                $label = $this->msg( 'translate-manage-action-rename-fuzzy' )->text();
735                $actions .= $this->radioLabel( $label, "msg/$id", "renamefuzzy", true );
736            }
737
738            $label = $this->msg( 'translate-manage-action-rename' )->text();
739            $actions .= $this->radioLabel( $label, "msg/$id", "rename", $renameSelected );
740        } else {
741            $label = $this->msg( 'translate-manage-action-import' )->text();
742            $actions .= $this->radioLabel( $label, "msg/$id", "import", true );
743        }
744
745        if ( $group->getSourceLanguage() !== $language ) {
746            // Allow user to ignore changes to non-source languages.
747            $label = $this->msg( 'translate-manage-action-ignore-change' )->text();
748            $actions .= $this->radioLabel( $label, "msg/$id", "ignore" );
749        }
750        $limit--;
751
752        $addedContent = ContentHandler::makeContent( (string)$addedMsg['content'], $addedTitle );
753        $addedRevision = new MutableRevisionRecord( $addedTitle );
754        $addedRevision->setContent( SlotRecord::MAIN, $addedContent );
755
756        $deletedContent = ContentHandler::makeContent( (string)$deletedMsg['content'], $deletedTitle );
757        $deletedRevision = new MutableRevisionRecord( $deletedTitle );
758        $deletedRevision->setContent( SlotRecord::MAIN, $deletedContent );
759
760        $this->diff->setRevisions( $deletedRevision, $addedRevision );
761
762        $menu = '';
763        if ( $group->getSourceLanguage() === $language && $this->hasRight ) {
764            // Only show rename and add as new option for source language.
765            $menu = Html::rawElement(
766                'button',
767                [
768                    'class' => 'smg-rename-actions',
769                    'type' => 'button',
770                    'data-group-id' => $group->getId(),
771                    'data-msgkey' => $addedKey,
772                    'data-msgtitle' => $addedTitle->getFullText()
773                ]
774            );
775        }
776
777        $actions = Html::rawElement( 'div', [ 'class' => 'smg-change-import-options' ], $actions );
778
779        $text = $this->diff->getDiff(
780            $deletedTitleLink,
781            $addedTitleLink . $menu . $actions,
782            $isEqual ? htmlspecialchars( $addedMsg['content'] ) : ''
783        );
784
785        $hidden = Html::hidden( $id, 1 );
786        $limit--;
787        $text .= $hidden;
788
789        return Html::rawElement(
790            'div',
791            [ 'class' => 'mw-translate-smg-change smg-change-rename' ],
792            $text
793        );
794    }
795
796    private function getRenameJobParams(
797        array $currentMsg,
798        MessageSourceChange $sourceChanges,
799        string $languageCode,
800        int $groupNamespace,
801        string $selectedVal,
802        bool $isSourceLang = true
803    ): ?array {
804        if ( $selectedVal === 'ignore' ) {
805            return null;
806        }
807
808        $params = [];
809        $currentMsgKey = $currentMsg['key'];
810        $matchedMsg = $sourceChanges->getMatchedMessage( $languageCode, $currentMsgKey );
811        if ( $matchedMsg === null ) {
812            throw new RuntimeException( "Could not find matched message for $currentMsgKey." );
813        }
814        $matchedMsgKey = $matchedMsg['key'];
815
816        if (
817            $sourceChanges->isPreviousState(
818                $languageCode,
819                $currentMsgKey,
820                [ MessageSourceChange::ADDITION, MessageSourceChange::CHANGE ]
821            )
822        ) {
823            $params['target'] = $matchedMsgKey;
824            $params['replacement'] = $currentMsgKey;
825            $replacementContent = $currentMsg['content'];
826        } else {
827            $params['target'] = $currentMsgKey;
828            $params['replacement'] = $matchedMsgKey;
829            $replacementContent = $matchedMsg['content'];
830        }
831
832        $params['fuzzy'] = $selectedVal === 'renamefuzzy';
833
834        $params['content'] = $replacementContent;
835
836        if ( $isSourceLang ) {
837            $params['targetTitle'] = Title::newFromText(
838                Utilities::title( $params['target'], $languageCode, $groupNamespace ),
839                $groupNamespace
840            );
841            $params['others'] = [];
842        }
843
844        return $params;
845    }
846
847    private function handleRenameSubmit(
848        MessageGroup $group,
849        MessageSourceChange $sourceChanges,
850        WebRequest $req,
851        string $language,
852        array &$postponed,
853        array &$jobData,
854        array &$modificationJobs
855    ): void {
856        $groupId = $group->getId();
857        $renames = $sourceChanges->getRenames( $language );
858        $isSourceLang = $group->getSourceLanguage() === $language;
859        $groupNamespace = $group->getNamespace();
860
861        foreach ( $renames as $key => $params ) {
862            // Since we're removing items from the array within the loop add
863            // a check here to ensure that the current key is still set.
864            if ( !isset( $renames[$key] ) ) {
865                continue;
866            }
867
868            $id = self::changeId( $groupId, $language, MessageSourceChange::RENAME, $key );
869
870            [ $renameMissing, $isCurrentKeyPresent ] = $this->isRenameMissing(
871                $req,
872                $sourceChanges,
873                $id,
874                $key,
875                $language,
876                $groupId,
877                $isSourceLang
878            );
879
880            if ( $renameMissing ) {
881                // we probably hit the limit with number of post parameters since neither
882                // addition nor deletion key is present.
883                $postponed[$groupId][$language][MessageSourceChange::RENAME][$key] = $params;
884                continue;
885            }
886
887            if ( !$isCurrentKeyPresent ) {
888                // still don't process this key, and wait for the matched rename
889                continue;
890            }
891
892            $selectedVal = $req->getVal( "msg/$id" );
893            $jobParams = $this->getRenameJobParams(
894                $params,
895                $sourceChanges,
896                $language,
897                $groupNamespace,
898                $selectedVal,
899                $isSourceLang
900            );
901
902            if ( $jobParams === null ) {
903                continue;
904            }
905
906            $targetStr = $jobParams[ 'target' ];
907            if ( $isSourceLang ) {
908                $jobData[ $targetStr ] = $jobParams;
909                // Send notification for fuzzy items
910                if ( isset( $jobParams[ 'targetTitle' ] ) && ( $jobParams[ 'fuzzy' ] ?? false ) ) {
911                    $this->messageGroupSubscription->queueMessage(
912                        $jobParams[ 'targetTitle' ],
913                        MessageGroupSubscription::STATE_UPDATED,
914                        $groupId
915                    );
916                }
917            } elseif ( isset( $jobData[ $targetStr ] ) ) {
918                // We are grouping the source rename, and content changes in other languages
919                // for the message together into a single job in order to avoid race conditions
920                // since jobs are not guaranteed to be run in order.
921                $jobData[ $targetStr ][ 'others' ][ $language ] = $jobParams[ 'content' ];
922            } else {
923                // the source was probably ignored, we should add this as a modification instead,
924                // since the source is not going to be renamed.
925                $title = Title::newFromText(
926                    Utilities::title( $targetStr, $language, $groupNamespace ),
927                    $groupNamespace
928                );
929                $modificationJobs[] = UpdateMessageJob::newJob( $title, $jobParams['content'] );
930            }
931
932            // remove the matched key in order to avoid double processing.
933            $matchedKey = $sourceChanges->getMatchedKey( $language, $key );
934            unset( $renames[$matchedKey] );
935        }
936    }
937
938    private function handleModificationsSubmit(
939        MessageGroup $group,
940        MessageSourceChange $sourceChanges,
941        WebRequest $req,
942        string $language,
943        array &$postponed,
944        array &$messageUpdateJob
945    ): void {
946        $groupId = $group->getId();
947        $subChanges = $sourceChanges->getModificationsForLanguage( $language );
948        $isSourceLanguage = $group->getSourceLanguage() === $language;
949
950        // Ignore renames
951        unset( $subChanges[ MessageSourceChange::RENAME ] );
952
953        // Handle additions, deletions, and changes.
954        foreach ( $subChanges as $type => $messages ) {
955            foreach ( $messages as $index => $params ) {
956                $key = $params['key'];
957                $id = self::changeId( $groupId, $language, $type, $key );
958                $title = Title::makeTitleSafe( $group->getNamespace(), "$key/$language" );
959
960                if ( !$this->isTitlePresent( $title, $type ) ) {
961                    continue;
962                }
963
964                if ( !$req->getCheck( $id ) ) {
965                    // We probably hit the limit with number of post parameters.
966                    $postponed[$groupId][$language][$type][$index] = $params;
967                    continue;
968                }
969
970                $selectedVal = $req->getVal( "msg/$id" );
971                if ( $type === MessageSourceChange::DELETION || $selectedVal === 'ignore' ) {
972                    continue;
973                }
974
975                $fuzzy = $selectedVal === 'fuzzy';
976                $messageUpdateJob[] = UpdateMessageJob::newJob( $title, $params['content'], $fuzzy );
977
978                if ( $isSourceLanguage ) {
979                    $this->sendNotificationsForChangedMessages( $groupId, $title, $type, $fuzzy );
980                }
981            }
982        }
983    }
984
985    /** @return UpdateMessageJob[][] */
986    private function createRenameJobs( array $jobParams ): array {
987        $jobs = [];
988        foreach ( $jobParams as $groupId => $groupJobParams ) {
989            $jobs[$groupId] ??= [];
990            foreach ( $groupJobParams as $params ) {
991                $jobs[$groupId][] = UpdateMessageJob::newRenameJob(
992                    $params['targetTitle'],
993                    $params['target'],
994                    $params['replacement'],
995                    $params['fuzzy'],
996                    $params['content'],
997                    $params['others']
998                );
999            }
1000        }
1001
1002        return $jobs;
1003    }
1004
1005    /** Checks if a title still exists and can be processed. */
1006    private function isTitlePresent( Title $title, string $type ): bool {
1007        // phpcs:ignore SlevomatCodingStandard.ControlStructures.UselessIfConditionWithReturn
1008        if (
1009            ( $type === MessageSourceChange::DELETION || $type === MessageSourceChange::CHANGE ) &&
1010            !$title->exists()
1011        ) {
1012            // This means that this change was probably introduced due to a rename
1013            // which removed the key. No need to process.
1014            return false;
1015        }
1016        return true;
1017    }
1018
1019    /**
1020     * Checks if a renamed message key is missing from the user request submission.
1021     * Checks the current key and the matched key. This is needed because as the
1022     * keys in the wiki are not submitted along with the request, only the incoming
1023     * modified keys are submitted.
1024     * @return bool[]
1025     * $response = [
1026     *   0 => (bool) True if rename is missing, false otherwise.
1027     *   1 => (bool) Was the current $id found?
1028     * ]
1029     */
1030    private function isRenameMissing(
1031        WebRequest $req,
1032        MessageSourceChange $sourceChanges,
1033        string $id,
1034        string $key,
1035        string $language,
1036        string $groupId,
1037        bool $isSourceLang
1038    ): array {
1039        if ( $req->getCheck( $id ) ) {
1040            return [ false, true ];
1041        }
1042
1043        $isCurrentKeyPresent = false;
1044
1045        // Checked the matched key is also missing to confirm if its truly missing
1046        $matchedKey = $sourceChanges->getMatchedKey( $language, $key );
1047        $matchedId = self::changeId( $groupId, $language, MessageSourceChange::RENAME, $matchedKey );
1048        if ( $req->getCheck( $matchedId ) ) {
1049            return [ false, $isCurrentKeyPresent ];
1050        }
1051
1052        // For non source language, if strings are equal, they are not shown on the UI
1053        // and hence not submitted.
1054        return [
1055            $isSourceLang || !$sourceChanges->isEqual( $language, $matchedKey ),
1056            $isCurrentKeyPresent
1057        ];
1058    }
1059
1060    private function getProcessingErrorMessage( array $errorGroups, int $totalGroupCount ): string {
1061        // Number of error groups, are less than the total groups processed.
1062        if ( count( $errorGroups ) < $totalGroupCount ) {
1063            $errorMsg = $this->msg( 'translate-smg-submitted-with-failure' )
1064                ->numParams( count( $errorGroups ) )
1065                ->params(
1066                    $this->getLanguage()->commaList( $errorGroups ),
1067                    $this->msg( 'translate-smg-submitted-others-processing' )
1068                )->parse();
1069        } else {
1070            $errorMsg = trim(
1071                $this->msg( 'translate-smg-submitted-with-failure' )
1072                    ->numParams( count( $errorGroups ) )
1073                    ->params( $this->getLanguage()->commaList( $errorGroups ), '' )
1074                    ->parse()
1075            );
1076        }
1077
1078        return $errorMsg;
1079    }
1080
1081    /** @return array<int|string, MessageGroup> */
1082    private function getGroupsFromCdb( Reader $reader ): array {
1083        $groups = [];
1084        $groupIds = Utilities::deserialize( $reader->get( '#keys' ) );
1085        foreach ( $groupIds as $id ) {
1086            $groups[$id] = MessageGroups::getGroup( $id );
1087        }
1088        return array_filter( $groups );
1089    }
1090
1091    /**
1092     * Add jobs to the queue, updates the interim cache, and start sync process for the group.
1093     * @param UpdateMessageJob[][] $modificationJobs
1094     * @param UpdateMessageJob[][] $renameJobs
1095     */
1096    private function startSync( array $modificationJobs, array $renameJobs ): void {
1097        // We are adding an empty array for groups that have no jobs. This is mainly done to
1098        // avoid adding unnecessary checks. Remove those using array_filter
1099        $modificationGroupIds = array_keys( array_filter( $modificationJobs ) );
1100        $renameGroupIds = array_keys( array_filter( $renameJobs ) );
1101        $uniqueGroupIds = array_unique( array_merge( $modificationGroupIds, $renameGroupIds ) );
1102        $jobQueueInstance = $this->jobQueueGroup;
1103
1104        foreach ( $uniqueGroupIds as $groupId ) {
1105            $messages = [];
1106            $messageKeys = [];
1107            $groupJobs = [];
1108
1109            $groupRenameJobs = $renameJobs[$groupId] ?? [];
1110            /** @var UpdateMessageJob $job */
1111            foreach ( $groupRenameJobs as $job ) {
1112                $groupJobs[] = $job;
1113                $messageUpdateParam = MessageUpdateParameter::createFromJob( $job );
1114                $messages[] = $messageUpdateParam;
1115
1116                // Build the handle to add the message key in interim cache
1117                $replacement = $messageUpdateParam->getReplacementValue();
1118                $targetTitle = Title::makeTitle( $job->getTitle()->getNamespace(), $replacement );
1119                $messageKeys[] = ( new MessageHandle( $targetTitle ) )->getKey();
1120            }
1121
1122            $groupModificationJobs = $modificationJobs[$groupId] ?? [];
1123            /** @var UpdateMessageJob $job */
1124            foreach ( $groupModificationJobs as $job ) {
1125                $groupJobs[] = $job;
1126                $messageUpdateParam = MessageUpdateParameter::createFromJob( $job );
1127                $messages[] = $messageUpdateParam;
1128
1129                $messageKeys[] = ( new MessageHandle( $job->getTitle() ) )->getKey();
1130            }
1131
1132            // Store all message keys in the interim cache - we're particularly interested in new
1133            // and renamed messages, but it's cleaner to just store everything.
1134            $group = MessageGroups::getGroup( $groupId );
1135            $this->messageIndex->storeInterim( $group, $messageKeys );
1136
1137            if ( $this->getConfig()->get( 'TranslateGroupSynchronizationCache' ) ) {
1138                $this->synchronizationCache->addMessages( $groupId, ...$messages );
1139                $this->synchronizationCache->markGroupForSync( $groupId );
1140
1141                LoggerFactory::getInstance( LogNames::GROUP_SYNCHRONIZATION )->info(
1142                    '[' . __CLASS__ . '] Synchronization started for {groupId} by {user}',
1143                    [
1144                        'groupId' => $groupId,
1145                        'user' => $this->getUser()->getName()
1146                    ]
1147                );
1148            }
1149
1150            // There is possibility for a race condition here: the translate_cache table / group sync
1151            // cache is not yet populated with the messages to be processed, but the jobs start
1152            // running and try to remove the message from the cache. This results in a "Key not found"
1153            // error. Avoid this condition by using a DeferredUpdate.
1154            DeferredUpdates::addCallableUpdate(
1155                static function () use ( $jobQueueInstance, $groupJobs ) {
1156                    $jobQueueInstance->push( $groupJobs );
1157                }
1158            );
1159
1160        }
1161    }
1162
1163    private function radioLabel(
1164        string $label,
1165        string $name,
1166        string $value,
1167        bool $checked = false
1168    ): string {
1169        return Html::rawElement(
1170            'label',
1171            [],
1172            Html::radio(
1173                $name,
1174                $checked,
1175                [ 'value' => $value ]
1176            ) . "\u{00A0}" . $label
1177        );
1178    }
1179
1180    private function sendNotificationsForChangedMessages( string $groupId, Title $title, $type, bool $fuzzy ): void {
1181        $subscriptionState = $type === MessageSourceChange::ADDITION ?
1182            MessageGroupSubscription::STATE_ADDED :
1183            MessageGroupSubscription::STATE_UPDATED;
1184
1185        if ( $subscriptionState === MessageGroupSubscription::STATE_UPDATED && !$fuzzy ) {
1186            // If the state is updated, but the change has not been marked as fuzzy,
1187            // lets not send a notification.
1188            $subscriptionState = null;
1189        }
1190
1191        if ( $subscriptionState ) {
1192            $this->messageGroupSubscription->queueMessage( $title, $subscriptionState, $groupId );
1193        }
1194    }
1195}