includes/Campaign.php
<?php
namespace MediaWiki\Extension\UploadWizard;
use InvalidArgumentException;
use Language;
use MediaWiki\Category\Category;
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Title\Title;
use Parser;
use ParserOptions;
use RequestContext;
use WANObjectCache;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\SelectQueryBuilder;
/**
* Class that represents a single upload campaign.
* An upload campaign is stored as a row in the uw_campaigns table,
* and its configuration is stored in the Campaign: namespace
*
* This class is 'readonly' - to modify the campaigns, please
* edit the appropriate Campaign: namespace page
*
* @file
* @ingroup Upload
*
* @since 1.2
*
* @license GPL-2.0-or-later
* @author Yuvi Panda <yuvipanda@gmail.com>
* @author Jeroen De Dauw < jeroendedauw@gmail.com >
*/
class Campaign {
/**
* The campaign configuration.
*
* @since 1.2
* @var array
*/
protected $config = [];
/**
* The campaign configuration, after wikitext properties have been parsed.
*
* @since 1.2
* @var array|null
*/
protected $parsedConfig = null;
/**
* Array of templates used in this campaign.
* Each item is an array with ( namespace, template_title )
* Stored without deduplication
*
* @since 1.2
* @var array
*/
protected $templates = [];
/**
* The Title representing the current campaign
*
* @since 1.4
* @var Title|null
*/
protected $title = null;
/**
* The RequestContext to use for operations performed from this object
*
* @since 1.4
* @var RequestContext|null
*/
protected $context = null;
/** @var WANObjectCache */
private $wanObjectCache;
/** @var \Wikimedia\Rdbms\IReadableDatabase */
private $dbr;
/** @var Parser */
private $parser;
/** @var \MediaWiki\Interwiki\InterwikiLookup */
private $interwikiLookup;
public static function newFromName( $name ) {
$campaignTitle = Title::makeTitleSafe( NS_CAMPAIGN, $name );
if ( $campaignTitle === null || !$campaignTitle->exists() ) {
return false;
}
return new Campaign( $campaignTitle );
}
public function __construct( $title, $config = null, $context = null ) {
$services = MediaWikiServices::getInstance();
$this->wanObjectCache = $services->getMainWANObjectCache();
$this->dbr = $services->getDBLoadBalancerFactory()->getReplicaDatabase();
$this->parser = $services->getParser();
$this->interwikiLookup = $services->getInterwikiLookup();
$wikiPageFactory = $services->getWikiPageFactory();
$this->title = $title;
if ( $config === null ) {
$content = $wikiPageFactory->newFromTitle( $title )->getContent();
if ( !$content instanceof CampaignContent ) {
throw new InvalidArgumentException( 'Wrong content model' );
}
$this->config = $content->getJsonData();
} else {
$this->config = $config;
}
if ( $context === null ) {
$this->context = RequestContext::getMain();
} else {
$this->context = $context;
}
}
/**
* Returns true if current campaign is enabled
*
* @since 1.4
*
* @return bool
*/
public function getIsEnabled() {
return $this->config !== null && $this->config['enabled'];
}
/**
* Returns name of current campaign
*
* @since 1.4
*
* @return string
*/
public function getName() {
return $this->title->getDBkey();
}
public function getTitle() {
return $this->title;
}
public function getTrackingCategory() {
$trackingCats = Config::getSetting( 'trackingCategory' );
return Title::makeTitleSafe(
NS_CATEGORY, str_replace( '$1', $this->getName(), $trackingCats['campaign'] )
);
}
public function getUploadedMediaCount() {
return Category::newFromTitle( $this->getTrackingCategory() )->getFileCount();
}
public function getTotalContributorsCount() {
$dbr = $this->dbr;
$fname = __METHOD__;
return $this->wanObjectCache->getWithSetCallback(
$this->wanObjectCache->makeKey( 'uploadwizard-campaign-contributors-count', $this->getName() ),
Config::getSetting( 'campaignStatsMaxAge' ),
function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname, $dbr ) {
$setOpts += Database::getCacheSetOptions( $dbr );
return $dbr->newSelectQueryBuilder()
->select( [ 'count' => 'COUNT(DISTINCT img_actor)' ] )
->from( 'categorylinks' )
->join( 'page', null, 'cl_from=page_id' )
->join( 'image', null, 'page_title=img_name' )
->where( [ 'cl_to' => $this->getTrackingCategory()->getDBkey(), 'cl_type' => 'file' ] )
->caller( $fname )
->useIndex( [ 'categorylinks' => 'cl_timestamp' ] )
->fetchField();
}
);
}
/**
* @param int $limit
*
* @return Title[]
*/
public function getUploadedMedia( $limit = 24 ) {
$result = $this->dbr->newSelectQueryBuilder()
->select( [ 'cl_from', 'page_namespace', 'page_title' ] )
->from( 'categorylinks' )
->join( 'page', null, 'cl_from=page_id' )
->where( [ 'cl_to' => $this->getTrackingCategory()->getDBkey(), 'cl_type' => 'file' ] )
->orderBy( 'cl_timestamp', SelectQueryBuilder::SORT_DESC )
->limit( $limit )
->useIndex( [ 'categorylinks' => 'cl_timestamp' ] )
->caller( __METHOD__ )
->fetchResultSet();
$images = [];
foreach ( $result as $row ) {
$images[] = Title::makeTitle( $row->page_namespace, $row->page_title );
}
return $images;
}
/**
* Returns all set config properties.
* Property name => property value
*
* @since 1.2
*
* @return array
*/
public function getRawConfig() {
return $this->config;
}
/**
* Update internal list of templates used in parsing this campaign
*
* @param ParserOutput $parserOutput
*/
private function updateTemplates( ParserOutput $parserOutput ) {
$templateIds = $parserOutput->getTemplateIds();
foreach ( $parserOutput->getTemplates() as $ns => $templates ) {
foreach ( $templates as $dbk => $id ) {
$this->templates[$ns][$dbk] = [ $id, $templateIds[$ns][$dbk] ];
}
}
}
/**
* Wrapper around OutputPage::parseInline
*
* @param string $value Wikitext to parse
* @param Language $lang
*
* @since 1.3
*
* @return string HTML
*/
private function parseValue( $value, Language $lang ) {
$parserOptions = ParserOptions::newFromContext( $this->context );
$parserOptions->setInterfaceMessage( true );
$parserOptions->setUserLang( $lang );
$parserOptions->setTargetLanguage( $lang );
$output = $this->parser->parse(
$value, $this->getTitle(), $parserOptions
);
$parsed = $output->getText( [
'enableSectionEditLinks' => false,
] );
$this->updateTemplates( $output );
return Parser::stripOuterParagraph( $parsed );
}
/**
* Parses the values in an assoc array as wikitext
*
* @param array $array
* @param Language $lang
* @param array|null $forKeys Array of keys whose values should be parsed
*
* @since 1.3
*
* @return array
*/
private function parseArrayValues( $array, Language $lang, $forKeys = null ) {
$parsed = [];
foreach ( $array as $key => $value ) {
if ( $forKeys !== null ) {
if ( in_array( $key, $forKeys ) ) {
if ( is_array( $value ) ) {
$parsed[$key] = $this->parseArrayValues( $value, $lang );
} else {
$parsed[$key] = $this->parseValue( $value, $lang );
}
} else {
$parsed[$key] = $value;
}
} elseif ( is_array( $value ) ) {
$parsed[$key] = $this->parseArrayValues( $value, $lang );
} else {
$parsed[$key] = $this->parseValue( $value, $lang );
}
}
return $parsed;
}
/**
* Returns all config parameters, after parsing the wikitext based ones
*
* @since 1.3
*
* @param Language|null $lang
* @return array
*/
public function getParsedConfig( Language $lang = null ) {
if ( $lang === null ) {
$lang = $this->context->getLanguage();
}
// We check if the parsed config for this campaign is cached. If it is available in cache,
// we then check to make sure that it is the latest version - by verifying that its
// timestamp is greater than or equal to the timestamp of the last time an invalidate was
// issued.
$memKey = $this->wanObjectCache->makeKey(
'uploadwizard-campaign',
$this->getName(),
'parsed-config',
$lang->getCode()
);
$depKeys = [ $this->makeInvalidateTimestampKey( $this->wanObjectCache ) ];
$curTTL = null;
$memValue = $this->wanObjectCache->get( $memKey, $curTTL, $depKeys );
if ( is_array( $memValue ) && $curTTL > 0 ) {
$this->parsedConfig = $memValue['config'];
}
if ( $this->parsedConfig === null ) {
$parsedConfig = [];
foreach ( $this->config as $key => $value ) {
switch ( $key ) {
case "title":
case "description":
$parsedConfig[$key] = $this->parseValue( $value, $lang );
break;
case "display":
foreach ( $value as $option => $optionValue ) {
if ( is_array( $optionValue ) ) {
$parsedConfig['display'][$option] = $this->parseArrayValues(
$optionValue,
$lang,
[ 'label' ]
);
} else {
$parsedConfig['display'][$option] = $this->parseValue( $optionValue, $lang );
}
}
break;
case "fields":
$parsedConfig['fields'] = [];
foreach ( $value as $field ) {
$parsedConfig['fields'][] = $this->parseArrayValues(
$field,
$lang,
[ 'label', 'options' ]
);
}
break;
case "whileActive":
case "afterActive":
case "beforeActive":
if ( array_key_exists( 'display', $value ) ) {
$value['display'] = $this->parseArrayValues( $value['display'], $lang );
}
$parsedConfig[$key] = $value;
break;
default:
$parsedConfig[$key] = $value;
break;
}
}
$this->parsedConfig = $parsedConfig;
$this->wanObjectCache->set( $memKey, [ 'timestamp' => time(), 'config' => $parsedConfig ] );
}
$uwDefaults = Config::getSetting( 'defaults' );
if ( array_key_exists( 'objref', $uwDefaults ) ) {
$this->applyObjectReferenceToButtons( $uwDefaults['objref'] );
}
$this->modifyIfNecessary();
return $this->parsedConfig;
}
/**
* Modifies the parsed config if there are time-based modifiers that are active.
*/
protected function modifyIfNecessary() {
foreach ( $this->parsedConfig as $cnf => $modifiers ) {
if ( $cnf === 'whileActive' && $this->isActive() ) {
$activeModifiers = $modifiers;
} elseif ( $cnf === 'afterActive' && $this->wasActive() ) {
$activeModifiers = $modifiers;
} elseif ( $cnf === 'beforeActive' ) {
$activeModifiers = $modifiers;
}
}
if ( isset( $activeModifiers ) ) {
foreach ( $activeModifiers as $cnf => $modifier ) {
switch ( $cnf ) {
case "autoAdd":
case "display":
if ( !array_key_exists( $cnf, $this->parsedConfig ) ) {
$this->parsedConfig[$cnf] = [];
}
$this->parsedConfig[$cnf] = array_merge( $this->parsedConfig[$cnf], $modifier );
break;
}
}
}
}
/**
* Returns the templates used in this Campaign's config
*
* @return array [ns => [ dbk => [page_id, rev_id ] ] ]
*/
public function getTemplates() {
if ( $this->parsedConfig === null ) {
$this->getParsedConfig();
}
return $this->templates;
}
/**
* Invalidate the cache for this campaign, in all languages
*
* Does so by simply writing a new invalidate timestamp to memcached.
* Since this invalidate timestamp is checked on every read, the cached entries
* for the campaign will be regenerated the next time there is a read.
*/
public function invalidateCache() {
$this->wanObjectCache->touchCheckKey( $this->makeInvalidateTimestampKey( $this->wanObjectCache ) );
}
/**
* Returns key used to store the last time the cache for a particular campaign was invalidated
*
* @param WANObjectCache $cache
* @return string
*/
private function makeInvalidateTimestampKey( WANObjectCache $cache ) {
return $cache->makeKey(
'uploadwizard-campaign',
$this->getName(),
'parsed-config',
'invalidate-timestamp'
);
}
/**
* Checks the current date against the configured start and end dates to determine
* whether the campaign is currently active.
*
* @return bool
*/
private function isActive() {
$now = time();
$start = array_key_exists(
'start', $this->parsedConfig
) ? strtotime( $this->parsedConfig['start'] ) : null;
$end = array_key_exists(
'end', $this->parsedConfig
) ? strtotime( $this->parsedConfig['end'] ) : null;
return ( $start === null || $start <= $now ) && ( $end === null || $end > $now );
}
/**
* Checks the current date against the configured start and end dates to determine
* whether the campaign was active in the past (and is not anymore)
*
* @return bool
*/
private function wasActive() {
$now = time();
$start = array_key_exists(
'start', $this->parsedConfig
) ? strtotime( $this->parsedConfig['start'] ) : null;
return ( $start === null || $start <= $now ) && !$this->isActive();
}
/**
* Generate the URL out of the object reference
*
* @param string $objRef
* @return bool|string
*/
private function getButtonHrefByObjectReference( $objRef ) {
$arrObjRef = explode( '|', $objRef );
if ( count( $arrObjRef ) > 1 ) {
[ $wiki, $title ] = $arrObjRef;
if ( $this->interwikiLookup->isValidInterwiki( $wiki ) ) {
return str_replace( '$1', $title, $this->interwikiLookup->fetch( $wiki )->getURL() );
}
}
return false;
}
/**
* Apply given object reference to buttons configured to use it as href
*
* @param string $objRef
*/
private function applyObjectReferenceToButtons( $objRef ) {
$customizableButtons = [ 'homeButton', 'beginButton' ];
foreach ( $customizableButtons as $button ) {
if ( isset( $this->parsedConfig['display'][$button]['target'] ) &&
$this->parsedConfig['display'][$button]['target'] === 'useObjref'
) {
$validUrl = $this->getButtonHrefByObjectReference( $objRef );
if ( $validUrl ) {
$this->parsedConfig['display'][$button]['target'] = $validUrl;
} else {
unset( $this->parsedConfig['display'][$button] );
}
}
}
}
}