Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
MessageWebImporter.php
1<?php
2declare( strict_types = 1 );
3
5
6use ContentHandler;
7use DifferenceEngine;
8use Html;
9use InvalidArgumentException;
10use Language;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Revision\SlotRecord;
16use MessageGroup;
18use RequestContext;
19use RuntimeException;
20use Sanitizer;
21use Title;
22use User;
23use Xml;
24
35 private Title $title;
36 private User $user;
37 private MessageGroup $group;
38 private string $code;
39 private $time;
41 private const MAX_PROCESSING_TIME = 43;
42
49 public function __construct( Title $title, User $user, $group = null, string $code = 'en' ) {
50 $this->setTitle( $title );
51 $this->setUser( $user );
52 $this->setGroup( $group );
53 $this->setCode( $code );
54 }
55
57 public function getTitle(): Title {
58 return $this->title;
59 }
60
61 public function setTitle( Title $title ): void {
62 $this->title = $title;
63 }
64
65 public function getUser(): User {
66 return $this->user;
67 }
68
69 public function setUser( User $user ): void {
70 $this->user = $user;
71 }
72
73 public function getGroup(): MessageGroup {
74 return $this->group;
75 }
76
78 public function setGroup( $group ): void {
79 if ( $group instanceof MessageGroup ) {
80 $this->group = $group;
81 } else {
82 $this->group = MessageGroups::getGroup( $group );
83 }
84 }
85
86 public function getCode(): string {
87 return $this->code;
88 }
89
90 public function setCode( string $code = 'en' ): void {
91 $this->code = $code;
92 }
93
94 protected function getAction(): string {
95 return $this->getTitle()->getLocalURL();
96 }
97
98 protected function doHeader(): string {
99 $formParams = [
100 'method' => 'post',
101 'action' => $this->getAction(),
102 'class' => 'mw-translate-manage'
103 ];
104
105 $csrfTokenSet = RequestContext::getMain()->getCsrfTokenSet();
106 return Xml::openElement( 'form', $formParams ) .
107 Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) .
108 Html::hidden( 'token', $csrfTokenSet->getToken() ) .
109 Html::hidden( 'process', 1 );
110 }
111
112 protected function doFooter(): string {
113 return '</form>';
114 }
115
116 protected function allowProcess(): bool {
117 $context = RequestContext::getMain();
118 $request = $context->getRequest();
119 $csrfTokenSet = $context->getCsrfTokenSet();
120
121 return $request->wasPosted()
122 && $request->getBool( 'process' )
123 && $csrfTokenSet->matchTokenField( 'token' );
124 }
125
126 protected function getActions(): array {
127 return [
128 'import',
129 $this->code === 'en' ? 'fuzzy' : 'conflict',
130 'ignore',
131 ];
132 }
133
134 public function execute( array $messages ): bool {
135 $context = RequestContext::getMain();
136 $output = $context->getOutput();
137
138 // Set up diff engine
139 $diff = new DifferenceEngine();
140 $diff->showDiffStyle();
141 $diff->setReducedLineNumbers();
142
143 // Check whether we do processing
144 $process = $this->allowProcess();
145
146 // Initialise collection
147 $group = $this->getGroup();
148 $code = $this->getCode();
149 $collection = $group->initCollection( $code );
150 $collection->loadTranslations();
151
152 $output->addHTML( $this->doHeader() );
153
154 // Initialise variable to keep track whether all changes were imported
155 // or not. If we're allowed to process, initially assume they were.
156 $allDone = $process;
157
158 // Determine changes for each message.
159 $changed = [];
160
161 foreach ( $messages as $key => $value ) {
162 $old = null;
163 $isExistingMessageFuzzy = false;
164
165 if ( isset( $collection[$key] ) ) {
166 // This returns null if no existing translation is found
167 $old = $collection[$key]->translation();
168 $isExistingMessageFuzzy = $collection[$key]->hasTag( 'fuzzy' );
169 }
170
171 if ( $old === null ) {
172 // We found a new translation for this message of the
173 // current group: import it.
174 if ( $process ) {
175 $action = 'import';
176 $this->doAction(
177 $action,
178 $group,
179 $key,
180 $value
181 );
182 }
183 // Show the user that we imported the new translation
184 $para = '<code class="mw-tmi-new">' . htmlspecialchars( $key ) . '</code>';
185 $name = $context->msg( 'translate-manage-import-new' )->rawParams( $para )
186 ->escaped();
187 $text = Utilities::convertWhiteSpaceToHTML( $value );
188 $changed[] = self::makeSectionElement( $name, 'new', $text );
189 } else {
190 // No changes at all, ignore
191 if ( $old === (string)$value ) {
192 continue;
193 }
194
195 // Check if the message is already fuzzy in the system, and then determine if there are changes
196 $oldTextForDiff = $old;
197 if ( $isExistingMessageFuzzy ) {
198 if ( MessageHandle::makeFuzzyString( $old ) === (string)$value ) {
199 continue;
200 }
201
202 // Normalize the display of FUZZY message diffs so that if an old message has
203 // a fuzzy tag, then that is added to the text used in the diff.
204 $oldTextForDiff = MessageHandle::makeFuzzyString( $old );
205 }
206
207 $oldContent = ContentHandler::makeContent( $oldTextForDiff, $diff->getTitle() );
208 $newContent = ContentHandler::makeContent( $value, $diff->getTitle() );
209 $diff->setContent( $oldContent, $newContent );
210 $text = $diff->getDiff( '', '' );
211
212 // This is a changed translation. Note it for the next steps.
213 $type = 'changed';
214
215 // Get the user instructions for the current message,
216 // submitted together with the form
217 $action = $context->getRequest()
218 ->getVal( self::escapeNameForPHP( "action-$type-$key" ) );
219
220 if ( $process ) {
221 if ( $changed === [] ) {
222 // Initialise the HTML list showing the changes performed
223 $changed[] = '<ul>';
224 }
225
226 if ( $action === null ) {
227 // We have been told to process the messages, but not
228 // what to do with this one. Tell the user.
229 $message = $context->msg(
230 'translate-manage-inconsistent',
231 wfEscapeWikiText( "action-$type-$key" )
232 )->parse();
233 $changed[] = "<li>$message</li></ul>";
234
235 // Also stop any further processing for the other messages.
236 $process = false;
237 } else {
238 // Check processing time
239 if ( !isset( $this->time ) ) {
240 $this->time = (int)wfTimestamp();
241 }
242
243 // We have all the necessary information on this changed
244 // translation: actually process the message
245 $messageKeyAndParams = $this->doAction(
246 $action,
247 $group,
248 $key,
249 $value
250 );
251
252 // Show what we just did, adding to the list of changes
253 $msgKey = array_shift( $messageKeyAndParams );
254 $params = $messageKeyAndParams;
255 $message = $context->msg( $msgKey, $params )->parse();
256 $changed[] = "<li>$message</li>";
257
258 // Stop processing further messages if too much time
259 // has been spent.
260 if ( $this->checkProcessTime() ) {
261 $process = false;
262 $message = $context->msg( 'translate-manage-toolong' )
263 ->numParams( self::MAX_PROCESSING_TIME )->parse();
264 $changed[] = "<li>$message</li></ul>";
265 }
266
267 continue;
268 }
269 }
270
271 // We are not processing messages, or no longer, or this was an
272 // un-actionable translation. We will eventually return false
273 $allDone = false;
274
275 // Prepare to ask the user what to do with this message
276 $actions = $this->getActions();
277 $defaultAction = $action ?: 'import';
278
279 $act = [];
280
281 // Give grep a chance to find the usages:
282 // translate-manage-action-import, translate-manage-action-conflict,
283 // translate-manage-action-ignore, translate-manage-action-fuzzy
284 foreach ( $actions as $action ) {
285 $label = $context->msg( "translate-manage-action-$action" )->text();
286 $name = self::escapeNameForPHP( "action-$type-$key" );
287 $id = Sanitizer::escapeIdForAttribute( "action-$key-$action" );
288 $act[] = Xml::radioLabel( $label, $name, $action, $id, $action === $defaultAction );
289 }
290
291 $param = '<code class="mw-tmi-diff">' . htmlspecialchars( $key ) . '</code>';
292 $name = $context->msg( 'translate-manage-import-diff' )
293 ->rawParams( $param, implode( ' ', $act ) )
294 ->escaped();
295
296 $changed[] = self::makeSectionElement( $name, $type, $text );
297 }
298 }
299
300 if ( !$process ) {
301 $collection->filter( 'hastranslation', false );
302 $keys = $collection->getMessageKeys();
303
304 $diff = array_diff( $keys, array_keys( $messages ) );
305
306 foreach ( $diff as $s ) {
307 $para = '<code class="mw-tmi-deleted">' . htmlspecialchars( $s ) . '</code>';
308 $name = $context->msg( 'translate-manage-import-deleted' )->rawParams( $para )->escaped();
309 $text = Utilities::convertWhiteSpaceToHTML( $collection[$s]->translation() );
310 $changed[] = self::makeSectionElement( $name, 'deleted', $text );
311 }
312 }
313
314 if ( $process || ( $changed === [] && $code !== 'en' ) ) {
315 if ( $changed === [] ) {
316 $output->addWikiMsg( 'translate-manage-nochanges-other' );
317 }
318
319 if ( $changed === [] || !str_starts_with( end( $changed ), '<li>' ) ) {
320 $changed[] = '<ul>';
321 }
322
323 $changed[] = '</ul>';
324
325 $languageName = Utilities::getLanguageName( $code, $context->getLanguage()->getCode() );
326 $message = $context
327 ->msg( 'translate-manage-import-done', $group->getId(), $group->getLabel(), $languageName )
328 ->parse();
329 $changed[] = Html::successBox( $message );
330 $output->addHTML( implode( "\n", $changed ) );
331 } else {
332 // END
333 if ( $changed !== [] ) {
334 if ( $code === 'en' ) {
335 $output->addWikiMsg( 'translate-manage-intro-en' );
336 } else {
337 $lang = Utilities::getLanguageName(
338 $code,
339 $context->getLanguage()->getCode()
340 );
341 $output->addWikiMsg( 'translate-manage-intro-other', $lang );
342 }
343 $output->addHTML( Html::hidden( 'language', $code ) );
344 $output->addHTML( implode( "\n", $changed ) );
345 $output->addHTML( Xml::submitButton( $context->msg( 'translate-manage-submit' )->text() ) );
346 } else {
347 $output->addWikiMsg( 'translate-manage-nochanges' );
348 }
349 }
350
351 $output->addHTML( $this->doFooter() );
352
353 return $allDone;
354 }
355
365 private function doAction(
366 string $action,
367 MessageGroup $group,
368 string $key,
369 string $message
370 ): array {
371 global $wgTranslateDocumentationLanguageCode;
372
373 $comment = '';
374 $code = $this->getCode();
375 $title = $this->makeTranslationTitle( $group, $key, $code );
376
377 if ( $action === 'import' || $action === 'conflict' ) {
378 if ( $action === 'import' ) {
379 $comment = wfMessage( 'translate-manage-import-summary' )->inContentLanguage()->plain();
380 } else {
381 $comment = wfMessage( 'translate-manage-conflict-summary' )->inContentLanguage()->plain();
382 $message = MessageHandle::makeFuzzyString( $message );
383 }
384
385 return self::doImport( $title, $message, $comment, $this->getUser() );
386 } elseif ( $action === 'ignore' ) {
387 return [ 'translate-manage-import-ignore', $key ];
388 } elseif ( $action === 'fuzzy' && $code !== 'en' &&
389 $code !== $wgTranslateDocumentationLanguageCode
390 ) {
391 $message = MessageHandle::makeFuzzyString( $message );
392
393 return self::doImport( $title, $message, $comment, $this->getUser() );
394 } elseif ( $action === 'fuzzy' && $code === 'en' ) {
395 return self::doFuzzy( $title, $message, $comment, $this->getUser() );
396 } else {
397 throw new InvalidArgumentException( "Unhandled action $action" );
398 }
399 }
400
401 protected function checkProcessTime() {
402 return (int)wfTimestamp() - $this->time >= self::MAX_PROCESSING_TIME;
403 }
404
406 private static function doImport(
407 Title $title,
408 string $message,
409 string $summary,
410 User $user
411 ): array {
412 $wikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
413 $content = ContentHandler::makeContent( $message, $title );
414 $status = $wikiPage->doUserEditContent(
415 $content,
416 $user,
417 $summary
418 );
419 $success = $status->isOK();
420
421 if ( $success ) {
422 return [ 'translate-manage-import-ok',
423 wfEscapeWikiText( $title->getPrefixedText() )
424 ];
425 }
426
427 $text = "Failed to import new version of page {$title->getPrefixedText()}\n";
428 $text .= $status->getWikiText();
429 throw new RuntimeException( $text );
430 }
431
433 public static function doFuzzy(
434 Title $title,
435 string $message,
436 string $comment,
437 ?User $user
438 ): array {
439 $context = RequestContext::getMain();
440 $services = MediaWikiServices::getInstance();
441
442 if ( !$context->getUser()->isAllowed( 'translate-manage' ) ) {
443 return [ 'badaccess-group0' ];
444 }
445
446 // Edit with fuzzybot if there is no user.
447 if ( !$user ) {
448 $user = FuzzyBot::getUser();
449 }
450
451 // Work on all subpages of base title.
452 $handle = new MessageHandle( $title );
453 $titleText = $handle->getKey();
454
455 $revStore = $services->getRevisionStore();
456 $queryInfo = $revStore->getQueryInfo( [ 'page' ] );
457 $dbw = $services->getDBLoadBalancer()->getConnection( DB_PRIMARY );
458 $rows = $dbw->select(
459 $queryInfo['tables'],
460 $queryInfo['fields'],
461 [
462 'page_namespace' => $title->getNamespace(),
463 'page_latest=rev_id',
464 'page_title' . $dbw->buildLike( "$titleText/", $dbw->anyString() ),
465 ],
466 __METHOD__,
467 [],
468 $queryInfo['joins']
469 );
470
471 $changed = [];
472 $slots = $revStore->getContentBlobsForBatch( $rows, [ SlotRecord::MAIN ] )->getValue();
473
474 foreach ( $rows as $row ) {
475 global $wgTranslateDocumentationLanguageCode;
476
477 $translationTitle = Title::makeTitle( (int)$row->page_namespace, $row->page_title );
478
479 // No fuzzy for English original or documentation language code.
480 if ( $translationTitle->getSubpageText() === 'en' ||
481 $translationTitle->getSubpageText() === $wgTranslateDocumentationLanguageCode
482 ) {
483 // Use imported text, not database text.
484 $text = $message;
485 } elseif ( isset( $slots[$row->rev_id] ) ) {
486 $slot = $slots[$row->rev_id][SlotRecord::MAIN];
487 $text = MessageHandle::makeFuzzyString( $slot->blob_data );
488 } else {
489 $text = MessageHandle::makeFuzzyString(
490 Utilities::getTextFromTextContent(
491 $revStore->newRevisionFromRow( $row )->getContent( SlotRecord::MAIN )
492 )
493 );
494 }
495
496 // Do actual import
497 $changed[] = self::doImport(
498 $translationTitle,
499 $text,
500 $comment,
501 $user
502 );
503 }
504
505 // Format return text
506 $text = '';
507 foreach ( $changed as $c ) {
508 $key = array_shift( $c );
509 $text .= '* ' . $context->msg( $key, $c )->plain() . "\n";
510 }
511
512 return [ 'translate-manage-import-fuzzy', "\n" . $text ];
513 }
514
524 private function makeTranslationTitle( MessageGroup $group, string $key, string $code ): Title {
525 $ns = $group->getNamespace();
526
527 return Title::makeTitleSafe( $ns, "$key/$code" );
528 }
529
539 public static function makeSectionElement(
540 string $legend,
541 string $type,
542 string $content,
543 Language $lang = null
544 ): string {
545 $containerParams = [ 'class' => "mw-tpt-sp-section mw-tpt-sp-section-type-{$type}" ];
546 $legendParams = [ 'class' => 'mw-tpt-sp-legend' ];
547 $contentParams = [ 'class' => 'mw-tpt-sp-content' ];
548 if ( $lang ) {
549 $contentParams['dir'] = $lang->getDir();
550 $contentParams['lang'] = $lang->getCode();
551 }
552
553 return Html::rawElement( 'div', $containerParams,
554 Html::rawElement( 'div', $legendParams, $legend ) .
555 Html::rawElement( 'div', $contentParams, $content )
556 );
557 }
558
564 private static function escapeNameForPHP( string $name ): string {
565 $replacements = [
566 '(' => '(OP)',
567 ' ' => '(SP)',
568 "\t" => '(TAB)',
569 '.' => '(DOT)',
570 "'" => '(SQ)',
571 "\"" => '(DQ)',
572 '%' => '(PC)',
573 '&' => '(AMP)',
574 ];
575
576 return strtr( $name, $replacements );
577 }
578}
return[ 'Translate:ConfigHelper'=> static function():ConfigHelper { return new ConfigHelper();}, 'Translate:CsvTranslationImporter'=> static function(MediaWikiServices $services):CsvTranslationImporter { return new CsvTranslationImporter( $services->getWikiPageFactory());}, 'Translate:EntitySearch'=> static function(MediaWikiServices $services):EntitySearch { return new EntitySearch($services->getMainWANObjectCache(), $services->getCollationFactory() ->makeCollation( 'uca-default-u-kn'), MessageGroups::singleton(), $services->getNamespaceInfo(), $services->get( 'Translate:MessageIndex'), $services->getTitleParser(), $services->getTitleFormatter());}, 'Translate:ExternalMessageSourceStateImporter'=> static function(MediaWikiServices $services):ExternalMessageSourceStateImporter { return new ExternalMessageSourceStateImporter($services->getMainConfig(), $services->get( 'Translate:GroupSynchronizationCache'), $services->getJobQueueGroup(), LoggerFactory::getInstance( 'Translate.GroupSynchronization'), $services->get( 'Translate:MessageIndex'));}, 'Translate:FileFormatFactory'=> static function(MediaWikiServices $services):FileFormatFactory { return new FileFormatFactory( $services->getObjectFactory());}, 'Translate:GroupSynchronizationCache'=> static function(MediaWikiServices $services):GroupSynchronizationCache { return new GroupSynchronizationCache( $services->get( 'Translate:PersistentCache'));}, 'Translate:HookRunner'=> static function(MediaWikiServices $services):HookRunner { return new HookRunner( $services->getHookContainer());}, 'Translate:MessageBundleStore'=> static function(MediaWikiServices $services):MessageBundleStore { return new MessageBundleStore($services->get( 'Translate:RevTagStore'), $services->getJobQueueGroup(), $services->getLanguageNameUtils(), $services->get( 'Translate:MessageIndex'));}, 'Translate:MessageGroupReviewStore'=> static function(MediaWikiServices $services):MessageGroupReviewStore { return new MessageGroupReviewStore($services->getDBLoadBalancer(), $services->get( 'Translate:HookRunner'));}, 'Translate:MessageGroupStatsTableFactory'=> static function(MediaWikiServices $services):MessageGroupStatsTableFactory { return new MessageGroupStatsTableFactory($services->get( 'Translate:ProgressStatsTableFactory'), $services->getDBLoadBalancer(), $services->getLinkRenderer(), $services->get( 'Translate:MessageGroupReviewStore'), $services->getMainConfig() ->get( 'TranslateWorkflowStates') !==false);}, 'Translate:MessageIndex'=> static function(MediaWikiServices $services):MessageIndex { $params=$services->getMainConfig() ->get( 'TranslateMessageIndex');if(is_string( $params)) { $params=(array) $params;} $class=array_shift( $params);return new $class( $params);}, 'Translate:MessagePrefixStats'=> static function(MediaWikiServices $services):MessagePrefixStats { return new MessagePrefixStats( $services->getTitleParser());}, 'Translate:ParsingPlaceholderFactory'=> static function():ParsingPlaceholderFactory { return new ParsingPlaceholderFactory();}, 'Translate:PersistentCache'=> static function(MediaWikiServices $services):PersistentCache { return new PersistentDatabaseCache($services->getDBLoadBalancer(), $services->getJsonCodec());}, 'Translate:ProgressStatsTableFactory'=> static function(MediaWikiServices $services):ProgressStatsTableFactory { return new ProgressStatsTableFactory($services->getLinkRenderer(), $services->get( 'Translate:ConfigHelper'));}, 'Translate:RevTagStore'=> static function(MediaWikiServices $services):RevTagStore { return new RevTagStore($services->getDBLoadBalancerFactory());}, 'Translate:SubpageListBuilder'=> static function(MediaWikiServices $services):SubpageListBuilder { return new SubpageListBuilder($services->get( 'Translate:TranslatableBundleFactory'), $services->getLinkBatchFactory());}, 'Translate:TranslatableBundleExporter'=> static function(MediaWikiServices $services):TranslatableBundleExporter { return new TranslatableBundleExporter($services->get( 'Translate:SubpageListBuilder'), $services->getWikiExporterFactory(), $services->getDBLoadBalancer());}, 'Translate:TranslatableBundleFactory'=> static function(MediaWikiServices $services):TranslatableBundleFactory { return new TranslatableBundleFactory($services->get( 'Translate:TranslatablePageStore'), $services->get( 'Translate:MessageBundleStore'));}, 'Translate:TranslatableBundleImporter'=> static function(MediaWikiServices $services):TranslatableBundleImporter { return new TranslatableBundleImporter($services->getWikiImporterFactory(), $services->get( 'Translate:TranslatablePageParser'), $services->getRevisionLookup());}, 'Translate:TranslatableBundleMover'=> static function(MediaWikiServices $services):TranslatableBundleMover { return new TranslatableBundleMover($services->getMovePageFactory(), $services->getJobQueueGroup(), $services->getLinkBatchFactory(), $services->get( 'Translate:TranslatableBundleFactory'), $services->get( 'Translate:SubpageListBuilder'), $services->getMainConfig() ->get( 'TranslatePageMoveLimit'));}, 'Translate:TranslatableBundleStatusStore'=> static function(MediaWikiServices $services):TranslatableBundleStatusStore { return new TranslatableBundleStatusStore($services->getDBLoadBalancer() ->getConnection(DB_PRIMARY), $services->getCollationFactory() ->makeCollation( 'uca-default-u-kn'), $services->getDBLoadBalancer() ->getMaintenanceConnectionRef(DB_PRIMARY));}, 'Translate:TranslatablePageParser'=> static function(MediaWikiServices $services):TranslatablePageParser { return new TranslatablePageParser($services->get( 'Translate:ParsingPlaceholderFactory'));}, 'Translate:TranslatablePageStore'=> static function(MediaWikiServices $services):TranslatablePageStore { return new TranslatablePageStore($services->get( 'Translate:MessageIndex'), $services->getJobQueueGroup(), $services->get( 'Translate:RevTagStore'), $services->getDBLoadBalancer(), $services->get( 'Translate:TranslatableBundleStatusStore'), $services->get( 'Translate:TranslatablePageParser'),);}, 'Translate:TranslationStashReader'=> static function(MediaWikiServices $services):TranslationStashReader { $db=$services->getDBLoadBalancer() ->getConnection(DB_REPLICA);return new TranslationStashStorage( $db);}, 'Translate:TranslationStatsDataProvider'=> static function(MediaWikiServices $services):TranslationStatsDataProvider { return new TranslationStatsDataProvider(new ServiceOptions(TranslationStatsDataProvider::CONSTRUCTOR_OPTIONS, $services->getMainConfig()), $services->getObjectFactory(), $services->getDBLoadBalancer());}, 'Translate:TranslationUnitStoreFactory'=> static function(MediaWikiServices $services):TranslationUnitStoreFactory { return new TranslationUnitStoreFactory( $services->getDBLoadBalancer());}, 'Translate:TranslatorActivity'=> static function(MediaWikiServices $services):TranslatorActivity { $query=new TranslatorActivityQuery($services->getMainConfig(), $services->getDBLoadBalancer());return new TranslatorActivity($services->getMainObjectStash(), $query, $services->getJobQueueGroup());}, 'Translate:TtmServerFactory'=> static function(MediaWikiServices $services):TtmServerFactory { $config=$services->getMainConfig();$default=$config->get( 'TranslateTranslationDefaultService');if( $default===false) { $default=null;} return new TtmServerFactory( $config->get( 'TranslateTranslationServices'), $default);}]
@phpcs-require-sorted-array
Factory class for accessing message groups individually by id or all of them as a list.
static makeSectionElement(string $legend, string $type, string $content, Language $lang=null)
Make section elements.
static doFuzzy(Title $title, string $message, string $comment, ?User $user)
__construct(Title $title, User $user, $group=null, string $code='en')
FuzzyBot - the misunderstood workhorse.
Definition FuzzyBot.php:15
Essentially random collection of helper functions, similar to GlobalFunctions.php.
Definition Utilities.php:31
Class for pointing to messages, like Title class is for titles.
static makeFuzzyString(string $text)
Check if a string has fuzzy string and if not, add it.
Interface for message groups.
Finds external changes for file based message groups.