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