Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 647 |
|
0.00% |
0 / 26 |
CRAP | |
0.00% |
0 / 1 |
ManageGroupsSpecialPage | |
0.00% |
0 / 647 |
|
0.00% |
0 / 26 |
21170 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDescription | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 45 |
|
0.00% |
0 / 1 |
110 | |||
getLimit | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
getLegend | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
showChanges | |
0.00% |
0 / 76 |
|
0.00% |
0 / 1 |
240 | |||
formatChange | |
0.00% |
0 / 100 |
|
0.00% |
0 / 1 |
650 | |||
processSubmit | |
0.00% |
0 / 77 |
|
0.00% |
0 / 1 |
240 | |||
changeId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
tabify | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
56 | |||
isMessageDefinitionPresent | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
showRenames | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
72 | |||
formatRename | |
0.00% |
0 / 55 |
|
0.00% |
0 / 1 |
56 | |||
getRenameJobParams | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
30 | |||
handleRenameSubmit | |
0.00% |
0 / 51 |
|
0.00% |
0 / 1 |
110 | |||
handleModificationsSubmit | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
72 | |||
createRenameJobs | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
isTitlePresent | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
20 | |||
isRenameMissing | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
getProcessingErrorMessage | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
getGroupsFromCdb | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
startSync | |
0.00% |
0 / 39 |
|
0.00% |
0 / 1 |
30 | |||
radioLabel | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
sendNotificationsForChangedMessages | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
30 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\Synchronization; |
5 | |
6 | use Cdb\Reader; |
7 | use DifferenceEngine; |
8 | use Exception; |
9 | use FileBasedMessageGroup; |
10 | use JobQueueGroup; |
11 | use MediaWiki\Cache\LinkBatchFactory; |
12 | use MediaWiki\Content\ContentHandler; |
13 | use MediaWiki\Content\TextContent; |
14 | use MediaWiki\Deferred\DeferredUpdates; |
15 | use MediaWiki\Extension\Translate\LogNames; |
16 | use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups; |
17 | use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroupSubscription; |
18 | use MediaWiki\Extension\Translate\MessageLoading\MessageHandle; |
19 | use MediaWiki\Extension\Translate\MessageLoading\MessageIndex; |
20 | use MediaWiki\Extension\Translate\MessageSync\MessageSourceChange; |
21 | use MediaWiki\Extension\Translate\Utilities\Utilities; |
22 | use MediaWiki\Html\Html; |
23 | use MediaWiki\Language\Language; |
24 | use MediaWiki\Logger\LoggerFactory; |
25 | use MediaWiki\MediaWikiServices; |
26 | use MediaWiki\Output\OutputPage; |
27 | use MediaWiki\Request\WebRequest; |
28 | use MediaWiki\Revision\MutableRevisionRecord; |
29 | use MediaWiki\Revision\RevisionLookup; |
30 | use MediaWiki\Revision\SlotRecord; |
31 | use MediaWiki\SpecialPage\DisabledSpecialPage; |
32 | use MediaWiki\SpecialPage\SpecialPage; |
33 | use MediaWiki\Title\NamespaceInfo; |
34 | use MediaWiki\Title\Title; |
35 | use MessageGroup; |
36 | use OOUI\ButtonInputWidget; |
37 | use PermissionsError; |
38 | use RuntimeException; |
39 | use Skin; |
40 | use 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 | */ |
53 | class 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 | } |