Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
TranslatablePage.php
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\PageTranslation;
5
6use LogicException;
10use MediaWiki\Linker\LinkTarget;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\Revision\RevisionLookup;
13use MediaWiki\Revision\RevisionRecord;
14use MediaWiki\Revision\SlotRecord;
18use MWException;
19use RuntimeException;
20use SpecialPage;
21use TextContent;
22use Title;
25use Wikimedia\Rdbms\Database;
26use Wikimedia\Rdbms\IResultWrapper;
28
40 public const METADATA_KEYS = [
41 'maxid',
42 'priorityforce',
43 'prioritylangs',
44 'priorityreason',
45 'transclusion',
46 'version'
47 ];
49 public const DISPLAY_TITLE_UNIT_ID = 'Page display title';
50
52 protected $title;
54 protected $revTagStore;
56 protected $text;
58 protected $revision;
60 protected $source;
62 protected $pageDisplayTitle;
64 private $targetLanguage;
65
67 protected function __construct( Title $title ) {
68 $this->title = $title;
69 $this->revTagStore = new RevTagStore();
70 }
71
77 public static function newFromText( Title $title, string $text ): self {
78 $obj = new self( $title );
79 $obj->text = $text;
80 $obj->source = 'text';
81
82 return $obj;
83 }
84
91 public static function newFromRevision( Title $title, int $revision ): self {
92 $rev = MediaWikiServices::getInstance()
93 ->getRevisionLookup()
94 ->getRevisionByTitle( $title, $revision );
95 if ( $rev === null ) {
96 throw new MWException( 'Revision is null' );
97 }
98
99 $obj = new self( $title );
100 $obj->source = 'revision';
101 $obj->revision = $revision;
102
103 return $obj;
104 }
105
110 public static function newFromTitle( Title $title ): self {
111 $obj = new self( $title );
112 $obj->source = 'title';
113
114 return $obj;
115 }
116
118 public function getTitle(): Title {
119 return $this->title;
120 }
121
123 public function getText(): string {
124 if ( $this->text !== null ) {
125 return $this->text;
126 }
127
128 $page = $this->getTitle()->getPrefixedDBkey();
129
130 if ( $this->source === 'title' ) {
131 $revision = $this->getMarkedTag();
132 if ( !is_int( $revision ) ) {
133 throw new LogicException(
134 "Trying to load a text for $page which is not marked for translation"
135 );
136 }
137 $this->revision = $revision;
138 }
139
140 $flags = TranslateUtils::shouldReadFromPrimary()
141 ? RevisionLookup::READ_LATEST
142 : RevisionLookup::READ_NORMAL;
143 $rev = MediaWikiServices::getInstance()
144 ->getRevisionLookup()
145 ->getRevisionByTitle( $this->getTitle(), $this->revision, $flags );
146 $content = $rev->getContent( SlotRecord::MAIN );
147 $text = ( $content instanceof TextContent ) ? $content->getText() : null;
148
149 if ( !is_string( $text ) ) {
150 throw new RuntimeException( "Failed to load text for $page" );
151 }
152
153 $this->text = $text;
154
155 return $this->text;
156 }
157
162 public function getRevision(): ?int {
163 return $this->revision;
164 }
165
171 public function getSourceLanguageCode(): string {
172 return $this->getTitle()->getPageLanguage()->getCode();
173 }
174
176 public function getMessageGroupId(): string {
177 return self::getMessageGroupIdFromTitle( $this->getTitle() );
178 }
179
181 public static function getMessageGroupIdFromTitle( Title $title ): string {
182 return 'page-' . $title->getPrefixedText();
183 }
184
190 $groupId = $this->getMessageGroupId();
191 $group = MessageGroups::getGroup( $groupId );
192 if ( !$group || $group instanceof WikiPageMessageGroup ) {
193 return $group;
194 }
195
196 throw new RuntimeException(
197 "Expected $groupId to be of type WikiPageMessageGroup; got " .
198 get_class( $group )
199 );
200 }
201
203 public function hasPageDisplayTitle(): bool {
204 // Cached value
205 if ( $this->pageDisplayTitle !== null ) {
206 return $this->pageDisplayTitle;
207 }
208
209 // Check if title section exists in list of sections
210 $factory = Services::getInstance()->getTranslationUnitStoreFactory();
211 $store = $factory->getReader( $this->getTitle() );
212 $this->pageDisplayTitle = in_array( self::DISPLAY_TITLE_UNIT_ID, $store->getNames() );
213
214 return $this->pageDisplayTitle;
215 }
216
218 public function getPageDisplayTitle( string $languageCode ): ?string {
219 // Return null if title not marked for translation
220 if ( !$this->hasPageDisplayTitle() ) {
221 return null;
222 }
223
224 // Display title from DB
225 $section = str_replace( ' ', '_', self::DISPLAY_TITLE_UNIT_ID );
226 $page = $this->getTitle()->getPrefixedDBkey();
227
228 try {
229 $group = $this->getMessageGroup();
230 } catch ( RuntimeException $e ) {
231 return null;
232 }
233
234 // Sanity check, seems to happen during moves
235 if ( !$group ) {
236 return null;
237 }
238
239 return $group->getMessage( "$page/$section", $languageCode, $group::READ_NORMAL );
240 }
241
242 public function getStrippedSourcePageText(): string {
243 $parser = Services::getInstance()->getTranslatablePageParser();
244 $text = $parser->cleanupTags( $this->getText() );
245 $text = preg_replace( '~<languages\s*/>\n?~s', '', $text );
246
247 return $text;
248 }
249
250 public static function getTranslationPageFromTitle( Title $title ): ?TranslationPage {
251 $self = self::isTranslationPage( $title );
252 if ( !$self ) {
253 return null;
254 }
255
256 return $self->getTranslationPage( $self->targetLanguage );
257 }
258
259 public function getTranslationPage( string $targetLanguage ): TranslationPage {
260 $mwServices = MediaWikiServices::getInstance();
261 $config = $mwServices->getMainConfig();
262 $parser = Services::getInstance()->getTranslatablePageParser();
263 $parserOutput = $parser->parse( $this->getText() );
264 $pageVersion = (int)TranslateMetadata::get( $this->getMessageGroupId(), 'version' );
265 $wrapUntranslated = $pageVersion >= 2;
266 $languageFactory = $mwServices->getLanguageFactory();
267
268 return new TranslationPage(
269 $parserOutput,
270 $this->getMessageGroup(),
271 $languageFactory->getLanguage( $targetLanguage ),
272 $languageFactory->getLanguage( $this->getSourceLanguageCode() ),
273 $config->get( 'TranslateKeepOutdatedTranslations' ),
274 $wrapUntranslated,
275 $this->getTitle()
276 );
277 }
278
279 protected static $tagCache = [];
280
282 public function addMarkedTag( int $revision, array $value = null ) {
283 $this->revTagStore->replaceTag( $this->getTitle(), RevTagStore::TP_MARK_TAG, $revision, $value );
284 self::clearSourcePageCache();
285 }
286
288 public function addReadyTag( int $revision ): void {
289 $this->revTagStore->replaceTag( $this->getTitle(), RevTagStore::TP_READY_TAG, $revision );
290 if ( !self::isSourcePage( $this->getTitle() ) ) {
291 self::clearSourcePageCache();
292 }
293 }
294
296 public function getMarkedTag(): ?int {
297 return $this->revTagStore->getLatestRevisionWithTag( $this->getTitle(), RevTagStore::TP_MARK_TAG );
298 }
299
301 public function getReadyTag(): ?int {
302 return $this->revTagStore->getLatestRevisionWithTag( $this->getTitle(), RevTagStore::TP_READY_TAG );
303 }
304
309 public function unmarkTranslatablePage(): void {
310 $aid = $this->getTitle()->getArticleID();
311 $dbw = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_PRIMARY );
312 $this->revTagStore->removeTags( $this->getTitle(), RevTagStore::TP_MARK_TAG, RevTagStore::TP_READY_TAG );
313 $dbw->delete( 'translate_sections', [ 'trs_page' => $aid ], __METHOD__ );
314
315 self::clearSourcePageCache();
316 }
317
323 public function getTranslationUrl( $code = false ): string {
324 $params = [
325 'group' => $this->getMessageGroupId(),
326 'action' => 'page',
327 'filter' => '',
328 'language' => $code,
329 ];
330
331 $translate = SpecialPage::getTitleFor( 'Translate' );
332
333 return $translate->getLocalURL( $params );
334 }
335
336 public function getMarkedRevs(): IResultWrapper {
337 $db = TranslateUtils::getSafeReadDB();
338
339 $fields = [ 'rt_revision', 'rt_value' ];
340 $conds = [
341 'rt_page' => $this->getTitle()->getArticleID(),
342 'rt_type' => RevTagStore::TP_MARK_TAG,
343 ];
344 $options = [ 'ORDER BY' => 'rt_revision DESC' ];
345
346 return $db->select( 'revtag', $fields, $conds, __METHOD__, $options );
347 }
348
350 public function getTranslationPages(): array {
351 $mwServices = MediaWikiServices::getInstance();
352 $knownLanguageCodes = $this->getMessageGroup()->getTranslatableLanguages()
353 ?? TranslateUtils::getLanguageNames( null );
354
355 $prefixedDbTitleKey = $this->getTitle()->getDBkey() . '/';
356 $baseNamespace = $this->getTitle()->getNamespace();
357
358 // Build a link batch query for all translation pages
359 $linkBatch = $mwServices->getLinkBatchFactory()->newLinkBatch();
360 foreach ( array_keys( $knownLanguageCodes ) as $code ) {
361 $linkBatch->add( $baseNamespace, $prefixedDbTitleKey . $code );
362 }
363
364 $translationPages = [];
365 foreach ( $linkBatch->getPageIdentities() as $pageIdentity ) {
366 if ( $pageIdentity->exists() ) {
367 $translationPages[] = Title::castFromPageIdentity( $pageIdentity );
368 }
369 }
370
371 return $translationPages;
372 }
373
375 public function getTranslationUnitPages( ?string $code = null ): array {
376 return $this->getTranslationUnitPagesByTitle( $this->title, $code );
377 }
378
379 public function getTranslationPercentages(): array {
380 // Calculate percentages for the available translations
381 try {
382 $group = $this->getMessageGroup();
383 } catch ( RuntimeException $e ) {
384 return [];
385 }
386
387 if ( !$group ) {
388 return [];
389 }
390
391 $titles = $this->getTranslationPages();
392 $temp = MessageGroupStats::forGroup( $this->getMessageGroupId() );
393 $stats = [];
394
395 foreach ( $titles as $t ) {
396 $handle = new MessageHandle( $t );
397 $code = $handle->getCode();
398
399 // Sometimes we want to display 0.00 for pages for which translation
400 // hasn't started yet.
401 $stats[$code] = 0.00;
402 if ( ( $temp[$code][MessageGroupStats::TOTAL] ?? 0 ) > 0 ) {
403 $total = $temp[$code][MessageGroupStats::TOTAL];
404 $translated = $temp[$code][MessageGroupStats::TRANSLATED];
405 $percentage = $translated / $total;
406 $stats[$code] = sprintf( '%.2f', $percentage );
407 }
408 }
409
410 // Content language is always up-to-date
411 $stats[$this->getSourceLanguageCode()] = 1.00;
412
413 return $stats;
414 }
415
416 public function getTransRev( string $suffix ) {
417 $title = Title::makeTitle( NS_TRANSLATIONS, $suffix );
418
420 $fields = 'rt_value';
421 $conds = [
422 'rt_page' => $title->getArticleID(),
423 'rt_type' => RevTagStore::TRANSVER_PROP,
424 ];
425 $options = [ 'ORDER BY' => 'rt_revision DESC' ];
426
427 return $db->selectField( 'revtag', $fields, $conds, __METHOD__, $options );
428 }
429
430 public function supportsTransclusion(): ?bool {
431 $transclusion = TranslateMetadata::get( $this->getMessageGroupId(), 'transclusion' );
432 if ( $transclusion === false ) {
433 return null;
434 }
435
436 return $transclusion === '1';
437 }
438
439 public function setTransclusion( bool $supportsTransclusion ): void {
441 $this->getMessageGroupId(),
442 'transclusion',
443 $supportsTransclusion ? '1' : '0'
444 );
445 }
446
447 public function getRevisionRecordWithFallback(): ?RevisionRecord {
448 $title = $this->getTitle();
449 $store = MediaWikiServices::getInstance()->getRevisionStore();
450 $revRecord = $store->getRevisionByTitle( $title->getSubpage( $this->targetLanguage ) );
451 if ( $revRecord ) {
452 return $revRecord;
453 }
454
455 // Fetch the source fallback
456 $sourceLanguage = $this->getMessageGroup()->getSourceLanguage();
457 return $store->getRevisionByTitle( $title->getSubpage( $sourceLanguage ) );
458 }
459
461 public function isMoveable(): bool {
462 return $this->getMarkedTag() !== null;
463 }
464
466 public function isDeletable(): bool {
467 return $this->getMarkedTag() !== null;
468 }
469
471 public static function isTranslationPage( Title $title ) {
472 $handle = new MessageHandle( $title );
473 $key = $handle->getKey();
474 $code = $handle->getCode();
475
476 if ( $key === '' || $code === '' ) {
477 return false;
478 }
479
480 $codes = MediaWikiServices::getInstance()->getLanguageNameUtils()->getLanguageNames();
481 global $wgTranslateDocumentationLanguageCode;
482 unset( $codes[$wgTranslateDocumentationLanguageCode] );
483
484 if ( !isset( $codes[$code] ) ) {
485 return false;
486 }
487
488 $newtitle = self::changeTitleText( $title, $key );
489
490 if ( !$newtitle ) {
491 return false;
492 }
493
494 $page = self::newFromTitle( $newtitle );
495
496 if ( $page->getMarkedTag() === null ) {
497 return false;
498 }
499
500 $page->targetLanguage = $code;
501
502 return $page;
503 }
504
505 private static function changeTitleText( Title $title, string $text ): ?Title {
506 return Title::makeTitleSafe( $title->getNamespace(), $text );
507 }
508
510 public static function parseTranslationUnit( LinkTarget $translationUnit ): array {
511 // Format is Translations:SourcePageNamespace:SourcePageName/SectionName/LanguageCode.
512 // We will drop the namespace immediately here.
513 $parts = explode( '/', $translationUnit->getText() );
514
515 // LanguageCode and SectionName are guaranteed to not have '/'.
516 $language = array_pop( $parts );
517 $section = array_pop( $parts );
518 $sourcepage = implode( '/', $parts );
519
520 return [
521 'sourcepage' => $sourcepage,
522 'section' => $section,
523 'language' => $language
524 ];
525 }
526
527 public static function isSourcePage( Title $title ): bool {
528 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
529 $cacheKey = $cache->makeKey( 'pagetranslation', 'sourcepages' );
530
531 $translatablePageIds = $cache->getWithSetCallback(
532 $cacheKey,
533 $cache::TTL_HOUR * 2,
534 static function ( $oldValue, &$ttl, array &$setOpts ) {
535 $dbr = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_REPLICA );
536 $setOpts += Database::getCacheSetOptions( $dbr );
537
538 return RevTagStore::getTranslatableBundleIds(
539 RevTagStore::TP_MARK_TAG, RevTagStore::TP_READY_TAG
540 );
541 },
542 [
543 'checkKeys' => [ $cacheKey ],
544 'pcTTL' => $cache::TTL_PROC_SHORT,
545 'pcGroup' => __CLASS__ . ':1'
546 ]
547 );
548
549 return in_array( $title->getArticleID(), $translatablePageIds );
550 }
551
553 public static function clearSourcePageCache(): void {
554 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
555 $cache->touchCheckKey( $cache->makeKey( 'pagetranslation', 'sourcepages' ) );
556 }
557}
558
559class_alias( TranslatablePage::class, 'TranslatablePage' );
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'), MessageIndex::singleton());}, 'Translate:GroupSynchronizationCache'=> static function(MediaWikiServices $services):GroupSynchronizationCache { return new GroupSynchronizationCache( $services->get( 'Translate:PersistentCache'));}, 'Translate:MessageBundleStore'=> static function(MediaWikiServices $services):MessageBundleStore { return new MessageBundleStore(new RevTagStore(), $services->getJobQueueGroup(), $services->getLanguageNameUtils(), $services->get( 'Translate:MessageIndex'));}, 'Translate:MessageGroupReview'=> static function(MediaWikiServices $services):MessageGroupReview { return new MessageGroupReview($services->getDBLoadBalancer(), $services->getHookContainer());}, '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: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:SubpageListBuilder'=> static function(MediaWikiServices $services):SubpageListBuilder { return new SubpageListBuilder($services->get( 'Translate:TranslatableBundleFactory'), $services->getLinkBatchFactory());}, 'Translate:TranslatableBundleFactory'=> static function(MediaWikiServices $services):TranslatableBundleFactory { return new TranslatableBundleFactory($services->get( 'Translate:TranslatablePageStore'), $services->get( 'Translate:MessageBundleStore'));}, '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: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(), new RevTagStore(), $services->getDBLoadBalancer());}, 'Translate:TranslationStashReader'=> static function(MediaWikiServices $services):TranslationStashReader { $db=$services->getDBLoadBalancer() ->getConnectionRef(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());}, '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
Class to manage revision tags for translatable bundles.
Translatable bundle represents a message group where its translatable content is defined on a wiki pa...
Mixed bag of methods related to translatable pages.
addReadyTag(int $revision)
Adds a tag which indicates that this page source is ready for marking for translation.
getMarkedTag()
Returns the latest revision which has marked tag, if any.
getText()
Returns the text for this translatable page.
hasPageDisplayTitle()
Check whether title is marked for translation.
static getMessageGroupIdFromTitle(Title $title)
Constructs MessageGroup id for any title.
static newFromRevision(Title $title, int $revision)
Constructs a translatable page from given revision.
getReadyTag()
Returns the latest revision which has ready tag, if any.
addMarkedTag(int $revision, array $value=null)
Adds a tag which indicates that this page is suitable for translation.
getRevision()
Revision is null if object was constructed using newFromText.
static newFromTitle(Title $title)
Constructs a translatable page from title.
getSourceLanguageCode()
Returns the source language of this translatable page.
unmarkTranslatablePage()
Removes all page translation feature data from the database.
getTranslationUrl( $code=false)
Produces a link to translation view of a translation page.
getMessageGroup()
Returns MessageGroup used for translating this page.
const METADATA_KEYS
List of keys in the metadata table that need to be handled for moves and deletions @phpcs-require-sor...
static parseTranslationUnit(LinkTarget $translationUnit)
Helper to guess translation page from translation unit.
static newFromText(Title $title, string $text)
Constructs a translatable page from given text.
getPageDisplayTitle(string $languageCode)
Get translated page title.
Minimal service container.
Definition Services.php:38
This class abstract MessageGroup statistics calculation and storing.
Factory class for accessing message groups individually by id or all of them as an list.
Class for pointing to messages, like Title class is for titles.
Essentially random collection of helper functions, similar to GlobalFunctions.php.
static getSafeReadDB()
Get a DB handle suitable for read and read-for-write cases.
Wraps the translatable page sections into a message group.