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