Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 121
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
PersistRevisionThreadItems
0.00% covered (danger)
0.00%
0 / 115
0.00% covered (danger)
0.00%
0 / 4
506
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
90
 process
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
90
 processRow
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools\Maintenance;
4
5use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
6use MediaWiki\Extension\DiscussionTools\ThreadItemStore;
7use MediaWiki\Language\Language;
8use MediaWiki\Maintenance\Maintenance;
9use MediaWiki\Revision\RevisionStore;
10use MediaWiki\Shell\Shell;
11use MediaWiki\Title\Title;
12use MWExceptionRenderer;
13use stdClass;
14use Throwable;
15use Wikimedia\Rdbms\IReadableDatabase;
16use Wikimedia\Rdbms\SelectQueryBuilder;
17
18$IP = getenv( 'MW_INSTALL_PATH' );
19if ( $IP === false ) {
20    $IP = __DIR__ . '/../../..';
21}
22require_once "$IP/maintenance/Maintenance.php";
23
24class PersistRevisionThreadItems extends Maintenance {
25
26    private IReadableDatabase $dbr;
27    private ThreadItemStore $itemStore;
28    private RevisionStore $revStore;
29    private Language $lang;
30
31    public function __construct() {
32        parent::__construct();
33        $this->requireExtension( 'DiscussionTools' );
34        $this->addDescription( 'Persist thread item information for the given pages/revisions' );
35        $this->addOption( 'rev', 'Revision ID to process', false, true, false, true );
36        $this->addOption( 'page', 'Page title to process', false, true, false, true );
37        $this->addOption( 'namespace', 'Namespace number to process', false, true, false, true );
38        $this->addOption( 'all', 'Process the whole wiki' );
39        $this->addOption( 'current', 'Process current revisions only' );
40        $this->addOption( 'start', 'Restart from this position (as printed by the script)', false, true );
41        $this->addOption( 'touched-after', 'Only process pages touched after this timestamp', false, true );
42        $this->addOption( 'touched-before', 'Only process pages touched before this timestamp', false, true );
43        $this->setBatchSize( 100 );
44    }
45
46    public function execute() {
47        $services = $this->getServiceContainer();
48
49        $this->dbr = $dbr = $this->getDB( DB_REPLICA );
50        $this->itemStore = $services->getService( 'DiscussionTools.ThreadItemStore' );
51        $this->revStore = $services->getRevisionStore();
52        $this->lang = $services->getLanguageFactory()->getLanguage( 'en' );
53
54        $qb = $dbr->newSelectQueryBuilder();
55
56        $qb->queryInfo( $this->revStore->getQueryInfo( [ 'page' ] ) );
57
58        if ( $this->getOption( 'all' ) ) {
59            // Do nothing
60
61        } elseif ( $this->getOption( 'namespace' ) ) {
62            $qb->where( [ 'page_namespace' => $this->getOption( 'namespace' ) ] );
63
64        } elseif ( $this->getOption( 'page' ) ) {
65            $linkBatch = $services->getLinkBatchFactory()->newLinkBatch();
66            foreach ( $this->getOption( 'page' ) as $page ) {
67                $linkBatch->addObj( Title::newFromText( $page ) );
68            }
69            $pageIds = array_map( static function ( $page ) {
70                return $page->getId();
71            }, $linkBatch->getPageIdentities() );
72
73            $qb->where( [ 'rev_page' => $pageIds ] );
74
75        } elseif ( $this->getOption( 'rev' ) ) {
76            $qb->where( [ 'rev_id' => $this->getOption( 'rev' ) ] );
77        } else {
78            $this->error( "One of 'all', 'page', or 'rev' required" );
79            $this->maybeHelp( true );
80            return;
81        }
82
83        // Add conditions from HookUtils::isAvailableForTitle().
84        // Keep this in sync with that method.
85        $nsInfo = $services->getNamespaceInfo();
86        $signatureNamespaces = array_values( array_filter(
87            $nsInfo->getValidNamespaces(),
88            [ $nsInfo, 'wantSignatures' ]
89        ) );
90        $qb->leftJoin( 'page_props', null, [
91            'pp_propname' => 'newsectionlink',
92            'pp_page = page_id',
93        ] );
94        $qb->where(
95            $dbr->expr( 'page_namespace', '=', $signatureNamespaces )
96                ->or( 'pp_propname', '!=', null )
97        );
98
99        if ( $this->getOption( 'current' ) ) {
100            $qb->where( 'rev_id = page_latest' );
101            $index = [ 'page_id' ];
102
103            if ( $this->getOption( 'touched-after' ) ) {
104                $qb->where( $dbr->expr(
105                    'page_touched', '>', $dbr->timestamp( $this->getOption( 'touched-after' ) )
106                ) );
107            }
108            if ( $this->getOption( 'touched-before' ) ) {
109                $qb->where( $dbr->expr(
110                    'page_touched', '<', $dbr->timestamp( $this->getOption( 'touched-before' ) )
111                ) );
112            }
113
114        } else {
115            // Process in order by page and time to avoid confusing results while the script is running
116            $index = [ 'rev_page', 'rev_timestamp', 'rev_id' ];
117        }
118
119        $this->process( $qb, $index );
120    }
121
122    private function process( SelectQueryBuilder $qb, array $index ): void {
123        $start = microtime( true );
124
125        $qb->caller( __METHOD__ );
126
127        // estimateRowCount() refuses to work when fields are set, so we can't just call it on $qb
128        $countQueryInfo = $qb->getQueryInfo();
129        $count = $qb->newSubquery()
130            ->rawTables( $countQueryInfo['tables'] )
131            ->where( $countQueryInfo['conds'] )
132            ->options( $countQueryInfo['options'] )
133            ->joinConds( $countQueryInfo['join_conds'] )
134            ->caller( __METHOD__ )
135            ->estimateRowCount();
136        $this->output( "Processing... (estimated $count rows)\n" );
137
138        $processed = 0;
139        $updated = 0;
140
141        $qb->orderBy( $index );
142        $batchSize = $this->getBatchSize();
143        $qb->limit( $batchSize );
144
145        $batchStart = null;
146        if ( $this->getOption( 'start' ) ) {
147            $batchStart = json_decode( $this->getOption( 'start' ) );
148            if ( !$batchStart ) {
149                $this->error( "Invalid 'start'" );
150            }
151        }
152
153        while ( true ) {
154            $qbForBatch = clone $qb;
155            if ( $batchStart ) {
156                $batchStartCond = $this->dbr->buildComparison( '>', array_combine( $index, $batchStart ) );
157                $qbForBatch->where( $batchStartCond );
158
159                $batchStartOutput = Shell::escape( json_encode( $batchStart ) );
160                $this->output( "--start $batchStartOutput\n" );
161            }
162
163            $res = $qbForBatch->fetchResultSet();
164            foreach ( $res as $row ) {
165                $updated += (int)$this->processRow( $row );
166            }
167            $processed += $res->numRows();
168
169            $this->output( "Processed $processed (updated $updated) of $count rows\n" );
170
171            $this->waitForReplication();
172
173            if ( $res->numRows() < $batchSize || !isset( $row ) ) {
174                // Done
175                break;
176            }
177
178            // Update the conditions to select the next batch.
179            $batchStart = [];
180            foreach ( $index as $field ) {
181                $batchStart[] = $row->$field;
182            }
183        }
184
185        $duration = microtime( true ) - $start;
186        $durationFormatted = $this->lang->formatTimePeriod( $duration );
187        $this->output( "Finished in $durationFormatted\n" );
188    }
189
190    /**
191     * @param stdClass $row Database table row
192     * @return bool
193     */
194    private function processRow( stdClass $row ): bool {
195        $changed = false;
196        try {
197            $rev = $this->revStore->newRevisionFromRow( $row );
198            $title = Title::newFromLinkTarget(
199                $rev->getPageAsLinkTarget()
200            );
201            if ( HookUtils::isAvailableForTitle( $title ) ) {
202                $threadItemSet = HookUtils::parseRevisionParsoidHtml( $rev, false )->getValueOrThrow();
203
204                // Store permalink data (even when store is disabled - T334258)
205                $changed = $this->itemStore->insertThreadItems( $rev, $threadItemSet );
206            }
207        } catch ( Throwable $e ) {
208            $this->output( "Error while processing revid=$row->rev_id, pageid=$row->rev_page\n" );
209            MWExceptionRenderer::output( $e, MWExceptionRenderer::AS_RAW );
210        }
211        return $changed;
212    }
213}
214
215$maintClass = PersistRevisionThreadItems::class;
216require_once RUN_MAINTENANCE_IF_MAIN;