MediaWiki master
refreshImageMetadata.php
Go to the documentation of this file.
1<?php
16// @codeCoverageIgnoreStart
17require_once __DIR__ . '/Maintenance.php';
18// @codeCoverageIgnoreEnd
19
31
38 private ?TitleParser $titleParser = null;
39
40 public function __construct() {
41 parent::__construct();
42
43 $this->addDescription( 'Script to update image metadata records' );
44 $this->setBatchSize( 200 );
45
46 $this->addOption(
47 'force',
48 'Reload metadata from file even if the metadata looks ok',
49 false,
50 false,
51 'f'
52 );
53 $this->addOption(
54 'broken-only',
55 'Only fix really broken records, leave old but still compatible records alone.'
56 );
57 $this->addOption(
58 'convert-to-json',
59 'Fix records with an out of date serialization format.'
60 );
61 $this->addOption(
62 'split',
63 'Enable splitting out large metadata items to the text table. Implies --convert-to-json.'
64 );
65 $this->addOption(
66 'verbose',
67 'Output extra information about each upgraded/non-upgraded file.',
68 false,
69 false,
70 'v'
71 );
72 $this->addOption( 'start', 'Name of file to start with', false, true );
73 $this->addOption( 'end', 'Name of file to end with', false, true );
74
75 $this->addOption(
76 'mediatype',
77 'Only refresh files with this media type, e.g. BITMAP, UNKNOWN etc.',
78 false,
79 true
80 );
81 $this->addOption(
82 'mime',
83 "Only refresh files with this MIME type. Can accept wild-card 'image/*'. "
84 . "Potentially inefficient unless 'mediatype' is also specified",
85 false,
86 true
87 );
88 $this->addOption(
89 'metadata-contains',
90 '(Inefficient!) Only refresh files where the img_metadata field '
91 . 'contains this string. Can be used if its known a specific '
92 . 'property was being extracted incorrectly.',
93 false,
94 true
95 );
96 $this->addOption(
97 'sleep',
98 'Time to sleep between each batch (in seconds). Default: 0',
99 false,
100 true
101 );
102 $this->addOption( 'oldimage', 'Run and refresh on oldimage table.' );
103 $this->addOption(
104 'listfile',
105 'File with file titles to refresh, one per line (newline delimited). '
106 . 'If provided, other filtering options (--mime, --mediatype, --metadata-contains, '
107 . '--start, --end) will be ignored.',
108 false,
109 true
110 );
111 }
112
113 public function execute() {
114 $force = $this->hasOption( 'force' );
115 $brokenOnly = $this->hasOption( 'broken-only' );
116 $verbose = $this->hasOption( 'verbose' );
117 $split = $this->hasOption( 'split' );
118 $sleep = (int)$this->getOption( 'sleep', 0 );
119 $reserialize = $this->hasOption( 'convert-to-json' );
120 $oldimage = $this->hasOption( 'oldimage' );
121 $listFile = $this->getOption( 'listfile', false );
122
123 $batchSize = (int)$this->getBatchSize();
124 if ( $batchSize <= 0 ) {
125 $this->fatalError( "Batch size is too low...", 12 );
126 }
127
128 $this->titleParser = $this->getServiceContainer()->getTitleParser();
129 $repo = $this->newLocalRepo( $force, $brokenOnly, $reserialize, $split );
130
131 if ( $listFile !== false ) {
132 // Process files by title list
133 $this->processFilesByTitles( $listFile, $repo, $oldimage, $force, $verbose, $sleep );
134 } else {
135 // Process files by database query
136 $this->processFilesByDatabase( $repo, $oldimage, $force, $verbose, $sleep );
137 }
138 }
139
150 private function processFilesByTitles(
151 string $listFile,
152 LocalRepo $repo,
153 bool $oldimage,
154 bool $force,
155 bool $verbose,
156 int $sleep
157 ): void {
158 if ( !file_exists( $listFile ) ) {
159 $this->fatalError( "List file does not exist: $listFile" );
160 }
161
162 $file = fopen( $listFile, 'r' );
163 if ( !$file ) {
164 $this->fatalError( "Unable to read list file: $listFile" );
165 }
166
167 // Read and validate all titles from the file
168 $requestedNames = [];
169 $invalidTitles = [];
170 $lineNum = 0;
171
172 while ( !feof( $file ) ) {
173 $line = trim( fgets( $file ) );
174 $lineNum++;
175
176 if ( $line === '' ) {
177 continue;
178 }
179
180 try {
181 $titleValue = $this->titleParser->parseTitle( $line, NS_FILE );
182 if ( $titleValue->getNamespace() !== NS_FILE ) {
183 $invalidTitles[] = [ 'line' => $lineNum, 'title' => $line ];
184 continue;
185 }
186 $requestedNames[$titleValue->getDBkey()] = $line;
187 } catch ( MalformedTitleException ) {
188 $invalidTitles[] = [ 'line' => $lineNum, 'title' => $line ];
189 continue;
190 }
191 }
192 fclose( $file );
193
194 // Report invalid titles
195 foreach ( $invalidTitles as $invalid ) {
196 $this->output( "Invalid file title on line {$invalid['line']}: '{$invalid['title']}'\n" );
197 }
198
199 if ( count( $requestedNames ) === 0 ) {
200 $this->output( "No valid titles found in list file.\n" );
201 return;
202 }
203
204 $this->output( "Found " . count( $requestedNames ) . " valid title(s) to process.\n" );
205
206 $dbw = $this->getPrimaryDB();
207 $batchSize = (int)$this->getBatchSize();
208 $nameField = $oldimage ? 'oi_name' : 'img_name';
209 $upgraded = 0;
210 $leftAlone = 0;
211 $foundNames = [];
212
213 $nameBatches = array_chunk( array_keys( $requestedNames ), $batchSize );
214
215 foreach ( $nameBatches as $nameBatch ) {
216 if ( $oldimage ) {
217 $queryBuilder = FileSelectQueryBuilder::newForOldFile( $dbw );
218 } else {
219 $queryBuilder = FileSelectQueryBuilder::newForFile( $dbw );
220 }
221 $queryBuilder
222 ->where( [ $nameField => $nameBatch ] )
223 ->caller( __METHOD__ );
224
225 $res = $queryBuilder->fetchResultSet();
226
227 [ $up, $left, $found ] = $this->processBatch( $res, $repo, $nameField, $force, $verbose );
228 $upgraded += $up;
229 $leftAlone += $left;
230 $foundNames += $found;
231
232 $this->waitForReplication();
233 if ( $sleep ) {
234 sleep( $sleep );
235 }
236 }
237
238 // Report files that were not found in the database
239 $notFound = array_diff_key( $requestedNames, $foundNames );
240 if ( count( $notFound ) > 0 ) {
241 $this->output( "\nThe following " . count( $notFound ) . " file(s) were not found in the database:\n" );
242 foreach ( $notFound as $dbKey => $originalTitle ) {
243 $this->output( " - $originalTitle\n" );
244 }
245 }
246
247 $this->outputResult( $upgraded, $leftAlone, $force );
248 }
249
259 private function processFilesByDatabase(
260 LocalRepo $repo,
261 bool $oldimage,
262 bool $force,
263 bool $verbose,
264 int $sleep
265 ): void {
266 $start = $this->getOption( 'start', false );
267 $dbw = $this->getPrimaryDB();
268 if ( $oldimage ) {
269 $fieldPrefix = 'oi_';
270 $queryBuilderTemplate = FileSelectQueryBuilder::newForOldFile( $dbw );
271 } else {
272 $fieldPrefix = 'img_';
273 $queryBuilderTemplate = FileSelectQueryBuilder::newForFile( $dbw );
274 }
275
276 $batchSize = (int)$this->getBatchSize();
277 $nameField = $fieldPrefix . 'name';
278 $upgraded = 0;
279 $leftAlone = 0;
280
281 $this->setConditions( $dbw, $queryBuilderTemplate, $fieldPrefix );
282 $queryBuilderTemplate
283 ->orderBy( $nameField, SelectQueryBuilder::SORT_ASC )
284 ->limit( $batchSize );
285
286 $batchCondition = [];
287 if ( $start !== false ) {
288 $batchCondition[] = $dbw->expr( $nameField, '>=', $start );
289 }
290
291 do {
292 $queryBuilder = clone $queryBuilderTemplate;
293 $res = $queryBuilder->andWhere( $batchCondition )
294 ->caller( __METHOD__ )->fetchResultSet();
295
296 [ $up, $left, $found ] = $this->processBatch( $res, $repo, $nameField, $force, $verbose );
297 $upgraded += $up;
298 $leftAlone += $left;
299
300 if ( $res->numRows() > 0 ) {
301 $lastName = array_key_last( $found );
302 $batchCondition = [ $dbw->expr( $nameField, '>', $lastName ) ];
303 }
304
305 $this->waitForReplication();
306 if ( $sleep ) {
307 sleep( $sleep );
308 }
309 } while ( $res->numRows() === $batchSize );
310
311 $this->outputResult( $upgraded, $leftAlone, $force );
312 }
313
324 private function processBatch(
325 IResultWrapper $res,
326 LocalRepo $repo,
327 string $nameField,
328 bool $force,
329 bool $verbose
330 ): array {
331 $upgraded = 0;
332 $leftAlone = 0;
333 $foundNames = [];
334
335 if ( $res->numRows() > 0 ) {
336 $firstRow = $res->current();
337 $firstName = $firstRow->$nameField;
338 $res->rewind();
339 $this->output( "Processing next {$res->numRows()} row(s) starting with $firstName.\n" );
340 }
341
342 foreach ( $res as $row ) {
343 $name = $row->$nameField;
344 $foundNames[$name] = true;
345
346 try {
347 $file = $repo->newFileFromRow( $row );
348 $file->maybeUpgradeRow();
349 if ( $file->getUpgraded() ) {
350 $this->output( "Refreshed File:$name.\n" );
351 $upgraded++;
352 } else {
353 if ( $force ) {
354 $file->upgradeRow();
355 if ( $verbose ) {
356 $this->output( "Forcibly refreshed File:$name.\n" );
357 }
358 } elseif ( $verbose ) {
359 $this->output( "Skipping File:$name.\n" );
360 }
361 $leftAlone++;
362 }
363 } catch ( Exception $e ) {
364 $this->output( "$name failed. {$e->getMessage()}\n" );
365 }
366 }
367
368 return [ $upgraded, $leftAlone, $foundNames ];
369 }
370
378 private function outputResult( int $upgraded, int $leftAlone, bool $force ): void {
379 $total = $upgraded + $leftAlone;
380 if ( $force ) {
381 $this->output( "\nFinished refreshing file metadata for $total files. "
382 . "$upgraded needed to be refreshed, $leftAlone did not need to "
383 . "be but were refreshed anyways.\n" );
384 } else {
385 $this->output( "\nFinished refreshing file metadata for $total files. "
386 . "$upgraded were refreshed, $leftAlone were already up to date.\n" );
387 }
388 }
389
395 private function setConditions(
397 SelectQueryBuilder $queryBuilder,
398 string $fieldPrefix
399 ): void {
400 $end = $this->getOption( 'end', false );
401 $mime = $this->getOption( 'mime', false );
402 $mediatype = $this->getOption( 'mediatype', false );
403 $like = $this->getOption( 'metadata-contains', false );
404
405 if ( $end !== false ) {
406 $queryBuilder->andWhere( $dbw->expr( $fieldPrefix . 'name', '<=', $end ) );
407 }
408 if ( $mime !== false ) {
409 [ $major, $minor ] = File::splitMime( $mime );
410 $queryBuilder->andWhere( [ $fieldPrefix . 'major_mime' => $major ] );
411 if ( $minor !== '*' ) {
412 $queryBuilder->andWhere( [ $fieldPrefix . 'minor_mime' => $minor ] );
413 }
414 }
415 if ( $mediatype !== false ) {
416 $queryBuilder->andWhere( [ $fieldPrefix . 'media_type' => $mediatype ] );
417 }
418 if ( $like ) {
419 $queryBuilder->andWhere(
420 $dbw->expr( $fieldPrefix . 'metadata', IExpression::LIKE,
421 new LikeValue( $dbw->anyString(), $like, $dbw->anyString() ) )
422 );
423 }
424 }
425
434 private function newLocalRepo( bool $force, bool $brokenOnly, bool $reserialize, bool $split ): LocalRepo {
435 if ( $brokenOnly && $force ) {
436 $this->fatalError( 'Cannot use --broken-only and --force together. ', 2 );
437 }
438 $reserialize = $reserialize || $split;
439 if ( $brokenOnly && $reserialize ) {
440 $this->fatalError( 'Cannot use --broken-only with --convert-to-json or --split. ',
441 2 );
442 }
443
444 $overrides = [
445 'updateCompatibleMetadata' => !$brokenOnly,
446 ];
447 if ( $reserialize ) {
448 $overrides['reserializeMetadata'] = true;
449 $overrides['useJsonMetadata'] = true;
450 }
451 if ( $split ) {
452 $overrides['useSplitMetadata'] = true;
453 }
454
455 return $this->getServiceContainer()->getRepoGroup()
456 ->newCustomLocalRepo( $overrides );
457 }
458}
459
460// @codeCoverageIgnoreStart
461$maintClass = RefreshImageMetadata::class;
462require_once RUN_MAINTENANCE_IF_MAIN;
463// @codeCoverageIgnoreEnd
const NS_FILE
Definition Defines.php:57
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:71
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:80
Local repository that stores files in the local filesystem and registers them in the wiki's own datab...
Definition LocalRepo.php:45
Abstract maintenance class for quickly writing and churning out maintenance scripts with minimal effo...
getBatchSize()
Returns batch size.
output( $out, $channel=null)
Throw some output to the user.
fatalError( $msg, $exitCode=1)
Output a message and terminate the current script.
addOption( $name, $description, $required=false, $withArg=false, $shortName=false, $multiOccurrence=false)
Add a parameter to the script.
waitForReplication()
Wait for replica DB servers to catch up.
hasOption( $name)
Checks to see if a particular option was set.
getOption( $name, $default=null)
Get an option, or return the default.
getServiceContainer()
Returns the main service container.
getPrimaryDB(string|false $virtualDomain=false)
addDescription( $text)
Set the description text.
MalformedTitleException is thrown when a TitleParser is unable to parse a title string.
A title parser service for MediaWiki.
Maintenance script to refresh image metadata fields.
__construct()
Default constructor.
execute()
Do the actual work.
Content of like value.
Definition LikeValue.php:14
Build SELECT queries with a fluent interface.
andWhere( $conds)
Add conditions to the query.
fetchResultSet()
Run the constructed SELECT query and return all results.
where( $conds)
Add conditions to the query.
A database connection without write operations.
expr(string $field, string $op, $value)
See Expression::__construct()
Result wrapper for grabbing data queried from an IDatabase object.
numRows()
Get the number of rows in a result object.
anyString()
Returns a token for buildLike() that denotes a '' to be used in a LIKE query.