| Current File : /home/jvzmxxx/wiki/extensions/Flow/Hooks.php |
<?php
use Flow\Collection\PostCollection;
use Flow\Container;
use Flow\Conversion\Utils;
use Flow\Exception\FlowException;
use Flow\Exception\PermissionException;
use Flow\Data\Listener\RecentChangesListener;
use Flow\Formatter\CheckUserQuery;
use Flow\Import\OptInUpdate;
use Flow\Model\UUID;
use Flow\OccupationController;
use Flow\SpamFilter\AbuseFilter;
use Flow\TalkpageManager;
use Flow\WorkflowLoader;
use Flow\WorkflowLoaderFactory;
class FlowHooks {
/**
* @var OccupationController Initialized during extension initialization
*/
protected static $occupationController;
/**
* @var AbuseFilter Initialized during extension initialization
*/
protected static $abuseFilter;
public static function onResourceLoaderRegisterModules( ResourceLoader &$resourceLoader ) {
global $wgFlowEventLogging;
// Only if EventLogging in Flow is enabled & EventLogging exists
if ( $wgFlowEventLogging && class_exists( 'ResourceLoaderSchemaModule' ) ) {
$resourceLoader->register( 'schema.FlowReplies', array(
'class' => 'ResourceLoaderSchemaModule',
'schema' => 'FlowReplies',
// See https://meta.wikimedia.org/wiki/Schema:FlowReplies, below title
'revision' => 10561344,
) );
}
// Register a dummy supportCheck module in case VE isn't loaded, as we attempt
// to load this module unconditionally on load.
if ( !$resourceLoader->isModuleRegistered( 'ext.visualEditor.supportCheck' ) ) {
$resourceLoader->register( 'ext.visualEditor.supportCheck', array() );
}
if ( class_exists( 'GuidedTourHooks' ) ) {
$resourceLoader->register( 'ext.guidedTour.tour.flowOptIn', array(
'localBasePath' => __DIR__ . '/modules',
'remoteExtPath' => 'Flow/modules',
'scripts' => 'tours/flowOptIn.js',
'styles' => 'tours/flowOptIn.less',
'messages' => array(
"flow-guidedtour-optin-welcome",
"flow-guidedtour-optin-welcome-description",
"flow-guidedtour-optin-find-old-conversations",
"flow-guidedtour-optin-find-old-conversations-description",
"flow-guidedtour-optin-feedback",
"flow-guidedtour-optin-feedback-description"
)
) );
}
return true;
}
public static function onBeforePageDisplay( OutputPage &$out, Skin &$skin ) {
$title = $skin->getTitle();
// Register guided tour if needed
if (
// Check that the cookie for Flow opt-in tour exists
$out->getRequest()->getCookie( 'Flow_optIn_guidedTour' ) &&
// Check that the user is on their own talk page
$out->getUser()->getTalkPage()->equals( $title ) &&
// Check that we are on a flow board
$title->getContentModel() === CONTENT_MODEL_FLOW_BOARD &&
// Check that guided tour exists
class_exists( 'GuidedTourHooks' )
) {
// Activate tour
GuidedTourLauncher::launchTourByCookie( 'flowOptIn', 'newTopic' );
// Destroy Flow cookie
$out->getRequest()->response()->setcookie( 'Flow_optIn_guidedTour', '', time() - 3600);
}
}
/**
* Initialized during extension initialization rather than
* in container so that non-flow pages don't load the container.
*
* @return OccupationController
*/
public static function getOccupationController() {
if ( self::$occupationController === null ) {
self::$occupationController = new TalkpageManager();
}
return self::$occupationController;
}
/**
* Initialized during extension initialization rather than
* in container so that non-flow pages don't load the container.
*
* @return AbuseFilter
*/
public static function getAbuseFilter() {
if ( self::$abuseFilter === null ) {
global $wgFlowAbuseFilterGroup,
$wgFlowAbuseFilterEmergencyDisableThreshold,
$wgFlowAbuseFilterEmergencyDisableCount,
$wgFlowAbuseFilterEmergencyDisableAge;
self::$abuseFilter = new AbuseFilter( $wgFlowAbuseFilterGroup );
self::$abuseFilter->setup( array(
'threshold' => $wgFlowAbuseFilterEmergencyDisableThreshold,
'count' => $wgFlowAbuseFilterEmergencyDisableCount,
'age' => $wgFlowAbuseFilterEmergencyDisableAge,
) );
}
return self::$abuseFilter;
}
/**
* Initialize Flow extension with necessary data, this function is invoked
* from $wgExtensionFunctions
*/
public static function initFlowExtension() {
global $wgFlowContentFormat;
// needed to determine if a page is occupied by flow
self::getOccupationController();
// necessary to provide flow options in abuse filter on-wiki pages
global $wgFlowAbuseFilterGroup;
if ( $wgFlowAbuseFilterGroup ) {
self::getAbuseFilter();
}
if ( $wgFlowContentFormat === 'html' && !Utils::isParsoidConfigured() ) {
wfDebugLog( 'Flow', __METHOD__ . ': Warning: $wgFlowContentFormat was set to \'html\', but you do not have Parsoid enabled. Changing $wgFlowContentFormat to \'wikitext\'' );
$wgFlowContentFormat = 'wikitext';
}
// development dependencies to simplify testing
if ( defined( 'MW_PHPUNIT_TEST' ) && file_exists( __DIR__ . '/vendor/autoload.php' ) ) {
require_once __DIR__ . '/vendor/autoload.php';
}
global $wgGrantPermissions;
// This is semantically equivalent to editing a talk page and
// blanking an offending post or topic.
$wgGrantPermissions['editpage']['flow-hide'] = true;
// We might want to make a separate grant for this, so it can be
// given out without giving out core 'protect'.
$wgGrantPermissions['protect']['flow-lock'] = true;
$wgGrantPermissions['delete']['flow-delete'] = true;
$wgGrantPermissions['delete']['flow-suppress'] = true;
$wgGrantPermissions['editpage']['flow-edit-post'] = true;
// Creating a board somewhere it normally can't be created is sort
// of like creating a page that can't normally be edited. But
// maybe make a grant.
$wgGrantPermissions['editprotected']['flow-create-board'] = true;
}
/**
* Reset anything that happened in self::initFlowExtension for
* unit tests
*/
public static function resetFlowExtension() {
self::$abuseFilter = null;
self::$occupationController = null;
}
/**
* Hook: LoadExtensionSchemaUpdates
*
* @param $updater DatabaseUpdater object
* @return bool true in all cases
*/
public static function getSchemaUpdates( DatabaseUpdater $updater ) {
$dir = __DIR__;
$baseSQLFile = "$dir/flow.sql";
$updater->addExtensionTable( 'flow_revision', $baseSQLFile );
$updater->addExtensionField( 'flow_revision', 'rev_last_edit_id', "$dir/db_patches/patch-revision_last_editor.sql" );
$updater->addExtensionField( 'flow_revision', 'rev_mod_reason', "$dir/db_patches/patch-moderation_reason.sql" );
if ( $updater->getDB()->getType() === 'sqlite' ) {
$updater->modifyExtensionField( 'flow_summary_revision', 'summary_workflow_id', "$dir/db_patches/patch-summary2header.sqlite.sql" );
$updater->modifyExtensionField( 'flow_revision', 'rev_comment', "$dir/db_patches/patch-rev_change_type.sqlite.sql" );
// sqlite ignores field types, this just substr's uuid's to 88 bits
$updater->modifyExtensionField( 'flow_workflow', 'workflow_id', "$dir/db_patches/patch-88bit_uuids.sqlite.sql" );
$updater->addExtensionField( 'flow_workflow', 'workflow_type', "$dir/db_patches/patch-add_workflow_type.sqlite" );
$updater->modifyExtensionField( 'flow_workflow', 'workflow_user_id', "$dir/db_patches/patch-default_null_workflow_user.sqlite.sql" );
} else {
// sqlite doesn't support alter table change, it also considers all types the same so
// this patch doesn't matter to it.
$updater->modifyExtensionField( 'flow_subscription', 'subscription_user_id', "$dir/db_patches/patch-subscription_user_id.sql" );
// renames columns, alternate patch is above for sqlite
$updater->modifyExtensionField( 'flow_summary_revision', 'summary_workflow_id', "$dir/db_patches/patch-summary2header.sql" );
// rename rev_change_type -> rev_comment, alternate patch is above for sqlite
$updater->modifyExtensionField( 'flow_revision', 'rev_comment', "$dir/db_patches/patch-rev_change_type.sql" );
// convert 128 bit uuid's into 88bit
$updater->modifyExtensionField( 'flow_workflow', 'workflow_id', "$dir/db_patches/patch-88bit_uuids.sql" );
$updater->addExtensionField( 'flow_workflow', 'workflow_type', "$dir/db_patches/patch-add_workflow_type.sql" );
$updater->modifyExtensionField( 'flow_workflow', 'workflow_user_id', "$dir/db_patches/patch-default_null_workflow_user.sql" );
// Doesn't need SQLite support, since SQLite doesn't care about text widths.
$updater->modifyExtensionField( 'flow_workflow', 'workflow_wiki', "$dir/db_patches/patch-increase_width_wiki_fields.sql" );
}
$updater->addExtensionIndex( 'flow_workflow', 'flow_workflow_lookup', "$dir/db_patches/patch-workflow_lookup_idx.sql" );
$updater->addExtensionIndex( 'flow_topic_list', 'flow_topic_list_topic_id', "$dir/db_patches/patch-topic_list_topic_id_idx.sql" );
$updater->modifyExtensionField( 'flow_revision', 'rev_change_type', "$dir/db_patches/patch-rev_change_type_update.sql" );
$updater->modifyExtensionField( 'recentchanges', 'rc_source', "$dir/db_patches/patch-rc_source.sql" );
$updater->modifyExtensionField( 'flow_revision', 'rev_change_type', "$dir/db_patches/patch-censor_to_suppress.sql" );
$updater->addExtensionField( 'flow_revision', 'rev_user_ip', "$dir/db_patches/patch-remove_usernames.sql" );
$updater->addExtensionField( 'flow_revision', 'rev_user_wiki', "$dir/db_patches/patch-add-wiki.sql" );
$updater->addExtensionIndex( 'flow_tree_revision', 'flow_tree_descendant_rev_id', "$dir/db_patches/patch-flow_tree_idx_fix.sql" );
$updater->dropExtensionField( 'flow_tree_revision', 'tree_orig_create_time', "$dir/db_patches/patch-tree_orig_create_time.sql" );
$updater->addExtensionIndex( 'flow_revision', 'flow_revision_user', "$dir/db_patches/patch-revision_user_idx.sql" );
$updater->modifyExtensionField( 'flow_revision', 'rev_user_ip', "$dir/db_patches/patch-revision_user_ip.sql" );
$updater->addExtensionField( 'flow_revision', 'rev_type_id', "$dir/db_patches/patch-rev_type_id.sql" );
$updater->addExtensionTable( 'flow_ext_ref', "$dir/db_patches/patch-add-linkstables.sql" );
$updater->dropExtensionTable( 'flow_definition', "$dir/db_patches/patch-drop_definition.sql" );
$updater->dropExtensionField( 'flow_workflow', 'workflow_user_ip', "$dir/db_patches/patch-drop_workflow_user.sql" );
$updater->addExtensionField( 'flow_revision', 'rev_content_length', "$dir/db_patches/patch-add-revision-content-length.sql" );
$updater->dropExtensionIndex( 'flow_ext_ref', 'flow_ext_ref_pk', "$dir/db_patches/patch-remove_unique_ref_indices.sql" );
$updater->addExtensionIndex( 'flow_workflow', 'flow_workflow_update_timestamp', "$dir/db_patches/patch-flow_workflow_update_timestamp_idx.sql" );
$updater->addExtensionField( 'flow_wiki_ref', 'ref_src_wiki', "$dir/db_patches/patch-reference_wiki.sql" );
$updater->addExtensionField( 'flow_wiki_ref', 'ref_id', "$dir/db_patches/patch-ref_id-phase1.sql" );
$updater->modifyExtensionField( 'flow_ext_ref', 'ref_target', "$dir/db_patches/patch-ref_target_not_null.sql" );
require_once __DIR__.'/maintenance/FlowUpdateRecentChanges.php';
$updater->addPostDatabaseUpdateMaintenance( 'FlowUpdateRecentChanges' );
require_once __DIR__.'/maintenance/FlowSetUserIp.php';
$updater->addPostDatabaseUpdateMaintenance( 'FlowSetUserIp' );
/*
* Remove old *_user_text columns once the maintenance script that
* moves the necessary data has been run.
* This duplicates what is being done in FlowSetUserIp already, but that
* was not always the case, so that script may have already run without
* having executed this.
*/
if ( $updater->updateRowExists( 'FlowSetUserIp' ) ) {
$updater->dropExtensionField( 'flow_revision', 'rev_user_text', "$dir/db_patches/patch-remove_usernames_2.sql" );
}
require_once __DIR__.'/maintenance/FlowUpdateUserWiki.php';
$updater->addPostDatabaseUpdateMaintenance( 'FlowUpdateUserWiki' );
require_once __DIR__.'/maintenance/FlowUpdateRevisionTypeId.php';
$updater->addPostDatabaseUpdateMaintenance( 'FlowUpdateRevisionTypeId' );
require_once __DIR__.'/maintenance/FlowPopulateLinksTables.php';
$updater->addPostDatabaseUpdateMaintenance( 'FlowPopulateLinksTables' );
require_once __DIR__.'/maintenance/FlowFixLog.php';
$updater->addPostDatabaseUpdateMaintenance( 'FlowFixLog' );
require_once __DIR__.'/maintenance/FlowUpdateWorkflowPageId.php';
$updater->addPostDatabaseUpdateMaintenance( 'FlowUpdateWorkflowPageId' );
require_once __DIR__.'/maintenance/FlowCreateTemplates.php';
$updater->addPostDatabaseUpdateMaintenance( 'FlowCreateTemplates' );
require_once __DIR__.'/maintenance/FlowFixLinks.php';
$updater->addPostDatabaseUpdateMaintenance( 'FlowFixLinks' );
require_once __DIR__.'/maintenance/FlowUpdateBetaFeaturePreference.php';
$updater->addPostDatabaseUpdateMaintenance( 'FlowUpdateBetaFeaturePreference' );
require_once __DIR__.'/maintenance/FlowPopulateRefId.php';
$updater->addPostDatabaseUpdateMaintenance( 'FlowPopulateRefId' );
/*
* Add primary key, but only after we've made sure the newly added
* column has been populated (otherwise they'd all be null values)
*/
if ( $updater->updateRowExists( 'FlowPopulateRefId' ) ) {
if ( $updater->getDB()->getType() === 'sqlite' ) {
$updater->addExtensionIndex( 'flow_wiki_ref', 'PRIMARY', "$dir/db_patches/patch-ref_id-phase2.sqlite.sql" );
} else {
$updater->addExtensionIndex( 'flow_wiki_ref', 'PRIMARY', "$dir/db_patches/patch-ref_id-phase2.sql" );
}
}
return true;
}
/**
* Hook: UnitTestsList
* @see http://www.mediawiki.org/wiki/Manual:Hooks/UnitTestsList
*
* @param &$files Array of unit test files
* @return bool true in all cases
*/
static function getUnitTests( &$files ) {
$it = new RecursiveDirectoryIterator( __DIR__ . '/tests/phpunit' );
$it = new RecursiveIteratorIterator( $it );
foreach ( $it as $path => $file ) {
if ( substr( $path, -8 ) === 'Test.php' ) {
$files[] = $path;
}
}
return true;
}
/**
* Loads RecentChanges list metadata into a temporary cache for later use.
*
* @param ChangesList $changesList
* @param array $rows
*/
public static function onChangesListInitRows( ChangesList $changesList, $rows ) {
if ( !( $changesList instanceof OldChangesList || $changesList instanceof EnhancedChangesList ) ) {
return;
}
set_error_handler( new Flow\RecoverableErrorHandler, -1 );
try {
/** @var Flow\Formatter\ChangesListQuery $query */
$query = Container::get( 'query.changeslist' );
$query->loadMetadataBatch(
$rows,
$changesList->isWatchlist()
);
} catch ( Exception $e ) {
MWExceptionHandler::logException( $e );
}
restore_error_handler();
}
/**
* Updates the given Flow topic line in an enhanced changes list (grouped RecentChanges).
*
* @param ChangesList $changesList
* @param string $articlelink
* @param string $s
* @param RecentChange $rc
* @param bool $unpatrolled
* @param bool $isWatchlist
* @return bool
*/
public static function onChangesListInsertArticleLink(
ChangesList &$changesList,
&$articlelink,
&$s,
&$rc,
$unpatrolled,
$isWatchlist
) {
if ( !( $changesList instanceof EnhancedChangesList ) ) {
// This method is only to update EnhancedChangesList.
// onOldChangesListRecentChangesLine allows updating OldChangesList,
// and supports adding wrapper classes.
return true;
}
$classes = null; // avoid pass-by-reference error
return self::processRecentChangesLine( $changesList, $articlelink, $rc, $classes, true );
}
/**
* Updates a Flow line in the old changes list (standard RecentChanges).
*
* @param ChangesList $changesList
* @param string $s
* @param RecentChange $rc
* @param array $classes
* @return bool
*/
public static function onOldChangesListRecentChangesLine(
ChangesList &$changesList,
&$s,
RecentChange $rc,
&$classes = array()
) {
return self::processRecentChangesLine( $changesList, $s, $rc, $classes );
}
/**
* Does the actual work for onOldChangesListRecentChangesLine and
* onChangesListInsertArticleLink hooks. Either updates an entire
* line with meta info (old changes), or simply updates the link to
* the topic (enhanced).
*
* @param ChangesList $changesList
* @param string $s
* @param RecentChange $rc
* @param array|null $classes
* @param bool $topicOnly
* @return bool
*/
protected static function processRecentChangesLine(
ChangesList &$changesList,
&$s,
RecentChange $rc,
&$classes = null,
$topicOnly = false
) {
$source = $rc->getAttribute( 'rc_source' );
if ( $source === null ) {
$rcType = (int) $rc->getAttribute( 'rc_type' );
if ( $rcType !== RC_FLOW ) {
return true;
}
} elseif ( $source !== Flow\Data\Listener\RecentChangesListener::SRC_FLOW ) {
return true;
}
set_error_handler( new Flow\RecoverableErrorHandler, -1 );
try {
/** @var Flow\Formatter\ChangesListQuery $query */
$query = Container::get( 'query.changeslist' );
$row = $query->getResult( $changesList, $rc, $changesList->isWatchlist() );
if ( $row === false ) {
restore_error_handler();
return false;
}
/** @var Flow\Formatter\ChangesListFormatter $formatter */
$formatter = Container::get( 'formatter.changeslist' );
$line = $formatter->format( $row, $changesList, $topicOnly );
} catch ( Exception $e ) {
wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting rc ' . $rc->getAttribute( 'rc_id' ) . ' ' . $e );
MWExceptionHandler::logException( $e );
restore_error_handler();
return false;
}
restore_error_handler();
if ( $line === false ) {
return false;
}
if ( is_array( $classes ) ) {
// Add the flow class to <li>
$classes[] = 'flow-recentchanges-line';
}
// Update the line markup
$s = $line;
return true;
}
/**
* Alter the enhanced RC links: (n changes | history)
* The default diff links are incorrect!
*
* @param EnhancedChangesList $changesList
* @param array $links
* @param RecentChange[] $block
* @return bool
*/
public static function onGetLogText( $changesList, &$links, $block ) {
$rc = $block[0];
// quit if non-flow
if ( !FlowHooks::isFlow( $rc ) ) {
return true;
}
set_error_handler( new Flow\RecoverableErrorHandler, -1 );
try {
/** @var Flow\Formatter\ChangesListQuery $query */
$query = Container::get( 'query.changeslist' );
$row = $query->getResult( $changesList, $rc, $changesList->isWatchlist() );
if ( $row === false ) {
restore_error_handler();
return false;
}
/** @var Flow\Formatter\ChangesListFormatter $formatter */
$formatter = Container::get( 'formatter.changeslist' );
$logTextLinks = $formatter->getLogTextLinks( $row, $changesList, $block, $links );
} catch ( Exception $e ) {
wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting rc logtext ' . $rc->getAttribute( 'rc_id' ) . ' ' . $e );
MWExceptionHandler::logException( $e );
restore_error_handler();
return false;
}
restore_error_handler();
if ($logTextLinks === false) {
return false;
}
$links = $logTextLinks;
return true;
}
/**
* @param EnhancedChangesList $changesList
* @param array $data
* @param RecentChange[] $block
* @param RecentChange $rc
* @return bool
*/
public static function onEnhancedChangesListModifyLineData( $changesList, &$data, $block, $rc ) {
return static::onEnhancedChangesListModifyBlockLineData( $changesList, $data, $rc );
}
/**
* @param EnhancedChangesList $changesList
* @param array $data
* @param RecentChange $rc
* @return bool
*/
public static function onEnhancedChangesListModifyBlockLineData( $changesList, &$data, $rc ) {
// quit if non-flow
if ( !FlowHooks::isFlow( $rc ) ) {
return true;
}
$query = Container::get( 'query.changeslist' );
$row = $query->getResult( $changesList, $rc, $changesList->isWatchlist() );
if ( $row === false ) {
return false;
}
/** @var Flow\Formatter\ChangesListFormatter $formatter */
$formatter = Container::get( 'formatter.changeslist' );
try {
$data['timestampLink'] = $formatter->getTimestampLink( $row, $changesList );
$data['recentChangesFlags'] = $formatter->getFlags( $row, $changesList );
} catch ( PermissionException $e ) {
return false;
}
return true;
}
/**
* Checks if the given recent change entry is from Flow
* @param RecentChange $rc
* @return bool
*/
private static function isFlow( $rc ) {
$source = $rc->getAttribute( 'rc_source' );
if ( $source === null ) {
$rcType = (int) $rc->getAttribute( 'rc_type' );
return $rcType === RC_FLOW;
} else {
return $source === RecentChangesListener::SRC_FLOW;
}
}
public static function onSpecialCheckUserGetLinksFromRow( CheckUser $checkUser, $row, &$links ) {
if ( !$row->cuc_type == RC_FLOW ) {
return true;
}
set_error_handler( new Flow\RecoverableErrorHandler, -1 );
$replacement = null;
try {
/** @var CheckUserQuery $query */
$query = Container::get( 'query.checkuser' );
// @todo: create hook to allow batch-loading this data, instead of doing piecemeal like this
$query->loadMetadataBatch( array( $row ) );
$row = $query->getResult( $checkUser, $row );
if ( $row !== false ) {
/** @var Flow\Formatter\CheckUserFormatter $formatter */
$formatter = Container::get( 'formatter.checkuser' );
$replacement = $formatter->format( $row, $checkUser->getContext() );
}
} catch ( Exception $e ) {
wfDebugLog( 'Flow', __METHOD__ . ': Exception formatting cu ' . json_encode( $row ) . ' ' . $e );
MWExceptionHandler::logException( $e );
}
restore_error_handler();
if ( $replacement === null ) {
// some sort of failure, but this is a RC_FLOW so blank out hist/diff links
// which aren't correct
unset( $links['history'] );
unset( $links['diff'] );
} else {
$links = $replacement;
}
return true;
}
/**
* Regular talk page "Create source" and "Add topic" links are quite useless
* in the context of Flow boards. Let's get rid of them.
*
* @param SkinTemplate $template
* @param array $links
* @return bool
*/
public static function onSkinTemplateNavigation( SkinTemplate &$template, &$links ) {
global $wgFlowCoreActionWhitelist,
$wgMFPageActions;
$title = $template->getTitle();
// if Flow is enabled on this talk page, overrule talk page red link
if ( $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
// Turn off page actions in MobileFrontend.
// FIXME: Find more elegant standard way of doing this.
$wgMFPageActions = array();
// watch star & delete links are inside the topic itself
if ( $title->getNamespace() === NS_TOPIC ) {
unset( $links['actions']['watch'] );
unset( $links['actions']['unwatch'] );
unset( $links['actions']['delete'] );
}
// hide all views unless whitelisted
foreach ( $links['views'] as $action => $data ) {
if ( !in_array( $action, $wgFlowCoreActionWhitelist ) ) {
unset( $links['views'][$action] );
}
}
// hide all actions unless whitelisted
foreach ( $links['actions'] as $action => $data ) {
if ( !in_array( $action, $wgFlowCoreActionWhitelist ) ) {
unset( $links['actions'][$action] );
}
}
if ( isset( $links['namespaces']['topic_talk'] ) ) {
// hide discussion page in Topic namespace(which is already discussion)
unset( $links['namespaces']['topic_talk'] );
// hide protection (topic protection is done via moderation)
unset( $links['actions']['protect'] );
// topic pages are also not movable
unset( $links['actions']['move'] );
}
}
return true;
}
/**
* Interact with the mobile skin's default modules on Flow enabled pages
*
* @param Skin $skin
* @param array $modules
* @return bool
*/
public static function onSkinMinervaDefaultModules( Skin $skin, array &$modules ) {
// Disable toggling on occupied talk pages in mobile
$title = $skin->getTitle();
if ( $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
$modules['toggling'] = array();
}
// Turn off default mobile talk overlay for these pages
if ( $title->canTalk() ) {
$talkPage = $title->getTalkPage();
if ( $talkPage->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
// TODO: Insert lightweight JavaScript that opens flow via ajax
$modules['talk'] = array();
}
}
return true;
}
/**
* When a (talk) page does not exist, one of the checks being performed is
* to see if the page had once existed but was removed. In doing so, the
* deletion & move log is checked.
*
* In theory, a Flow board could overtake a non-existing talk page. If that
* board is later removed, this will be run to see if a message can be
* displayed to inform the user if the page has been deleted/moved.
*
* Since, in Flow, we also write (topic, post, ...) deletion to the deletion
* log, we don't want those to appear, since they're not actually actions
* related to that talk page (rather: they were actions on the board)
*
* @param array &$conds Array of conditions
* @param array &$logTypes Array of log types
* @return bool
*/
public static function onMissingArticleConditions( array &$conds, array $logTypes ) {
global $wgLogActionsHandlers;
/** @var Flow\FlowActions $actions */
$actions = Container::get( 'flow_actions' );
foreach ( $actions->getActions() as $action ) {
foreach ( $logTypes as $logType ) {
// Check if Flow actions are defined for the requested log types
// and make sure they're ignored.
if ( isset( $wgLogActionsHandlers["$logType/flow-$action"] ) ) {
$conds[] = "log_action != " . wfGetDB( DB_SLAVE )->addQuotes( "flow-$action" );
}
}
}
return true;
}
/**
* Adds Flow entries to watchlists
*
* @param array &$types Type array to modify
* @return boolean true
*/
public static function onSpecialWatchlistGetNonRevisionTypes( &$types ) {
$types[] = RC_FLOW;
return true;
}
/**
* Make sure no user can register a flow-*-usertext username, to avoid
* confusion with a real user when we print e.g. "Suppressed" instead of a
* username. Additionally reserve the username used to add a revision on
* taking over a page.
*
* @param array $names
* @return bool
*/
public static function onUserGetReservedNames( &$names ) {
$permissions = Flow\Model\AbstractRevision::$perms;
foreach ( $permissions as $permission ) {
$names[] = "msg:flow-$permission-usertext";
}
$names[] = 'msg:flow-system-usertext';
// Reserve the bot account we use during content model changes & LQT conversion
$names[] = FLOW_TALK_PAGE_MANAGER_USER;
return true;
}
// Static variables that do not vary by request; delivered through startup module
public static function onResourceLoaderGetConfigVars( &$vars ) {
global $wgFlowEditorList, $wgFlowAjaxTimeout;
$vars['wgFlowEditorList'] = $wgFlowEditorList;
$vars['wgFlowMaxTopicLength'] = Flow\Model\PostRevision::MAX_TOPIC_LENGTH;
$vars['wgFlowMentionTemplate'] = wfMessage( 'flow-ve-mention-template-title' )->inContentLanguage()->plain();
$vars['wgFlowAjaxTimeout'] = $wgFlowAjaxTimeout;
return true;
}
/**
* Intercept contribution entries and format those belonging to Flow
*
* @param ContribsPager $pager Contributions object
* @param string &$ret The HTML line
* @param stdClass $row The data for this line
* @param array &$classes the classes to add to the surrounding <li>
* @return bool
*/
public static function onDeletedContributionsLineEnding( $pager, &$ret, $row, &$classes ) {
global $wgHooks;
static $javascriptIncluded = false;
if ( !$row instanceof Flow\Formatter\FormatterRow ) {
return true;
}
set_error_handler( new Flow\RecoverableErrorHandler, -1 );
try {
/** @var Flow\Formatter\ContributionsFormatter $formatter */
$formatter = Container::get( 'formatter.contributions' );
$line = $formatter->format( $row, $pager );
} catch ( Exception $e ) {
wfDebugLog( 'Flow', __METHOD__ . ': Failed formatting contribution ' . json_encode( $row ) . ': ' . $e->getMessage() );
MWExceptionHandler::logException( $e );
$line = false;
}
restore_error_handler();
if ( $line === false ) {
return false;
}
$classes[] = 'mw-flow-contribution';
$ret = $line;
// If we output one or more lines of contributions entries we also need to include
// the javascript that hooks into moderation actions.
// @todo not a huge fan of this static variable, what else though?
if ( !$javascriptIncluded ) {
$javascriptIncluded = true;
$wgHooks['SpecialPageAfterExecute'][] = function( $specialPage, $subPage ) {
$specialPage->getOutput()->addModules( array( 'ext.flow.contributions' ) );
$specialPage->getOutput()->addModuleStyles( array( 'ext.flow.contributions.styles' ) );
};
}
return true;
}
/**
* Intercept contribution entries and format those belonging to Flow
*
* @param ContribsPager $pager Contributions object
* @param string &$ret The HTML line
* @param stdClass $row The data for this line
* @param array &$classes the classes to add to the surrounding <li>
* @return bool
*/
public static function onContributionsLineEnding( $pager, &$ret, $row, &$classes ) {
return static::onDeletedContributionsLineEnding( $pager, $ret, $row, $classes );
}
/**
* Convert flow contributions entries into FeedItem instances
* for ApiFeedContributions
*
* @param object $row Single row of data from ContribsPager
* @param IContextSource $ctx The context to creat the feed item within
* @param FeedItem &$feedItem Return value holder for created feed item.
* @return bool
*/
public static function onContributionsFeedItem( $row, IContextSource $ctx, FeedItem &$feedItem = null ) {
if ( !$row instanceof Flow\Formatter\FormatterRow ) {
return true;
}
set_error_handler( new Flow\RecoverableErrorHandler, -1 );
try {
/** @var Flow\Formatter\FeedItemFormatter $formatter */
$formatter = Container::get( 'formatter.contributions.feeditem' );
$result = $formatter->format( $row, $ctx );
} catch ( Exception $e ) {
wfDebugLog( 'Flow', __METHOD__ . ': Failed formatting contribution ' . json_encode( $row ) . ': ' . $e->getMessage() );
MWExceptionHandler::logException( $e );
return false;
}
restore_error_handler();
if ( $result instanceof FeedItem ) {
$feedItem = $result;
return true;
} else {
// If we failed to render a flow row, cancel it. This could be
// either permissions or bugs.
return false;
}
}
/**
* Adds Flow contributions to the DeletedContributions special page
*
* @param $data array an array of results of all contribs queries, to be
* merged to form all contributions data
* @param ContribsPager $pager Object hooked into
* @param string $offset Index offset, inclusive
* @param int $limit Exact query limit
* @param bool $descending Query direction, false for ascending, true for descending
* @return bool
*/
public static function onDeletedContributionsQuery( &$data, $pager, $offset, $limit, $descending ) {
set_error_handler( new Flow\RecoverableErrorHandler, -1 );
try {
/** @var Flow\Formatter\ContributionsQuery $query */
$query = Container::get( 'query.contributions' );
$results = $query->getResults( $pager, $offset, $limit, $descending );
} catch ( Exception $e ) {
wfDebugLog( 'Flow', __METHOD__ . ': Failed contributions query' );
MWExceptionHandler::logException( $e );
$results = false;
}
restore_error_handler();
if ( $results === false ) {
return false;
}
$data[] = $results;
return true;
}
/**
* Adds Flow contributions to the Contributions special page
*
* @param $data array an array of results of all contribs queries, to be
* merged to form all contributions data
* @param ContribsPager $pager Object hooked into
* @param string $offset Index offset, inclusive
* @param int $limit Exact query limit
* @param bool $descending Query direction, false for ascending, true for descending
* @return bool
*/
public static function onContributionsQuery( &$data, $pager, $offset, $limit, $descending ) {
// Flow has nothing to do with the tag filter, so ignore tag searches
if ( $pager->tagFilter != false ) {
return true;
}
return static::onDeletedContributionsQuery( $data, $pager, $offset, $limit, $descending );
}
/**
* Adds lazy-load methods for AbstractRevision objects.
*
* @param string $method: Method to generate the variable
* @param AbuseFilterVariableHolder $vars
* @param array $parameters Parameters with data to compute the value
* @param mixed &$result Result of the computation
* @return bool
*/
public static function onAbuseFilterComputeVariable( $method, AbuseFilterVariableHolder $vars, $parameters, &$result ) {
// fetch all lazy-load methods
$methods = self::$abuseFilter->lazyLoadMethods();
// method isn't known here
if ( !isset( $methods[$method] ) ) {
return true;
}
// fetch variable result from lazy-load method
$result = $methods[$method]( $vars, $parameters );
return false;
}
/**
* Abort notifications regarding occupied pages coming from the RecentChange class.
* Flow has its own notifications through Echo.
*
* Also don't notify for actions made by the talk page manager.
*
* @param User $editor
* @param Title $title
* @return bool false to abort email notification
*/
public static function onAbortEmailNotification( $editor, $title ) {
if ( $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
// Since we are aborting the notification we need to manually update the watchlist
EmailNotification::updateWatchlistTimestamp( $editor, $title, wfTimestampNow() );
return false;
}
if ( !$editor instanceof User ) {
return true;
}
if ( self::isTalkpageManagerUser( $editor ) ) {
return false;
}
return true;
}
/**
* Suppress all Echo notifications generated by the Talk page manager user
*
* @param EchoEvent $event
* @return bool
*/
public static function onBeforeEchoEventInsert( EchoEvent $event ) {
$agent = $event->getAgent();
if ( $agent === null ) {
return true;
}
if ( self::isTalkpageManagerUser( $agent ) ) {
return false;
}
return true;
}
/**
* Suppress the 'You have new messages!' indication when a change to a
* user talk page is done by the talk page manager user.
*
* @param WikiPage $page
* @param User $recipient
* @return bool
*/
public static function onArticleEditUpdateNewTalk( WikiPage $page, User $recipient ) {
$user = User::newFromId( $page->getUser( Revision::RAW ) );
if ( self::isTalkpageManagerUser( $user ) ) {
return false;
}
return true;
}
/**
* @param User $user
* @return bool
*/
private static function isTalkpageManagerUser( User $user ) {
return $user->getName() === FLOW_TALK_PAGE_MANAGER_USER;
}
/**
* Don't send email notifications that are imported from LiquidThreads. It will
* still be in their web notifications (if enabled), but they will never be
* notified via email (regardless of batching settings) for this particular
* notification.
*
*/
public static function onEchoAbortEmailNotification( User $user, EchoEvent $event ) {
$extra = $event->getExtra();
if ( isset( $extra['lqtThreadId'] ) && $extra['lqtThreadId'] !== null ) {
return false;
}
return true;
}
public static function onInfoAction( IContextSource $ctx, &$pageinfo ) {
if ( $ctx->getTitle()->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
return true;
}
// All of the info in this section is wrong for Flow pages,
// so we'll just remove it.
unset( $pageinfo['header-edits'] );
// These keys are wrong on Flow pages, so we'll remove them
static $badMessageKeys = array( 'pageinfo-length' );
foreach ( $pageinfo['header-basic'] as $num => $val ) {
if ( $val[0] instanceof Message && in_array( $val[0]->getKey(), $badMessageKeys ) ) {
unset($pageinfo['header-basic'][$num]);
}
}
return true;
}
/**
* @param RecentChange $rc
* @param array &$rcRow
* @return bool
*/
public static function onCheckUserInsertForRecentChange( RecentChange $rc, array &$rcRow ) {
if ( $rc->getAttribute( 'rc_source' ) !== Flow\Data\Listener\RecentChangesListener::SRC_FLOW ) {
return true;
}
$params = unserialize( $rc->getAttribute( 'rc_params' ) );
$change = $params['flow-workflow-change'];
// don't forget to increase the version number when data format changes
$comment = CheckUserQuery::VERSION_PREFIX;
$comment .= ',' . $change['action'];
$comment .= ',' . $change['workflow'];
$comment .= ',' . $change['revision'];
if ( isset( $change['post'] ) ) {
$comment .= ',' . $change['post'];
}
$rcRow['cuc_comment'] = $comment;
return true;
}
public static function onIRCLineURL( &$url, &$query, RecentChange $rc ) {
if ( $rc->getAttribute( 'rc_source' ) !== Flow\Data\Listener\RecentChangesListener::SRC_FLOW ) {
return true;
}
set_error_handler( new Flow\RecoverableErrorHandler, -1 );
$result = null;
try {
/** @var Flow\Formatter\IRCLineUrlFormatter $formatter */
$formatter = Container::get( 'formatter.irclineurl' );
$result = $formatter->format( $rc );
} catch ( Exception $e ) {
$result = null;
wfDebugLog( 'Flow', __METHOD__ . ': Failed formatting rc ' . $rc->getAttribute( 'rc_id' ) . ': ' . $e->getMessage() );
MWExceptionHandler::logException( $e );
}
restore_error_handler();
if ( $result !== null ) {
$url = $result;
$query = '';
}
return true;
}
public static function onWhatLinksHereProps( $row, Title $title, Title $target, &$props ) {
set_error_handler( new Flow\RecoverableErrorHandler, -1 );
try {
/** @var Flow\ReferenceClarifier $clarifier */
$clarifier = Flow\Container::get( 'reference.clarifier' );
$newProps = $clarifier->getWhatLinksHereProps( $row, $title, $target );
$props = array_merge( $props, $newProps );
} catch ( Exception $e ) {
wfDebugLog( 'Flow', sprintf(
'%s: Failed formatting WhatLinksHere for %s to %s',
__METHOD__,
$title->getFullText(),
$target->getFullText()
) );
MWExceptionHandler::logException( $e );
}
restore_error_handler();
return true;
}
/**
* Add topiclist sortby to preferences.
*
* @param $user User object
* @param &$preferences array Preferences object
* @return bool
*/
public static function onGetPreferences( $user, &$preferences ) {
$preferences['flow-topiclist-sortby'] = array(
'type' => 'api',
);
$preferences['flow-editor'] = array(
'type' => 'api'
);
$preferences['flow-side-rail-state'] = array(
'type' => 'api'
);
return true;
}
/**
* ResourceLoaderTestModules hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/ResourceLoaderTestModules
*
* @param array $testModules
* @param ResourceLoader $resourceLoader
* @return bool
*/
public static function onResourceLoaderTestModules( array &$testModules,
ResourceLoader &$resourceLoader
) {
global $wgResourceModules;
// find test files for every RL module
foreach ( $wgResourceModules as $key => $module ) {
if ( preg_match( '/ext.flow(?:\.|$)/', $key ) && isset( $module['scripts'] ) ) {
$testFiles = array();
$scripts = (array) $module['scripts'];
foreach ( $scripts as $script ) {
$testFile = 'tests/qunit/' . dirname( $script ) . '/test_' . basename( $script );
// if a test file exists for a given JS file, add it
if ( file_exists( __DIR__ . '/' . $testFile ) ) {
$testFiles[] = $testFile;
}
}
// if test files exist for given module, create a corresponding test module
if ( count( $testFiles ) > 0 ) {
$module = array(
'remoteExtPath' => 'Flow',
'dependencies' => array( $key ),
'localBasePath' => __DIR__,
'scripts' => $testFiles,
);
$testModules['qunit']["$key.tests"] = $module;
}
}
}
return true;
}
/**
* Don't (un)watch a non-existing flow topic
*
* @param User $user
* @param WikiPage $page
* $param Status $status
*/
public static function onWatchArticle( &$user, WikiPage &$page, &$status ) {
$title = $page->getTitle();
if ( $title->getNamespace() == NS_TOPIC ) {
// @todo - use !$title->exists()?
/** @var Flow\Data\ManagerGroup $storage */
$storage = Container::get( 'storage' );
$found = $storage->find(
'PostRevision',
array( 'rev_type_id' => strtolower( $title->getDBkey() ) ),
array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
);
if ( !$found ) {
return false;
}
$post = reset( $found );
if ( !$post->isTopicTitle() ) {
return false;
}
}
return true;
}
/**
* Adds the topic namespace.
*/
public static function onCanonicalNamespaces( &$list ) {
$list[NS_TOPIC] = 'Topic';
return true;
}
/**
* Checks whether this is a valid move technically. MovePageIsValidMove should not
* be affected by the specific user, or user permissions.
*
* Those are handled in onMovePageCheckPermissions, called later.
*
* @param Title $oldTitle Old title
* @param Title $newTitle New title
* @param Status $status Status to update with any technical issues
*
* @return true to continue, false to abort the hook
*/
public static function onMovePageIsValidMove( Title $oldTitle, Title $newTitle, Status $status ) {
// We only care about moving Flow boards, and *not* moving Flow topics
// (but both are CONTENT_MODEL_FLOW_BOARD)
if ( $oldTitle->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
return true;
}
// Pages within the Topic namespace are not movable
// This is also checked by NamespaceIsMovable.
if ( $oldTitle->getNamespace() === NS_TOPIC ) {
$status->fatal( 'flow-error-move-topic' );
return false;
}
$occupationController = self::getOccupationController();
$flowStatus = $occupationController->checkIfCreationIsPossible( $newTitle, /*mustNotExist*/ true );
$status->merge( $flowStatus );
return true;
}
/**
* Checks whether user has permission to move the board.
*
* Technical restrictions are handled in onMovePageIsValidMove, called earlier.
*
* @param Title $oldTitle Old title
* @param Title $newTitle New title
* @param User $user User doing the move
* @param string $reason Reason for the move
* @param Status $status Status updated with any permissions issue
*
* @return true to continue, false to abort the hook
*/
public static function onMovePageCheckPermissions( Title $oldTitle, Title $newTitle, User $user, $reason, Status $status ) {
// Only affect moves if the source has Flow content model
if ( $oldTitle->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
return true;
}
$occupationController = self::getOccupationController();
$permissionStatus = $occupationController->checkIfUserHasPermission(
$newTitle,
$user
);
$status->merge( $permissionStatus );
return true;
}
/**
* @param Title $title
* @param string[] $urls
* @return bool
*/
public static function onTitleSquidURLs( Title $title, array &$urls ) {
if ( $title->getNamespace() !== NS_TOPIC ) {
return true;
}
try {
$uuid = WorkflowLoaderFactory::uuidFromTitle( $title );
} catch ( Flow\Exception\InvalidInputException $e ) {
MWExceptionHandler::logException( $e );
wfDebugLog( 'Flow', __METHOD__ . ': Invalid title ' . $title->getPrefixedText() );
return true;
}
/** @var Flow\Data\ManagerGroup $storage */
$storage = Container::get( 'storage' );
$workflow = $storage->get( 'Workflow', $uuid );
if ( !$workflow instanceof Flow\Model\Workflow ) {
wfDebugLog( 'Flow', __METHOD__ . ': Title for non-existent Workflow ' . $title->getPrefixedText() );
return true;
}
$urls = array_merge(
$urls,
$workflow->getOwnerTitle()->getSquidURLs()
);
return true;
}
/**
* @param array $tools Extra links
* @param Title $title
* @param bool $redirect Whether the page is a redirect
* @param Skin $skin
* @param string $link
* @return bool
*/
public static function onWatchlistEditorBuildRemoveLine( &$tools, $title, $redirect, $skin, &$link = '' ) {
if ( $title->getNamespace() !== NS_TOPIC ) {
// Leave all non Flow topics alone!
return true;
}
/*
* Link to talk page is no applicable for Flow topics
* Note that key 'talk' doesn't exist prior to
* https://gerrit.wikimedia.org/r/#/c/156522/, so on old MW's, the link
* to talk page will still be present.
*/
unset( $tools['talk'] );
if ( !$link ) {
/*
* https://gerrit.wikimedia.org/r/#/c/156118/ adds argument $link.
* Prior to that patch, it was impossible to change the link, so
* let's quit early if it doesn't exist.
*/
return true;
}
try {
// Find the title text of this specific topic
$uuid = WorkflowLoaderFactory::uuidFromTitle( $title );
$collection = PostCollection::newFromId( $uuid );
$revision = $collection->getLastRevision();
} catch ( Exception $e ) {
wfWarn( __METHOD__ . ': Failed to locate revision for: ' . $title->getDBKey() );
return true;
}
$content = $revision->getContent( 'topic-title-plaintext' );
$link = Linker::link( $title, htmlspecialchars( $content ) );
return true;
}
/**
* @param array $watchlistInfo Watchlisted pages
* @return bool
*/
public static function onWatchlistEditorBeforeFormRender( &$watchlistInfo ) {
if ( !isset( $watchlistInfo[NS_TOPIC] ) ) {
// No topics watchlisted
return true;
}
$ids = array_keys( $watchlistInfo[NS_TOPIC] );
// build array of queries to be executed all at once
$queries = array();
foreach( $ids as $id ) {
try {
$uuid = WorkflowLoaderFactory::uuidFromTitlePair( NS_TOPIC, $id );
$queries[] = array( 'rev_type_id' => $uuid );
} catch ( Exception $e ) {
// invalid id
unset( $watchlistInfo[NS_TOPIC][$id] );
}
}
/** @var Flow\Data\ManagerGroup $storage */
$storage = Container::get( 'storage' );
/*
* Now, finally find all requested topics - this will be stored in
* local cache so subsequent calls (in onWatchlistEditorBuildRemoveLine)
* will just find these in memory, instead of doing a bunch of network
* requests.
*/
$storage->findMulti(
'PostRevision',
$queries,
array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
);
return true;
}
/**
* For integration with the UserMerge extension. Provides the database and
* sets of table/column pairs to update user id's within.
*
* @param array $updateFields
* @return bool
*/
public static function onUserMergeAccountFields( &$updateFields ) {
/** @var Flow\Data\Utils\UserMerger $merger */
$merger = Container::get( 'user_merger' );
foreach ( $merger->getAccountFields() as $row ) {
$updateFields[] = $row;
}
return true;
}
/**
* Finalize the merge by purging any cached value that contained $oldUser
*/
public static function onMergeAccountFromTo( User &$oldUser, User &$newUser ) {
/** @var Flow\Data\Utils\UserMerger $merger */
$merger = Container::get( 'user_merger' );
$merger->finalizeMerge( $oldUser->getId(), $newUser->getId() );
return true;
}
/**
* Gives precedence to Flow over LQT.
*/
public static function onIsLiquidThreadsPage( Title $title, &$isLqtPage ) {
if ( $isLqtPage && $title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
$isLqtPage = false;
}
return true;
}
/**
* @param int $namespace
* @param bool $movable
* @return bool
*/
public static function onNamespaceIsMovable( $namespace, &$movable ) {
if ( $namespace === NS_TOPIC ) {
$movable = false;
}
return true;
}
public static function onCategoryViewerDoCategoryQuery( $type, $res ) {
if ( $type !== 'page' ) {
return true;
}
/** @var Flow\Formatter\CategoryViewerQuery */
$query = Container::get( 'query.categoryviewer' );
$query->loadMetadataBatch( $res );
return true;
}
public static function onCategoryViewerGenerateLink( $type, Title $title, $html, &$link ) {
if ( $type !== 'page' || $title->getNamespace() !== NS_TOPIC ) {
return true;
}
$uuid = UUID::create( strtolower( $title->getDBkey() ) );
if ( !$uuid ) {
return true;
}
/** @var Flow\Formatter\CategoryViewerQuery */
$query = Container::get( 'query.categoryviewer' );
$row = $query->getResult( $uuid );
/** @var Flow\Formatter\CategoryViewerFormatter */
$formatter = Container::get( 'formatter.categoryviewer' );
$result = $formatter->format( $row );
if ( $result ) {
$link = $result;
}
return true;
}
/**
* Gets error HTML for attempted NS_TOPIC deletion using core interface
*
* @param Title $title Topic title they are attempting to delete
* @return string Error html
*/
protected static function getTopicDeletionError( Title $title ) {
$error = wfMessage( 'flow-error-core-topic-deletion', $title->getFullURL() )->parse();
$wrappedError = Html::rawElement( 'span', array(
'class' => 'plainlinks',
), $error );
return $wrappedError;
}
// This should block them from wasting their time filling the form, but it won't
// without a core change. However, it does show the message.
/**
* Shows an error message when the user visits the deletion form if the page is in
* the Topic namespace.
*
* @param WikiPage $article Page the user requested to delete
* @param OutputPage $out Output page
* @param string &$reason Pre-filled reason given for deletion (note, this could
* be used to customize this for boards and/or topics later)
* @return bool False if it is a Topic; otherwise, true
*/
public static function onArticleConfirmDelete( $article, $output, &$reason ) {
$title = $article->getTitle();
if ( $title->inNamespace( NS_TOPIC ) ) {
$output->addHTML( FlowHooks::getTopicDeletionError( $title ) );
return false;
}
return true;
}
/**
* Blocks topics from being deleted using the core deletion process, since it
* doesn't work.
*
* @param WikiPage &$article Page the user requested to delete
* @param User &$user User who requested to delete the article
* @param string &$reason Reason given for deletion
* @param string &$error Error explaining why we are not allowing the deletion
* @return bool False if it is a Topic (to block it); otherwise, true
*/
public static function onArticleDelete( WikiPage &$article, User &$user, &$reason, &$error ) {
$title = $article->getTitle();
if ( $title->inNamespace( NS_TOPIC ) ) {
$error = FlowHooks::getTopicDeletionError( $title );
return false;
}
return true;
}
/**
* Evicts topics from Squid/Varnish when the board is deleted.
* We do permission checks for this scenario, but since the topic isn't deleted
* at the core level, we need to evict it from Varnish ourselves.
*
* @param WikiPage &$article Deleted article
* @param User &$user User that deleted article
* @param string $reason Reason given
* @param int $articleId Article ID of deleted article
* @param Content $content Content that was deleted, or null on error
* @param LogEntry $logEntry Log entry for deletion
*/
public static function onArticleDeleteComplete( WikiPage &$article, User &$user, $reason, $articleId, Content $content = null, LogEntry $logEntry ) {
$title = $article->getTitle();
// Topics use the same content model, but can't be deleted at the core
// level currently.
if ( $content !== null &&
$title->getNamespace() !== NS_TOPIC &&
$title->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
$storage = Container::get( 'storage' );
DeferredUpdates::addCallableUpdate( function () use ( $storage, $articleId ) {
/** @var \Flow\Model\Workflow[] $workflows */
$workflows = $storage->find( 'Workflow', array(
'workflow_wiki' => wfWikiID(),
'workflow_page_id' => $articleId,
) );
if ( !$workflows ) {
return;
}
$topicTitles = [];
foreach ( $workflows as $workflow ) {
if ( $workflow->getType() === 'topic' ) {
$topicTitles[] = $workflow->getArticleTitle();
}
}
$update = CdnCacheUpdate::newFromTitles( $topicTitles );
DeferredUpdates::addUpdate( $update ); // run right after this
} );
}
return true;
}
/**
* @param Title $title Title corresponding to the article restored
* @param Revision $revision Revision just undeleted
* @param string $oldPageId Old page ID stored with that revision when it was in the archive table
* @return bool
*/
public static function onArticleRevisionUndeleted( Title $title, Revision $revision, $oldPageId ) {
if ( $revision->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
// complete hack to make sure that when the page is saved to new
// location and rendered it doesn't throw an error about the wrong title
Container::get( 'factory.loader.workflow' )->pageMoveInProgress();
// Reassociate the Flow board associated with this undeleted revision.
$boardMover = Container::get( 'board_mover' );
$boardMover->move( intval( $oldPageId ), $title );
}
return true;
}
/**
* @param Title $title Title corresponding to the article restored
* @param bool $created Whether or not the restoration caused the page to be created (i.e. it didn't exist before).
* @param string $comment The comment associated with the undeletion.
* @param int $oldPageId ID of page previously deleted (from archive table)
* @throws InvalidUndeleteException
* @return bool
*/
public static function onArticleUndelete( Title $title, $create, $comment, $oldPageId ) {
$boardMover = Container::get( 'board_mover' );
$boardMover->commit();
}
/**
* Occurs at the beginning of the MovePage process (just after the startAtomic).
*
* Perhaps ContentModel should be extended to be notified about moves explicitly.
*/
public static function onTitleMoveStarting( Title $oldTitle, Title $newTitle, User $user ) {
if ( $oldTitle->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
// $newTitle doesn't yet exist, but after the move it'll still have
// the same ID $oldTitle used to have
// Since we don't want to wait until after the page has been moved
// to start preparing relevant Flow moves, I'll make it reflect the
// correct ID already
$bogusTitle = clone $newTitle;
$bogusTitle->resetArticleID( $oldTitle->getArticleID() );
// This is only safe because we have called
// checkIfCreationIsPossible and (usually) checkIfUserHasPermission.
Container::get( 'occupation_controller' )->forceAllowCreation( $bogusTitle );
// complete hack to make sure that when the page is saved to new
// location and rendered it doesn't throw an error about the wrong title
Container::get( 'factory.loader.workflow' )->pageMoveInProgress();
// open a database transaction and prepare everything for the move, but
// don't commit yet. That is done below in self::onTitleMoveCompleting
$boardMover =Container::get( 'board_mover' );
$boardMover->move( $oldTitle->getArticleID(), $bogusTitle );
}
return true;
}
public static function onTitleMoveCompleting( Title $oldTitle, Title $newTitle, User $user, $pageid, $redirid, $reason, Revision $revision ) {
if ( $newTitle->getContentModel() === CONTENT_MODEL_FLOW_BOARD ) {
Container::get( 'board_mover' )->commit();
}
return true;
}
public static function onShowMissingArticle( Article $article ) {
if ( $article->getPage()->getContentModel() !== CONTENT_MODEL_FLOW_BOARD ) {
return true;
}
if ( $article->getTitle()->getNamespace() === NS_TOPIC ) {
// @todo pretty message about invalid workflow
throw new FlowException( 'Non-existent topic' );
}
$emptyContent = ContentHandler::getForModelID( CONTENT_MODEL_FLOW_BOARD )->makeEmptyContent();
$parserOutput = $emptyContent->getParserOutput( $article->getTitle() );
$article->getContext()->getOutput()->addParserOutput( $parserOutput );
return false;
}
/**
* Excludes NS_TOPIC from the list of searchable namespaces
*
* @param array $namespaces Associative array mapping namespace index
* to name
* @return bool
*/
public static function onSearchableNamespaces( &$namespaces ) {
unset( $namespaces[NS_TOPIC] );
return true;
}
/**
* @return bool
*/
private static function isBetaFeatureAvailable() {
global $wgBetaFeaturesWhitelist, $wgFlowEnableOptInBetaFeature;
return $wgFlowEnableOptInBetaFeature &&
( !is_array( $wgBetaFeaturesWhitelist ) || in_array( BETA_FEATURE_FLOW_USER_TALK_PAGE, $wgBetaFeaturesWhitelist ) );
}
/**
* @param User $user
* @param array $prefs
* @return bool
*/
public static function onGetBetaFeaturePreferences( $user, &$prefs ) {
global $wgExtensionAssetsPath;
if ( !self::isBetaFeatureAvailable() ) {
return true;
}
$prefs[BETA_FEATURE_FLOW_USER_TALK_PAGE] = array(
// The first two are message keys
'label-message' => 'flow-talk-page-beta-feature-message',
'desc-message' => 'flow-talk-page-beta-feature-description',
'screenshot' => array(
'ltr' => "$wgExtensionAssetsPath/Flow/images/betafeature-flow-ltr.svg",
'rtl' => "$wgExtensionAssetsPath/Flow/images/betafeature-flow-rtl.svg",
),
'info-link' => 'https://www.mediawiki.org/wiki/Flow',
'discussion-link' => 'https://www.mediawiki.org/wiki/Talk:Flow',
'exempt-from-auto-enrollment' => true,
);
return true;
}
/**
* @param User $user
* @param array $options
* @return bool
*/
public static function onUserSaveOptions( $user, &$options ) {
if (
!class_exists( BetaFeatures::class ) ||
!self::isBetaFeatureAvailable()
) {
return true;
}
if ( !array_key_exists( BETA_FEATURE_FLOW_USER_TALK_PAGE, $options ) ) {
return true;
}
$userClone = User::newFromId( $user->getId() );
$before = BetaFeatures::isFeatureEnabled( $userClone, BETA_FEATURE_FLOW_USER_TALK_PAGE );
$after = $options[BETA_FEATURE_FLOW_USER_TALK_PAGE];
$action = null;
if ( !$before && $after ) {
$action = OptInUpdate::$ENABLE;
// Check if the user had a flow board
$c = new Flow\Import\OptInController();
if ( !$c->hasFlowBoardArchive( $user ) ) {
// Enable the guided tour by setting the cookie
RequestContext::getMain()->getRequest()->response()->setCookie( 'Flow_optIn_guidedTour', '1' );
}
} elseif ( $before && !$after ) {
$action = OptInUpdate::$DISABLE;
}
if ( $action ) {
DeferredUpdates::addUpdate( new OptInUpdate( $action, $user->getTalkPage(), $user ) );
}
return true;
}
/**
* @param WikiImporter $importer
* @return bool
*/
public static function onImportHandleToplevelXMLTag( WikiImporter $importer ) {
// only init Flow's importer once, then re-use it
static $flowImporter = null;
if ( $flowImporter === null ) {
// importer can be dry-run (= parse, but don't store), but we can only
// derive that from mPageOutCallback. I'll set a new value (which will
// return the existing value) to see if it's in dry-run mode (= null)
$callback = $importer->setPageOutCallback( null );
// restore previous mPageOutCallback value
$importer->setPageOutCallback( $callback );
$flowImporter = new \Flow\Dump\Importer( $importer );
if ( $callback !== null ) {
// not in dry-run mode
$flowImporter->setStorage( Container::get( 'storage' ) );
}
}
$reader = $importer->getReader();
$tag = $reader->localName;
$type = $reader->nodeType;
if ( $tag == 'board' ) {
if ( $type === XMLReader::ELEMENT ) {
$flowImporter->handleBoard();
}
return false;
} elseif ( $tag == 'description' ) {
if ( $type === XMLReader::ELEMENT ) {
$flowImporter->handleHeader();
}
return false;
} elseif ( $tag == 'topic' ) {
if ( $type === XMLReader::ELEMENT ) {
$flowImporter->handleTopic();
}
return false;
} elseif ( $tag == 'post' ) {
if ( $type === XMLReader::ELEMENT ) {
$flowImporter->handlePost();
}
return false;
} elseif ( $tag == 'summary' ) {
if ( $type === XMLReader::ELEMENT ) {
$flowImporter->handleSummary();
}
return false;
} elseif ( $tag == 'children' ) {
return false;
}
return true;
}
public static function onNukeGetNewPages( $username, $pattern, $namespace, $limit, &$pages ) {
if ( $namespace && $namespace !== NS_TOPIC ) {
// not interested in any Topics
return true;
}
// Remove any pre-existing Topic pages.
// They are coming from the recentchanges table.
// Most likely the filters were not applied correctly.
$pages = array_filter( $pages, function( $entry ) {
/** @var Title $title */
$title = $entry[0];
return $title->getNamespace() !== NS_TOPIC;
} );
if ( $pattern ) {
// pattern is not supported
return true;
}
if ( !RequestContext::getMain()->getUser()->isAllowed( 'flow-delete' ) ) {
// there's no point adding topics since the current user won't be allowed to delete them
return true;
}
// how many are we allowed to retrieve now
$newLimit = $limit - count( $pages );
// we can't add anything
if ( $newLimit < 1 ) {
return true;
}
$dbFactory = Container::get( 'db.factory' );
/** @var Database $dbr */
$dbr = $dbFactory->getDB( DB_SLAVE );
// if a username is specified, search only for that user
$userWhere = array();
if ( $username ) {
$user = User::newFromName( $username );
if ( $user ) {
$userWhere = array( 'tree_orig_user_id' => $user->getId() );
} else {
$userWhere = array( 'tree_orig_user_ip' => $username );
}
}
// limit results to the range of RC
global $wgRCMaxAge;
$rcTimeLimit = UUID::getComparisonUUID( strtotime("-$wgRCMaxAge seconds") );
// get latest revision id for each topic
$result = $dbr->select(
array(
'r' => 'flow_revision',
'flow_tree_revision',
'flow_workflow',
),
array(
'revId' => 'MAX(r.rev_id)',
'userIp' => "tree_orig_user_ip",
'userId' => "tree_orig_user_id",
),
array_merge( array(
'tree_parent_id' => null,
'r.rev_type' => 'post',
'workflow_wiki' => wfWikiID(),
'workflow_id > ' . $dbr->addQuotes( $rcTimeLimit->getBinary() )
), $userWhere ),
__METHOD__,
array(
'GROUP BY' => 'r.rev_type_id'
),
array(
'flow_tree_revision' => array( 'INNER JOIN', 'r.rev_type_id=tree_rev_descendant_id' ),
'flow_workflow' => array( 'INNER JOIN', 'r.rev_type_id=workflow_id' ),
)
);
if ( $result->numRows() < 1 ) {
return true;
}
$revIds = array();
foreach( $result as $r ) {
$revIds[$r->revId] = array( 'userIp' => $r->userIp, 'userId' => $r->userId, 'name' => false );
}
// get non-moderated revisions
$result = $dbr->select(
'flow_revision',
array(
'topicId' => 'rev_type_id',
'revId' => 'rev_id'
),
array(
'rev_mod_state' => '',
'rev_id' => array_keys( $revIds )
),
__METHOD__,
array(
'LIMIT' => $newLimit,
'ORDER BY' => 'rev_type_id DESC'
)
);
// all topics previously found appear to be moderated
if ( $result->numRows() < 1 ) {
return true;
}
// keep only the relevant topics in [topicId => userInfo] format
$limitedRevIds = array();
foreach ( $result as $r ) {
$limitedRevIds[$r->topicId] = $revIds[$r->revId];
}
// fill usernames if no $username filter was specified
if ( !$username ) {
$userIds = array_map(
function ( $userInfo ) { return $userInfo['userId']; },
array_values( $limitedRevIds )
);
$userIds = array_filter( $userIds );
$userMap = array();
if ( $userIds ) {
$wikiDbr = $dbFactory->getWikiDB( DB_SLAVE );
$result = $wikiDbr->select(
'user',
array( 'user_id', 'user_name' ),
array( 'user_id' => array_values( $userIds ) )
);
foreach( $result as $r ) {
$userMap[$r->user_id] = $r->user_name;
}
}
// set name in userInfo structure
foreach( $limitedRevIds as $topicId => &$userInfo ) {
if ( $userInfo['userIp'] ) {
$userInfo['name'] = $userInfo['userIp'];
} elseif ( $userInfo['userId'] ) {
$userInfo['name'] = $userMap[$userInfo['userId']];
} else {
$userInfo['name'] = false;
$topicIdAlpha = UUID::create( $topicId )->getAlphadecimal();
wfLogWarning( __METHOD__ . ": Cannot find user information for topic {$topicIdAlpha}" );
}
}
}
// add results to the list of pages to nuke
foreach( $limitedRevIds as $topicId => $userInfo ) {
$pages[] = array(
Title::makeTitle( NS_TOPIC, UUID::create( $topicId )->getAlphadecimal() ),
$userInfo['name']
);
}
return true;
}
public static function onNukeDeletePage( Title $title, $reason, &$deletionResult ) {
if ( $title->getNamespace() !== NS_TOPIC ) {
// we don't handle it
return true;
}
$action = 'moderate-topic';
$params = array(
'topic' => array(
'moderationState' => 'delete',
'reason' => $reason,
'page' => $title->getPrefixedText()
),
);
/** @var WorkflowLoaderFactory $factory */
$factory = Container::get( 'factory.loader.workflow' );
$workflowId = WorkflowLoaderFactory::uuidFromTitle( $title );
/** @var WorkflowLoader $loader */
$loader = $factory->createWorkflowLoader( $title, $workflowId );
$blocks = $loader->getBlocks();
$blocksToCommit = $loader->handleSubmit(
RequestContext::getMain(),
$action,
$params
);
$result = true;
$errors = array();
foreach ( $blocks as $block ) {
if ( $block->hasErrors() ) {
$result = false;
$errorKeys = $block->getErrors();
foreach ( $errorKeys as $errorKey ) {
$errors[] = $block->getErrorMessage( $errorKey );
}
}
}
if ( $result ) {
$loader->commit( $blocksToCommit );
$deletionResult = true;
} else {
$deletionResult = false;
$msg = "Failed to delete {$title->getPrefixedText()}. Errors: " . implode( '. ', $errors );
wfLogWarning( $msg );
}
// we've handled the deletion, abort the hook
return false;
}
}