Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 127
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
FuzzyTranslationsMaintenanceScript
0.00% covered (danger)
0.00%
0 / 127
0.00% covered (danger)
0.00%
0 / 8
420
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
2
 initServices
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 fuzzyTranslations
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getMessageContentsFromRows
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getPagesForPattern
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 getPagesForUser
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 updateMessage
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Diagnostics;
5
6use CommentStoreComment;
7use ContentHandler;
8use IDBAccessObject;
9use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot;
10use MediaWiki\Extension\Translate\Utilities\BaseMaintenanceScript;
11use MediaWiki\Extension\Translate\Utilities\Utilities;
12use MediaWiki\MediaWikiServices;
13use MediaWiki\Page\WikiPageFactory;
14use MediaWiki\Revision\RevisionStore;
15use MediaWiki\Revision\SlotRecord;
16use MediaWiki\Title\Title;
17use MediaWiki\User\ActorMigration;
18use MediaWiki\User\UserFactory;
19use User;
20use Wikimedia\Rdbms\ILoadBalancer;
21use Wikimedia\Rdbms\IResultWrapper;
22
23/**
24 * @since 2022.01
25 * @license GPL-2.0-or-later
26 * @author Niklas Laxström
27 */
28class FuzzyTranslationsMaintenanceScript extends BaseMaintenanceScript {
29    private ActorMigration $actorMigration;
30    private UserFactory $userFactory;
31    private RevisionStore $revisionStore;
32    private ILoadBalancer $DBLoadBalancer;
33    private WikiPageFactory $wikiPageFactory;
34
35    public function __construct() {
36        parent::__construct();
37        $this->addDescription( 'Fuzzy bot command line script.' );
38        $this->addArg(
39            'arg',
40            'Title pattern or username if user option is provided.'
41        );
42        $this->addOption(
43            'really',
44            '(optional) Really fuzzy, no dry-run'
45        );
46        $this->addOption(
47            'skiplanguages',
48            '(optional) Skip some languages (comma separated)',
49            self::OPTIONAL,
50            self::HAS_ARG
51        );
52        $this->addOption(
53            'comment',
54            '(optional) Comment for updating',
55            self::OPTIONAL,
56            self::HAS_ARG
57        );
58        $this->addOption(
59            'user',
60            '(optional) Fuzzy the translations made by user given as an argument.',
61            self::OPTIONAL,
62            self::NO_ARG
63        );
64        $this->requireExtension( 'Translate' );
65    }
66
67    private function initServices(): void {
68        $mwServices = MediaWikiServices::getInstance();
69        $this->actorMigration = $mwServices->getActorMigration();
70        $this->userFactory = $mwServices->getUserFactory();
71        $this->revisionStore = $mwServices->getRevisionStore();
72        $this->DBLoadBalancer = $mwServices->getDBLoadBalancer();
73        $this->wikiPageFactory = $mwServices->getWikiPageFactory();
74    }
75
76    public function execute(): void {
77        $this->initServices();
78
79        $skipLanguages = [];
80        if ( $this->hasOption( 'skiplanguages' ) ) {
81            $skipLanguages = array_map(
82                'trim',
83                explode( ',', $this->getOption( 'skiplanguages' ) )
84            );
85        }
86
87        if ( $this->hasOption( 'user' ) ) {
88            $user = $this->userFactory->newFromName( $this->getArg( 0 ) );
89            $pages = $this->getPagesForUser( $user, $skipLanguages );
90        } else {
91            $pages = $this->getPagesForPattern( $this->getArg( 0 ), $skipLanguages );
92        }
93
94        $dryrun = !$this->hasOption( 'really' );
95        $comment = $this->getOption( 'comment' );
96        $this->fuzzyTranslations( $pages, $dryrun, $comment );
97    }
98
99    private function fuzzyTranslations( array $pages, bool $dryrun, ?string $comment ): void {
100        $count = count( $pages );
101        $this->output( "Found $count pages to update.", 'pagecount' );
102
103        foreach ( $pages as [ $title, $text ] ) {
104            $this->updateMessage( $title, TRANSLATE_FUZZY . $text, $dryrun, $comment );
105        }
106    }
107
108    /**
109     * Gets the message contents from database rows.
110     * @param IResultWrapper $rows
111     * @return array containing page titles and the text content of the page
112     */
113    private function getMessageContentsFromRows( IResultWrapper $rows ): array {
114        $messagesContents = [];
115        $slots = $this->revisionStore->getContentBlobsForBatch( $rows, [ SlotRecord::MAIN ] )->getValue();
116        foreach ( $rows as $row ) {
117            $title = Title::makeTitle( $row->page_namespace, $row->page_title );
118            if ( isset( $slots[$row->rev_id] ) ) {
119                $text = $slots[$row->rev_id][SlotRecord::MAIN]->blob_data;
120            } else {
121                $content = $this->revisionStore
122                    ->newRevisionFromRow( $row, IDBAccessObject::READ_NORMAL, $title )
123                    ->getContent( SlotRecord::MAIN );
124                $text = Utilities::getTextFromTextContent( $content );
125            }
126            $messagesContents[] = [ $title, $text ];
127        }
128        return $messagesContents;
129    }
130
131    /** Searches pages that match given patterns */
132    private function getPagesForPattern( string $pattern, array $skipLanguages = [] ): array {
133        $dbr = $this->DBLoadBalancer->getMaintenanceConnectionRef( DB_REPLICA );
134
135        $conds = [
136            'page_latest=rev_id',
137        ];
138
139        $title = Title::newFromText( $pattern );
140        if ( $title->inNamespace( NS_MAIN ) ) {
141            $namespace = $this->getConfig()->get( 'TranslateMessageNamespaces' );
142        } else {
143            $namespace = $title->getNamespace();
144        }
145
146        $conds['page_namespace'] = $namespace;
147        $conds[] = 'page_title' . $dbr->buildLike( $title->getDBkey(), $dbr->anyString() );
148
149        if ( count( $skipLanguages ) ) {
150            $skiplist = $dbr->makeList( $skipLanguages );
151            $conds[] = "substring_index(page_title, '/', -1) NOT IN ($skiplist)";
152        }
153
154        $queryInfo = $this->revisionStore->getQueryInfo( [ 'page' ] );
155        $rows = $dbr->newSelectQueryBuilder()
156            ->select( $queryInfo['fields'] )
157            ->tables( $queryInfo['tables'] )
158            ->joinConds( $queryInfo['joins'] )
159            ->where( $conds )
160            ->caller( __METHOD__ )
161            ->fetchResultSet();
162        return $this->getMessageContentsFromRows( $rows );
163    }
164
165    private function getPagesForUser( User $user, array $skipLanguages = [] ): array {
166        $dbr = $this->DBLoadBalancer->getMaintenanceConnectionRef( DB_REPLICA );
167
168        $revWhere = $this->actorMigration->getWhere( $dbr, 'rev_user', $user );
169        $conds = [
170            'page_latest=rev_id',
171            $revWhere['conds'],
172            'page_namespace' => $this->getConfig()->get( 'TranslateMessageNamespaces' ),
173            'page_title' . $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() ),
174        ];
175        if ( count( $skipLanguages ) ) {
176            $skiplist = $dbr->makeList( $skipLanguages );
177            $conds[] = "substring_index(page_title, '/', -1) NOT IN ($skiplist)";
178        }
179
180        $queryInfo = $this->revisionStore->getQueryInfo( [ 'page', 'user' ] );
181        $rows = $dbr->newSelectQueryBuilder()
182            ->select( $queryInfo['fields'] )
183            ->tables( $queryInfo['tables'] )
184            ->joinConds( $queryInfo['joins'] )
185            ->where( $conds )
186            ->caller( __METHOD__ )
187            ->fetchResultSet();
188
189        return $this->getMessageContentsFromRows( $rows );
190    }
191
192    /**
193     * Does the actual edit if possible.
194     * @param Title $title
195     * @param string $text
196     * @param bool $dryrun Whether to really do it or just show what would be done.
197     * @param string|null $comment Edit summary.
198     */
199    private function updateMessage( Title $title, string $text, bool $dryrun, ?string $comment = null ) {
200        $this->output( "Updating {$title->getPrefixedText()}... ", $title );
201
202        $documentationLanguageCode = $this->getConfig()->get( 'TranslateDocumentationLanguageCode' );
203        $items = explode( '/', $title->getText(), 2 );
204        if ( isset( $items[1] ) && $items[1] === $documentationLanguageCode ) {
205            $this->output( 'IGNORED!', $title );
206
207            return;
208        }
209
210        if ( $dryrun ) {
211            $this->output( 'DRY RUN!', $title );
212
213            return;
214        }
215
216        $wikiPage = $this->wikiPageFactory->newFromTitle( $title );
217        $summary = CommentStoreComment::newUnsavedComment( $comment ?? 'Marking as fuzzy' );
218        $content = ContentHandler::makeContent( $text, $title );
219        $updater = $wikiPage->newPageUpdater( FuzzyBot::getUser() );
220        $updater
221            ->setContent( SlotRecord::MAIN, $content )
222            ->saveRevision( $summary, EDIT_FORCE_BOT | EDIT_UPDATE );
223        $status = $updater->getStatus();
224
225        $this->output( $status->isOK() ? 'OK' : 'FAILED', $title );
226    }
227}