Current File : /home/jvzmxxx/wiki1/extensions/Flow/includes/Block/Topic.php
<?php

namespace Flow\Block;

use Flow\Container;
use Flow\Data\ManagerGroup;
use Flow\Data\Pager\HistoryPager;
use Flow\Exception\DataModelException;
use Flow\Exception\FailCommitException;
use Flow\Exception\FlowException;
use Flow\Exception\InvalidActionException;
use Flow\Exception\InvalidDataException;
use Flow\Exception\InvalidInputException;
use Flow\Exception\PermissionException;
use Flow\Formatter\PostHistoryQuery;
use Flow\Formatter\RevisionFormatter;
use Flow\Formatter\RevisionViewQuery;
use Flow\Formatter\TopicHistoryQuery;
use Flow\Model\AbstractRevision;
use Flow\Model\PostRevision;
use Flow\Model\UUID;
use Flow\Model\Workflow;
use Flow\NotificationController;
use Flow\Repository\RootPostLoader;
use Message;

class TopicBlock extends AbstractBlock {

	/**
	 * @var PostRevision|null
	 */
	protected $root;

	/**
	 * @var PostRevision|null
	 */
	protected $topicTitle;

	/**
	 * @var RootPostLoader|null
	 */
	protected $rootLoader;

	/**
	 * @var PostRevision|null
	 */
	protected $newRevision;

	/**
	 * @var array
	 */
	protected $requestedPost = array();

	/**
	 * @var array Map of data to be passed on as
	 *  commit metadata for event handlers
	 */
	protected $extraCommitMetadata = array();

	protected $supportedPostActions = array(
		// Standard editing
		'edit-post', 'reply',
		// Moderation
		'moderate-topic',
		'moderate-post',
		// lock or unlock topic
		'lock-topic',
		// Other stuff
		'edit-title',
		'undo-edit-post',
	);

	protected $supportedGetActions = array(
		'reply', 'view', 'history', 'edit-post', 'edit-title', 'compare-post-revisions', 'single-view',
		'view-topic', 'view-topic-history', 'view-post', 'view-post-history', 'undo-edit-post',
		'moderate-topic', 'moderate-post', 'lock-topic',
	);

	// @Todo - fill in the template names
	protected $templates = array(
		'single-view' => 'single_view',
		'view' => '',
		'reply' => '',
		'history' => 'history',
		'edit-post' => '',
		'undo-edit-post' => 'undo_edit',
		'edit-title' => 'edit_title',
		'compare-post-revisions' => 'diff_view',
		'moderate-topic' => 'moderate_topic',
		'moderate-post' => 'moderate_post',
		'lock-topic' => 'lock',
	);

	public function __construct( Workflow $workflow, ManagerGroup $storage, $root ) {
		parent::__construct( $workflow, $storage );
		if ( $root instanceof PostRevision ) {
			$this->root = $root;
		} elseif ( $root instanceof RootPostLoader ) {
			$this->rootLoader = $root;
		} else {
			throw new DataModelException(
				'Expected PostRevision or RootPostLoader, received: ' . is_object( $root ) ? get_class( $root ) : gettype( $root ), 'invalid-input'
			);
		}
	}

	protected function validate() {
		$topicTitle = $this->loadTopicTitle();
		if ( !$topicTitle ) {
			// permissions issue, self::loadTopicTitle should have added appropriate
			// error messages already.
			return;
		}

		switch( $this->action ) {
		case 'edit-title':
			$this->validateEditTitle();
			break;

		case 'reply':
			$this->validateReply();
			break;

		case 'moderate-topic':
		case 'lock-topic':
			$this->validateModerateTopic();
			break;

		case 'moderate-post':
			$this->validateModeratePost();
			break;

		case 'restore-post':
			// @todo still necessary?
			$this->validateModeratePost();
			break;

		case 'undo-edit-post':
		case 'edit-post':
			$this->validateEditPost();
			break;

		case 'edit-topic-summary':
			// pseudo-action does not do anything, only includes data in api response
			break;

		default:
			throw new InvalidActionException( "Unexpected action: {$this->action}", 'invalid-action' );
		}
	}

	protected function validateEditTitle() {
		if ( $this->workflow->isNew() ) {
			$this->addError( 'content', $this->context->msg( 'flow-error-no-existing-workflow' ) );
			return;
		}
		if ( !isset( $this->submitted['content'] ) || !is_string( $this->submitted['content'] ) ) {
			$this->addError( 'content', $this->context->msg( 'flow-error-missing-title' ) );
			return;
		}
		$this->submitted['content'] = trim( $this->submitted['content'] );
		$len = mb_strlen( $this->submitted['content'] );
		if ( $len === 0 ) {
			$this->addError( 'content', $this->context->msg( 'flow-error-missing-title' ) );
			return;
		}
		if ( $len > PostRevision::MAX_TOPIC_LENGTH ) {
			$this->addError( 'content', $this->context->msg( 'flow-error-title-too-long', PostRevision::MAX_TOPIC_LENGTH ) );
			return;
		}
		if ( empty( $this->submitted['prev_revision'] ) ) {
			$this->addError( 'prev_revision', $this->context->msg( 'flow-error-missing-prev-revision-identifier' ) );
			return;
		}
		$topicTitle = $this->loadTopicTitle();
		if ( !$topicTitle ) {
			return;
		}
		if ( !$this->permissions->isAllowed( $topicTitle, 'edit-title' ) ) {
			$this->addError( 'permissions', $this->getDisallowedErrorMessage( $topicTitle ) );
			return;
		}
		if ( $topicTitle->getRevisionId()->getAlphadecimal() !== $this->submitted['prev_revision'] ) {
			// This is a reasonably effective way to ensure prev revision matches, but for guarantees against race
			// conditions there also exists a unique index on rev_prev_revision in mysql, meaning if someone else inserts against the
			// parent we and the submitter think is the latest, our insert will fail.
			// TODO: Catch whatever exception happens there, make sure the most recent revision is the one in the cache before
			// handing user back to specific dialog indicating race condition
			$this->addError(
				'prev_revision',
				$this->context->msg( 'flow-error-prev-revision-mismatch' )->params(
					$this->submitted['prev_revision'],
					$topicTitle->getRevisionId()->getAlphadecimal(),
					$this->context->getUser()->getName()
				),
				array( 'revision_id' => $topicTitle->getRevisionId()->getAlphadecimal() ) // save current revision ID
			);
			return;
		}

		$this->newRevision = $topicTitle->newNextRevision(
			$this->context->getUser(),
			$this->submitted['content'],
			'topic-title-wikitext',
			'edit-title',
			$this->workflow->getArticleTitle()
		);
		if ( !$this->checkSpamFilters( $topicTitle, $this->newRevision ) ) {
			return;
		}
	}

	protected function validateReply() {
		if ( trim( $this->submitted['content'] ) === '' ) {
			$this->addError( 'content', $this->context->msg( 'flow-error-missing-content' ) );
			return;
		}
		if ( !isset( $this->submitted['replyTo'] ) ) {
			$this->addError( 'replyTo', $this->context->msg( 'flow-error-missing-replyto' ) );
			return;
		}

		$post = $this->loadRequestedPost( $this->submitted['replyTo'] );
		if ( !$post ) {
			return; // loadRequestedPost adds its own errors
		}
		if ( !$this->permissions->isAllowed( $post, 'reply' ) ) {
			$this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) );
			return;
		}
		$this->newRevision = $post->reply(
			$this->workflow,
			$this->context->getUser(),
			$this->submitted['content'],
			// default to wikitext when not specified, for old API requests
			isset( $this->submitted['format'] ) ? $this->submitted['format'] : 'wikitext'
		);
		if ( !$this->checkSpamFilters( null, $this->newRevision ) ) {
			return;
		}

		$this->extraCommitMetadata['reply-to'] = $post;
	}

	protected function validateModerateTopic() {
		$root = $this->loadRootPost();
		if ( !$root ) {
			return;
		}

		$this->doModerate( $root );
	}

	protected function validateModeratePost() {
		if ( empty( $this->submitted['postId'] ) ) {
			$this->addError( 'post', $this->context->msg( 'flow-error-missing-postId' ) );
			return;
		}

		$post = $this->loadRequestedPost( $this->submitted['postId'] );
		if ( !$post ) {
			// loadRequestedPost added its own messages to $this->errors;
			return;
		}
		if ( $post->isTopicTitle() ) {
			$this->addError( 'moderate', $this->context->msg( 'flow-error-not-a-post' ) );
			return;
		}
		$this->doModerate( $post );
	}

	protected function doModerate( PostRevision $post ) {
		if (
			$this->submitted['moderationState'] === AbstractRevision::MODERATED_LOCKED
			&& $post->isModerated()
		) {
			$this->addError( 'moderate', $this->context->msg( 'flow-error-lock-moderated-post' ) );
			return;
		}

		// Moderation state supplied in request parameters
		$moderationState = isset( $this->submitted['moderationState'] )
			? $this->submitted['moderationState']
			: null;

		// $moderationState should be a string like 'restore', 'suppress', etc.  The exact strings allowed
		// are checked below with $post->isValidModerationState(), but this is checked first otherwise
		// a blank string would restore a post(due to AbstractRevision::MODERATED_NONE === '').
		if ( ! $moderationState ) {
			$this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-state' ) );
			return;
		}

		/*
		 * BC: 'suppress' used to be called 'censor', 'lock' was 'close' &
		 * 'unlock' was 'reopen'
		 */
		$bc = array(
			'censor' => AbstractRevision::MODERATED_SUPPRESSED,
			'close' => AbstractRevision::MODERATED_LOCKED,
			'reopen' => 'un' . AbstractRevision::MODERATED_LOCKED
		);
		$moderationState = str_replace( array_keys( $bc ), array_values( $bc ), $moderationState );

		// these all just mean set to no moderation, it returns a post to unmoderated status
		$allowedRestoreAliases = array( 'unlock', 'unhide', 'undelete', 'unsuppress', /* BC for unlock: */ 'reopen' );
		if ( in_array( $moderationState, $allowedRestoreAliases ) ) {
			$moderationState = 'restore';
		}
		// By allowing the moderationState to be sourced from $this->submitted['moderationState']
		// we no longer have a unique action name for use with the permissions system.  This rebuilds
		// an action name. e.x. restore-post, restore-topic, suppress-topic, etc.
		$action = $moderationState . ( $post->isTopicTitle() ? "-topic" : "-post" );

		if ( $moderationState === 'restore' ) {
			$newState = AbstractRevision::MODERATED_NONE;
		} else {
			$newState = $moderationState;
		}

		if ( ! $post->isValidModerationState( $newState ) ) {
			$this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-state' ) );
			return;
		}
		if ( !$this->permissions->isAllowed( $post, $action ) ) {
			$this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) );
			return;
		}

		if ( trim( $this->submitted['reason'] ) === '' ) {
			$this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-reason' ) );
			return;
		}

		$reason = $this->submitted['reason'];

		$this->newRevision = $post->moderate( $this->context->getUser(), $newState, $action, $reason );
		if ( !$this->newRevision ) {
			$this->addError( 'moderate', $this->context->msg( 'flow-error-not-allowed' ) );
			return;
		}
	}

	protected function validateEditPost() {
		if ( empty( $this->submitted['postId'] ) ) {
			$this->addError( 'post', $this->context->msg( 'flow-error-missing-postId' ) );
			return;
		}
		if ( trim( $this->submitted['content'] ) === '' ) {
			$this->addError( 'content', $this->context->msg( 'flow-error-missing-content' ) );
			return;
		}
		if ( empty( $this->submitted['prev_revision'] ) ) {
			$this->addError( 'prev_revision', $this->context->msg( 'flow-error-missing-prev-revision-identifier' ) );
			return;
		}
		$post = $this->loadRequestedPost( $this->submitted['postId'] );
		if ( !$post ) {
			return;
		}
		if ( !$this->permissions->isAllowed( $post, 'edit-post' ) ) {
			$this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) );
			return;
		}
		if ( $post->getRevisionId()->getAlphadecimal() !== $this->submitted['prev_revision'] ) {
			// This is a reasonably effective way to ensure prev revision
			// matches, but for guarantees against race conditions there
			// also exists a unique index on rev_prev_revision in mysql,
			// meaning if someone else inserts against the parent we and
			// the submitter think is the latest, our insert will fail.
			//
			// TODO: Catch whatever exception happens there, make sure the
			// most recent revision is the one in the cache before handing
			// user back to specific dialog indicating race condition
			$this->addError(
				'prev_revision',
				$this->context->msg( 'flow-error-prev-revision-mismatch' )->params(
					$this->submitted['prev_revision'],
					$post->getRevisionId()->getAlphadecimal(),
					$this->context->getUser()->getName()
				),
				array( 'revision_id' => $post->getRevisionId()->getAlphadecimal() ) // save current revision ID
			);
			return;
		}

		$this->newRevision = $post->newNextRevision(
			$this->context->getUser(),
			$this->submitted['content'],
			// default to wikitext when not specified, for old API requests
			isset( $this->submitted['format'] ) ? $this->submitted['format'] : 'wikitext',
			'edit-post',
			$this->workflow->getArticleTitle()
		);

		if ( $this->newRevision->getRevisionId()->equals( $post->getRevisionId() ) ) {
			$this->extraCommitMetadata['null-edit'] = true;
		} elseif ( !$this->checkSpamFilters( $post, $this->newRevision ) ) {
			return;
		}
	}

	public function commit() {

		switch( $this->action ) {
		case 'edit-topic-summary':
			// pseudo-action does not do anything, only includes data in api response
			return array();

		case 'reply':
		case 'moderate-topic':
		case 'lock-topic':
		case 'restore-post':
		case 'moderate-post':
		case 'edit-title':
		case 'undo-edit-post':
		case 'edit-post':
			if ( $this->newRevision === null ) {
				throw new FailCommitException( 'Attempt to save null revision', 'fail-commit' );
			}

			$metadata = $this->extraCommitMetadata + array(
				'workflow' => $this->workflow,
				'topic-title' => $this->loadTopicTitle(),
			);
			if ( !$metadata['topic-title'] instanceof PostRevision ) {
				// permissions failure, should never have gotten this far
				throw new PermissionException( 'Not Allowed', 'insufficient-permission' );
			}
			if ( $this->newRevision->getPostId()->equals( $metadata['topic-title']->getPostId() ) ) {
				// When performing actions against the topic-title self::loadTopicTitle
				// returns the previous revision.
				$metadata['topic-title'] = $this->newRevision;
			}

			// store data, unless we're dealing with a null-edit (in which case
			// is storing the same thing not only pointless, it can even be
			// incorrect, since listeners will run & generate notifications etc)
			if ( !isset( $this->extraCommitMetadata['null-edit'] ) ) {
				$this->storage->put( $this->newRevision, $metadata );
				$this->workflow->updateLastUpdated( $this->newRevision->getRevisionId() );
				$this->storage->put( $this->workflow, $metadata );

				if ( strpos( $this->action, 'moderate-' ) === 0 ) {
					$topicId = $this->newRevision->getCollection()->getRoot()->getId();

					$moderate = $this->newRevision->isModerated()
						&& ( $this->newRevision->getModerationState() === PostRevision::MODERATED_DELETED
							|| $this->newRevision->getModerationState() === PostRevision::MODERATED_SUPPRESSED );

					/** @var NotificationController $controller */
					$controller = Container::get( 'controller.notification' );
					if ( $this->action === 'moderate-topic' ) {
						$controller->moderateTopicNotifications( $topicId, $moderate );
					} elseif ( $this->action === 'moderate-post' ) {
						$postId = $this->newRevision->getPostId();
						$controller->moderatePostNotifications( $topicId, $postId, $moderate );
					}
				}
			}

			$newRevision = $this->newRevision;

			// If no context was loaded render the post in isolation
			// @todo make more explicit
			try {
				$newRevision->getChildren();
			} catch ( \MWException $e ) {
				$newRevision->setChildren( array() );
			}

			$returnMetadata = array(
				'post-id' => $this->newRevision->getPostId(),
				'post-revision-id' => $this->newRevision->getRevisionId(),
			);

			return $returnMetadata;

		default:
			throw new InvalidActionException( "Unknown commit action: {$this->action}", 'invalid-action' );
		}
	}

	public function renderApi( array $options ) {
		$output = array( 'type' => $this->getName() );

		$topic = $this->loadTopicTitle();
		if ( !$topic ) {
			return $output + $this->finalizeApiOutput($options);
		}

		// there's probably some OO way to turn this stack of if/else into
		// something nicer. Consider better ways before extending this with
		// more conditionals
		switch ( $this->action ) {
			case 'history':
				// single post history or full topic?
				if ( isset( $options['postId'] ) ) {
					// singular post history
					$output += $this->renderPostHistoryApi( $options, UUID::create( $options['postId'] ) );
				} else {
					// post history for full topic
					$output += $this->renderTopicHistoryApi( $options );
				}
				break;

			case 'single-view':
				if ( isset( $options['revId'] ) ) {
					$revId = $options['revId'];
				} else {
					throw new InvalidInputException( 'A revision must be provided', 'invalid-input' );
				}
				$output += $this->renderSingleViewApi( $revId );
				break;

			case 'lock-topic':
				// Treat topic as a post, only the post + summary are needed
				$result = $this->renderPostApi( $options, $this->workflow->getId() );
				$topicId = $result['roots'][0];
				$revisionId = $result['posts'][$topicId][0];
				$output += $result['revisions'][$revisionId];
				break;

			case 'compare-post-revisions':
				$output += $this->renderDiffViewApi( $options );
				break;

			case 'undo-edit-post':
				$output += $this->renderUndoApi( $options );
				break;

			case 'view-post-history':
				// View entire history of single post
				$output += $this->renderPostHistoryApi( $options, UUID::create( $options['postId'] ), false );
				break;

			case 'view-topic-history':
				// View entire history of a topic's posts
				$output += $this->renderTopicHistoryApi( $options, false );
				break;

			// Any actions require (re)rendering the whole topic
			case 'edit-post':
			case 'moderate-post':
			case 'restore-post':
			case 'reply':
			case 'moderate-topic':
			case 'view-topic':
			case 'view' && !isset( $options['postId'] ) && !isset( $options['revId'] );
				// view full topic
				$output += $this->renderTopicApi( $options );
				break;

			case 'edit-title':
			case 'view-post':
			case 'view':
			default:
				// view single post, possibly specific revision
				$output += $this->renderPostApi( $options );
				break;
		}

		return $output + $this->finalizeApiOutput($options);
	}

	/**
	 * @param array $options
	 * @return array
	 */
	protected function finalizeApiOutput( $options ) {
		if ( $this->wasSubmitted() ) {
			// Failed actions, like reply, end up here
			return array(
				'submitted' => $this->submitted,
				'errors' => $this->errors,
			);
		} else {
			return array(
				'submitted' => $options,
				'errors' => $this->errors,
			);
		}
	}

	// @Todo - duplicated logic in other diff view block
	protected function renderDiffViewApi( array $options ) {
		if ( !isset( $options['newRevision'] ) ) {
			throw new InvalidInputException( 'A revision must be provided for comparison', 'revision-comparison' );
		}
		$oldRevision = null;
		if ( isset( $options['oldRevision'] ) ) {
			$oldRevision = $options['oldRevision'];
		}
		list( $new, $old ) = Container::get( 'query.post.view' )->getDiffViewResult( UUID::create( $options['newRevision'] ), UUID::create( $oldRevision ) );

		return array(
			'revision' => Container::get( 'formatter.revision.diff.view' )->formatApi( $new, $old, $this->context )
		);
	}

	// @Todo - duplicated logic in other single view block
	protected function renderSingleViewApi( $revId ) {
		$row = Container::get( 'query.post.view' )->getSingleViewResult( $revId );

		if ( !$this->permissions->isAllowed( $row->revision, 'view' ) ) {
			$this->addError( 'permissions', $this->getDisallowedErrorMessage( $row->revision ) );
			return array();
		}

		return array(
			'revision' => Container::get( 'formatter.revisionview' )->formatApi( $row, $this->context )
		);
	}

	protected function renderTopicApi( array $options, $workflowId = '' ) {
		$serializer = Container::get( 'formatter.topic' );
		$format = isset( $options['format'] ) ? $options['format'] : 'fixed-html';
		$serializer->setContentFormat( $format );

		if ( !$workflowId ) {
			if ( $this->workflow->isNew() ) {
				return $serializer->buildEmptyResult( $this->workflow );
			}
			$workflowId = $this->workflow->getId();
		}

		if ( $this->submitted !== null ) {
			$options += $this->submitted;
		}

		// In the topic level responses we only want to force a single revision
		// to wikitext (the one we're editing), not the entire thing.
		if ( $this->action === 'edit-post' && !empty( $options['revId'] ) ) {
			$uuid = UUID::create( $options['revId'] );
			if ( $uuid ) {
				$serializer->setContentFormat( 'wikitext', $uuid );
			}
		}

		return $serializer->formatApi(
			$this->workflow,
			Container::get( 'query.topiclist' )->getResults( array( $workflowId ) ),
			$this->context
		);
	}

	/**
	 * @todo Any failed action performed against a single revisions ends up here.
	 * To generate forms with validation errors in the non-javascript renders we
	 * need to add something to this output, but not sure what yet
	 */
	protected function renderPostApi( array $options, $postId = '' ) {
		if ( $this->workflow->isNew() ) {
			throw new FlowException( 'No posts can exist for non-existent topic' );
		}

		$format = isset( $options['format'] ) ? $options['format'] : 'fixed-html';
		$serializer = $this->getRevisionFormatter( $format );

		if ( !$postId ) {
			if ( isset( $options['postId'] ) ) {
				$postId = $options['postId'];
			} elseif( $this->newRevision ) {
				// API results after a reply will have no $postId (ID is not yet
				// known when the reply is submitted) so we'll grab it from the
				// newly added revision
				$postId = $this->newRevision->getPostId();
			} else {
				throw new FlowException('No post id specified');
			}
		} else {
			// $postId is only set for lock-topic, which should default to
			// wikitext instead of html
			$format = isset( $options['format'] ) ? $options['format'] : 'wikitext';
			$serializer->setContentFormat( $format, UUID::create( $postId ) );
		}

		$row = Container::get( 'query.singlepost' )->getResult( UUID::create( $postId ) );
		$serialized = $serializer->formatApi( $row, $this->context );
		if ( !$serialized ) {
			return null;
		}

		return array(
			'roots' => array( $serialized['postId'] ),
			'posts' => array(
				$serialized['postId'] => array( $serialized['revisionId'] ),
			),
			'revisions' => array(
				$serialized['revisionId'] => $serialized,
			)
		);
	}

	protected function renderUndoApi( array $options ) {
		if ( $this->workflow->isNew() ) {
			throw new FlowException( 'No posts can exist for non-existent topic' );
		}

		if ( !isset( $options['startId'], $options['endId'] ) ) {
			throw new InvalidInputException( 'Both startId and endId must be provided' );
		}

		/** @var RevisionViewQuery */
		$query = Container::get( 'query.post.view' );
		$rows = $query->getUndoDiffResult( $options['startId'], $options['endId'] );
		if ( !$rows ) {
			throw new InvalidInputException( 'Could not load revision to undo' );
		}

		$serializer = Container::get( 'formatter.undoedit' );
		return $serializer->formatApi( $rows[0], $rows[1], $rows[2], $this->context );
	}

	/**
	 * @param string $format Content format (html|wikitext|fixed-html|topic-title-html|topic-title-wikitext)
	 * @return RevisionFormatter
	 */
	protected function getRevisionFormatter( $format ) {
		$serializer = Container::get( 'formatter.revision' );
		$serializer->setContentFormat( $format );

		return $serializer;
	}

	protected function renderTopicHistoryApi( array $options, $navbar = true ) {
		if ( $this->workflow->isNew() ) {
			throw new FlowException( 'No topic history can exist for non-existent topic' );
		}
		return $this->processHistoryResult( Container::get( 'query.topic.history' ), $this->workflow->getId(), $options, $navbar );
	}

	protected function renderPostHistoryApi( array $options, UUID $postId, $navbar = true ) {
		if ( $this->workflow->isNew() ) {
			throw new FlowException( 'No post history can exist for non-existent topic' );
		}
		return $this->processHistoryResult( Container::get( 'query.post.history' ), $postId, $options, $navbar );
	}

	/**
	 * Process the history result for either topic or post
	 *
	 * @param TopicHistoryQuery|PostHistoryQuery $query
	 * @param UUID $uuid
	 * @param array $options
	 * @param bool $navbar Whether to include the page navbar
	 * @return array
	 */
	protected function processHistoryResult( /* TopicHistoryQuery|PostHistoryQuery */ $query, UUID $uuid, $options, $navbar = true ) {
		global $wgRequest;

		$format = isset( $options['format'] ) ? $options['format'] : 'fixed-html';
		$serializer = $this->getRevisionFormatter( $format );
		$serializer->setIncludeHistoryProperties( true );

		list( $limit, /* $offset */ ) = $wgRequest->getLimitOffset();
		// don't use offset from getLimitOffset - that assumes an int, which our
		// UUIDs are not
		$offset = $wgRequest->getText( 'offset' );
		$offset = $offset ? UUID::create( $offset ) : null;

		$pager = new HistoryPager( $query, $uuid );
		$pager->setLimit( $limit );
		$pager->setOffset( $offset );
		$pager->doQuery();
		$history = $pager->getResult();

		$revisions = array();
		foreach ( $history as $row ) {
			$serialized = $serializer->formatApi( $row, $this->context, 'history' );
			// if the user is not allowed to see this row it will return empty
			if ( $serialized ) {
				$revisions[] = $serialized;
			}
		}

		$response = array( 'revisions' => $revisions );
		if ( $navbar ) {
			$response['navbar'] = $pager->getNavigationBar();
		}
		return $response;
	}

	/**
	 * @return PostRevision|null
	 */
	public function loadRootPost() {
		if ( $this->root !== null ) {
			return $this->root;
		}

		$rootPost = $this->rootLoader->get( $this->workflow->getId() );

		if ( $this->permissions->isAllowed( $rootPost, 'view' ) ) {
			// topicTitle is same as root, difference is root has children populated to full depth
			return $this->topicTitle = $this->root = $rootPost;
		}

		$this->addError( 'moderation', $this->context->msg( 'flow-error-not-allowed' ) );

		return null;
	}

	/**
	 * @param string $action Permissions action to require to return revision
	 * @return AbstractRevision|null
	 * @throws InvalidDataException
	 */
	public function loadTopicTitle( $action = 'view' ) {
		if ( $this->workflow->isNew() ) {
			throw new InvalidDataException( 'New workflows do not have any related content', 'missing-topic-title' );
		}

		if ( $this->topicTitle === null ) {
			$found = $this->storage->find(
				'PostRevision',
				array( 'rev_type_id' => $this->workflow->getId() ),
				array( 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 )
			);
			if ( !$found ) {
				throw new InvalidDataException( 'Every workflow must have an associated topic title', 'missing-topic-title' );
			}
			$this->topicTitle = reset( $found );

			// this method loads only title, nothing else; otherwise, you're
			// looking for loadRootPost
			$this->topicTitle->setChildren( array() );
			$this->topicTitle->setDepth( 0 );
			$this->topicTitle->setRootPost( $this->topicTitle );
		}

		if ( !$this->permissions->isAllowed( $this->topicTitle, $action ) ) {
			$this->addError( 'permissions', $this->getDisallowedErrorMessage( $this->topicTitle ) );
			return null;
		}

		return $this->topicTitle;
	}

	/**
	 * @todo Move this to AbstractBlock and use for summary/header/etc.
	 * @param AbstractRevision $revision
	 * @return Message
	 */
	protected function getDisallowedErrorMessage( AbstractRevision $revision ) {
		if ( in_array( $this->action, array( 'moderate-topic', 'moderate-post' ) ) ) {
			/*
			 * When failing to moderate an already moderated action (like
			 * undo), show the more general "you have insufficient
			 * permissions for this action" message, rather than the
			 * specialized "this topic is <hidden|deleted|suppressed>" msg.
			 */
			return $this->context->msg( 'flow-error-not-allowed' );
		}

		$state = $revision->getModerationState();

		// display simple message
		// i18n messages:
		//  flow-error-not-allowed-hide,
		//  flow-error-not-allowed-reply-to-hide-topic
		//  flow-error-not-allowed-delete
		//  flow-error-not-allowed-reply-to-delete-topic
		//  flow-error-not-allowed-suppress
		//  flow-error-not-allowed-reply-to-suppress-topic
		if ( $revision instanceof PostRevision ) {
			$type = $revision->isTopicTitle() ? 'topic' : 'post';
		} else {
			$type = $revision->getRevisionType();
		}

		// Show a snippet of the relevant log entry if available.
		if ( \LogPage::isLogType( $state ) ) {
			// check if user has sufficient permissions to see log
			$logPage = new \LogPage( $state );
			if ( $this->context->getUser()->isAllowed( $logPage->getRestriction() ) ) {
				// LogEventsList::showLogExtract will write to OutputPage, but we
				// actually just want that text, to write it ourselves wherever we want,
				// so let's create an OutputPage object to then get the content from.
				$rc = new \RequestContext();
				$output = $rc->getOutput();

				// get log extract
				$entries = \LogEventsList::showLogExtract(
					$output,
					array( $state ),
					$this->workflow->getArticleTitle()->getPrefixedText(),
					'',
					array(
						'lim' => 10,
						'showIfEmpty' => false,
						// i18n messages:
						//  flow-error-not-allowed-hide-extract
						//  flow-error-not-allowed-reply-to-hide-topic-extract
						//  flow-error-not-allowed-delete-extract
						//  flow-error-not-allowed-reply-to-delete-topic-extract
						//  flow-error-not-allowed-suppress-extract
						//  flow-error-not-allowed-reply-to-suppress-topic-extract
						'msgKey' => array(
							array(
								"flow-error-not-allowed-{$this->action}-to-$state-$type",
								"flow-error-not-allowed-$state-extract",
							),
						)
					)
				);

				// check if there were any log extracts
				if ( $entries ) {
					$message = new \RawMessage( '$1' );
					return $message->rawParams( $output->getHTML() );
				}
			}
		}

		return $this->context->msg( array(
			// set of keys to try in order
			"flow-error-not-allowed-{$this->action}-to-$state-$type",
			"flow-error-not-allowed-$state",
			"flow-error-not-allowed"
		) );
	}

	/**
	 * Loads the post referenced by $postId. Returns null when:
	 *    $postId does not belong to the workflow
	 *    The user does not have view access to the topic title
	 *    The user does not have view access to the referenced post
	 * All these conditions add a relevant error message to $this->errors when returning null
	 *
	 * @param UUID|string $postId The post being requested
	 * @return PostRevision|null
	 */
	protected function loadRequestedPost( $postId ) {
		if ( !$postId instanceof UUID ) {
			$postId = UUID::create( $postId );
		}

		if ( $this->rootLoader === null ) {
			// Since there is no root loader the full tree is already loaded
			$topicTitle = $root = $this->loadRootPost();
			if ( !$topicTitle ) {
				return null;
			}
			$post = $root->getDescendant( $postId );
			if ( $post === null ) {
				// The requested postId is not a member of the current workflow
				$this->addError( 'post', $this->context->msg( 'flow-error-invalid-postId', $postId->getAlphadecimal() ) );
				return null;
			}
		} else {
			// Load the post and its root
			$found = $this->rootLoader->getWithRoot( $postId );
			if ( !$found['post'] || !$found['root'] || !$found['root']->getPostId()->equals( $this->workflow->getId() ) ) {
				$this->addError( 'post', $this->context->msg( 'flow-error-invalid-postId', $postId->getAlphadecimal() ) );
				return null;
			}
			$this->topicTitle = $topicTitle = $found['root'];
			$post = $found['post'];

			// using the path to the root post, we can know the post's depth
			$rootPath = $this->rootLoader->getTreeRepo()->findRootPath( $postId );
			$post->setDepth( count( $rootPath ) - 1 );
			$post->setRootPost( $found['root'] );
		}

		if ( $this->permissions->isAllowed( $topicTitle, 'view' )
			&& $this->permissions->isAllowed( $post, 'view' ) ) {
			return $post;
		}

		$this->addError( 'moderation', $this->context->msg( 'flow-error-not-allowed' ) );
		return null;
	}

	// The prefix used for form data$pos
	public function getName() {
		return 'topic';
	}

	/**
	 * @param \OutputPage $out
	 *
	 * @todo Provide more informative page title for actions other than view,
     *       e.g. "Hide post in <TITLE>", "Unlock <TITLE>", etc.
	 */
	public function setPageTitle( \OutputPage $out ) {
		$topic = $this->loadTopicTitle( $this->action === 'history' ? 'history' : 'view' );
		if ( !$topic ) {
			return;
		}

		$title = $this->workflow->getOwnerTitle();
		$out->setPageTitle( $out->msg( 'flow-topic-first-heading', $title->getPrefixedText() ) );
		if ( $this->permissions->isAllowed( $topic, 'view' ) ) {
			if ( $this->action === 'undo-edit-post' ) {
				$key = 'flow-undo-edit-post';
			} else {
				$key = 'flow-topic-html-title';
			}
			$out->setHtmlTitle( $out->msg( $key, array(
				// This must be a rawParam to not expand {{foo}} in the title, it must
				// not be htmlspecialchar'd because OutputPage::setHtmlTitle handles that.
				Message::rawParam( $topic->getContent( 'topic-title-plaintext' ) ),
				$title->getPrefixedText()
			) ) );
		} else {
			$out->setHtmlTitle( $title->getPrefixedText() );
		}
		$out->setSubtitle( '&lt; ' . \Linker::link( $title ) );
	}
}