Current File : /home/jvzmxxx/wiki1/extensions/Flow/includes/Formatter/RevisionFormatter.php
<?php

namespace Flow\Formatter;

use Flow\Collection\PostCollection;
use Flow\Exception\PermissionException;
use Flow\Repository\UserNameBatch;
use Flow\Exception\FlowException;
use Flow\Model\AbstractRevision;
use Flow\Model\Anchor;
use Flow\Model\PostRevision;
use Flow\Model\PostSummary;
use Flow\Model\UUID;
use Flow\Conversion\Utils;
use Flow\RevisionActionPermissions;
use Flow\Templating;
use Flow\UrlGenerator;
use ApiResult;
use GenderCache;
use IContextSource;
use Message;

/**
 * This implements a serializer for converting revision objects
 * into an array of localized and sanitized data ready for user
 * consumption.
 *
 * The formatApi method is the primary method of interacting with
 * this serializer. The results of formatApi can be passed on to
 * html formatting or emitted directly as an api response.
 *
 * For performance considerations of special purpose formatters like
 * CheckUser methods that build pieces of the api response are also
 * public.
 *
 * @todo can't output as api yet, Message instances are returned
 *  for the various strings.
 *
 * @todo this needs a better name, RevisionSerializer? not sure yet
 */
class RevisionFormatter {

	/**
	 * @var RevisionActionPermissions
	 */
	protected $permissions;

	/**
	 * @var Templating
	 */
	protected $templating;

	/**
	 * @var UrlGenerator;
	 */
	protected $urlGenerator;

	/**
	 * @var bool
	 */
	protected $includeProperties = false;

	/**
	 * @var bool
	 */
	protected $includeContent = true;

	/**
	 * @var string[] Allowed content formats
	 *
	 *  See setContentFormat.
	 */
	protected $allowedContentFormats = array( 'html', 'wikitext', 'fixed-html',
		'topic-title-html', 'topic-title-wikitext' );

	/**
	 * @var string Default content format for revision output
	 */
	protected $contentFormat = 'fixed-html';

	/**
	 * @var array Map from alphadecimal revision id to content format override
	 */
	protected $revisionContentFormat = array();

	/**
	 * @var int
	 */
	protected $maxThreadingDepth;

	/**
	 * @var Message[]
	 */
	protected $messages = array();

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

	/**
	 * @var UserNameBatch
	 */
	protected $usernames;

	/**
	 * @var GenderCache
	 */
	protected $genderCache;

	/**
	 * @param RevisionActionPermissions $permissions
	 * @param Templating $templating
	 * @param UserNameBatch $usernames
	 * @param int $maxThreadingDepth
	 */
	public function __construct(
		RevisionActionPermissions $permissions,
		Templating $templating,
		UserNameBatch $usernames,
		$maxThreadingDepth
	) {
		$this->permissions = $permissions;
		$this->templating = $templating;
		$this->urlGenerator = $this->templating->getUrlGenerator();
		$this->usernames = $usernames;
		$this->genderCache = GenderCache::singleton();
		$this->maxThreadingDepth = $maxThreadingDepth;
	}

	/**
	 * The self::buildProperties method is fairly expensive and only used for rendering
	 * history entries.  As such it is optimistically disabled unless requested
	 * here
	 *
	 * @param bool $shouldInclude
	 */
	public function setIncludeHistoryProperties( $shouldInclude ) {
		$this->includeProperties = (bool)$shouldInclude;
	}

	/**
	 * Outputing content can be somehwat expensive, as most of the content is loaded
	 * into DOMDocuemnts for processing of relidlinks and badimages.  Set this to false
	 * if the content will not be used such as for recent changes.
	 */
	public function setIncludeContent( $shouldInclude ) {
		$this->includeContent = (bool)$shouldInclude;
	}

	/**
	 * Sets the content format for all revisions formatted by this formatter, or a
	 * particular revision.
	 *
	 * @param string $format Format to use for revision content.  If no revision ID is
	 *  given, this is a default format, and the allowed formats are 'html', 'wikitext',
	 *  and 'fixed-html'.
	 *
	 *  For the default format, 'fixed-html' will be converted to 'topic-title-html'
	 *  when formatting a topic title.  'html' and 'wikitext' will be converted to
	 *  'topic-title-wikitext' for topic titles (because 'html' and 'wikitext' are
	 *  editable, and 'topic-title-html' is not editable).
	 *
	 *  If a revision ID is given, the allowed formats are 'html', 'wikitext',
	 *  'fixed-html', 'topic-title-html', and 'topic-title-wikitext'.  However, the
	 *  format will not be converted, and must be valid for the given revision ('html',
	 *  'wikitext', and 'fixed-html' are valid only for non-topic titles.
	 *  'topic-title-html' and 'topic-title-wikitext' are only valid for topic titles.
	 *  Otherwise, an exception will be thrown later.
	 * @param UUID $revisionId Revision ID this format applies for.
	 */
	public function setContentFormat( $format, UUID $revisionId = null ) {
		if ( false === array_search( $format, $this->allowedContentFormats ) ) {
			throw new FlowException( "Unknown content format: $format" );
		}
		if ( $revisionId === null ) {
			// set default content format
			$this->contentFormat = $format;
		} else {
			// set per-revision content format
			$this->revisionContentFormat[$revisionId->getAlphadecimal()] = $format;
		}
	}

	/**
	 * @param FormatterRow $row
	 * @param IContextSource $ctx
	 * @param string $action action from FlowActions
	 * @return array|bool
	 * @throws FlowException
	 * @throws PermissionException
	 * @throws \Exception
	 * @throws \Flow\Exception\InvalidInputException
	 * @throws \TimestampException
	 */
	public function formatApi( FormatterRow $row, IContextSource $ctx, $action = 'view' ) {
		$this->permissions->setUser( $ctx->getUser() );

		if ( !$this->permissions->isAllowed( $row->revision, $action ) ) {
			return false;
		}

		$moderatedRevision = $this->templating->getModeratedRevision( $row->revision );
		$ts = $row->revision->getRevisionId()->getTimestampObj();
		$res = array(
			ApiResult::META_BC_BOOLS => array(
				'isOriginalContent',
				'isModerated',
				'isLocked',
				'isModeratedNotLocked',
			),
			'workflowId' => $row->workflow->getId()->getAlphadecimal(),
			'articleTitle' => $row->workflow->getArticleTitle()->getPrefixedText(),
			'revisionId' => $row->revision->getRevisionId()->getAlphadecimal(),
			'timestamp' => $ts->getTimestamp( TS_MW ),
			'changeType' => $row->revision->getChangeType(),
			// @todo push all date formatting to the render side?
			'dateFormats' => $this->getDateFormats( $row->revision, $ctx ),
			'properties' => $this->buildProperties( $row->workflow->getId(), $row->revision, $ctx, $row ),
			'isOriginalContent' => $row->revision->isOriginalContent(),
			'isModerated' => $moderatedRevision->isModerated(),
			// These are read urls
			'links' => $this->buildLinks( $row ),
			// These are write urls
			'actions' => $this->buildActions( $row ),
			'size' => array(
				'old' => $row->revision->getPreviousContentLength(),
				'new' => $row->revision->getContentLength(),
			),
			'author' => $this->serializeUser(
				$row->revision->getUserWiki(),
				$row->revision->getUserId(),
				$row->revision->getUserIp()
			),
			'lastEditUser' => $this->serializeUser(
				$row->revision->getLastContentEditUserWiki(),
				$row->revision->getLastContentEditUserId(),
				$row->revision->getLastContentEditUserIp()
			),
			'lastEditId' => $row->revision->isOriginalContent() ? null : $row->revision->getLastContentEditId()->getAlphadecimal(),
			'previousRevisionId' => $row->revision->isFirstRevision()
				? null
				: $row->revision->getPrevRevisionId()->getAlphadecimal(),
		);

		if ( $res['isModerated'] ) {
			$res['moderator'] = $this->serializeUser(
				$moderatedRevision->getModeratedByUserWiki(),
				$moderatedRevision->getModeratedByUserId(),
				$moderatedRevision->getModeratedByUserIp()
			);
			// @todo why moderate instead of moderated or something else?
			$res['moderateState'] = $moderatedRevision->getModerationState();
			$res['moderateReason'] = array(
				'content' => $moderatedRevision->getModeratedReason(),
				'format' => 'plaintext',
			);
			$res['isLocked'] = $moderatedRevision->isLocked();
		} else {
			$res['isLocked'] = false;
		}
		// to avoid doing this check in handlebars
		$res['isModeratedNotLocked'] = $moderatedRevision->isModerated() && !$moderatedRevision->isLocked();

		if ( $this->includeContent ) {
			$contentFormat = $this->decideContentFormat( $row->revision );

			// @todo better name?
			$res['content'] = array(
				'content' => $this->templating->getContent( $row->revision, $contentFormat ),
				'format' => $contentFormat
			);
		}

		if ( $row instanceof TopicRow ) {
			$res[ApiResult::META_BC_BOOLS] = array_merge(
				$res[ApiResult::META_BC_BOOLS],
				array(
					'isWatched',
					'watchable',
				)
			);
			if ( $row->summary ) {
				$summary = $this->formatApi( $row->summary, $ctx, $action );
				if ( $summary ) {
					$res['summary'] = array(
						'revision' =>  $summary,
					);
				}
			}

			// Only non-anon users can watch/unwatch a flow topic
			// isWatched - the topic is watched by current user
			// watchable - the user could watch the topic, eg, anon-user can't watch a topic
			if ( !$ctx->getUser()->isAnon() ) {
				// default topic is not watched and topic is not always watched
				$res['isWatched'] = (bool) $row->isWatched;
				$res['watchable'] = true;
			} else {
				$res['watchable'] = false;
			}
		}

		if ( $row->revision instanceof PostRevision ) {
			$res[ApiResult::META_BC_BOOLS] = array_merge(
				$res[ApiResult::META_BC_BOOLS],
				array(
					'isMaxThreadingDepth',
					'isNewPage',
				)
			);

			$replyTo = $row->revision->getReplyToId();
			$res['replyToId'] = $replyTo ? $replyTo->getAlphadecimal() : null;
			$res['postId'] = $row->revision->getPostId()->getAlphadecimal();
			$res['isMaxThreadingDepth'] = $row->revision->getDepth() >= $this->maxThreadingDepth;
			$res['creator'] = $this->serializeUser(
				$row->revision->getCreatorWiki(),
				$row->revision->getCreatorId(),
				$row->revision->getCreatorIp()
			);

			// Always output this along with topic titles so they
			// have a safe parameter to use within l10n for content
			// output.
			if ( $row->revision->isTopicTitle() && !isset( $res['properties']['topic-of-post'] ) ) {
				$res['properties']['topic-of-post'] = $this->processParam(
					'topic-of-post',
					$row->revision,
					$row->workflow->getId(),
					$ctx,
					$row
				);

				$res['properties']['topic-of-post-text-from-html'] = $this->processParam(
					'topic-of-post-text-from-html',
					$row->revision,
					$row->workflow->getId(),
					$ctx,
					$row
				);

				// moderated posts won't have that property
				if ( isset( $res['properties']['topic-of-post-text-from-html']['plaintext'] ) ) {
					$res['content']['plaintext'] = $res['properties']['topic-of-post-text-from-html']['plaintext'];
				}
			}

			$res['isNewPage'] = $row->isFirstReply && $row->revision->isFirstRevision();

		} elseif ( $row->revision instanceof PostSummary ) {
			$res['creator'] = $this->serializeUser(
				$row->revision->getCreatorWiki(),
				$row->revision->getCreatorId(),
				$row->revision->getCreatorIp()
			);
		}

		return $res;
	}

	/**
	 * @param array $userData Contains `name`, `wiki`, and `gender` keys
	 * @return array
	 */
	public function serializeUserLinks( $userData ) {
		$name = $userData['name'];
		if ( isset( $this->userLinks[$name] ) ) {
			return $this->userLinks[$name];
		}

		$talkPageTitle = null;
		$userTitle = \Title::newFromText( $name, NS_USER );
		if ( $userTitle ) {
			$talkPageTitle = $userTitle->getTalkPage();
		}

		$blockTitle = \SpecialPage::getTitleFor( 'Block', $name );

		$userContribsTitle = \SpecialPage::getTitleFor( 'Contributions', $name );
		$userLinksBCBools = array(
			'_BC_bools' => array(
				'exists',
			),
		);
		$links = array(
			'contribs' => array(
				'url' => $userContribsTitle->getLinkURL(),
				'title' => $userContribsTitle->getText(),
				'exists' => true,
			) + $userLinksBCBools,
			'userpage' => array(
				'url' => $userTitle->getLinkURL(),
				'title' => $userTitle->getText(),
				'exists' => $userTitle->isKnown(),
			) + $userLinksBCBools,
		);

		if ( $talkPageTitle ) {
			$links['talk'] = array(
				'url' => $talkPageTitle->getLinkURL(),
				'title' => $talkPageTitle->getPrefixedText(),
				'exists' => $talkPageTitle->isKnown()
			) + $userLinksBCBools;
		}
		// is this right permissions? typically this would
		// be sourced from Linker::userToolLinks, but that
		// only undertands html strings.
		if ( $this->permissions->getUser()->isAllowed( 'block' ) ) {
			// only is the user has blocking rights
			$links += array(
				"block" => array(
					'url' => $blockTitle->getLinkURL(),
					'title' => wfMessage( 'blocklink' ),
					'exists' => true
				) + $userLinksBCBools,
			);
		}

		return $this->userLinks[$name] = $links;
	}

	public function serializeUser( $userWiki, $userId, $userIp ) {
		$res = array(
			'name' => $this->usernames->get( $userWiki, $userId, $userIp ),
			'wiki' => $userWiki,
			'gender' => 'unknown',
			'links' => array(),
			'id' => $userId
		);
		// Only works for the local wiki
		if ( wfWikiID() === $userWiki ) {
			$res['gender'] = $this->genderCache->getGenderOf( $res['name'], __METHOD__ );
		}
		if ( $res['name'] ) {
			$res['links'] = $this->serializeUserLinks( $res );
		}

		return $res;
	}

	/**
	 * @param AbstractRevision $revision
	 * @param IContextSource $ctx
	 * @return array Contains [timeAndDate, date, time]
	 */
	public function getDateFormats( AbstractRevision $revision, IContextSource $ctx ) {
		// also restricted to history
		if ( $this->includeProperties === false ) {
			return array();
		}

		$timestamp = $revision->getRevisionId()->getTimestampObj()->getTimestamp( TS_MW );
		$user = $ctx->getUser();
		$lang = $ctx->getLanguage();

		return array(
			'timeAndDate' => $lang->userTimeAndDate( $timestamp, $user ),
			'date' => $lang->userDate( $timestamp, $user ),
			'time' => $lang->userTime( $timestamp, $user ),
		);
	}

	/**
	 * @param FormatterRow $row
	 * @return array
	 * @throws FlowException
	 */
	public function buildActions( FormatterRow $row ) {
		$user = $this->permissions->getUser();
		$workflow = $row->workflow;
		$title = $workflow->getArticleTitle();

		// If a user does not have rights to perform actions on this page return
		// an empty array of actions.
		if ( !$workflow->userCan( 'edit', $user ) ) {
			return array();
		}

		$revision = $row->revision;
		$action = $revision->getChangeType();
		$workflowId = $workflow->getId();
		$revId = $revision->getRevisionId();
		$postId = method_exists( $revision, 'getPostId' ) ? $revision->getPostId() : null;
		$actionTypes = $this->permissions->getActions()->getValue( $action, 'actions' );
		if ( $actionTypes === null ) {
			wfDebugLog( 'Flow', __METHOD__ . ": No actions defined for action: $action" );
			return array();
		}

		// actions primarily vary by revision type...
		$links = array();
		foreach ( $actionTypes as $type ) {
			if ( !$this->permissions->isAllowed( $revision, $type ) ) {
				continue;
			}
			switch( $type ) {
			case 'thank':
				if (
					// thanks extension must be available
					class_exists( 'ThanksHooks' ) &&
					// anons can't give a thank
					!$user->isAnon() &&
					// can only thank for PostRevisions
					// (other revision objects have no getCreator* methods)
					$revision instanceof PostRevision &&
					// only thank a logged in user
					$revision->getCreatorId() > 0 &&
					// can't thank self
					$user->getId() !== $revision->getCreatorId()
				) {
					$links['thank'] = $this->urlGenerator->thankAction( $postId );
				}
				break;

			case 'reply':
				if ( !$postId ) {
					throw new FlowException( "$type called without \$postId" );
				} elseif ( !$revision instanceof PostRevision ) {
					throw new FlowException( "$type called without PostRevision object" );
				}

				/*
				 * If the post being replied to is the most recent post
				 * of its depth, the reply link should point to parent
				 */
				$replyToId = $postId;
				$replyToRevision = $revision;
				if ( $row->isLastReply ) {
					$replyToId = $replyToRevision->getReplyToId();
					$replyToRevision = PostCollection::newFromId( $replyToId )->getLastRevision();
				}

				/*
				 * If the post being replied to is at or exceeds the max
				 * threading depth, the reply link should point to parent.
				 */
				while ( $replyToRevision->getDepth() >= $this->maxThreadingDepth ) {
					$replyToId = $replyToRevision->getReplyToId();
					$replyToRevision = PostCollection::newFromId( $replyToId )->getLastRevision();
				}

				$links['reply'] = $this->urlGenerator->replyAction(
					$title,
					$workflowId,
					$replyToId,
					$revision->isTopicTitle()
				);
				break;

			case 'edit-header':
				$links['edit'] = $this->urlGenerator->editHeaderAction( $title, $workflowId, $revId );
				break;

			case 'edit-title':
				if ( !$postId ) {
					throw new FlowException( "$type called without \$postId" );
				}
				$links['edit'] = $this->urlGenerator
					->editTitleAction( $title, $workflowId, $postId, $revId );
				break;

			case 'edit-post':
				if ( !$postId ) {
					throw new FlowException( "$type called without \$postId" );
				}
				$links['edit'] = $this->urlGenerator
					->editPostAction( $title, $workflowId, $postId, $revId );
				break;

			case 'undo-edit-header':
			case 'undo-edit-post':
			case 'undo-edit-topic-summary':
				if ( !$revision->isFirstRevision() ) {
					$links['undo'] = $this->urlGenerator->undoAction( $revision, $title, $workflowId );
				}
				break;


			case 'hide-post':
				if ( !$postId ) {
					throw new FlowException( "$type called without \$postId" );
				}
				$links['hide'] = $this->urlGenerator->hidePostAction( $title, $workflowId, $postId );
				break;

			case 'delete-topic':
				$links['delete'] = $this->urlGenerator->deleteTopicAction( $title, $workflowId );
				break;

			case 'delete-post':
				if ( !$postId ) {
					throw new FlowException( "$type called without \$postId" );
				}
				$links['delete'] = $this->urlGenerator->deletePostAction( $title, $workflowId, $postId );
				break;

			case 'suppress-topic':
				$links['suppress'] = $this->urlGenerator->suppressTopicAction( $title, $workflowId );
				break;

			case 'suppress-post':
				if ( !$postId ) {
					throw new FlowException( "$type called without \$postId" );
				}
				$links['suppress'] = $this->urlGenerator->suppressPostAction( $title, $workflowId, $postId );
				break;

			case 'lock-topic':
				// lock topic link is only available to topics
				if ( !$revision instanceof PostRevision || !$revision->isTopicTitle() ) {
					continue;
				}

				$links['lock'] = $this->urlGenerator->lockTopicAction( $title, $workflowId );
				break;

			case 'restore-topic':
				$moderateAction = $flowAction = null;
				switch ( $revision->getModerationState() ) {
				case AbstractRevision::MODERATED_LOCKED:
					$moderateAction = 'unlock';
					$flowAction = 'lock-topic';
					break;
				case AbstractRevision::MODERATED_HIDDEN:
				case AbstractRevision::MODERATED_DELETED:
				case AbstractRevision::MODERATED_SUPPRESSED:
					$moderateAction = 'un' . $revision->getModerationState();
					$flowAction = 'moderate-topic';
					break;
				}
				if ( isset( $moderateAction ) && $moderateAction ) {
					$links[$moderateAction] = $this->urlGenerator->restoreTopicAction( $title, $workflowId, $moderateAction, $flowAction );
				}
				break;

			case 'restore-post':
				if ( !$postId ) {
					throw new FlowException( "$type called without \$postId" );
				}
				$moderateAction = $flowAction = null;
				switch( $revision->getModerationState() ) {
				case AbstractRevision::MODERATED_HIDDEN:
				case AbstractRevision::MODERATED_DELETED:
				case AbstractRevision::MODERATED_SUPPRESSED:
					$moderateAction = 'un' . $revision->getModerationState();
					$flowAction = 'moderate-post';
					break;
				}
				if ( $moderateAction ) {
					$links[$moderateAction] = $this->urlGenerator->restorePostAction( $title, $workflowId, $postId, $moderateAction, $flowAction );
				}
				break;

			case 'hide-topic':
				$links['hide'] = $this->urlGenerator->hideTopicAction( $title, $workflowId );
				break;

			// Need to use 'edit-topic-summary' to match FlowActions
			case 'edit-topic-summary':
				// summarize link is only available to topic workflow
				if( !in_array( $workflow->getType(), array( 'topic', 'topicsummary' ) ) ) {
					continue;
				}
				$links['summarize'] = $this->urlGenerator->editTopicSummaryAction( $title, $workflowId );
				break;


			default:
				wfDebugLog( 'Flow', __METHOD__ . ': unkown action link type: ' . $type );
				break;
			}
		}

		return $links;
	}

	/**
	 * @param FormatterRow $row
	 * @return Anchor[]
	 * @throws FlowException
	 */
	public function buildLinks( FormatterRow $row ) {
		$workflow = $row->workflow;
		$revision = $row->revision;
		$title = $workflow->getArticleTitle();
		$action = $revision->getChangeType();
		$workflowId = $workflow->getId();
		$revId = $revision->getRevisionId();
		$postId = method_exists( $revision, 'getPostId' ) ? $revision->getPostId() : null;

		$linkTypes = $this->permissions->getActions()->getValue( $action, 'links' );
		if ( $linkTypes === null ) {
			wfDebugLog( 'Flow', __METHOD__ . ": No links defined for action: $action" );
			return array();
		}

		$links = array();
		foreach ( $linkTypes as $type ) {
			switch( $type ) {
			case 'watch-topic':
				$links['watch-topic'] = $this->urlGenerator->watchTopicLink( $title, $workflowId );
				break;

			case 'unwatch-topic':
				$links['unwatch-topic'] = $this->urlGenerator->unwatchTopicLink( $title, $workflowId );
				break;

			case 'topic':
				$links['topic'] = $this->urlGenerator->topicLink( $title, $workflowId );
				break;

			case 'post':
				if ( !$postId ) {
					wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render post link' );
					break;
				}
				$links['post'] = $this->urlGenerator->postLink( $title, $workflowId, $postId );
				break;

			case 'header-revision':
				$links['header-revision'] = $this->urlGenerator
					->headerRevisionLink( $title, $workflowId, $revId );
				break;

			case 'topic-revision':
				if ( !$postId ) {
					wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render revision link' );
					break;
				}

				$links['topic-revision'] = $this->urlGenerator
					->topicRevisionLink( $title, $workflowId, $revId );
				break;

			case 'post-revision':
				if ( !$postId ) {
					wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render revision link' );
					break;
				}

				$links['post-revision'] = $this->urlGenerator
					->postRevisionLink( $title, $workflowId, $postId, $revId );
				break;

			case 'summary-revision':
				$links['summary-revision'] = $this->urlGenerator
					->summaryRevisionLink( $title, $workflowId, $revId );
				break;

			case 'post-history':
				if ( !$postId ) {
					wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render post-history link' );
					break;
				}
				$links['post-history'] = $this->urlGenerator->postHistoryLink( $title, $workflowId, $postId );
				break;

			case 'topic-history':
				$links['topic-history'] = $this->urlGenerator->workflowHistoryLink( $title, $workflowId );
				break;

			case 'board-history':
				$links['board-history'] = $this->urlGenerator->boardHistoryLink( $title );
				break;

			/** @noinspection PhpMissingBreakStatementInspection */
			case 'diff-header':
				$diffCallback = isset( $diffCallback ) ? $diffCallback : array( $this->urlGenerator, 'diffHeaderLink' );
				// don't break, diff links are rendered below
			/** @noinspection PhpMissingBreakStatementInspection */
			case 'diff-post':
				$diffCallback = isset( $diffCallback ) ? $diffCallback : array( $this->urlGenerator, 'diffPostLink' );
				// don't break, diff links are rendered below
			case 'diff-post-summary':
				$diffCallback = isset( $diffCallback ) ? $diffCallback : array( $this->urlGenerator, 'diffSummaryLink' );

				/*
				 * To diff against previous revision, we don't really need that
				 * revision id; if no particular diff id is specified, it will
				 * assume a diff against previous revision. However, we do want
				 * to make sure that a previous revision actually exists to diff
				 * against. This could result in a network request (fetching the
				 * current revision), but it's likely being loaded anyways.
				 */
				if ( $revision->getPrevRevisionId() !== null ) {
					$links['diff'] = call_user_func( $diffCallback, $title, $workflowId, $revId );

					/*
					 * Different formatters have different terminology for the link
					 * that diffs a certain revision to the previous revision.
					 *
					 * E.g.: Special:Contributions has "diff" ($links['diff']),
					 * ?action=history has "prev" ($links['prev']).
					 */
					$links['diff-prev'] = clone $links['diff'];
					$lastMsg = new Message( 'last' );
					$links['diff-prev']->setTitleMessage( $lastMsg );
					$links['diff-prev']->setMessage( $lastMsg );
				}

				/*
				 * To diff against the current revision, we need to know the id
				 * of this last revision. This could be an additional network
				 * request, though anything using formatter likely already needs
				 * to request the most current revision (e.g. to check
				 * permissions) so we should be able to get it from local cache.
				 */
				$cur = $row->currentRevision;
				if ( !$revId->equals( $cur->getRevisionId() ) ) {
					$links['diff-cur'] = call_user_func( $diffCallback, $title, $workflowId, $cur->getRevisionId(), $revId );
					$curMsg = new Message( 'cur' );
					$links['diff-cur']->setTitleMessage( $curMsg );
					$links['diff-cur']->setMessage( $curMsg );
				}
				break;

			case 'workflow':
				$links['workflow'] = $this->urlGenerator->workflowLink( $title, $workflowId );
				break;

			default:
				wfDebugLog( 'Flow', __METHOD__ . ': unkown action link type: ' . $type );
				break;
			}
		}


		return $links;
	}

	/**
	 * Build api properties defined in FlowActions for this change type
	 *
	 * This is a fairly expensive function(compared to the other methods in this class).
	 * As such its only output when specifically requested
	 *
	 * @param UUID $workflowId
	 * @param AbstractRevision $revision
	 * @param IContextSource $ctx
	 * @param FormatterRow|null $row
	 * @return array
	 */
	public function buildProperties(
		UUID $workflowId,
		AbstractRevision $revision,
		IContextSource $ctx,
		FormatterRow $row = null
	) {
		if ( $this->includeProperties === false ) {
			return array();
		}

		$changeType = $revision->getChangeType();
		$actions = $this->permissions->getActions();
		$params = $actions->getValue( $changeType, 'history', 'i18n-params' );
		if ( !$params ) {
			// should we have a sigil for i18n with no parameters?
			wfDebugLog( 'Flow', __METHOD__ . ": No i18n params for changeType $changeType on " . $revision->getRevisionId()->getAlphadecimal() );
			return array();
		}

		$res = array( '_key' => $actions->getValue( $changeType, 'history', 'i18n-message' ) );
		foreach ( $params as $param ) {
			$res[$param] = $this->processParam( $param, $revision, $workflowId, $ctx, $row );
		}

		return $res;
	}

	/**
	 * Mimic Echo parameter formatting
	 *
	 * @param string $param The requested i18n parameter
	 * @param AbstractRevision|AbstractRevision[] $revision The revision or
	 *  revisions to format or an array of revisions
	 * @param UUID $workflowId The UUID of the workflow $revision belongs tow
	 * @param IContextSource $ctx
	 * @param FormatterRow|null $row
	 * @return mixed A valid parameter for a core Message instance. These
	 *  parameters will be used with Message::parse
	 * @throws FlowException
	 */
	public function processParam(
		$param,
		$revision,
		UUID $workflowId,
		IContextSource $ctx,
		FormatterRow $row = null
	) {
		switch ( $param ) {
		case 'creator-text':
			if ( $revision instanceof PostRevision ) {
				return $this->usernames->getFromTuple( $revision->getCreatorTuple() );
			} else {
				return '';
			}

		case 'user-text':
			return $this->usernames->getFromTuple( $revision->getUserTuple() );

		case 'user-links':
			return Message::rawParam( $this->templating->getUserLinks( $revision ) );

		case 'summary':
			if ( !$this->permissions->isAllowed( $revision, 'view' ) ) {
				return '';
			}

			/*
			 * Fetch in HTML; unparsed wikitext in summary is pointless.
			 * Larger-scale wikis will likely also store content in html, so no
			 * Parsoid roundtrip is needed then (and if it *is*, it'll already
			 * be needed to render Flow discussions, so this is manageable)
			 */
			$content = $this->templating->getContent( $revision, 'fixed-html' );
			// strip html tags and decode to plaintext
			$content = Utils::htmlToPlaintext( $content, 140, $ctx->getLanguage() );
			return Message::plaintextParam( $content );

		case 'wikitext':
			if ( !$this->permissions->isAllowed( $revision, 'view' ) ) {
				return '';
			}

			$format = $revision->getWikitextFormat();

			$content = $this->templating->getContent( $revision, $format );
			// This must be escaped and marked raw to prevent special chars in
			// content, like $1, from changing the i18n result
			return Message::plaintextParam( $content );

		// This is potentially two networked round trips, much too expensive for
		// the rendering loop
		case 'prev-wikitext':
			if ( $revision->isFirstRevision() ) {
				return '';
			}
			if ( $row === null ) {
				$previousRevision = $revision->getCollection()->getPrevRevision( $revision );
			} else {
				$previousRevision = $row->previousRevision;
			}
			if ( !$previousRevision ) {
				return '';
			}
			if ( !$this->permissions->isAllowed( $previousRevision, 'view' ) ) {
				return '';
			}

			$format = $revision->getWikitextFormat();

			$content = $this->templating->getContent( $previousRevision, $format );
			return Message::plaintextParam( $content );
		case 'plaintext':
			if ( !$this->permissions->isAllowed( $revision, 'view' ) ) {
				return '';
			}

			$format = $revision->getHtmlFormat();

			$content = Utils::htmlToPlaintext( $this->templating->getContent( $revision, $format ) );
			return Message::plaintextParam( $content );

		// This is potentially two networked round trips, much too expensive for
		// the rendering loop
		case 'prev-plaintext':
			if ( $revision->isFirstRevision() ) {
				return '';
			}
			if ( $row === null ) {
				$previousRevision = $revision->getCollection()->getPrevRevision( $revision );
			} else {
				$previousRevision = $row->previousRevision;
			}
			if ( !$previousRevision ) {
				return '';
			}
			if ( !$this->permissions->isAllowed( $previousRevision, 'view' ) ) {
				return '';
			}

			$format = $revision->getHtmlFormat();

			$content = Utils::htmlToPlaintext( $this->templating->getContent( $previousRevision, $format ) );
			return Message::plaintextParam( $content );

		case 'workflow-url':
			return $this->urlGenerator
				->workflowLink( null, $workflowId )
				->getFullUrl();

		case 'post-url':
			if ( !$revision instanceof PostRevision ) {
				throw new FlowException( 'Expected PostRevision but received' . get_class( $revision ) );
			}
			return $this->urlGenerator
				->postLink( null, $workflowId, $revision->getPostId() )
				->getFullUrl();

		case 'moderated-reason':
			// don-t parse wikitext in the moderation reason
			return Message::plaintextParam( $revision->getModeratedReason() );

		case 'topic-of-post':
			if ( !$revision instanceof PostRevision ) {
				throw new FlowException( 'Expected PostRevision but received ' . get_class( $revision ) );
			}

			$root = $revision->getRootPost();
			if ( !$this->permissions->isAllowed( $root, 'view-topic-title' ) ) {
				return '';
			}

			$content = $this->templating->getContent( $root, 'topic-title-wikitext' );

			// TODO: We need to use plaintextParam or similar to avoid parsing,
			// but the API output says "plaintext", which is confusing and
			// should be fixed.  From the API consumer's perspective, it's
			// topic-title-wikitext.
			return Message::plaintextParam( $content );

		// Strip the tags from the HTML version to produce text:
		// [[Red link 3]], [[Adrines]], [[Media:Earth.jpg]], http://example.com =>
		// Red link 3, Adrines, Media:Earth.jpg, http://example.com
		case 'topic-of-post-text-from-html':
			if ( !$revision instanceof PostRevision ) {
				throw new FlowException( 'Expected PostRevision but received ' . get_class( $revision ) );
			}

			$root = $revision->getRootPost();
			if ( !$this->permissions->isAllowed( $root, 'view-topic-title' ) ) {
				return '';
			}

			$content = $this->templating->getContent( $root, 'topic-title-plaintext' );

			return Message::plaintextParam( $content );

		case 'post-of-summary':
			if ( !$revision instanceof PostSummary ) {
				throw new FlowException( 'Expected PostSummary but received ' . get_class( $revision ) );
			}

			/** @var PostRevision $post */
			$post = $revision->getCollection()->getPost()->getLastRevision();
			$permissionAction = $post->isTopicTitle() ? 'view-topic-title' : 'view';
			if ( !$this->permissions->isAllowed( $post, $permissionAction ) ) {
				return '';
			}

			if ( $post->isTopicTitle() ) {
				return Message::plaintextParam( $this->templating->getContent( $post, 'topic-title-plaintext' ) );
			} else {
				return Message::rawParam( $this->templating->getContent( $post, 'fixed-html' ) );
			}

		case 'bundle-count':
			return Message::numParam( count( $revision ) );

		default:
			wfWarn( __METHOD__ . ': Unknown formatter parameter: ' . $param );
			return '';
		}
	}

	protected function msg( $key /*...*/ ) {
		$params = func_get_args();
		if ( count( $params ) !== 1 ) {
			array_shift( $params );
			return wfMessage( $key, $params );
		}
		if ( !isset( $this->messages[$key] ) ) {
			$this->messages[$key] = new Message( $key );
		}
		return $this->messages[$key];
	}

	/**
	 * Determines the exact output content format, given the requested content format
	 * and the revision type.
	 *
	 * @param AbstractRevision $revision
	 * @return string Content format
	 * @throws FlowException If a per-revision format was given and it is
	 *  invalid for the revision type (topic title/non-topic title).
	 */
	public function decideContentFormat( AbstractRevision $revision ) {
		$requestedRevFormat = null;
		$requestedDefaultFormat = null;

		$alpha = $revision->getRevisionId()->getAlphadecimal();
		if ( isset( $this->revisionContentFormat[$alpha] ) ) {
			$requestedRevFormat = $this->revisionContentFormat[$alpha];
		} else {
			$requestedDefaultFormat = $this->contentFormat;
		}

		if ( $revision instanceof PostRevision && $revision->isTopicTitle() ) {
			return $this->decideTopicTitleContentFormat( $revision, $requestedRevFormat, $requestedDefaultFormat );
		} else {
			return $this->decideNonTopicTitleContentFormat( $revision, $requestedRevFormat, $requestedDefaultFormat );
		}
	}

	/**
	 * Decide the content format for a topic title
	 *
	 * @param PostRevision $topicTitle Topic title revision
	 * @param string|null $requestedRevFormat Format requested for this specific revision
	 * @param string|null $requestedDefaultFormat Default format requested
	 * @return string
	 * @throws FlowException If a per-revision format was given and it is
	 *  invalid for topic titles.
	 */
	protected function decideTopicTitleContentFormat( PostRevision $topicTitle, $requestedRevFormat, $requestedDefaultFormat ) {
		if ( $requestedRevFormat !== null ) {
			if ( $requestedRevFormat !== 'topic-title-html' &&
				$requestedRevFormat !== 'topic-title-wikitext' ) {

				throw new FlowException( 'Per-revision format for a topic title must be \'topic-title-html\' or \'topic-title-wikitext\'' );
			}
			return $requestedRevFormat;
		} else {
			// Since this is a default format, we'll canonicalize it.

			// Because these are both editable formats, and this is the only
			// editable topic title format.
			if ( $requestedDefaultFormat === 'topic-title-wikitext' || $requestedDefaultFormat === 'html' || $requestedDefaultFormat === 'wikitext' ) {
				return 'topic-title-wikitext';
			} else {
				return 'topic-title-html';
			}
		}
	}

	/**
	 * Decide the content format for revisions other than topic titles
	 *
	 * @param AbstractRevision $revision Revision to decide format for
	 * @param string|null $requestedRevFormat Format requested for this specific revision
	 * @param string|null $requestedDefaultFormat Default format requested
	 * @return string
	 * @throws FlowException If a per-revision format was given and it is
	 *  invalid for this type
	 */
	protected function decideNonTopicTitleContentFormat( AbstractRevision $revision, $requestedRevFormat, $requestedDefaultFormat ) {
		if ( $requestedRevFormat !== null ) {
			if ( $requestedRevFormat === 'topic-title-html' ||
				$requestedRevFormat === 'topic-title-wikitext' ) {

				throw new FlowException( 'Invalid per-revision format.  Only topic titles can use  \'topic-title-html\' and \'topic-title-wikitext\'' );
			}
			return $requestedRevFormat;
		} else {
			if ( $requestedDefaultFormat === 'topic-title-html' ||
				$requestedDefaultFormat === 'topic-title-wikitext' ) {

				throw new FlowException( 'Default format of \'topic-title-html\' or \'topic-title-wikitext\' can only be used to format topic titles.' );
			}

			return $requestedDefaultFormat;
		}
	}
}