Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.18% covered (danger)
18.18%
38 / 209
6.25% covered (danger)
6.25%
1 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMediaStatistics
18.18% covered (danger)
18.18%
38 / 209
6.25% covered (danger)
6.25%
1 / 16
489.62
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 isExpensive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryInfo
45.90% covered (danger)
45.90%
28 / 61
0.00% covered (danger)
0.00%
0 / 1
2.63
 getOrderFields
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 outputResults
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 outputTableEnd
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 outputTableRow
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
2
 makePercentPretty
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getExtensionList
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 outputTableStart
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 getTableHeaderRow
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 outputMediaType
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 splitFakeTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formatResult
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 preprocessResults
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\Html\Html;
10use MediaWiki\MainConfigNames;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\Output\OutputPage;
13use MediaWiki\Page\LinkBatchFactory;
14use MediaWiki\Parser\Sanitizer;
15use MediaWiki\Skin\Skin;
16use MediaWiki\SpecialPage\QueryPage;
17use MediaWiki\SpecialPage\SpecialPage;
18use Wikimedia\Mime\MimeAnalyzer;
19use Wikimedia\Rdbms\IConnectionProvider;
20use Wikimedia\Rdbms\IReadableDatabase;
21use Wikimedia\Rdbms\IResultWrapper;
22
23/**
24 * Implements Special:MediaStatistics
25 *
26 * @ingroup SpecialPage
27 * @author Brian Wolff
28 */
29class SpecialMediaStatistics extends QueryPage {
30
31    public const MAX_LIMIT = 5000;
32
33    protected int $totalCount = 0;
34    protected int $totalBytes = 0;
35
36    /**
37     * @var int Combined file size of all files in a section
38     */
39    protected $totalPerType = 0;
40
41    /**
42     * @var int Combined file count of all files in a section
43     */
44    protected $countPerType = 0;
45
46    /**
47     * @var int Combined file size of all files
48     */
49    protected $totalSize = 0;
50
51    private readonly int $migrationStage;
52
53    public function __construct(
54        private readonly MimeAnalyzer $mimeAnalyzer,
55        IConnectionProvider $dbProvider,
56        LinkBatchFactory $linkBatchFactory
57    ) {
58        parent::__construct( 'MediaStatistics' );
59        // Generally speaking there is only a small number of file types,
60        // so just show all of them.
61        $this->limit = self::MAX_LIMIT;
62        $this->shownavigation = false;
63        $this->setDatabaseProvider( $dbProvider );
64        $this->setLinkBatchFactory( $linkBatchFactory );
65        $this->migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
66            MainConfigNames::FileSchemaMigrationStage
67        );
68    }
69
70    /** @inheritDoc */
71    public function isExpensive() {
72        return true;
73    }
74
75    /**
76     * Query to do.
77     *
78     * This abuses the query cache table by storing mime types as "titles".
79     *
80     * This will store entries like [[Media:BITMAP;image/jpeg;200;20000]]
81     * where the form is Media type;mime type;count;bytes.
82     *
83     * This relies on the behaviour that when value is tied, the order things
84     * come out of querycache table is the order they went in. Which is hacky.
85     * However, other special pages like Special:Deadendpages and
86     * Special:BrokenRedirects also rely on this.
87     * @return array
88     */
89    public function getQueryInfo() {
90        $dbr = $this->getDatabaseProvider()->getReplicaDatabase();
91        if ( $this->migrationStage & SCHEMA_COMPAT_READ_OLD ) {
92            $fakeTitle = $dbr->buildConcat( [
93                'img_media_type',
94                $dbr->addQuotes( ';' ),
95                'img_major_mime',
96                $dbr->addQuotes( '/' ),
97                'img_minor_mime',
98                $dbr->addQuotes( ';' ),
99                $dbr->buildStringCast( 'COUNT(*)' ),
100                $dbr->addQuotes( ';' ),
101                $dbr->buildStringCast( 'SUM( img_size )' )
102            ] );
103            return [
104                'tables' => [ 'image' ],
105                'fields' => [
106                    'title' => $fakeTitle,
107                    'namespace' => NS_MEDIA, /* needs to be something */
108                    'value' => '1'
109                ],
110                'options' => [
111                    'GROUP BY' => [
112                        'img_media_type',
113                        'img_major_mime',
114                        'img_minor_mime',
115                    ]
116                ]
117            ];
118        } else {
119            $fakeTitle = $dbr->buildConcat( [
120                'ft_media_type',
121                $dbr->addQuotes( ';' ),
122                'ft_major_mime',
123                $dbr->addQuotes( '/' ),
124                'ft_minor_mime',
125                $dbr->addQuotes( ';' ),
126                $dbr->buildStringCast( 'COUNT(*)' ),
127                $dbr->addQuotes( ';' ),
128                $dbr->buildStringCast( 'SUM( fr_size )' )
129            ] );
130            return [
131                'tables' => [ 'file', 'filetypes', 'filerevision' ],
132                'fields' => [
133                    'title' => $fakeTitle,
134                    'namespace' => NS_MEDIA, /* needs to be something */
135                    'value' => '1'
136                ],
137                'conds' => [
138                    'file_deleted' => 0
139                ],
140                'options' => [
141                    'GROUP BY' => [
142                        'ft_media_type',
143                        'ft_major_mime',
144                        'ft_minor_mime'
145                    ]
146                ],
147                'join_conds' => [
148                    'filetypes' => [ 'JOIN', 'file_type = ft_id' ],
149                    'filerevision' => [ 'JOIN', 'file_latest = fr_id' ]
150                ]
151            ];
152        }
153    }
154
155    /**
156     * How to sort the results
157     *
158     * It's important that img_media_type come first, otherwise the
159     * tables will be fragmented.
160     * @return array Fields to sort by
161     */
162    protected function getOrderFields() {
163        if ( $this->migrationStage & SCHEMA_COMPAT_READ_OLD ) {
164            return [ 'img_media_type', 'count(*)', 'img_major_mime', 'img_minor_mime' ];
165        } else {
166            return [ 'ft_media_type', 'count(*)', 'ft_major_mime', 'ft_minor_mime' ];
167        }
168    }
169
170    /**
171     * Output the results of the query.
172     *
173     * @param OutputPage $out
174     * @param Skin $skin (deprecated presumably)
175     * @param IReadableDatabase $dbr
176     * @param IResultWrapper $res Results from query
177     * @param int $num Number of results
178     * @param int $offset Paging offset (Should always be 0 in our case)
179     */
180    protected function outputResults( $out, $skin, $dbr, $res, $num, $offset ) {
181        $prevMediaType = null;
182        foreach ( $res as $row ) {
183            $mediaStats = $this->splitFakeTitle( $row->title );
184            if ( count( $mediaStats ) < 4 ) {
185                continue;
186            }
187            [ $mediaType, $mime, $totalCount, $totalBytes ] = $mediaStats;
188            if ( $prevMediaType !== $mediaType ) {
189                if ( $prevMediaType !== null ) {
190                    // We're not at beginning, so we have to
191                    // close the previous table.
192                    $this->outputTableEnd();
193                }
194                $this->outputMediaType( $mediaType );
195                $this->totalPerType = 0;
196                $this->countPerType = 0;
197                $this->outputTableStart( $mediaType );
198                $prevMediaType = $mediaType;
199            }
200            $this->outputTableRow( $mime, intval( $totalCount ), intval( $totalBytes ) );
201        }
202        if ( $prevMediaType !== null ) {
203            $this->outputTableEnd();
204            // add total size of all files
205            $this->outputMediaType( 'total' );
206            $this->getOutput()->addWikiTextAsInterface(
207                $this->msg( 'mediastatistics-allbytes' )
208                    ->numParams( $this->totalSize )
209                    ->sizeParams( $this->totalSize )
210                    ->numParams( $this->totalCount )
211                    ->text()
212            );
213        }
214    }
215
216    /**
217     * Output closing </table>
218     */
219    protected function outputTableEnd() {
220        $this->getOutput()->addHTML(
221            Html::closeElement( 'tbody' ) .
222            Html::closeElement( 'table' )
223        );
224        $this->getOutput()->addWikiTextAsInterface(
225                $this->msg( 'mediastatistics-bytespertype' )
226                    ->numParams( $this->totalPerType )
227                    ->sizeParams( $this->totalPerType )
228                    ->numParams( $this->makePercentPretty( $this->totalPerType / $this->totalBytes ) )
229                    ->numParams( $this->countPerType )
230                    ->numParams( $this->makePercentPretty( $this->countPerType / $this->totalCount ) )
231                    ->text()
232        );
233        $this->totalSize += $this->totalPerType;
234    }
235
236    /**
237     * Output a row of the stats table
238     *
239     * @param string $mime mime type (e.g. image/jpeg)
240     * @param int $count Number of images of this type
241     * @param int $bytes Total space for images of this type
242     */
243    protected function outputTableRow( $mime, $count, $bytes ) {
244        $mimeSearch = SpecialPage::getTitleFor( 'MIMEsearch', $mime );
245        $linkRenderer = $this->getLinkRenderer();
246        $row = Html::rawElement(
247            'td',
248            [],
249            $linkRenderer->makeLink( $mimeSearch, $mime )
250        );
251        $row .= Html::rawElement(
252            'td',
253            [],
254            $this->getExtensionList( $mime )
255        );
256        $row .= Html::rawElement(
257            'td',
258            // Make sure js sorts it in numeric order
259            [ 'data-sort-value' => $count ],
260            $this->msg( 'mediastatistics-nfiles' )
261                ->numParams( $count )
262                /** @todo Check to be sure this really should have number formatting */
263                ->numParams( $this->makePercentPretty( $count / $this->totalCount ) )
264                ->parse()
265        );
266        $row .= Html::rawElement(
267            'td',
268            // Make sure js sorts it in numeric order
269            [ 'data-sort-value' => $bytes ],
270            $this->msg( 'mediastatistics-nbytes' )
271                ->numParams( $bytes )
272                ->sizeParams( $bytes )
273                /** @todo Check to be sure this really should have number formatting */
274                ->numParams( $this->makePercentPretty( $bytes / $this->totalBytes ) )
275                ->parse()
276        );
277        $this->totalPerType += $bytes;
278        $this->countPerType += $count;
279        $this->getOutput()->addHTML( Html::rawElement( 'tr', [], $row ) );
280    }
281
282    /**
283     * @param float $decimal A decimal percentage (ie for 12.3%, this would be 0.123)
284     * @return string The percentage formatted so that 3 significant digits are shown.
285     */
286    protected function makePercentPretty( $decimal ) {
287        $decimal *= 100;
288        // Always show three useful digits
289        if ( $decimal == 0 ) {
290            return '0';
291        }
292        if ( $decimal >= 100 ) {
293            return '100';
294        }
295        $percent = sprintf( "%." . max( 0, 2 - floor( log10( $decimal ) ) ) . "f", $decimal );
296        // Then remove any trailing 0's
297        return preg_replace( '/\.?0*$/', '', $percent );
298    }
299
300    /**
301     * Given a mime type, return a comma separated list of allowed extensions.
302     *
303     * @param string $mime mime type
304     * @return string Comma separated list of allowed extensions (e.g. ".ogg, .oga")
305     */
306    private function getExtensionList( $mime ) {
307        $exts = $this->mimeAnalyzer->getExtensionsFromMimeType( $mime );
308        if ( !$exts ) {
309            return '';
310        }
311        foreach ( $exts as &$ext ) {
312            $ext = htmlspecialchars( '.' . $ext );
313        }
314
315        return $this->getLanguage()->commaList( $exts );
316    }
317
318    /**
319     * Output the start of the table
320     *
321     * Including opening <table>, and first <tr> with column headers.
322     * @param string $mediaType
323     */
324    protected function outputTableStart( $mediaType ) {
325        $out = $this->getOutput();
326        $out->addModuleStyles( 'jquery.tablesorter.styles' );
327        $out->addModules( 'jquery.tablesorter' );
328        $out->addHTML(
329            Html::openElement(
330                'table',
331                [ 'class' => [
332                    'mw-mediastats-table',
333                    'mw-mediastats-table-' . strtolower( $mediaType ),
334                    'sortable',
335                    'wikitable'
336                ] ]
337            ) .
338            Html::rawElement( 'thead', [], $this->getTableHeaderRow() ) .
339            Html::openElement( 'tbody' )
340        );
341    }
342
343    /**
344     * Get (not output) the header row for the table
345     *
346     * @return string The header row of the table
347     */
348    protected function getTableHeaderRow() {
349        $headers = [ 'mimetype', 'extensions', 'count', 'totalbytes' ];
350        $ths = '';
351        foreach ( $headers as $header ) {
352            $ths .= Html::rawElement(
353                'th',
354                [],
355                // for grep:
356                // mediastatistics-table-mimetype, mediastatistics-table-extensions
357                // mediastatistics-table-count, mediastatistics-table-totalbytes
358                $this->msg( 'mediastatistics-table-' . $header )->parse()
359            );
360        }
361        return Html::rawElement( 'tr', [], $ths );
362    }
363
364    /**
365     * Output a header for a new media type section
366     *
367     * @param string $mediaType A media type (e.g. from the MEDIATYPE_xxx constants)
368     */
369    protected function outputMediaType( $mediaType ) {
370        $this->getOutput()->addHTML(
371            Html::element(
372                'h2',
373                [
374                    'id' => Sanitizer::escapeIdForAttribute(
375                        'mw-mediastats-mediatype-' . strtolower( $mediaType )
376                    ),
377                    'class' => [
378                        'mw-mediastats-mediatype',
379                        'mw-mediastats-mediatype-' . strtolower( $mediaType )
380                    ]
381                ],
382                // for grep
383                // mediastatistics-header-unknown, mediastatistics-header-bitmap,
384                // mediastatistics-header-drawing, mediastatistics-header-audio,
385                // mediastatistics-header-video, mediastatistics-header-multimedia,
386                // mediastatistics-header-office, mediastatistics-header-text,
387                // mediastatistics-header-executable, mediastatistics-header-archive,
388                // mediastatistics-header-3d,
389                $this->msg( 'mediastatistics-header-' . strtolower( $mediaType ) )->text()
390            )
391        );
392        /** @todo Possibly could add a message here explaining what the different types are.
393         *    not sure if it is needed though.
394         */
395    }
396
397    /**
398     * parse the fake title format that this special page abuses querycache with.
399     *
400     * @param string $fakeTitle A string formatted as <media type>;<mime type>;<count>;<bytes>
401     * @return array The constituent parts of $fakeTitle
402     */
403    private function splitFakeTitle( $fakeTitle ) {
404        return explode( ';', $fakeTitle, 4 );
405    }
406
407    /**
408     * What group to put the page in
409     * @return string
410     */
411    protected function getGroupName() {
412        return 'media';
413    }
414
415    /** @inheritDoc */
416    public function formatResult( $skin, $result ) {
417        return false;
418    }
419
420    /**
421     * Initialize total values so we can figure out percentages later.
422     *
423     * @param IReadableDatabase $dbr
424     * @param IResultWrapper $res
425     */
426    public function preprocessResults( $dbr, $res ) {
427        $this->executeLBFromResultWrapper( $res );
428        $this->totalCount = $this->totalBytes = 0;
429        foreach ( $res as $row ) {
430            $mediaStats = $this->splitFakeTitle( $row->title );
431            $this->totalCount += $mediaStats[2] ?? 0;
432            $this->totalBytes += $mediaStats[3] ?? 0;
433        }
434        $res->seek( 0 );
435    }
436}
437
438// @codeCoverageIgnoreStart
439/**
440 * Retain the old class name for backwards compatibility.
441 * @deprecated since 1.41
442 */
443class_alias( SpecialMediaStatistics::class, 'SpecialMediaStatistics' );
444// @codeCoverageIgnoreEnd