Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 109
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SyncTranslatableBundleStatusMaintenanceScript
0.00% covered (danger)
0.00%
0 / 109
0.00% covered (danger)
0.00%
0 / 11
812
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getUpdateKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doDBUpdates
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 fetchTranslatableBundles
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 identifyDifferences
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
42
 determineStatus
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getTranslatableBundle
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 syncStatus
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 removeStatus
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 outputDifferences
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 outputBundleInfo
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Diagnostics;
5
6use MediaWiki\Extension\Translate\MessageGroupProcessing\RevTagStore;
7use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundle;
8use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundleFactory;
9use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundleStatus;
10use MediaWiki\Extension\Translate\PageTranslation\PageTranslationSpecialPage;
11use MediaWiki\Extension\Translate\PageTranslation\TranslatablePage;
12use MediaWiki\Extension\Translate\PageTranslation\TranslatablePageStatus;
13use MediaWiki\Extension\Translate\Services;
14use MediaWiki\Maintenance\LoggedUpdateMaintenance;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Title\Title;
17use RuntimeException;
18
19/**
20 * Script to identify the status of the translatable bundles in the rev_tag table
21 * and update them in the translatable_bundles page.
22 */
23class SyncTranslatableBundleStatusMaintenanceScript extends LoggedUpdateMaintenance {
24    private const INDENT_SPACER = '  ';
25    private const STATUS_NAME_MAPPING = [
26        TranslatablePageStatus::PROPOSED => 'Proposed',
27        TranslatablePageStatus::ACTIVE => 'Active',
28        TranslatablePageStatus::OUTDATED => 'Outdated',
29        TranslatablePageStatus::BROKEN => 'Broken'
30    ];
31    private const SYNC_BATCH_STATUS = 15;
32    private const SCRIPT_VERSION = 1;
33
34    public function __construct() {
35        parent::__construct();
36        $this->addDescription( 'Sync translatable bundle status with values from the rev_tag table' );
37        $this->requireExtension( 'Translate' );
38    }
39
40    /** @inheritDoc */
41    protected function getUpdateKey(): string {
42        return __CLASS__ . '_v' . self::SCRIPT_VERSION;
43    }
44
45    /** @inheritDoc */
46    protected function doDBUpdates(): bool {
47        $this->output( "... Syncing translatable bundle status ...\n\n" );
48
49        $this->output( "Fetching translatable bundles and their statues\n\n" );
50        $translatableBundles = $this->fetchTranslatableBundles();
51        $translatableBundleStatuses = Services::getInstance()
52            ->getTranslatableBundleStatusStore()
53            ->getAllWithStatus();
54
55        $differences = $this->identifyDifferences( $translatableBundles, $translatableBundleStatuses );
56
57        $this->outputDifferences( $differences['missing'], 'Missing' );
58        $this->outputDifferences( $differences['incorrect'], 'Incorrect' );
59        $this->outputDifferences( $differences['extra'], 'Extra' );
60
61        $this->output( "\nSynchronizing...\n\n" );
62
63        $this->syncStatus( $differences['missing'], 'Missing' );
64        $this->syncStatus( $differences['incorrect'], 'Incorrect' );
65        $this->removeStatus( $differences['extra'] );
66
67        $this->output( "\n...Done syncing translatable status...\n" );
68
69        return true;
70    }
71
72    private function fetchTranslatableBundles(): array {
73        // Fetch the translatable pages
74        $resultWrapper = PageTranslationSpecialPage::loadPagesFromDB();
75        return PageTranslationSpecialPage::buildPageArray( $resultWrapper );
76
77        // TODO: Fetch message bundles
78    }
79
80    /**
81     * This function compares the bundles and bundles statuses to identify,
82     * - Missing bundles in translatable statuses
83     * - Extra bundles in translatable statuses
84     * - Incorrect statuses in translatable statuses
85     * The data from the rev_tag table is treated as the source of truth.
86     */
87    private function identifyDifferences(
88        array $translatableBundles,
89        array $translatableBundleStatuses
90    ): array {
91        $result = [
92            'missing' => [],
93            'extra' => [],
94            'incorrect' => []
95        ];
96
97        $bundleFactory = Services::getInstance()->getTranslatableBundleFactory();
98        foreach ( $translatableBundles as $bundleId => $bundleInfo ) {
99            $title = $bundleInfo['title'];
100            $bundle = $this->getTranslatableBundle( $bundleFactory, $title );
101            $bundleStatus = $this->determineStatus( $bundle, $bundleInfo );
102
103            if ( !$bundleStatus ) {
104                // Ignore pages for which status could not be determined.
105                continue;
106            }
107
108            if ( !isset( $translatableBundleStatuses[$bundleId] ) ) {
109                // Identify missing records in translatable_bundles
110                $response = [
111                    'title' => $title,
112                    'status' => $bundleStatus,
113                    'page_id' => $bundleId
114                ];
115                $result['missing'][] = $response;
116            } elseif ( !$bundleStatus->isEqual( $translatableBundleStatuses[$bundleId] ) ) {
117                // Identify incorrect records in translatable_bundles
118                $response = [
119                    'title' => $title,
120                    'status' => $bundleStatus,
121                    'page_id' => $bundleId
122                ];
123                $result['incorrect'][] = $response;
124            }
125        }
126
127        // Identify extra records in translatable_bundles
128        $extraStatusBundleIds = array_diff_key( $translatableBundleStatuses, $translatableBundles );
129        foreach ( $extraStatusBundleIds as $extraBundleId => $statusId ) {
130            $title = Title::newFromID( $extraBundleId );
131            $response = [
132                'title' => $title,
133                // TODO: This should be determined dynamically when we start supporting MessageBundles
134                'status' => new TranslatablePageStatus( $statusId ),
135                'page_id' => $extraBundleId
136            ];
137
138            $result['extra'][] = $response;
139        }
140
141        return $result;
142    }
143
144    private function determineStatus(
145        TranslatableBundle $bundle,
146        array $bundleInfo
147    ): ?TranslatableBundleStatus {
148        if ( $bundle instanceof TranslatablePage ) {
149            return $bundle::determineStatus(
150                $bundleInfo[RevTagStore::TP_READY_TAG] ?? null,
151                $bundleInfo[RevTagStore::TP_MARK_TAG] ?? null,
152                $bundleInfo['latest']
153            );
154        } else {
155            // TODO: Add determineStatus as a function to TranslatableBundle abstract class and then
156            // implement it in MessageBundle. It may not take the same set of parameters though.
157            throw new RuntimeException( 'Method determineStatus not implemented for MessageBundle' );
158        }
159    }
160
161    private function getTranslatableBundle(
162        TranslatableBundleFactory $tbFactory,
163        Title $title
164    ): TranslatableBundle {
165        $bundle = $tbFactory->getBundle( $title );
166        if ( $bundle ) {
167            return $bundle;
168        }
169
170        // This page has a revision tag, lets assume that this is a translatable page
171        // Broken pages for example will not be in the cache
172        // TODO: Is there a better way to handle this?
173        return TranslatablePage::newFromTitle( $title );
174    }
175
176    private function syncStatus( array $bundlesWithDifference, string $differenceType ): void {
177        if ( !$bundlesWithDifference ) {
178            $this->output( "No \"$differenceType\" bundle statuses\n" );
179            return;
180        }
181
182        $this->output( "Syncing \"$differenceType\" bundle statuses\n" );
183
184        $bundleFactory = Services::getInstance()->getTranslatableBundleFactory();
185        $tpStore = Services::getInstance()->getTranslatablePageStore();
186        $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
187
188        $bundleCountProcessed = 0;
189        foreach ( $bundlesWithDifference as $bundleInfo ) {
190            $pageId = $bundleInfo['page_id'];
191            $bundleTitle = $bundleInfo['title'] ?? null;
192            if ( !$bundleTitle instanceof Title ) {
193                $this->fatalError( "No title for page with id: $pageId \n" );
194            }
195
196            $bundle = $this->getTranslatableBundle( $bundleFactory, $bundleTitle );
197            if ( $bundle instanceof TranslatablePage ) {
198                // TODO: Eventually we want to add this method to the TranslatableBundleStore
199                // and then call updateStatus on it. After that we won't have to check for the
200                // type of the translatable bundle.
201                $tpStore->updateStatus( $bundleTitle );
202            }
203
204            if ( $bundleCountProcessed % self::SYNC_BATCH_STATUS === 0 ) {
205                $lbFactory->waitForReplication();
206            }
207
208            ++$bundleCountProcessed;
209        }
210
211        $this->output( "Completed sync for \"$differenceType\" bundle statuses\n" );
212    }
213
214    private function removeStatus( array $extraBundleInfo ): void {
215        if ( !$extraBundleInfo ) {
216            $this->output( "No \"extra\" bundle statuses\n" );
217            return;
218        }
219        $this->output( "Removing \"extra\" bundle statuses\n" );
220        $pageIds = [];
221        foreach ( $extraBundleInfo as $bundleInfo ) {
222            $pageIds[] = $bundleInfo['page_id'];
223        }
224
225        $tbStatusStore = Services::getInstance()->getTranslatableBundleStatusStore();
226        $tbStatusStore->removeStatus( ...$pageIds );
227        $this->output( "Removed \"extra\" bundle statuses\n" );
228    }
229
230    private function outputDifferences( array $bundlesWithDifference, string $differenceType ): void {
231        if ( $bundlesWithDifference ) {
232            $this->output( "$differenceType translatable bundles statuses:\n" );
233            foreach ( $bundlesWithDifference as $bundle ) {
234                $this->outputBundleInfo( $bundle );
235            }
236        } else {
237            $this->output( "No \"$differenceType\" translatable bundle statuses found!\n" );
238        }
239    }
240
241    private function outputBundleInfo( array $bundle ): void {
242        $titlePrefixedDbKey = $bundle['title'] instanceof Title ?
243            $bundle['title']->getPrefixedDBkey() : '<Title not available>';
244        $id = str_pad( (string)$bundle['page_id'], 7, ' ', STR_PAD_LEFT );
245        $status = self::STATUS_NAME_MAPPING[$bundle['status']->getId()];
246        $this->output( self::INDENT_SPACER . "* [Id: $id$titlePrefixedDbKey$status\n" );
247    }
248}