Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.45% covered (danger)
18.45%
38 / 206
6.25% covered (danger)
6.25%
1 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMediaStatistics
18.54% covered (danger)
18.54%
38 / 205
6.25% covered (danger)
6.25%
1 / 16
483.66
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.16% covered (danger)
45.16%
28 / 62
0.00% covered (danger)
0.00%
0 / 1
2.66
 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 / 10
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\Skin\Skin;
15use MediaWiki\SpecialPage\QueryPage;
16use MediaWiki\SpecialPage\SpecialPage;
17use Wikimedia\Mime\MimeAnalyzer;
18use Wikimedia\Rdbms\IConnectionProvider;
19use Wikimedia\Rdbms\IReadableDatabase;
20use Wikimedia\Rdbms\IResultWrapper;
21
22/**
23 * Implements Special:MediaStatistics
24 *
25 * @ingroup SpecialPage
26 * @author Brian Wolff
27 */
28class SpecialMediaStatistics extends QueryPage {
29
30    public const MAX_LIMIT = 5000;
31
32    protected int $totalCount = 0;
33    protected int $totalBytes = 0;
34
35    /**
36     * @var int Combined file size of all files in a section
37     */
38    protected $totalPerType = 0;
39
40    /**
41     * @var int Combined file count of all files in a section
42     */
43    protected $countPerType = 0;
44
45    /**
46     * @var int Combined file size of all files
47     */
48    protected $totalSize = 0;
49
50    private readonly int $migrationStage;
51
52    public function __construct(
53        private readonly MimeAnalyzer $mimeAnalyzer,
54        IConnectionProvider $dbProvider,
55        LinkBatchFactory $linkBatchFactory
56    ) {
57        parent::__construct( 'MediaStatistics' );
58        // Generally speaking there is only a small number of file types,
59        // so just show all of them.
60        $this->limit = self::MAX_LIMIT;
61        $this->shownavigation = false;
62        $this->setDatabaseProvider( $dbProvider );
63        $this->setLinkBatchFactory( $linkBatchFactory );
64        $this->migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
65            MainConfigNames::FileSchemaMigrationStage
66        );
67    }
68
69    /** @inheritDoc */
70    public function isExpensive() {
71        return true;
72    }
73
74    /**
75     * Query to do.
76     *
77     * This abuses the query cache table by storing mime types as "titles".
78     *
79     * This will store entries like [[Media:BITMAP;image/jpeg;200;20000]]
80     * where the form is Media type;mime type;count;bytes.
81     *
82     * This relies on the behaviour that when value is tied, the order things
83     * come out of querycache table is the order they went in. Which is hacky.
84     * However, other special pages like Special:Deadendpages and
85     * Special:BrokenRedirects also rely on this.
86     * @return array
87     */
88    public function getQueryInfo() {
89        $dbr = $this->getDatabaseProvider()->getReplicaDatabase();
90        if ( $this->migrationStage & SCHEMA_COMPAT_READ_OLD ) {
91            $fakeTitle = $dbr->buildConcat( [
92                'img_media_type',
93                $dbr->addQuotes( ';' ),
94                'img_major_mime',
95                $dbr->addQuotes( '/' ),
96                'img_minor_mime',
97                $dbr->addQuotes( ';' ),
98                $dbr->buildStringCast( 'COUNT(*)' ),
99                $dbr->addQuotes( ';' ),
100                $dbr->buildStringCast( 'SUM( img_size )' )
101            ] );
102            return [
103                'tables' => [ 'image' ],
104                'fields' => [
105                    'title' => $fakeTitle,
106                    'namespace' => NS_MEDIA, /* needs to be something */
107                    'value' => '1'
108                ],
109                'options' => [
110                    'GROUP BY' => [
111                        'img_media_type',
112                        'img_major_mime',
113                        'img_minor_mime',
114                    ]
115                ]
116            ];
117        } else {
118            $fakeTitle = $dbr->buildConcat( [
119                'ft_media_type',
120                $dbr->addQuotes( ';' ),
121                'ft_major_mime',
122                $dbr->addQuotes( '/' ),
123                'ft_minor_mime',
124                $dbr->addQuotes( ';' ),
125                $dbr->buildStringCast( 'COUNT(*)' ),
126                $dbr->addQuotes( ';' ),
127                $dbr->buildStringCast( 'SUM( fr_size )' )
128            ] );
129            return [
130                'tables' => [ 'file', 'filetypes', 'filerevision' ],
131                'fields' => [
132                    'title' => $fakeTitle,
133                    'namespace' => NS_MEDIA, /* needs to be something */
134                    'value' => '1'
135                ],
136                'conds' => [
137                    'file_deleted' => 0
138                ],
139                'options' => [
140                    'GROUP BY' => [
141                        'file_type',
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 [ 'file_type', 'count(*)', 'ft_media_type', '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                [ 'class' => [
374                    'mw-mediastats-mediatype',
375                    'mw-mediastats-mediatype-' . strtolower( $mediaType )
376                ] ],
377                // for grep
378                // mediastatistics-header-unknown, mediastatistics-header-bitmap,
379                // mediastatistics-header-drawing, mediastatistics-header-audio,
380                // mediastatistics-header-video, mediastatistics-header-multimedia,
381                // mediastatistics-header-office, mediastatistics-header-text,
382                // mediastatistics-header-executable, mediastatistics-header-archive,
383                // mediastatistics-header-3d,
384                $this->msg( 'mediastatistics-header-' . strtolower( $mediaType ) )->text()
385            )
386        );
387        /** @todo Possibly could add a message here explaining what the different types are.
388         *    not sure if it is needed though.
389         */
390    }
391
392    /**
393     * parse the fake title format that this special page abuses querycache with.
394     *
395     * @param string $fakeTitle A string formatted as <media type>;<mime type>;<count>;<bytes>
396     * @return array The constituent parts of $fakeTitle
397     */
398    private function splitFakeTitle( $fakeTitle ) {
399        return explode( ';', $fakeTitle, 4 );
400    }
401
402    /**
403     * What group to put the page in
404     * @return string
405     */
406    protected function getGroupName() {
407        return 'media';
408    }
409
410    /** @inheritDoc */
411    public function formatResult( $skin, $result ) {
412        return false;
413    }
414
415    /**
416     * Initialize total values so we can figure out percentages later.
417     *
418     * @param IReadableDatabase $dbr
419     * @param IResultWrapper $res
420     */
421    public function preprocessResults( $dbr, $res ) {
422        $this->executeLBFromResultWrapper( $res );
423        $this->totalCount = $this->totalBytes = 0;
424        foreach ( $res as $row ) {
425            $mediaStats = $this->splitFakeTitle( $row->title );
426            $this->totalCount += $mediaStats[2] ?? 0;
427            $this->totalBytes += $mediaStats[3] ?? 0;
428        }
429        $res->seek( 0 );
430    }
431}
432
433/**
434 * Retain the old class name for backwards compatibility.
435 * @deprecated since 1.41
436 */
437class_alias( SpecialMediaStatistics::class, 'SpecialMediaStatistics' );