Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.51% covered (warning)
50.51%
99 / 196
27.27% covered (danger)
27.27%
6 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Campaign
50.51% covered (warning)
50.51%
99 / 196
27.27% covered (danger)
27.27%
6 / 22
855.76
0.00% covered (danger)
0.00%
0 / 1
 newFromName
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 __construct
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
4.49
 getIsEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTrackingCategory
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getUploadedMediaCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTotalContributorsCount
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 getUploadedMedia
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getRawConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateTemplates
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 parseValue
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 parseArrayValues
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
7.33
 getParsedConfig
70.91% covered (warning)
70.91%
39 / 55
0.00% covered (danger)
0.00%
0 / 1
27.89
 modifyIfNecessary
33.33% covered (danger)
33.33%
5 / 15
0.00% covered (danger)
0.00%
0 / 1
54.67
 getTemplates
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 invalidateCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeInvalidateTimestampKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isActive
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 wasActive
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getButtonHrefByObjectReference
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 applyObjectReferenceToButtons
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3namespace MediaWiki\Extension\UploadWizard;
4
5use InvalidArgumentException;
6use Language;
7use MediaWiki\Category\Category;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\Parser\ParserOutput;
10use MediaWiki\Title\Title;
11use Parser;
12use ParserOptions;
13use RequestContext;
14use WANObjectCache;
15use Wikimedia\Rdbms\Database;
16use Wikimedia\Rdbms\SelectQueryBuilder;
17
18/**
19 * Class that represents a single upload campaign.
20 * An upload campaign is stored as a row in the uw_campaigns table,
21 * and its configuration is stored in the Campaign: namespace
22 *
23 * This class is 'readonly' - to modify the campaigns, please
24 * edit the appropriate Campaign: namespace page
25 *
26 * @file
27 * @ingroup Upload
28 *
29 * @since 1.2
30 *
31 * @license GPL-2.0-or-later
32 * @author Yuvi Panda <yuvipanda@gmail.com>
33 * @author Jeroen De Dauw < jeroendedauw@gmail.com >
34 */
35class Campaign {
36
37    /**
38     * The campaign configuration.
39     *
40     * @since 1.2
41     * @var array
42     */
43    protected $config = [];
44
45    /**
46     * The campaign configuration, after wikitext properties have been parsed.
47     *
48     * @since 1.2
49     * @var array|null
50     */
51    protected $parsedConfig = null;
52
53    /**
54     * Array of templates used in this campaign.
55     * Each item is an array with ( namespace, template_title )
56     * Stored without deduplication
57     *
58     * @since 1.2
59     * @var array
60     */
61    protected $templates = [];
62
63    /**
64     * The Title representing the current campaign
65     *
66     * @since 1.4
67     * @var Title|null
68     */
69    protected $title = null;
70
71    /**
72     * The RequestContext to use for operations performed from this object
73     *
74     * @since 1.4
75     * @var RequestContext|null
76     */
77    protected $context = null;
78
79    /** @var WANObjectCache */
80    private $wanObjectCache;
81
82    /** @var \Wikimedia\Rdbms\IReadableDatabase */
83    private $dbr;
84
85    /** @var Parser */
86    private $parser;
87
88    /** @var \MediaWiki\Interwiki\InterwikiLookup */
89    private $interwikiLookup;
90
91    public static function newFromName( $name ) {
92        $campaignTitle = Title::makeTitleSafe( NS_CAMPAIGN, $name );
93        if ( $campaignTitle === null || !$campaignTitle->exists() ) {
94            return false;
95        }
96
97        return new Campaign( $campaignTitle );
98    }
99
100    public function __construct( $title, $config = null, $context = null ) {
101        $services = MediaWikiServices::getInstance();
102        $this->wanObjectCache = $services->getMainWANObjectCache();
103        $this->dbr = $services->getDBLoadBalancerFactory()->getReplicaDatabase();
104        $this->parser = $services->getParser();
105        $this->interwikiLookup = $services->getInterwikiLookup();
106        $wikiPageFactory = $services->getWikiPageFactory();
107
108        $this->title = $title;
109        if ( $config === null ) {
110            $content = $wikiPageFactory->newFromTitle( $title )->getContent();
111            if ( !$content instanceof CampaignContent ) {
112                throw new InvalidArgumentException( 'Wrong content model' );
113            }
114            $this->config = $content->getJsonData();
115        } else {
116            $this->config = $config;
117        }
118        if ( $context === null ) {
119            $this->context = RequestContext::getMain();
120        } else {
121            $this->context = $context;
122        }
123    }
124
125    /**
126     * Returns true if current campaign is enabled
127     *
128     * @since 1.4
129     *
130     * @return bool
131     */
132    public function getIsEnabled() {
133        return $this->config !== null && $this->config['enabled'];
134    }
135
136    /**
137     * Returns name of current campaign
138     *
139     * @since 1.4
140     *
141     * @return string
142     */
143    public function getName() {
144        return $this->title->getDBkey();
145    }
146
147    public function getTitle() {
148        return $this->title;
149    }
150
151    public function getTrackingCategory() {
152        $trackingCats = Config::getSetting( 'trackingCategory' );
153        return Title::makeTitleSafe(
154            NS_CATEGORY, str_replace( '$1', $this->getName(), $trackingCats['campaign'] )
155        );
156    }
157
158    public function getUploadedMediaCount() {
159        return Category::newFromTitle( $this->getTrackingCategory() )->getFileCount();
160    }
161
162    public function getTotalContributorsCount() {
163        $dbr = $this->dbr;
164        $fname = __METHOD__;
165
166        return $this->wanObjectCache->getWithSetCallback(
167            $this->wanObjectCache->makeKey( 'uploadwizard-campaign-contributors-count', $this->getName() ),
168            Config::getSetting( 'campaignStatsMaxAge' ),
169            function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname, $dbr ) {
170                $setOpts += Database::getCacheSetOptions( $dbr );
171
172                return $dbr->newSelectQueryBuilder()
173                    ->select( [ 'count' => 'COUNT(DISTINCT img_actor)' ] )
174                    ->from( 'categorylinks' )
175                    ->join( 'page', null, 'cl_from=page_id' )
176                    ->join( 'image', null, 'page_title=img_name' )
177                    ->where( [ 'cl_to' => $this->getTrackingCategory()->getDBkey(), 'cl_type' => 'file' ] )
178                    ->caller( $fname )
179                    ->useIndex( [ 'categorylinks' => 'cl_timestamp' ] )
180                    ->fetchField();
181            }
182        );
183    }
184
185    /**
186     * @param int $limit
187     *
188     * @return Title[]
189     */
190    public function getUploadedMedia( $limit = 24 ) {
191        $result = $this->dbr->newSelectQueryBuilder()
192            ->select( [ 'cl_from', 'page_namespace', 'page_title' ] )
193            ->from( 'categorylinks' )
194            ->join( 'page', null, 'cl_from=page_id' )
195            ->where( [ 'cl_to' => $this->getTrackingCategory()->getDBkey(), 'cl_type' => 'file' ] )
196            ->orderBy( 'cl_timestamp', SelectQueryBuilder::SORT_DESC )
197            ->limit( $limit )
198            ->useIndex( [ 'categorylinks' => 'cl_timestamp' ] )
199            ->caller( __METHOD__ )
200            ->fetchResultSet();
201
202        $images = [];
203        foreach ( $result as $row ) {
204            $images[] = Title::makeTitle( $row->page_namespace, $row->page_title );
205        }
206
207        return $images;
208    }
209
210    /**
211     * Returns all set config properties.
212     * Property name => property value
213     *
214     * @since 1.2
215     *
216     * @return array
217     */
218    public function getRawConfig() {
219        return $this->config;
220    }
221
222    /**
223     * Update internal list of templates used in parsing this campaign
224     *
225     * @param ParserOutput $parserOutput
226     */
227    private function updateTemplates( ParserOutput $parserOutput ) {
228        $templateIds = $parserOutput->getTemplateIds();
229        foreach ( $parserOutput->getTemplates() as $ns => $templates ) {
230            foreach ( $templates as $dbk => $id ) {
231                $this->templates[$ns][$dbk] = [ $id, $templateIds[$ns][$dbk] ];
232            }
233        }
234    }
235
236    /**
237     * Wrapper around OutputPage::parseInline
238     *
239     * @param string $value Wikitext to parse
240     * @param Language $lang
241     *
242     * @since 1.3
243     *
244     * @return string HTML
245     */
246    private function parseValue( $value, Language $lang ) {
247        $parserOptions = ParserOptions::newFromContext( $this->context );
248        $parserOptions->setInterfaceMessage( true );
249        $parserOptions->setUserLang( $lang );
250        $parserOptions->setTargetLanguage( $lang );
251
252        $output = $this->parser->parse(
253            $value, $this->getTitle(), $parserOptions
254        );
255        $parsed = $output->getText( [
256            'enableSectionEditLinks' => false,
257        ] );
258
259        $this->updateTemplates( $output );
260
261        return Parser::stripOuterParagraph( $parsed );
262    }
263
264    /**
265     * Parses the values in an assoc array as wikitext
266     *
267     * @param array $array
268     * @param Language $lang
269     * @param array|null $forKeys Array of keys whose values should be parsed
270     *
271     * @since 1.3
272     *
273     * @return array
274     */
275    private function parseArrayValues( $array, Language $lang, $forKeys = null ) {
276        $parsed = [];
277        foreach ( $array as $key => $value ) {
278            if ( $forKeys !== null ) {
279                if ( in_array( $key, $forKeys ) ) {
280                    if ( is_array( $value ) ) {
281                        $parsed[$key] = $this->parseArrayValues( $value, $lang );
282                    } else {
283                        $parsed[$key] = $this->parseValue( $value, $lang );
284                    }
285                } else {
286                    $parsed[$key] = $value;
287                }
288            } elseif ( is_array( $value ) ) {
289                $parsed[$key] = $this->parseArrayValues( $value, $lang );
290            } else {
291                $parsed[$key] = $this->parseValue( $value, $lang );
292            }
293        }
294        return $parsed;
295    }
296
297    /**
298     * Returns all config parameters, after parsing the wikitext based ones
299     *
300     * @since 1.3
301     *
302     * @param Language|null $lang
303     * @return array
304     */
305    public function getParsedConfig( Language $lang = null ) {
306        if ( $lang === null ) {
307            $lang = $this->context->getLanguage();
308        }
309
310        // We check if the parsed config for this campaign is cached. If it is available in cache,
311        // we then check to make sure that it is the latest version - by verifying that its
312        // timestamp is greater than or equal to the timestamp of the last time an invalidate was
313        // issued.
314        $memKey = $this->wanObjectCache->makeKey(
315            'uploadwizard-campaign',
316            $this->getName(),
317            'parsed-config',
318            $lang->getCode()
319        );
320        $depKeys = [ $this->makeInvalidateTimestampKey( $this->wanObjectCache ) ];
321
322        $curTTL = null;
323        $memValue = $this->wanObjectCache->get( $memKey, $curTTL, $depKeys );
324        if ( is_array( $memValue ) && $curTTL > 0 ) {
325            $this->parsedConfig = $memValue['config'];
326        }
327
328        if ( $this->parsedConfig === null ) {
329            $parsedConfig = [];
330            foreach ( $this->config as $key => $value ) {
331                switch ( $key ) {
332                    case "title":
333                    case "description":
334                        $parsedConfig[$key] = $this->parseValue( $value, $lang );
335                        break;
336                    case "display":
337                        foreach ( $value as $option => $optionValue ) {
338                            if ( is_array( $optionValue ) ) {
339                                $parsedConfig['display'][$option] = $this->parseArrayValues(
340                                    $optionValue,
341                                    $lang,
342                                    [ 'label' ]
343                                );
344                            } else {
345                                $parsedConfig['display'][$option] = $this->parseValue( $optionValue, $lang );
346                            }
347                        }
348                        break;
349                    case "fields":
350                        $parsedConfig['fields'] = [];
351                        foreach ( $value as $field ) {
352                            $parsedConfig['fields'][] = $this->parseArrayValues(
353                                $field,
354                                $lang,
355                                [ 'label', 'options' ]
356                            );
357                        }
358                        break;
359                    case "whileActive":
360                    case "afterActive":
361                    case "beforeActive":
362                        if ( array_key_exists( 'display', $value ) ) {
363                            $value['display'] = $this->parseArrayValues( $value['display'], $lang );
364                        }
365                        $parsedConfig[$key] = $value;
366                        break;
367                    default:
368                        $parsedConfig[$key] = $value;
369                        break;
370                }
371            }
372
373            $this->parsedConfig = $parsedConfig;
374
375            $this->wanObjectCache->set( $memKey, [ 'timestamp' => time(), 'config' => $parsedConfig ] );
376        }
377
378        $uwDefaults = Config::getSetting( 'defaults' );
379        if ( array_key_exists( 'objref', $uwDefaults ) ) {
380            $this->applyObjectReferenceToButtons( $uwDefaults['objref'] );
381        }
382        $this->modifyIfNecessary();
383
384        return $this->parsedConfig;
385    }
386
387    /**
388     * Modifies the parsed config if there are time-based modifiers that are active.
389     */
390    protected function modifyIfNecessary() {
391        foreach ( $this->parsedConfig as $cnf => $modifiers ) {
392            if ( $cnf === 'whileActive' && $this->isActive() ) {
393                $activeModifiers = $modifiers;
394            } elseif ( $cnf === 'afterActive' && $this->wasActive() ) {
395                $activeModifiers = $modifiers;
396            } elseif ( $cnf === 'beforeActive' ) {
397                $activeModifiers = $modifiers;
398            }
399        }
400
401        if ( isset( $activeModifiers ) ) {
402            foreach ( $activeModifiers as $cnf => $modifier ) {
403                switch ( $cnf ) {
404                    case "autoAdd":
405                    case "display":
406                        if ( !array_key_exists( $cnf, $this->parsedConfig ) ) {
407                            $this->parsedConfig[$cnf] = [];
408                        }
409
410                        $this->parsedConfig[$cnf] = array_merge( $this->parsedConfig[$cnf], $modifier );
411                        break;
412                }
413            }
414        }
415    }
416
417    /**
418     * Returns the templates used in this Campaign's config
419     *
420     * @return array [ns => [ dbk => [page_id, rev_id ] ] ]
421     */
422    public function getTemplates() {
423        if ( $this->parsedConfig === null ) {
424            $this->getParsedConfig();
425        }
426        return $this->templates;
427    }
428
429    /**
430     * Invalidate the cache for this campaign, in all languages
431     *
432     * Does so by simply writing a new invalidate timestamp to memcached.
433     * Since this invalidate timestamp is checked on every read, the cached entries
434     * for the campaign will be regenerated the next time there is a read.
435     */
436    public function invalidateCache() {
437        $this->wanObjectCache->touchCheckKey( $this->makeInvalidateTimestampKey( $this->wanObjectCache ) );
438    }
439
440    /**
441     * Returns key used to store the last time the cache for a particular campaign was invalidated
442     *
443     * @param WANObjectCache $cache
444     * @return string
445     */
446    private function makeInvalidateTimestampKey( WANObjectCache $cache ) {
447        return $cache->makeKey(
448            'uploadwizard-campaign',
449            $this->getName(),
450            'parsed-config',
451            'invalidate-timestamp'
452        );
453    }
454
455    /**
456     * Checks the current date against the configured start and end dates to determine
457     * whether the campaign is currently active.
458     *
459     * @return bool
460     */
461    private function isActive() {
462        $today = strtotime( date( "Y-m-d" ) );
463        $start = array_key_exists(
464            'start', $this->parsedConfig
465        ) ? strtotime( $this->parsedConfig['start'] ) : null;
466        $end = array_key_exists(
467            'end', $this->parsedConfig
468        ) ? strtotime( $this->parsedConfig['end'] ) : null;
469
470        return ( $start === null || $start <= $today ) && ( $end === null || $end > $today );
471    }
472
473    /**
474     * Checks the current date against the configured start and end dates to determine
475     * whether the campaign was active in the past (and is not anymore)
476     *
477     * @return bool
478     */
479    private function wasActive() {
480        $today = strtotime( date( "Y-m-d" ) );
481        $start = array_key_exists(
482            'start', $this->parsedConfig
483        ) ? strtotime( $this->parsedConfig['start'] ) : null;
484
485        return ( $start === null || $start <= $today ) && !$this->isActive();
486    }
487
488    /**
489     * Generate the URL out of the object reference
490     *
491     * @param string $objRef
492     * @return bool|string
493     */
494    private function getButtonHrefByObjectReference( $objRef ) {
495        $arrObjRef = explode( '|', $objRef );
496        if ( count( $arrObjRef ) > 1 ) {
497            [ $wiki, $title ] = $arrObjRef;
498            if ( $this->interwikiLookup->isValidInterwiki( $wiki ) ) {
499                return str_replace( '$1', $title, $this->interwikiLookup->fetch( $wiki )->getURL() );
500            }
501        }
502        return false;
503    }
504
505    /**
506     * Apply given object reference to buttons configured to use it as href
507     *
508     * @param string $objRef
509     */
510    private function applyObjectReferenceToButtons( $objRef ) {
511        $customizableButtons = [ 'homeButton', 'beginButton' ];
512
513        foreach ( $customizableButtons as $button ) {
514            if ( isset( $this->parsedConfig['display'][$button]['target'] ) &&
515                $this->parsedConfig['display'][$button]['target'] === 'useObjref'
516            ) {
517                $validUrl = $this->getButtonHrefByObjectReference( $objRef );
518                if ( $validUrl ) {
519                    $this->parsedConfig['display'][$button]['target'] = $validUrl;
520                } else {
521                    unset( $this->parsedConfig['display'][$button] );
522                }
523            }
524        }
525    }
526}