Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.78% covered (danger)
21.78%
22 / 101
22.22% covered (danger)
22.22%
2 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslatablePageStore
21.78% covered (danger)
21.78%
22 / 101
22.22% covered (danger)
22.22%
2 / 9
232.04
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 move
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 handleNullRevisionInsert
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 delete
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 unmark
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 performStatusUpdate
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 updateStatus
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 moveMetadata
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 removeFromAggregateGroups
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\MessageGroupProcessing;
5
6use AggregateMessageGroup;
7use DeferredUpdates;
8use IDBAccessObject;
9use InvalidArgumentException;
10use JobQueueGroup;
11use MediaWiki\Extension\Translate\MessageLoading\MessageIndex;
12use MediaWiki\Extension\Translate\MessageProcessing\MessageGroupMetadata;
13use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage;
14use MediaWiki\Extension\Translate\PageTranslation\TranslatablePageParser;
15use MediaWiki\Extension\Translate\PageTranslation\UpdateTranslatablePageJob;
16use MediaWiki\Page\PageIdentity;
17use MediaWiki\Revision\RevisionRecord;
18use MediaWiki\Revision\SlotRecord;
19use MediaWiki\Title\Title;
20use RuntimeException;
21use TextContent;
22use Wikimedia\Rdbms\ILoadBalancer;
23
24/**
25 * @author Abijeet Patro
26 * @author Niklas Laxström
27 * @since 2022.03
28 * @license GPL-2.0-or-later
29 */
30class TranslatablePageStore implements TranslatableBundleStore {
31    private MessageIndex $messageIndex;
32    private JobQueueGroup $jobQueue;
33    private RevTagStore $revTagStore;
34    private ILoadBalancer $loadBalancer;
35    private TranslatableBundleStatusStore $translatableBundleStatusStore;
36    private TranslatablePageParser $translatablePageParser;
37    private MessageGroupMetadata $messageGroupMetadata;
38
39    public function __construct(
40        MessageIndex $messageIndex,
41        JobQueueGroup $jobQueue,
42        RevTagStore $revTagStore,
43        ILoadBalancer $loadBalancer,
44        TranslatableBundleStatusStore $translatableBundleStatusStore,
45        TranslatablePageParser $translatablePageParser,
46        MessageGroupMetadata $messageGroupMetadata
47    ) {
48        $this->messageIndex = $messageIndex;
49        $this->jobQueue = $jobQueue;
50        $this->revTagStore = $revTagStore;
51        $this->loadBalancer = $loadBalancer;
52        $this->translatableBundleStatusStore = $translatableBundleStatusStore;
53        $this->translatablePageParser = $translatablePageParser;
54        $this->messageGroupMetadata = $messageGroupMetadata;
55    }
56
57    public function move( Title $oldName, Title $newName ): void {
58        $oldTranslatablePage = TranslatablePage::newFromTitle( $oldName );
59        $newTranslatablePage = TranslatablePage::newFromTitle( $newName );
60        $oldGroupId = $oldTranslatablePage->getMessageGroupId();
61        $newGroupId = $newTranslatablePage->getMessageGroupId();
62
63        $this->messageGroupMetadata->moveMetadata( $oldGroupId, $newGroupId, TranslatablePage::METADATA_KEYS );
64
65        $this->moveMetadata( $oldGroupId, $newGroupId );
66
67        TranslatablePage::clearSourcePageCache();
68
69        // Re-render the pages to get everything in sync
70        MessageGroups::singleton()->recache();
71        // Update message index now so that, when after this job the MoveTranslationUnits hook
72        // runs in deferred updates, it will not run MessageIndexRebuildJob (T175834).
73        $this->messageIndex->rebuild();
74
75        $job = UpdateTranslatablePageJob::newFromPage( TranslatablePage::newFromTitle( $newName ) );
76        $this->jobQueue->push( $job );
77    }
78
79    public function handleNullRevisionInsert( TranslatableBundle $bundle, RevisionRecord $revision ): void {
80        if ( !$bundle instanceof TranslatablePage ) {
81            throw new InvalidArgumentException(
82                'Expected $bundle to be of type TranslatablePage, got ' . get_class( $bundle )
83            );
84        }
85
86        $pageContent = $revision->getContent( SlotRecord::MAIN );
87        if ( !$pageContent instanceof TextContent ) {
88            throw new RuntimeException( "Translatable page {$bundle->getTitle()} has non-textual content." );
89        }
90
91        // Check if the revision still has the <translate> tag
92        $pageText = $pageContent->getText();
93        if ( $this->translatablePageParser->containsMarkup( $pageText ) ) {
94            $this->revTagStore->replaceTag( $bundle->getTitle(), RevTagStore::TP_READY_TAG, $revision->getId() );
95            TranslatablePage::clearSourcePageCache();
96        }
97    }
98
99    /** Delete a translatable page */
100    public function delete( Title $title ): void {
101        $dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
102        $dbw->delete( 'translate_sections', [ 'trs_page' => $title->getArticleID() ], __METHOD__ );
103
104        $this->unmark( $title );
105    }
106
107    /** Unmark a translatable page */
108    public function unmark( PageIdentity $title ): void {
109        $translatablePage = TranslatablePage::newFromTitle( $title );
110        $translatablePage->getTranslationPercentages();
111        foreach ( $translatablePage->getTranslationPages() as $page ) {
112            $page->invalidateCache();
113        }
114
115        $groupId = $translatablePage->getMessageGroupId();
116        $this->messageGroupMetadata->clearMetadata( $groupId, TranslatablePage::METADATA_KEYS );
117        $this->removeFromAggregateGroups( $groupId );
118
119        // Remove tags after all group related work is done in order to avoid breaking calls to
120        // TranslatablePage::getMessageGroup incase the group cache is not populated
121        $this->revTagStore->removeTags( $title, RevTagStore::TP_MARK_TAG, RevTagStore::TP_READY_TAG );
122        $this->translatableBundleStatusStore->removeStatus( $title->getId() );
123
124        MessageGroups::singleton()->recache();
125        $this->messageIndex->rebuild();
126
127        TranslatablePage::clearSourcePageCache();
128        $translatablePage->getTitle()->invalidateCache();
129    }
130
131    /** Queues an update for the status of the translatable page. Update is not done immediately. */
132    public function performStatusUpdate( Title $title ): void {
133        DeferredUpdates::addCallableUpdate(
134            function () use ( $title ) {
135                $this->updateStatus( $title );
136            }
137        );
138    }
139
140    /** @internal public only for testing. Use ::performStatusUpdate instead */
141    public function updateStatus( Title $title ): ?TranslatableBundleStatus {
142        $revTags = $this->revTagStore->getLatestRevisionsForTags(
143            $title,
144            RevTagStore::TP_MARK_TAG,
145            RevTagStore::TP_READY_TAG
146        );
147
148        $status = TranslatablePage::determineStatus(
149            $revTags[RevTagStore::TP_READY_TAG] ?? null,
150            $revTags[RevTagStore::TP_MARK_TAG] ?? null,
151            $title->getLatestRevID( IDBAccessObject::READ_LATEST )
152        );
153
154        if ( $status ) {
155            $this->translatableBundleStatusStore->setStatus(
156                $title, $status, TranslatablePage::class
157            );
158        }
159
160        return $status;
161    }
162
163    private function moveMetadata( string $oldGroupId, string $newGroupId ): void {
164        // Make the changes in aggregate groups metadata, if present in any of them.
165        $aggregateGroups = MessageGroups::getGroupsByType( AggregateMessageGroup::class );
166        $this->messageGroupMetadata->preloadGroups( array_keys( $aggregateGroups ), __METHOD__ );
167
168        foreach ( $aggregateGroups as $id => $group ) {
169            $subgroups = $this->messageGroupMetadata->get( $id, 'subgroups' );
170            if ( $subgroups === false ) {
171                continue;
172            }
173
174            $subgroups = explode( ',', $subgroups );
175            $subgroups = array_flip( $subgroups );
176            if ( isset( $subgroups[$oldGroupId] ) ) {
177                $subgroups[$newGroupId] = $subgroups[$oldGroupId];
178                unset( $subgroups[$oldGroupId] );
179                $subgroups = array_flip( $subgroups );
180                $this->messageGroupMetadata->set(
181                    $group->getId(),
182                    'subgroups',
183                    implode( ',', $subgroups )
184                );
185            }
186        }
187
188        // Move discouraged status
189        $priority = MessageGroups::getPriority( $oldGroupId );
190        if ( $priority !== '' ) {
191            MessageGroups::setPriority( $newGroupId, $priority );
192            MessageGroups::setPriority( $oldGroupId, '' );
193        }
194    }
195
196    private function removeFromAggregateGroups( string $groupId ): void {
197        // remove the page from aggregate groups, if present in any of them.
198        $aggregateGroups = MessageGroups::getGroupsByType( AggregateMessageGroup::class );
199        $this->messageGroupMetadata->preloadGroups( array_keys( $aggregateGroups ), __METHOD__ );
200        foreach ( $aggregateGroups as $group ) {
201            $subgroups = $this->messageGroupMetadata->get( $group->getId(), 'subgroups' );
202            if ( $subgroups !== false ) {
203                $subgroups = explode( ',', $subgroups );
204                $subgroups = array_flip( $subgroups );
205                if ( isset( $subgroups[$groupId] ) ) {
206                    unset( $subgroups[$groupId] );
207                    $subgroups = array_flip( $subgroups );
208                    $this->messageGroupMetadata->set(
209                        $group->getId(),
210                        'subgroups',
211                        implode( ',', $subgroups )
212                    );
213                }
214            }
215        }
216    }
217}