View file upload/library/XenForo/Model/Tag.php

File size: 23.99Kb
<?php

class XenForo_Model_Tag extends XenForo_Model
{
	const CONTENT_TYPE = 0;
	const CONTENT_ID = 1;

	public function splitTags($list)
	{
		return preg_split('/\s*,\s*/', $list, -1, PREG_SPLIT_NO_EMPTY);
	}

	public function normalizeTag($tag)
	{
		$tag = utf8_strtolower($tag);

		try
		{
			// if this matches, then \v isn't known (appears to be PCRE < 7.2) so don't strip
			if (!preg_match('/\v/', 'v'))
			{
				$new = preg_replace('/\v+/u', ' ', $tag);
				if (is_string($new))
				{
					$tag = $new;
				}
			}
		}
		catch (Exception $e) {}
		$tag = preg_replace('/\s+/u', ' ', $tag);

		$tag = preg_replace('/^[^\d\pL]+(.*)[^\d\pL]+$/siUu', '$1', $tag);
		$tag = trim($tag);

		return $tag;
	}

	public function isValidTag($tag)
	{
		$length = utf8_strlen($tag);
		$lengthLimits = XenForo_Application::getOptions()->tagLength;

		$minLength = max($lengthLimits['min'], 1);
		$maxLength = $lengthLimits['max'] <= 0 ? 100 : min($lengthLimits['max'], 100);

		if ($length < $minLength)
		{
			return false;
		}
		if ($length > $maxLength)
		{
			return false;
		}

		$validation = XenForo_Application::getOptions()->tagValidation;

		$disallowed = preg_split('/\r?\n/', $validation['disallowedWords']);
		if ($disallowed)
		{
			foreach ($disallowed AS $disallowedCheck)
			{
				$disallowedCheck = trim($disallowedCheck);
				if ($disallowedCheck === '')
				{
					continue;
				}
				if (stripos($tag, $disallowedCheck) !== false)
				{
					return false;
				}
			}
		}

		if ($validation['matchRegex'] && !preg_match('/\W[\s\w]*e[\s\w]*$/', $validation['matchRegex']))
		{
			try
			{
				if (!preg_match($validation['matchRegex'], $tag))
				{
					return false;
				}
			}
			catch (Exception $e)
			{
				XenForo_Error::logException($e, false);
			}
		}

		$censored = XenForo_Helper_String::censorString($tag);
		if ($censored != $tag)
		{
			return false;
		}

		return true;
	}

	public function getFoundTagsInList(array $search, array $dbList, &$notFound = array())
	{
		$found = array();
		$notFound = array();

		foreach ($search AS $tag)
		{
			$tag = $this->normalizeTag($tag);
			$tagCompare = utf8_deaccent($tag);
			$foundKey = null;

			foreach ($dbList AS $id => $dbTag)
			{
				if (utf8_deaccent($dbTag['tag']) == $tagCompare)
				{
					$foundKey = $id;
					break;
				}
			}

			if ($foundKey === null)
			{
				$notFound[] = $tag;
			}
			else
			{
				$found[$foundKey] = $dbList[$foundKey];
			}
		}

		return $found;
	}

	public function getTagByUrl($tagUrl)
	{
		return $this->_getDb()->fetchRow("
			SELECT *
			FROM xf_tag
			WHERE tag_url = ?
		", $tagUrl);
	}

	public function getTagById($tagId)
	{
		return $this->_getDb()->fetchRow("
			SELECT *
			FROM xf_tag
			WHERE tag_id = ?
		", $tagId);
	}

	public function autoCompleteTag($tag, $limit = 10)
	{
		return $this->fetchAllKeyed($this->limitQueryResults(
			"
				SELECT *
				FROM xf_tag
				WHERE tag LIKE " . XenForo_Db::quoteLike($tag, 'r') . "
					AND (use_count > 0 OR permanent = 1)
				ORDER BY tag
			", $limit
		), 'tag_id');
	}

	public function getTagList($containing = null, array $fetchOptions = array())
	{
		$limitOptions = $this->prepareLimitFetchOptions($fetchOptions);

		if ($containing && strlen($containing))
		{
			$containingSql = "AND tag LIKE " . XenForo_Db::quoteLike($containing, 'lr');
		}
		else
		{
			$containingSql = '';
		}

		if (isset($fetchOptions['order']))
		{
			switch ($fetchOptions['order'])
			{
				case 'use_count': $orderBy = 'use_count DESC'; break;
				case 'last_use_date': $orderBy = 'last_use_date DESC'; break;
				case 'tag':
				default: $orderBy = 'tag';
			}
		}
		else
		{
			$orderBy = 'tag';
		}

		return $this->fetchAllKeyed($this->limitQueryResults(
			"
				SELECT *
				FROM xf_tag
				WHERE 1=1 {$containingSql}
				ORDER BY {$orderBy}
			", $limitOptions['limit'], $limitOptions['offset']
		), 'tag_id');
	}

	public function countTagList($containing = null)
	{
		if ($containing && strlen($containing))
		{
			$containingSql = "AND tag LIKE " . XenForo_Db::quoteLike($containing, 'lr');
		}
		else
		{
			$containingSql = '';
		}

		return $this->_getDb()->fetchOne("
			SELECT COUNT(*)
			FROM xf_tag
			WHERE 1=1 {$containingSql}
		");
	}

	public function getTag($tag)
	{
		return $this->_getDb()->fetchRow("
			SELECT *
			FROM xf_tag
			WHERE tag = ?
		", $tag);
	}

	public function getTags(array $tags, &$notFound = array())
	{
		$notFound = array();

		$normalized = array();

		foreach ($tags AS $k => $tag)
		{
			$tag = $this->normalizeTag($tag);
			if (strlen($tag))
			{
				$normalized[$k] = $tag;
			}
		}

		if (!$normalized)
		{
			return array();
		}

		$dbTags = $this->fetchAllKeyed("
			SELECT *
			FROM xf_tag
			WHERE tag IN (" . $this->_getDb()->quote($normalized) . ")
		", 'tag_id');

		return $this->getFoundTagsInList($normalized, $dbTags, $notFound);
	}

	public function getTagsInRange($start, $limit)
	{
		return $this->fetchAllKeyed($this->limitQueryResults("
			SELECT *
			FROM xf_tag
			WHERE tag_id > ?
			ORDER BY tag_id
		", $limit), 'tag_id', $start);
	}

	public function getTagsForContent($contentType, $contentId)
	{
		return $this->fetchAllKeyed("
			SELECT tag_content.*, tag.*
			FROM xf_tag_content AS tag_content
			INNER JOIN xf_tag AS tag ON (tag.tag_id = tag_content.tag_id)
			WHERE tag_content.content_type = ?
				AND tag_content.content_id = ?
			ORDER BY tag.tag
		", 'tag_id', array($contentType, $contentId));
	}

	public function getContentTagCache($contentType, $contentId)
	{
		$tags = $this->getTagsForContent($contentType, $contentId);
		$cache = array();
		foreach ($tags AS $tag)
		{
			$cache[$tag['tag_id']] = array(
				'tag' => $tag['tag'],
				'tag_url' => $tag['tag_url']
			);
		}

		return $cache;
	}

	public function getTagListForEdit($contentType, $contentId, $editOthers, $userId = null)
	{
		if ($userId === null)
		{
			$userId = XenForo_Visitor::getUserId();
		}

		$editable = array();
		$uneditable = array();

		foreach ($this->getTagsForContent($contentType, $contentId) AS $tag)
		{
			if (!$editOthers && $userId != $tag['add_user_id'])
			{
				$uneditable[] = $tag['tag'];
			}
			else
			{
				$editable[] = $tag['tag'];
			}
		}

		return array(
			'editable' => $editable,
			'uneditable' => $uneditable
		);
	}

	public function getContentIdsByTagId($tagId, $limit, $visibleOnly = true)
	{
		$results = $this->_getDb()->query($this->limitQueryResults(
			"
				SELECT tag_content_id, content_type, content_id
				FROM xf_tag_content
				WHERE tag_id = ?
					" . ($visibleOnly ? "AND visible = 1" : '') . "
				ORDER BY content_date DESC
			", max(1, $limit)
		), $tagId);
		$output = array();
		while ($result = $results->fetch())
		{
			$output[$result['tag_content_id']] = array($result['content_type'], $result['content_id']);
		}

		return $output;
	}

	public function getTagsForCloud($limit, $minUses = 1)
	{
		$tagIds = $this->_getDb()->fetchCol($this->limitQueryResults(
			"
				SELECT tag_id
				FROM xf_tag
				WHERE use_count >= ?
				ORDER BY use_count DESC
			", $limit
		), $minUses);
		if (!$tagIds)
		{
			return array();
		}

		return $this->fetchAllKeyed("
			SELECT *
			FROM xf_tag
			WHERE tag_id IN (" . $this->_getDb()->quote($tagIds) . ")
			ORDER BY tag
		", 'tag_id');
	}

	public function getTagCloudLevels(array $tags, $levels = 7)
	{
		if (!$tags)
		{
			return array();
		}

		$uses = XenForo_Application::arrayColumn($tags, 'use_count');
		$min = min($uses);
		$max = max($uses);
		$levelSize = ($max - $min) / $levels;

		$output = array();

		if ($min == $max)
		{
			$middle = ceil($levels / 2);
			foreach ($tags AS $id => $tag)
			{
				$output[$id] = $middle;
			}
		}
		else
		{
			foreach ($tags AS $id => $tag)
			{
				$diffFromMin = $tag['use_count'] - $min;
				if (!$diffFromMin)
				{
					$level = 1;
				}
				else
				{
					$level = min($levels, ceil($diffFromMin / $levelSize));
				}
				$output[$id] = $level;
			}
		}

		return $output;
	}

	public function createTag($tag)
	{
		$tag = $this->normalizeTag($tag);
		if (!strlen($tag))
		{
			return null;
		}

		$dw = XenForo_DataWriter::create('XenForo_DataWriter_Tag');
		$dw->set('tag', $tag);
		$dw->preSave();
		if ($dw->getErrors())
		{
			$existingId = $this->_getDb()->fetchOne("
				SELECT tag_id
				FROM xf_tag
				WHERE tag = ?
			", $tag);
			if ($existingId)
			{
				return $existingId;
			}
			else
			{
				return null;
			}
		}

		$dw->save();

		return $dw->get('tag_id');
	}

	protected function _getUrlVersionOfTag($tag)
	{
		$db = $this->_getDb();

		$urlVersion = preg_replace('/[^a-zA-Z0-9_ -]/', '', utf8_romanize(utf8_deaccent($tag)));
		$urlVersion = preg_replace('/[ -]+/', '-', $urlVersion);

		if (!strlen($urlVersion))
		{
			$urlVersion = 1 + intval($db->fetchOne("
				SELECT MAX(tag_id)
				FROM xf_tag
			"));
		}
		else
		{
			$existing = $db->fetchRow("
				SELECT *
				FROM xf_tag
				WHERE tag_url = ?
					OR (tag_url LIKE ? AND tag_url REGEXP ?)
				ORDER BY tag_id DESC
				LIMIT 1
			", array ($urlVersion, "$urlVersion-%", "^{$urlVersion}-[0-9]+\$"));
			if ($existing)
			{
				$counter = 1;
				if ($existing['tag_url'] != $urlVersion && preg_match('/-(\d+)$/', $existing['tag_url'], $match))
				{
					$counter = $match[1];
				}

				$testExists = true;
				while ($testExists)
				{
					$counter++;
					$testExists = $db->fetchOne("
						SELECT tag_id
						FROM xf_tag
						WHERE tag_url = ?
					", "$urlVersion-$counter");
				}

				$urlVersion .= "-$counter";
			}
		}

		return $urlVersion;
	}

	public function adjustContentTags($contentType, $contentId, array $addIds, array $removeIds, $userId = null)
	{
		$handler = $this->getTagHandler($contentType);
		if (!$handler)
		{
			return null;
		}

		$content = $handler->getBasicContent($contentId);
		if (!$content)
		{
			return null;
		}

		if ($userId === null)
		{
			$userId = XenForo_Visitor::getUserId();
		}

		$db = $this->_getDb();
		XenForo_Db::beginTransaction($db);

		if ($removeIds)
		{
			$db->query("
				DELETE FROM xf_tag_content
				WHERE tag_id IN (" . $db->quote($removeIds) . ")
					AND content_type = ?
					AND content_id = ?
			", array($contentType, $contentId));
			$this->recalculateTagUsage($removeIds);
		}

		if ($addIds)
		{
			$contentDate = $handler->getContentDate($content);
			$visibleSql = $handler->getContentVisibility($content) ? 1 : 0;

			foreach ($addIds AS $addId)
			{
				$res = $this->_getDb()->query("
					INSERT IGNORE INTO xf_tag_content
						(content_type, content_id, tag_id, add_user_id, add_date, content_date, visible)
					VALUES
						(?, ?, ?, ?, ?, ?, ?)
				", array($contentType, $contentId, $addId, $userId, XenForo_Application::$time, $contentDate, $visibleSql));
				if ($res->rowCount() && $visibleSql)
				{
					$this->_getDb()->query("
						UPDATE xf_tag
						SET use_count = use_count + 1,
							last_use_date = ?
						WHERE tag_id = ?
					", array(XenForo_Application::$time, $addId));
				}
			}
		}

		$cache = $this->getContentTagCache($contentType, $contentId);
		$handler->updateContentTagCache($content, $cache);

		XenForo_Db::commit($db);

		return $cache;
	}

	public function rebuildTagCache($contentType, $contentId)
	{
		$handler = $this->getTagHandler($contentType);
		if (!$handler)
		{
			return false;
		}

		$content = $handler->getBasicContent($contentId);
		if (!$content)
		{
			return false;
		}

		$cache = $this->getContentTagCache($contentType, $contentId);
		$handler->updateContentTagCache($content, $cache);

		return true;
	}

	public function updateContentVisibility($contentType, $contentId, $visibility)
	{
		$db = $this->_getDb();
		$tagIds = $db->fetchAll("
			SELECT tag_id, tag_content_id, visible
			FROM xf_tag_content
			WHERE content_type = ?
				AND content_id = ?
		", array($contentType, $contentId));
		if (!$tagIds)
		{
			return;
		}

		$newVisibleSql = $visibility ? 1 : 0;
		$update = array();
		$recalc = array();
		foreach ($tagIds AS $tag)
		{
			if ($newVisibleSql != $tag['visible'])
			{
				$update[] = $tag['tag_content_id'];
				$recalc[] = $tag['tag_id'];
			}
		}
		if (!$update)
		{
			return;
		}

		XenForo_Db::beginTransaction($db);

		$db->update('xf_tag_content',
			array('visible' => $newVisibleSql),
			'tag_content_id IN (' . $db->quote($update) . ')'
		);
		$this->recalculateTagUsage($recalc);

		XenForo_Db::commit($db);
	}

	public function deleteContentTags($contentType, $contentId)
	{
		$db = $this->_getDb();
		$tagIds = $db->fetchPairs("
			SELECT tag_id, visible
			FROM xf_tag_content
			WHERE content_type = ?
				AND content_id = ?
		", array($contentType, $contentId));
		if (!$tagIds)
		{
			return;
		}

		$recalc = array();
		foreach ($tagIds AS $id => $visible)
		{
			if ($visible)
			{
				$recalc[] = $id;
			}
		}

		XenForo_Db::beginTransaction($db);

		$db->query("
			DELETE FROM xf_tag_content
			WHERE content_type = ?
				AND content_id = ?
		", array($contentType, $contentId));

		$this->recalculateTagUsage($recalc);

		XenForo_Db::commit($db);
	}

	public function recalculateTagUsageByContentTagged($contentType, $contentId)
	{
		$tagIds = $this->_getDb()->fetchCol("
			SELECT tag_id
			FROM xf_tag_content
			WHERE content_type = ?
				AND content_id = ?
		", array($contentType, $contentId));
		$this->recalculateTagUsage($tagIds);
	}

	public function recalculateTagUsage($tagIds)
	{
		if (!$tagIds)
		{
			return;
		}

		if (!is_array($tagIds))
		{
			$tagIds = array($tagIds);
		}

		$db = $this->_getDb();

		$tags = $db->fetchPairs("
			SELECT tag_id, permanent
			FROM xf_tag
			WHERE tag_id IN (" . $db->quote($tagIds) . ")
		");
		$results = $this->fetchAllKeyed("
			SELECT tag_id,
				COUNT(IF(visible, 1, NULL)) AS use_count,
				COUNT(*) AS raw_use_count,
				MAX(IF(visible, add_date, 0)) AS last_use_date
			FROM xf_tag_content
			WHERE tag_id IN (" . $db->quote($tagIds) . ")
			GROUP BY tag_id
		", 'tag_id');

		XenForo_Db::beginTransaction($db);

		foreach ($tags AS $tagId => $permanent)
		{
			$delete = false;

			if (isset($results[$tagId]))
			{
				$result = $results[$tagId];
				if (!$result['use_count'] && !$result['raw_use_count'])
				{
					// this shouldn't actually happen since there shouldn't be a row
					$delete = true;
				}
				else
				{
					$db->update('xf_tag', array(
						'use_count' => $result['use_count'],
						'last_use_date' => $result['last_use_date']
					), 'tag_id = ' . $db->quote($tagId));
				}
			}
			else
			{
				$delete = true;
			}

			if ($delete)
			{
				if ($permanent)
				{
					$db->update('xf_tag', array(
						'use_count' => 0,
						'last_use_date' => 0
					), 'tag_id = ' . $db->quote($tagId));
				}
				else
				{
					$db->delete('xf_tag', 'tag_id = ' . $db->quote($tagId));
				}
			}
		}

		XenForo_Db::commit($db);
	}

	public function mergeTags($sourceTagId, $targetTagId)
	{
		$db = $this->_getDb();

		XenForo_Db::beginTransaction($db);

		$db->query("
			UPDATE IGNORE xf_tag_content
			SET tag_id = ?
			WHERE tag_id = ?
		", array($targetTagId, $sourceTagId));
		$db->query("DELETE FROM xf_tag_result_cache WHERE tag_id = ?", $targetTagId);

		// this handles cases where the content already had the target tag
		$db->query("DELETE FROM xf_tag WHERE tag_id = ?", $sourceTagId);
		$db->query("DELETE FROM xf_tag_content WHERE tag_id = ?", $sourceTagId);

		$this->recalculateTagUsage($targetTagId);

		XenForo_Db::commit($db);

		XenForo_Application::defer('TagRecache', array(
			'tagId' => $targetTagId
		), 'tagUpdate' . $targetTagId, true);
	}

	public function getTagResultsCache($tagId, $userId = null)
	{
		if ($userId === null)
		{
			$userId = XenForo_Visitor::getUserId();
		}

		return $this->_getDb()->fetchRow("
			SELECT *
			FROM xf_tag_result_cache
			WHERE tag_id = ?
				AND user_id = ?
				AND expiry_date > ?
		", array($tagId, $userId, XenForo_Application::$time));
	}

	public function insertTagResultsCache($tagId, array $results, $userId = null)
	{
		if ($userId === null)
		{
			$userId = XenForo_Visitor::getUserId();
		}

		$expiry = XenForo_Application::$time + 60*60;

		$this->_getDb()->query("
			INSERT INTO xf_tag_result_cache
				(tag_id, user_id, cache_date, expiry_date, results)
			VALUES
				(?, ?, ?, ?, ?)
			ON DUPLICATE KEY UPDATE
				cache_date = VALUES(cache_date),
				expiry_date = VALUES(expiry_date),
				results = VALUES(results)
		", array($tagId, $userId, XenForo_Application::$time, $expiry, json_encode($results)));
	}

	public function pruneTagResultsCache($cutOff = null)
	{
		if ($cutOff === null)
		{
			$cutOff = XenForo_Application::$time;
		}

		$this->_getDb()->delete('xf_tag_result_cache', 'expiry_date <= ' . intval($cutOff));
	}

	/**
	 * Groups tag results by the content type they belong to.
	 *
	 * @param array $results Format: [] => array(content type, content id)
	 *
	 * @return array Format: [content type][content id] => content id
	 */
	public function groupTagResultsByType(array $results)
	{
		$resultsGrouped = array();
		foreach ($results AS $result)
		{
			$resultsGrouped[$result[self::CONTENT_TYPE]][$result[self::CONTENT_ID]] = $result[self::CONTENT_ID];
		}

		return $resultsGrouped;
	}

	/**
	 * Gets the data for the tag results that are actually viewable. If no
	 * data is returned, the result is not viewable and should be hidden.
	 *
	 * @param array $resultsGrouped Tag results, grouped by type (see {@link groupTagResultsByType()})
	 * @param array $handlers Tag handler objects for all necessary content types
	 * @param boolean $prepareData True if the data should be prepared as well
	 * @param array|null $viewingUser Information about the viewing user (keys: user_id, permission_combination_id, permissions) or null for visitor
	 *
	 * @return array Result data grouped, format: [content type][content id] => data
	 */
	public function getViewableTagResultData(array $resultsGrouped, array $handlers, $prepareData = true, array $viewingUser = null)
	{
		$this->standardizeViewingUserReference($viewingUser);

		$dataGrouped = array();
		foreach ($handlers AS $contentType => $handler)
		{
			if (!isset($resultsGrouped[$contentType]))
			{
				continue;
			}

			$dataResults = $handler->getDataForResults($resultsGrouped[$contentType], $viewingUser, $resultsGrouped);
			foreach ($dataResults AS $dataId => $data)
			{
				if (!$handler->canViewResult($data, $viewingUser))
				{
					unset($dataResults[$dataId]);
					continue;
				}

				if ($prepareData)
				{
					$dataResults[$dataId] = $handler->prepareResult($data, $viewingUser);
				}
			}

			$dataGrouped[$contentType] = $dataResults;
		}

		return $dataGrouped;
	}

	/**
	 * Filters a list of tag results to those that are viewable.
	 *
	 * @param array $results Tag results ([] => array(content type, content id)
	 * @param array|null $viewingUser Information about the viewing user (keys: user_id, permission_combination_id, permissions) or null for visitor
	 * @param array $preparableData Returns data which can be prepared and then used for display (keys: results, handlers)
	 *
	 * @return array Same as input results, but unviewable entries removed
	 */
	public function getViewableTagResults(array $results, array $viewingUser = null, &$preparableData = null)
	{
		$resultsGrouped = $this->groupTagResultsByType($results);
		$handlers = $this->getTagHandlers(array_keys($resultsGrouped));

		$dataGrouped = $this->getViewableTagResultData($resultsGrouped, $handlers, false, $viewingUser);

		foreach ($results AS $resultId => $result)
		{
			if (!isset($dataGrouped[$result[self::CONTENT_TYPE]][$result[self::CONTENT_ID]]))
			{
				unset($results[$resultId]);
			}
		}

		$preparableData = array(
			'results' => $dataGrouped,
			'handlers' => $handlers
		);

		return $results;
	}

	/**
	 * Gets the tag results ready for display (using the handlers).
	 * The results (in the returned "results" key) have extra, type-specific data
	 * included with them.
	 *
	 * @param array $results Tag results ([] => array(content type, content id)
	 * @param array|null $viewingUser Information about the viewing user (keys: user_id, permission_combination_id, permissions) or null for visitor
	 *
	 * @return array Keys: results, handlers
	 */
	public function getTagResultsForDisplay(array $results, array $viewingUser = null)
	{
		$resultsGrouped = $this->groupTagResultsByType($results);
		$handlers = $this->getTagHandlers(array_keys($resultsGrouped));

		$dataGrouped = $this->getViewableTagResultData($resultsGrouped, $handlers, true, $viewingUser);

		foreach ($results AS $resultId => $result)
		{
			if (isset($dataGrouped[$result[self::CONTENT_TYPE]][$result[self::CONTENT_ID]]))
			{
				$results[$resultId]['content'] = $dataGrouped[$result[self::CONTENT_TYPE]][$result[self::CONTENT_ID]];
			}
			else
			{
				unset($results[$resultId]);
			}
		}

		if (!$results)
		{
			return false;
		}

		return array(
			'results' => $results,
			'handlers' => $handlers
		);
	}

	public function finalizeUnpreparedResults(array $unpreparedResults, array $pageResultIds, array $viewingUser = null)
	{
		$finalResults = array();
		$results = $unpreparedResults['results'];
		$handlers = $unpreparedResults['handlers'];

		$this->standardizeViewingUserReference($viewingUser);

		foreach ($pageResultIds AS $key => $pageResult)
		{
			$type = $pageResult[self::CONTENT_TYPE];
			$id = $pageResult[self::CONTENT_ID];

			if (!isset($results[$type][$id]) || !isset($handlers[$type]))
			{
				continue;
			}

			$result = $results[$type][$id];
			$handler = $handlers[$type];

			$finalResults[$key] = array(
				self::CONTENT_TYPE => $type,
				self::CONTENT_ID => $id,
				'content' => $handler->prepareResult($result, $viewingUser)
			);
		}

		return array(
			'results' => $finalResults,
			'handlers' => $handlers
		);
	}

	/**
	 * Returns the slice of tag results for the requested page.
	 *
	 * @param array $tagCache Tag cache, containing results
	 * @param integer $page
	 * @param integer $perPage
	 *
	 * @return array Results for the specified page
	 */
	public function sliceTagResultsToPage(array $tagCache, $page, $perPage)
	{
		if ($page < 1)
		{
			$page = 1;
		}

		if (!isset($tagCache['resultsCache']))
		{
			$tagCache['resultsCache'] = json_decode($tagCache['results'], true);
		}

		return array_slice($tagCache['resultsCache'], ($page - 1) * $perPage, $perPage);
	}

	/**
	 * @param string $contentType
	 * @return XenForo_TagHandler_Tagger
	 *
	 * @throws Exception
	 */
	public function getTagger($contentType)
	{
		$handler = $this->getTagHandler($contentType);
		if (!$handler)
		{
			throw new InvalidArgumentException("Unknown content type '$contentType'");
		}

		$class = XenForo_Application::resolveDynamicClass('XenForo_TagHandler_Tagger');
		return new $class($handler, $this);
	}

	/**
	 * @param string $contentType
	 *
	 * @return XenForo_TagHandler_Abstract|null
	 */
	public function getTagHandler($contentType)
	{
		$handlerClass = $this->getContentTypeField($contentType, 'tag_handler_class');
		if (!$handlerClass || !class_exists($handlerClass))
		{
			return null;
		}

		$handlerClass = XenForo_Application::resolveDynamicClass($handlerClass);
		return new $handlerClass($contentType);
	}

	/**
	 * @param array $contentTypes
	 *
	 * @return XenForo_TagHandler_Abstract[]
	 */
	public function getTagHandlers(array $contentTypes)
	{
		$handlers = array();
		foreach ($contentTypes AS $contentType)
		{
			$handler = $this->getTagHandler($contentType);
			if ($handler)
			{
				$handlers[$contentType] = $handler;
			}
		}

		return $handlers;
	}
}