Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.52% covered (warning)
66.52%
147 / 221
48.57% covered (danger)
48.57%
17 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslatablePage
66.82% covered (warning)
66.82%
147 / 220
48.57% covered (danger)
48.57%
17 / 35
231.00
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 newFromText
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 newFromRevision
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 newFromTitle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageIdentity
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getText
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
7.66
 getRevision
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSourceLanguageCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMessageGroupId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMessageGroupIdFromTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMessageGroup
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 hasPageDisplayTitle
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getPageDisplayTitle
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getStrippedSourcePageText
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getTranslationPageFromTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getTranslationPage
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 addMarkedTag
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addReadyTag
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getMarkedTag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReadyTag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTranslationUrl
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getTranslationPages
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 getTranslationUnitPages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTranslationPercentages
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 supportsTransclusion
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getRevisionRecordWithFallback
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 isMoveable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isDeletable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isTranslationPage
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 parseTranslationUnit
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 isSourcePage
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
2
 clearSourcePageCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 determineStatus
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 getCacheValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\PageTranslation;
5
6use LogicException;
7use MediaWiki\Content\TextContent;
8use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
9use MediaWiki\Extension\Translate\MessageGroupProcessing\RevTagStore;
10use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundle;
11use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
12use MediaWiki\Extension\Translate\Services;
13use MediaWiki\Extension\Translate\Statistics\MessageGroupStats;
14use MediaWiki\Extension\Translate\Utilities\Utilities;
15use MediaWiki\Languages\LanguageNameUtils;
16use MediaWiki\Linker\LinkTarget;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Page\PageIdentity;
19use MediaWiki\Page\PageReference;
20use MediaWiki\Revision\RevisionRecord;
21use MediaWiki\Revision\SlotRecord;
22use MediaWiki\SpecialPage\SpecialPage;
23use MediaWiki\Title\Title;
24use RuntimeException;
25use Wikimedia\Rdbms\Database;
26use Wikimedia\Rdbms\IDBAccessObject;
27use WikiPageMessageGroup;
28
29/**
30 * Mixed bag of methods related to translatable pages.
31 * @author Niklas Laxström
32 * @license GPL-2.0-or-later
33 * @ingroup PageTranslation
34 */
35class TranslatablePage extends TranslatableBundle {
36    /**
37     * List of keys in the metadata table that need to be handled for moves and deletions
38     * @phpcs-require-sorted-array
39     */
40    public const METADATA_KEYS = [
41        'maxid',
42        'priorityforce',
43        'prioritylangs',
44        'priorityreason',
45        'transclusion',
46        'version'
47    ];
48    /** @var string Name of the section which contains the translated page title. */
49    public const DISPLAY_TITLE_UNIT_ID = 'Page display title';
50
51    protected PageIdentity $title;
52    protected RevTagStore $revTagStore;
53    /** @var ?string Text contents of the page. */
54    protected $text;
55    /** @var ?int Revision of the page, if applicable. */
56    protected $revision;
57    /** @var string From which source this object was constructed: text, revision or title */
58    protected $source;
59    /** @var ?bool Whether the title should be translated */
60    protected $pageDisplayTitle;
61    /** @var ?string */
62    private $targetLanguage;
63
64    protected function __construct( PageIdentity $title ) {
65        $this->title = $title;
66        $this->revTagStore = Services::getInstance()->getRevTagStore();
67    }
68
69    /**
70     * Constructs a translatable page from given text.
71     * Some functions will fail unless you set revision
72     * parameter manually.
73     */
74    public static function newFromText( Title $title, string $text ): self {
75        $obj = new self( $title );
76        $obj->text = $text;
77        $obj->source = 'text';
78
79        return $obj;
80    }
81
82    /**
83     * Constructs a translatable page from given revision.
84     * The revision must belong to the title given or unspecified
85     * behavior will happen.
86     */
87    public static function newFromRevision( PageIdentity $title, int $revision ): self {
88        $rev = MediaWikiServices::getInstance()
89            ->getRevisionLookup()
90            ->getRevisionByTitle( $title, $revision );
91        if ( $rev === null ) {
92            throw new RuntimeException( 'Revision is null' );
93        }
94
95        $obj = new self( $title );
96        $obj->source = 'revision';
97        $obj->revision = $revision;
98
99        return $obj;
100    }
101
102    /**
103     * Constructs a translatable page from title.
104     * The text of last marked revision is loaded when needed.
105     */
106    public static function newFromTitle( PageIdentity $title ): self {
107        $obj = new self( $title );
108        $obj->source = 'title';
109
110        return $obj;
111    }
112
113    /** @inheritDoc */
114    public function getTitle(): Title {
115        return Title::castFromPageIdentity( $this->title );
116    }
117
118    public function getPageIdentity(): PageIdentity {
119        return $this->title;
120    }
121
122    /** Returns the text for this translatable page. */
123    public function getText(): string {
124        if ( $this->text !== null ) {
125            return $this->text;
126        }
127
128        if ( $this->source === 'title' ) {
129            $revision = $this->getMarkedTag();
130            if ( !is_int( $revision ) ) {
131                throw new LogicException(
132                    "Trying to load a text for {$this->getPageIdentity()} which is not marked for translation"
133                );
134            }
135            $this->revision = $revision;
136        }
137
138        $flags = Utilities::shouldReadFromPrimary()
139            ? IDBAccessObject::READ_LATEST
140            : IDBAccessObject::READ_NORMAL;
141        $rev = MediaWikiServices::getInstance()
142            ->getRevisionLookup()
143            ->getRevisionByTitle( $this->getPageIdentity(), $this->revision, $flags );
144        $content = $rev->getContent( SlotRecord::MAIN );
145        $text = ( $content instanceof TextContent ) ? $content->getText() : null;
146
147        if ( !is_string( $text ) ) {
148            throw new RuntimeException( "Failed to load text for {$this->getPageIdentity()}" );
149        }
150
151        $this->text = $text;
152
153        return $this->text;
154    }
155
156    /**
157     * Revision is null if object was constructed using newFromText.
158     * @return null|int
159     */
160    public function getRevision(): ?int {
161        return $this->revision;
162    }
163
164    /**
165     * Returns the source language of this translatable page. In other words
166     * the language in which the page without language code is written.
167     * @since 2013-01-28
168     */
169    public function getSourceLanguageCode(): string {
170        return $this->getTitle()->getPageLanguage()->getCode();
171    }
172
173    /** @inheritDoc */
174    public function getMessageGroupId(): string {
175        return self::getMessageGroupIdFromTitle( $this->getPageIdentity() );
176    }
177
178    /** Constructs MessageGroup id for any title. */
179    public static function getMessageGroupIdFromTitle( PageReference $page ): string {
180        return 'page-' . MediaWikiServices::getInstance()->getTitleFormatter()->getPrefixedText( $page );
181    }
182
183    /**
184     * Returns MessageGroup used for translating this page. It may still be empty
185     * if the page has not been ever marked.
186     */
187    public function getMessageGroup(): ?WikiPageMessageGroup {
188        $groupId = $this->getMessageGroupId();
189        $group = MessageGroups::getGroup( $groupId );
190        if ( !$group || $group instanceof WikiPageMessageGroup ) {
191            return $group;
192        }
193
194        throw new RuntimeException(
195            "Expected $groupId to be of type WikiPageMessageGroup; got " .
196            get_class( $group )
197        );
198    }
199
200    /** Check whether title is marked for translation */
201    public function hasPageDisplayTitle(): bool {
202        // Cached value
203        if ( $this->pageDisplayTitle !== null ) {
204            return $this->pageDisplayTitle;
205        }
206
207        // Check if title section exists in list of sections
208        $factory = Services::getInstance()->getTranslationUnitStoreFactory();
209        $store = $factory->getReader( $this->getPageIdentity() );
210        $this->pageDisplayTitle = in_array( self::DISPLAY_TITLE_UNIT_ID, $store->getNames() );
211
212        return $this->pageDisplayTitle;
213    }
214
215    /** Get translated page title. */
216    public function getPageDisplayTitle( string $languageCode ): ?string {
217        // Return null if title not marked for translation
218        if ( !$this->hasPageDisplayTitle() ) {
219            return null;
220        }
221
222        // Display title from DB
223        $section = str_replace( ' ', '_', self::DISPLAY_TITLE_UNIT_ID );
224        $page = MediaWikiServices::getInstance()->getTitleFormatter()->getPrefixedDBkey( $this->getPageIdentity() );
225
226        try {
227            $group = $this->getMessageGroup();
228        } catch ( RuntimeException $e ) {
229            return null;
230        }
231
232        // Sanity check, seems to happen during moves
233        if ( !$group ) {
234            return null;
235        }
236
237        return $group->getMessage( "$page/$section", $languageCode, IDBAccessObject::READ_NORMAL );
238    }
239
240    public function getStrippedSourcePageText(): string {
241        $parser = Services::getInstance()->getTranslatablePageParser();
242        $text = $parser->cleanupTags( $this->getText() );
243        $text = preg_replace( '~<languages\s*/>\n?~s', '', $text );
244
245        return $text;
246    }
247
248    public static function getTranslationPageFromTitle( Title $title ): ?TranslationPage {
249        $self = self::isTranslationPage( $title );
250        return $self ? $self->getTranslationPage( $self->targetLanguage ) : null;
251    }
252
253    public function getTranslationPage( string $targetLanguage ): TranslationPage {
254        $mwServices = MediaWikiServices::getInstance();
255        $config = $mwServices->getMainConfig();
256        $services = Services::getInstance();
257        $parser = $services->getTranslatablePageParser();
258        $parserOutput = $parser->parse( $this->getText() );
259        $pageVersion = (int)$services->getMessageGroupMetadata()
260            ->get( $this->getMessageGroupId(), 'version' );
261        $wrapUntranslated = $pageVersion >= 2;
262        $languageFactory = $mwServices->getLanguageFactory();
263
264        return new TranslationPage(
265            $parserOutput,
266            $this->getMessageGroup(),
267            $languageFactory->getLanguage( $targetLanguage ),
268            $languageFactory->getLanguage( $this->getSourceLanguageCode() ),
269            $config->get( 'TranslateKeepOutdatedTranslations' ),
270            $wrapUntranslated,
271            $this->getTitle()
272        );
273    }
274
275    /** Adds a tag which indicates that this page is suitable for translation. */
276    public function addMarkedTag( int $revision, ?array $value = null ) {
277        $this->revTagStore->replaceTag( $this->getPageIdentity(), RevTagStore::TP_MARK_TAG, $revision, $value );
278        self::clearSourcePageCache();
279    }
280
281    /** Adds a tag which indicates that this page source is ready for marking for translation. */
282    public function addReadyTag( int $revision ): void {
283        $this->revTagStore->replaceTag( $this->getPageIdentity(), RevTagStore::TP_READY_TAG, $revision );
284        if ( !self::isSourcePage( $this->getPageIdentity() ) ) {
285            self::clearSourcePageCache();
286        }
287    }
288
289    /** Returns the latest revision which has marked tag, if any. */
290    public function getMarkedTag(): ?int {
291        return $this->revTagStore->getLatestRevisionWithTag( $this->getPageIdentity(), RevTagStore::TP_MARK_TAG );
292    }
293
294    /** Returns the latest revision which has ready tag, if any. */
295    public function getReadyTag(): ?int {
296        return $this->revTagStore->getLatestRevisionWithTag( $this->getPageIdentity(), RevTagStore::TP_READY_TAG );
297    }
298
299    /**
300     * Produces a link to translation view of a translation page.
301     * @param string|bool $code MediaWiki language code. Default: false.
302     * @return string Relative url
303     */
304    public function getTranslationUrl( $code = false ): string {
305        $params = [
306            'group' => $this->getMessageGroupId(),
307            'action' => 'page',
308            'filter' => '',
309            'language' => $code,
310        ];
311
312        $translate = SpecialPage::getTitleFor( 'Translate' );
313
314        return $translate->getLocalURL( $params );
315    }
316
317    /** @inheritDoc */
318    public function getTranslationPages(): array {
319        $mwServices = MediaWikiServices::getInstance();
320
321        $messageGroup = $this->getMessageGroup();
322        $knownLanguageCodes = $messageGroup ? $messageGroup->getTranslatableLanguages() : null;
323        $knownLanguageCodes ??= Utilities::getLanguageNames( LanguageNameUtils::AUTONYMS );
324
325        $prefixedDbTitleKey = $this->getPageIdentity()->getDBkey() . '/';
326        $baseNamespace = $this->getPageIdentity()->getNamespace();
327
328        // Build a link batch query for all translation pages
329        $linkBatch = $mwServices->getLinkBatchFactory()->newLinkBatch();
330        foreach ( array_keys( $knownLanguageCodes ) as $code ) {
331            $linkBatch->add( $baseNamespace, $prefixedDbTitleKey . $code );
332        }
333
334        $translationPages = [];
335        foreach ( $linkBatch->getPageIdentities() as $pageIdentity ) {
336            if ( $pageIdentity->exists() ) {
337                $translationPages[] = Title::castFromPageIdentity( $pageIdentity );
338            }
339        }
340
341        return $translationPages;
342    }
343
344    /** @inheritDoc */
345    public function getTranslationUnitPages( ?string $code = null ): array {
346        return $this->getTranslationUnitPagesByTitle( $this->title, $code );
347    }
348
349    public function getTranslationPercentages(): array {
350        // Calculate percentages for the available translations
351        try {
352            $group = $this->getMessageGroup();
353        } catch ( RuntimeException $e ) {
354            return [];
355        }
356
357        if ( !$group ) {
358            return [];
359        }
360
361        $titles = $this->getTranslationPages();
362        $temp = MessageGroupStats::forGroup( $this->getMessageGroupId(), MessageGroupStats::FLAG_CACHE_ONLY );
363        $stats = [];
364
365        foreach ( $titles as $t ) {
366            $handle = new MessageHandle( $t );
367            $code = $handle->getCode();
368
369            // Sometimes we want to display 0.00 for pages for which translation
370            // hasn't started yet.
371            $stats[$code] = 0.00;
372            if ( ( $temp[$code][MessageGroupStats::TOTAL] ?? 0 ) > 0 ) {
373                $total = $temp[$code][MessageGroupStats::TOTAL];
374                $translated = $temp[$code][MessageGroupStats::TRANSLATED];
375                $percentage = $translated / $total;
376                $stats[$code] = sprintf( '%.2f', $percentage );
377            }
378        }
379
380        // Content language is always up-to-date
381        $stats[$this->getSourceLanguageCode()] = 1.00;
382
383        return $stats;
384    }
385
386    public function supportsTransclusion(): ?bool {
387        $transclusion = Services::getInstance()
388            ->getMessageGroupMetadata()
389            ->get( $this->getMessageGroupId(), 'transclusion' );
390        if ( $transclusion === false ) {
391            return null;
392        }
393
394        return $transclusion === '1';
395    }
396
397    public function getRevisionRecordWithFallback(): ?RevisionRecord {
398        $title = $this->getTitle();
399        $store = MediaWikiServices::getInstance()->getRevisionStore();
400        $revRecord = $store->getRevisionByTitle( $title->getSubpage( $this->targetLanguage ) );
401        if ( $revRecord ) {
402            return $revRecord;
403        }
404
405        // Fetch the source fallback
406        return $store->getRevisionByTitle( $title->getSubpage( $this->getSourceLanguageCode() ) );
407    }
408
409    /** @inheritDoc */
410    public function isMoveable(): bool {
411        return $this->getMarkedTag() !== null;
412    }
413
414    /** @inheritDoc */
415    public function isDeletable(): bool {
416        return $this->getMarkedTag() !== null;
417    }
418
419    /** @return bool|self */
420    public static function isTranslationPage( Title $title ) {
421        $handle = new MessageHandle( $title );
422        if ( !Utilities::isTranslationPage( $handle ) ) {
423            return false;
424        }
425
426        $languageCode = $handle->getCode();
427        $newTitle = $handle->getTitleForBase();
428
429        $page = self::newFromTitle( $newTitle );
430
431        if ( $page->getMarkedTag() === null ) {
432            return false;
433        }
434
435        $page->targetLanguage = $languageCode;
436
437        return $page;
438    }
439
440    /** Helper to guess translation page from translation unit. */
441    public static function parseTranslationUnit( LinkTarget $translationUnit ): array {
442        // Format is Translations:SourcePageNamespace:SourcePageName/SectionName/LanguageCode.
443        // We will drop the namespace immediately here.
444        $parts = explode( '/', $translationUnit->getText() );
445
446        // LanguageCode and SectionName are guaranteed to not have '/'.
447        $language = array_pop( $parts );
448        $section = array_pop( $parts );
449        $sourcepage = implode( '/', $parts );
450
451        return [
452            'sourcepage' => $sourcepage,
453            'section' => $section,
454            'language' => $language
455        ];
456    }
457
458    public static function isSourcePage( PageIdentity $page ): bool {
459        if ( !$page->exists() ) {
460            // No point in loading all translatable pages if the page
461            // doesn’t exist. This also avoids PreconditionExceptions
462            // if $page is a Title pointing to a non-proper page like
463            // a special page.
464            return false;
465        }
466
467        $localCache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
468        $localKey = $localCache->makeKey( 'pagetranslation', 'sourcepages', 'local' );
469        // Store the value in the local cache for a short duration to reduce the number of
470        // times we hit the WAN cache. See: T366455
471        $translatablePageIds = $localCache->getWithSetCallback(
472            $localKey,
473            $localCache::TTL_SECOND * 8,
474            static function () {
475                $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
476                $cacheKey = $cache->makeKey( 'pagetranslation', 'sourcepages' );
477
478                return $cache->getWithSetCallback(
479                    $cacheKey,
480                    $cache::TTL_HOUR * 2,
481                    [ TranslatablePage::class, 'getCacheValue' ],
482                    [
483                        'checkKeys' => [ $cacheKey ],
484                        'pcTTL' => $cache::TTL_PROC_SHORT,
485                        'pcGroup' => __CLASS__ . ':1',
486                        'version' => 3,
487                    ]
488                );
489            },
490            $localCache::READ_LATEST
491        );
492
493        return str_contains( $translatablePageIds, ( ',' . $page->getId() . ',' ) );
494    }
495
496    /** Clears the source page cache */
497    public static function clearSourcePageCache(): void {
498        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
499        $cache->touchCheckKey( $cache->makeKey( 'pagetranslation', 'sourcepages' ) );
500    }
501
502    public static function determineStatus(
503        ?int $readyRevisionId,
504        ?int $markRevisionId,
505        int $latestRevisionId
506    ): ?TranslatablePageStatus {
507        $status = null;
508        if ( $markRevisionId === null ) {
509            // Never marked, check that the latest version is ready
510            if ( $readyRevisionId === $latestRevisionId ) {
511                $status = TranslatablePageStatus::PROPOSED;
512            } else {
513                // Otherwise, ignore such pages
514                return null;
515            }
516        } elseif ( $readyRevisionId === $latestRevisionId ) {
517            if ( $markRevisionId === $readyRevisionId ) {
518                // Marked and latest version is fine
519                $status = TranslatablePageStatus::ACTIVE;
520            } else {
521                $status = TranslatablePageStatus::OUTDATED;
522            }
523        } else {
524            // Marked but latest version is not fine
525            $status = TranslatablePageStatus::BROKEN;
526        }
527
528        return new TranslatablePageStatus( $status );
529    }
530
531    /**
532     * Get list of translatable page ids to be stored in the cache
533     * @internal
534     * @param mixed $oldValue
535     * @param int &$ttl
536     * @param array &$setOpts
537     * @return string
538     */
539    public static function getCacheValue( $oldValue, &$ttl, array &$setOpts ): string {
540        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
541        $setOpts += Database::getCacheSetOptions( $dbr );
542
543        $ids = RevTagStore::getTranslatableBundleIds(
544            RevTagStore::TP_MARK_TAG,
545            RevTagStore::TP_READY_TAG
546        );
547
548        // Adding a comma at the end and beginning so that we can check for page ID
549        // existence with the "," delimiters
550        return ',' . implode( ',', $ids ) . ',';
551    }
552}
553
554class_alias( TranslatablePage::class, 'TranslatablePage' );