<?php
/*========================================================================*\
|| ###################################################################### ||
|| # vBulletin 6.2.3 - Licence Number L3138846
|| # ------------------------------------------------------------------ # ||
|| # Copyright 2000-2026 MH Sub I, LLC dba vBulletin. All Rights Reserved.  # ||
|| # This file may not be redistributed in whole or significant part.   # ||
|| # ----------------- VBULLETIN IS NOT FREE SOFTWARE ----------------- # ||
|| # http://www.vbulletin.com | http://www.vbulletin.com/license.html   # ||
|| ###################################################################### ||
\*========================================================================*/

class vBSphinxSearch_Core extends vB_Search_Core
{
	// some extensions depend on these being protected even though the core code does not.
	protected $sphinxDB;
	protected $table;
	protected $what = ['id'];
	protected $match = '';
	protected $where = [];
	protected $groupby = '';
	protected $sort = [];
	protected $done_permission_check;

	// no extension uses these properties yet.
	private $filters = [];

	protected static $field_map = [
		'rank'      => 'score',
		'relevance' => 'score',
		'author'    => 'userid'
	];

	protected $attributes_definition = [
		'contenttypeid'        => 'rt_attr_uint',
		'lastcontentid'        => 'rt_attr_uint',
		'lastauthorid'         => 'rt_attr_uint',
		'parentid'             => 'rt_attr_uint',
		'starter'              => 'rt_attr_uint',
		// Keep these in sync with the "starter" sort fields, see get_sort_field_for_topic_view()
		// These fields are those of the matched node's starter.
		'starterparent'        => 'rt_attr_uint', //the starter's parent id - the channelid
		'starteruser'          => 'rt_attr_uint', // the starter's user id
		'starterlastcontent'   => 'rt_attr_uint', // ...
		'starterpublishdate'   => 'rt_attr_uint',
		'startercreated'       => 'rt_attr_uint',
		'startertextcount'     => 'rt_attr_uint',
		'starterdisplayorder'  => 'rt_attr_uint',
		'startervotes'         => 'rt_attr_uint',
		// TODO: propagate starter's string sorting field changes to descendants on starter update - VBV-17652
		'startertitlesort'     => 'rt_attr_string',
		'starterauthorname'    => 'rt_attr_string',
		'closure'              => 'rt_attr_multi', // it's the list of all the node's parents and the node itself
		'depth'                => 'rt_attr_json', // the depth of the node's each parent
		'userid'               => 'rt_attr_uint',
		'authorname'           => 'rt_attr_string',
		'setfor'               => 'rt_attr_uint',
		'sentto'               => 'rt_attr_multi', //populated from the sentto table
		'showpublished'        => 'rt_attr_uint',
		'approved'             => 'rt_attr_uint',
		'showapproved'         => 'rt_attr_uint',
		'viewperms'            => 'rt_attr_uint',
		'featured'             => 'rt_attr_uint',
		'inlist'               => 'rt_attr_uint',
		'protected'            => 'rt_attr_uint',
		'votes'                => 'rt_attr_uint',
		'lastcontent'          => 'rt_attr_uint',
		'created'              => 'rt_attr_uint',
		'publishdate'          => 'rt_attr_uint',
		'unpublishdate'        => 'rt_attr_uint',
		'prefixid'             => 'rt_attr_uint',
		'tagid'                => 'rt_attr_multi', //populated from the tagnode table
		'textcount'            => 'rt_attr_uint',
		'trendingweight'       => 'rt_attr_uint',
		'eventstartdate'       => 'rt_attr_uint',
		'eventenddate'         => 'rt_attr_uint',
		'titlesort'            => 'rt_attr_string',
		'hasanswer'            => 'rt_attr_uint',
	];

	public function __construct()
	{
		$config = vB::getConfig();
		$this->table = $config['Database']['dbname'];
	}

	protected function initConnection()
	{
		if (!$this->sphinxDB)
		{
			$config = vB::getConfig();
			$this->connect($config);
		}
	}

	/**
	 *	Connects to the sphinx daemon.
	 *
	 *	@param array $config The configuration array from vB:getConfig
	 *
	 *	@throws vB_Exception_Api 'sphinx_not_configured' if the host or port are not set.
	 */
	private function connect($config)
	{
		if (empty($config['Misc']['sphinx_host']) OR empty($config['Misc']['sphinx_port']))
		{
			throw new vB_Exception_Api('sphinx_not_configured');
		}

		$noDbConfig = [];
		$this->sphinxDB = new vBSphinxSearch_Connection($noDbConfig, $config);
		$this->sphinxDB->connect('', $config['Misc']['sphinx_host'], $config['Misc']['sphinx_port'], '', '', false);
	}

	private function attributeIsString($fieldname)
	{
		switch($this->attributes_definition[$fieldname])
		{
			case 'rt_attr_string':
			case 'rt_attr_json':
				$isString = true;
				break;
			default:
				$isString = false;
				break;
		}

		return $isString;
	}

	private function propagateStarterInheritedFields($node, $changes)
	{
		$isStarter = ($node['starter'] == $node['nodeid']);
		if ($isStarter)
		{
			// Take care of updating any of the children's starter_x fields.
			// We do it here in case there's no text change and we short-circuit
			// out of this function before we reach the end of function.

			$map = $this->get_starter_inherited_fields();
			$setSQL = [];
			foreach ($map AS $__starterkey => $__childkey)
			{
				$needsChange = (
					empty($changes['existing']) OR
					isset($changes['changes'][$__childkey])
				);
				// VBV-17652
				// We can't do updates on strings yet, only delete/re-insert.
				// Along with enabling sorting by starter.title & starter.author, we should
				// also add code to do delete/re-insert OR update (depending on minimum
				// supported sphinxsearch version) for the two string fields so that on
				// starter update, the replies/comment search indices' startertitlesort &
				// starterauthor fields are properly updated.
				$isStringChange = $this->attributeIsString($__childkey);
				if ($needsChange AND !$isStringChange)
				{
					$__key =  $this->sphinxDB->clean_identifier($__childkey);
					$__value = $this->quote_smart($__childkey, $node[$__childkey], false);
					$setSQL[] = "$__key = $__value";
				}
			}

			if (!empty($setSQL))
			{
				$setSQL = " SET " . implode(", ", $setSQL);
				$query = "UPDATE " . $this->table . $setSQL . " WHERE starter = " . intval($node['starter']);
				$this->sphinxDB->query_write($query);
			}
		}
	}

	/**************INDEXING**************************/

	// The core search doesn't grab type specific data and, for the most part we don't need it.  We don't generally
	// allow filtering on type specific data because it isn't applicable to all types but we make some exceptions.
	// The db based searches use the data in the tables without duplication but Sphinx needs to duplicate everything.
	// We'll need this for any additional standalone search implementation so we should probably figure out how to
	// move this to core but don't want to add the overhead for the more common DB searches.
	private function addTypeAttributes(array &$nodes) : void
	{
		// currently the only extra attributes are related to events.
		$eventtype = vB_Types::instance()->getContentTypeID('vBForum_Event');

		$eventids = [];
		foreach ($nodes AS $node)
		{
			if ($node['contenttypeid'] == $eventtype)
			{
				$eventids[] = $node['nodeid'];
			}
		}

		$assertor = vB::getDbAssertor();
		$events = $assertor->getRows('vBForum:event',
			[
				vB_dB_Query::COLUMNS_KEY => ['nodeid', 'eventstartdate', 'eventenddate'],
				vB_dB_Query::CONDITIONS_KEY => ['nodeid' => $eventids]
			],
			false,
			'nodeid'
		);

		// unfortunately this isn't keyed by id and we don't want to try to reformat it so that it is because the list could
		// be large and we don't want to trigger mem copies if we can avoid it.
		foreach ($nodes AS &$node)
		{
			if (isset($events[$node['nodeid']]))
			{
				$node['eventstartdate'] = $events[$node['nodeid']]['eventstartdate'];
				$node['eventenddate'] = $events[$node['nodeid']]['eventenddate'];
			}
		}
	}

	public function indexText($node, $title, $text, $forceupdate = false)
	{
		$nodes = [&$node];
		$this->addTypeAttributes($nodes);
		return $this->indexTextInternal($node, $title, $text, $forceupdate);
	}

	private function indexTextInternal($node, $title, $text, $forceupdate)
	{
		$this->initConnection();
		$changes = $this->compute_changes($node);

		$this->propagateStarterInheritedFields($node, $changes);
		// compute_changes() sets the starter_x fields for the current node, & it'll be INSERTed
		// below. The REPLACE case should not matter as this node (unless it's a channel) should
		// have the same starter, and be updated by the above UPDATE query.

		$assertor = vB::getDbAssertor();

		// no meaningful words, no index
		if ((empty($changes['existing']) OR !empty($changes['reindex'])) AND empty($title) AND empty($text))
		{
			// updating the CRC32 value so it would not be picked up by the indexer again
			$assertor->update('vBForum:node', ['CRC32' => 1], ['nodeid' => $node['nodeid']]);
			return false;
		}

		if (empty($title) AND empty($text))
		{
			$crc = 1;
		}
		else
		{
			$crc = $this->crc_uint_string($title . $text);
		}

		// check if the same content has already been indexed (update)
		if (!empty($changes['existing']) AND !$forceupdate AND $node['CRC32'] == $crc AND empty($changes['changes']))
		{
			// no need to index if the content hasn't changed
			return false;
		}

		// text has changed, update the existing node with the new CRC
		if ($node['CRC32'] != $crc)
		{
			$assertor->update('vBForum:node', ['CRC32' => $crc], ['nodeid' => $node['nodeid']]);
		}

		$values = ["'" . $this->sphinxEscapeString($title) . "'", "'" . $this->sphinxEscapeString($text) . "'"];

		foreach ($this->attributes_definition AS $key => $type)
		{
			//need to do a name switch due to name conflicts with title
			if ($key == 'titlesort')
			{
				$value = $node['title'] ?? '';
			}
			else
			{
				$value = $node[$key] ?? '';
			}

			//this is a string but sphinx needs it to be an int
			if ($key == 'prefixid')
			{
				$value = $this->crc_uint_string($value);
			}

			$values[] = $this->quote_smart($key, $value, false);
		}

		$query = (empty($changes['existing']) ? 'INSERT' : 'REPLACE') . " INTO " . $this->table . "(
				id, title, content, " . implode(', ', array_keys($this->attributes_definition)) . "
			)
			VALUES (
				$node[nodeid], " . implode(', ', $values) . "
			)
		";


		$this->sphinxDB->query_write($query);

		$config = vB::getConfig();
		if (!empty($config['Misc']['debug_sql']))
		{
			echo "$query;\n";
		}

		if ($err = $this->sphinxDB->error())
		{
			throw new vB_Exception_Database($err);
		}

		return true;
	}

	/**
	 *	Index a batch of nodes
	 */
	public function indexTextBatch($nodes, $titles, $texts, $forceupdate = false)
	{
		// This exists because we need it for the dbsearch implemenation and we need
		// to maintain a common interface.  For now just index each node individually as before.
		$this->addTypeAttributes($nodes);
		foreach ($nodes AS $nodeid => $node)
		{
			$this->indexTextInternal($node, $titles[$nodeid], $texts[$nodeid], $forceupdate);
		}
	}

	public function reIndexAll($silent = false)
	{
		$this->initConnection();

		$config = vB::getConfig();
		$command = $config['Misc']['sphinx_path'] . '/bin/indexer --config ' . $config['Misc']['sphinx_config'] .
			' --sighup-each --rotate ' . ($silent ? '--quiet ' : '') . ($this->table . '_disk');

		if ($silent)
		{
			$this->exec_command($command, $silent);
		}
		else
		{
			$this->exec_command_feedback($command, $silent);
		}
		// need to restart the service
		$this->restart($silent);
		$this->sphinxDB->query_write($query = "TRUNCATE RTINDEX " . $this->table);
		$this->sphinxDB->query_write("ATTACH INDEX " . ($this->table . '_disk') . " TO RTINDEX " . $this->table);
		vB::getDbAssertor()->update('vBForum:node', ['CRC32' => 1], vB_dB_Query::CONDITION_ALL);
		return true;
	}

	/**
	 *	Updates the trending post data based
	 *
	 *	@param int $lookback -- the time, in hours, that we consider threads and replies for trending
	 *	@param int $minimum -- the minimum time, in hours, that we will take as the thread lifetime.
	 *		Threads created more recently than this will be consider to have been created minimum hours
	 *		ago for purposes of scoring
	 */
	public function indexTrending($lookback, $minimum)
	{
		$this->initConnection();

		//sphinx delegates some searches to the DB implementation.
		//we need to keep the DB info up to date or that's not going to work
		$vBDBSearch_Core = new vBDBSearch_Core();
		$vBDBSearch_Core->indexTrending($lookback, $minimum);

		$lookback = (int) $lookback;
		$minimum = (int) $minimum;

		$db = vB::getDbAssertor();

		$channelApi = vB_Api::instance('content_channel');
		$channels = $channelApi->fetchTopLevelChannelIds();

		$channelTypeId = vB_Types::instance()->getContentTypeID('vBForum_Channel');

		$timenow = vB::getRequest()->getTimenow();
		$timecut = $timenow - ($lookback * 3600);

		$query = 'UPDATE ' . $this->table . ' SET trendingweight = 0 WHERE trendingweight > 0';
		$this->sphinxDB->query_write($query);

		$result = $db->assertQuery('vBDbSearch:getTrendingWeights', [
			'timenow' => $timenow,
			'timecut' => $timecut,
			'excludetypes' => $channelTypeId,
			'minlifetime' => $minimum,
			'specialchannelid' => $channels['special'],
		]);

		foreach ($result AS $row)
		{
			$query = 'UPDATE ' . $this->table . ' SET trendingweight = ' . intval($row['weight']) . ' WHERE starter = ' . intval($row['nodeid']);
			$this->sphinxDB->query_write($query);
		}
	}

	public function attributeChanged($nodeid)
	{
		$this->initConnection();

		try
		{
			$node = vB_Library::instance('node')->getNodeBare($nodeid);
		}
		catch (Exception $e)
		{
			return;
		}

		$changes = $this->compute_changes($node);
		if (!empty($changes['reindex']))
		{
			return $this->index($nodeid, false);
		}
		else
		{
			// If we're not completely reindexing, we still might have to update some int/timestamp fields.
			$this->propagateStarterInheritedFields($node, $changes);
		}

		//this is a string but sphinx needs it to be an int
		if (isset($changes['changes']['prefixid']))
		{
			$changes['changes']['prefixid'] = $this->crc_uint_string($changes['changes']['prefixid']);
		}

		$values = [];
		foreach ($changes['changes'] as $key => $value)
		{
			$values[] = "$key = " . $this->quote_smart($key, $value, false);
		}

		if (empty($values))
		{
			return false;
		}

		$query = "UPDATE " . $this->table . " SET " . implode(', ', $values) . " WHERE id = " . intval($nodeid);
		$this->sphinxDB->query_write($query);

		$config = vB::getConfig();
		if (!empty($config['Misc']['debug_sql']))
		{
			echo "$query;\n";
		}

	}

	/**************DELETING**************************/

	public function emptyIndex()
	{
		$this->initConnection();

		$config = vB::getConfig();
		parent::emptyIndex();
		$this->sphinxDB->query_write($query = "TRUNCATE RTINDEX " . $this->table);
		if (!empty($config['Misc']['debug_sql']))
		{
			echo "$query;\n";
		}
	}

	public function delete($nodeid, $node = false)
	{
		$this->initConnection();

		$nodeid = intval($nodeid);
		$config = vB::getConfig();
		$res = $this->sphinxDB->query($query = '
			SELECT id
			FROM ' . $this->table . "
			WHERE closure = $nodeid
			/**" . __FUNCTION__ . (defined('THIS_SCRIPT') ? '- ' . THIS_SCRIPT : '') . "**/"
		);

		$children = [$nodeid];
		while ($row = $this->sphinxDB->fetch_array($res))
		{
			$children[] = $row['id'];
		}

		$this->sphinxDB->query_write($query = "DELETE FROM " . $this->table . " WHERE id IN (" . implode(',',$children) . ")");
		if (!empty($config['Misc']['debug_sql']))
		{
			echo "$query;\n";
		}

		//delete any trending information for this node.  We add it for the DB Implementation, we need to remove it.
		$assertor = vB::getDbAssertor();
		$assertor->delete('vBForum:trending', ['nodeid' => $nodeid]);
	}


	public function deleteBulk($nodeids)
	{
		$this->initConnection();

		if (empty($nodeids))
		{
			return;
		}

		$config = vB::getConfig();
		$res = $this->sphinxDB->query($query = '
			SELECT id
			FROM ' . $this->table . "
			WHERE closure IN (" . implode(',', $nodeids) . ")
			/**" . __FUNCTION__ . (defined('THIS_SCRIPT') ? '- ' . THIS_SCRIPT : '') . "**/"
		);

		while ($row = $this->sphinxDB->fetch_array($res))
		{
			$nodeids[] = $row['id'];
		}

		$this->sphinxDB->query_write($query = "DELETE FROM " . $this->table . " WHERE id IN (" . implode(',',$nodeids) . ")");
		if (!empty($config['Misc']['debug_sql']))
		{
			echo "$query;\n";
		}
	}

	/**************SEARCHING**************************/
	protected function useSphinxSearch($criteria) : bool
	{
		$keywords = $criteria->get_raw_keywords();

		// if there are no keywords, fall back to DB search
		if (empty($keywords))
		{
			return false;
		}

		return true;
	}

	public function getResults(vB_Search_Criteria $criteria)
	{
		$config = vB::getConfig();

		// Fall back to DB search unless conditions (default: has keywords) are met.
		if (!$this->useSphinxSearch($criteria))
		{
			$vBDBSearch_Core = new vBDBSearch_Core();
			return $vBDBSearch_Core->getResults($criteria);
		}

		$rawKeywords = $criteria->get_raw_keywords();

		//do this *after* we fall back.  That way if there is an error on the connection
		//we won't be affected if the search isn't going to sphinx.
		$this->initConnection();

		$results = $this->getTwoPassResults($criteria);

		//getTwoPassResults will return an array of results or a string cache key
		if (is_array($results))
		{
			// Note that vB_Search_Core::getTwoPassCacheKey() does not return a cachekey
			// if $criteria has any post processors, so this is only hit when there's no post processors
			// and we can return the nodeids right away.
			$nodeids = [];
			foreach ($results AS $nodeid)
			{
				$nodeids[$nodeid] = $nodeid;
			}
			return $nodeids;
		}
		else
		{
			$cacheKey = $results;
		}

		$this->filters = [
			'make_equals_filter'    => $criteria->get_equals_filters(),
			'make_notequals_filter' => $criteria->get_notequals_filters(),
			'make_range_filter'     => $criteria->get_range_filters(),
			'complex'               => $criteria->get_complex_filters(),
		];

		$this->process_sort($criteria);

		if (!empty($this->filters['make_equals_filter']['view']))
		{
			$this->process_view_filters($this->filters['make_equals_filter']['view'], $criteria);
			unset($this->filters['make_equals_filter']['view']);
		}

		if (!empty($this->filters['make_equals_filter']['follow']))
		{
			$this->process_follow_filters($this->filters['make_equals_filter']['follow'], $criteria);
			unset($this->filters['make_equals_filter']['follow']);
		}

		// my channels
		if (!empty($this->filters['make_equals_filter']['my_channels']))
		{
			$this->process_my_channels_filter($this->filters['make_equals_filter']['my_channels']);
			unset($this->filters['make_equals_filter']['my_channels']);
		}

		// channel include exclude
		if (!empty($this->filters['complex']['inc_exc_channel']))
		{
			$this->process_inc_exc_channel_filter($this->filters['complex']['inc_exc_channel']);
			unset($this->filters['complex']['inc_exc_channel']);
		}

		//handle equals filters
		$this->process_filters($criteria, 'make_equals_filter', $cacheKey ? true : false);
		//handle notequals filters
		$this->process_filters($criteria, 'make_notequals_filter', $cacheKey ? true : false);
		//handle range filters
		$this->process_filters($criteria, 'make_range_filter', $cacheKey ? true : false);

		if (!$this->done_permission_check)
		{
			$this->setNodePermTerms($cacheKey ? true : false);
		}

		$field = $criteria->is_title_only() ? '@title ' : '';
		$keywords = $this->adjustKeywordsForSphinx($rawKeywords);

		//this won't matter if Sphinx case folding is set up correctly, but we lowercase the text
		//before we index it so we should do the same before we search on it just to be consistent.
		$string = vB::getString();
		$keywords = $string->strtolower($keywords);

		// Default sphinxsearchcore will fall back to DB search if there are no keywords. However,
		// we want to allow extensions to let sphinxsearch handle certain complex sorting even if there are
		// no keyword searches.
		if (!empty($keywords))
		{
			// Using a separate var rather than doing something like $this->where['MATCH'], because setting &
			// unsetting it in the where array before imploding would require array copying etc & is messy.
			$this->match = "MATCH('$field" . $this->sphinxEscapeKeywords($keywords) . "')";
		}
		else
		{
			$this->match = '';
		}

		$post_processors = $criteria->get_post_processors();

		$query_limit = '';

		//note that without a limit clause sphinx will default to a limit of 20
		//currently with the "no limit" searches we don't use keywords and delegate
		//to the DB search so it isn't a problem, but its something we need to sort
		//out at some point.
		if (!$criteria->getNoLimit())
		{
			$maxresults = vB::getDatastore()->getOption('maxresults');
			$maxresults = ($maxresults > 0) ? $maxresults : 0;

			/*	This is a hacky compromise (ew) and not fully tested, and we may end up taking it out or modifying it.
			 *	This is to handle the possibility that a post sphinx-search process (like when using the unread_only
			 *	filter) can severely limit the # of search results after it was already limited by $maxresults. Users
			 *	might be expecting that the very end result set is limited by the maxresults option, meaning we should
			 *	place the LIMIT after the post processes, but if we don't place a limit on sphinx search, it might
			 *	happily return all the nodes ever (not a very useful search in that case, but could happen). So the
			 *	compromise is to use an "expected post process cull ratio" so that the final result is closer to the
			 *	$maxresults. This means that any post processors should limit the max results themselves!
			 */
			if (!empty($post_processors))
			{
				// 1.5 is a completely arbitrary value. We have absolutely no research to show that 1.5 is the best ratio, but
				// it just seems like a nice and conservative starting point.
				$maxresults = $maxresults * 1.5;
			}

			//this probably doesn't do what the person who wrote it thinks it does.  The default limit
			//for sphinx is 20
			if (!empty($maxresults))
			{
				$query_limit = "LIMIT " . $maxresults . " OPTION max_matches=" . $maxresults;
			}
		}

		if (!empty($keywords))
		{
			$this->sphinxDB->suppressSyntaxErrorsNextQuery();
		}

		$commentTag = "\n\t\t\t/**" . __FUNCTION__ . (defined('THIS_SCRIPT') ? '- ' . THIS_SCRIPT : '') . "**/";
		$query = $this->generateSearchQuery($query_limit) . $commentTag;
		$res = $this->sphinxDB->query($query);

		if (!empty($config['Misc']['debug_sql']))
		{
			echo "$query;\n";
		}

		if (!empty($keywords) AND $this->sphinxDB->lastQueryHadSyntaxError())
		{
			/*
			Notes on "... syntax error, unexpected '-'" or "... unexpected $end near ''..."

			These errors seems to most frequently occur for cases like "something -- something else".
			Certain characters are sphinx match operators (e.g. | for OR, - for word negation)
			so we should not escape them if we want users to be able to utilize the advanced
			search operations. However, if it's like a dash as part of a compound word or
			double dashes syntatically equivalent to an emdash, that just breaks the
			search because sphinx can't understand what was meant.
			In that case, let's assume that the user meant those characters literally and
			not as operators, and do a full escape on the keywords string.
			Note that on the first pass, words like "OR" and "NOT" are converted to the sphinx
			operators "|" and "-" (see adjustKeywordsForSphinx()). For the second pass, perhaps
			we should skip that and again assume that the user meant those words literally.

			As such, skip adjustKeywordsForSphinx() (this is not a security function, but just a
			utility/usability), and use sphinxEscapeString() instead of sphinxEscapeKeywords()
			*/
			$this->match = "MATCH('$field" . $this->sphinxEscapeString($rawKeywords) . "')";

			$query = $this->generateSearchQuery($query_limit) . $commentTag;
			$res = $this->sphinxDB->query($query);

			if (!empty($config['Misc']['debug_sql']))
			{
				echo "$query;\n";
			}
		}

		if ($cacheKey)
		{
			$this->what[] = 'starteruser';
			$this->what[] = 'starterparent';
		}

		$nodeids = [];
		while ($row = $this->sphinxDB->fetch_array($res))
		{
			$value = current($row);
			$nodeids[$value] = $value;
		}
		if (empty($nodeids))
		{
			return [];
		}

		if (empty($post_processors))
		{
			if ($cacheKey)
			{
				if (!empty($this->filters['make_equals_filter']['channelid']))
				{
					vB_Search_Core::saveSecondPassResults($nodeids, $cacheKey, $this->filters['make_equals_filter']['channelid']);

				}
				else
				{
					vB_Search_Core::saveSecondPassResults($nodeids, $cacheKey);
				}
			}
		}
		else
		{
			foreach ($post_processors AS $post_processor)
			{
				$vBSphinxSearch_Core = new vBSphinxSearch_Core(); // why do we need a new instance every time...?
				$nodeids = $vBSphinxSearch_Core->$post_processor($nodeids, $criteria);
			}

			if (empty($nodeids))
			{
				return [];
			}
			$criteria->reset_post_processors();
		}
		return $nodeids;
	}

	private function generateSearchQuery($query_limit)
	{
		$groupby = '';
		if (!empty($this->groupby))
		{
			$groupby = '
				GROUP BY ' . $this->groupby;
		}

		// TODO: sticky sort is first in DB search.
		$query = '
			SELECT ' . implode(', ', $this->what) . '
			FROM ' . $this->table . '
			WHERE ' . $this->generateWhereClause() . $groupby . '
			ORDER BY ' . implode(', ', $this->sort) . "
			$query_limit";
		return $query;
	}

	private function generateWhereClause()
	{
		/*
		Previous code wants MATCH to always be the first item in the where clause.
		I thought I recalled this was due to sphinx erroring out when certain where conditions
		came before the MATCH(), but I can't seem to reproduce this on testing. It may
		depend on the particular columns or conditions.
		Without knowing more, I do not want to change this behavior.
		I want a better way than "assume it's at 0th position" to guarantee that we're editing
		the correct element when we swap the match keywords out and keep everything else in
		place.
		 */
		if (!empty($this->match))
		{
			$where = $this->match;
			if (count($this->where) > 0)
			{
				$where .= ' AND ';
			}
		}
		else
		{
			$where = '';
		}

		$where .= implode(' AND ', $this->where);

		return $where;
	}

	protected function postProcessorGetLimitValue(vB_Search_Criteria $criteria)
	{
		if (!$criteria->getNoLimit())
		{
			$maxresults = vB::getDatastore()->getOption('maxresults');
			$maxresults = ($maxresults > 0) ? $maxresults : 0;
			return $maxresults;
		}
		return 0;
	}

	/**
	 *	Handle processing for the equals / range filters
	 * @param object $criteria vB_Search_Criteria
	 * @param array $filter_method string The name of the method to call to create a
	 *		where snippet for this kind of filter (currently equals and range -- not planning
	 *		to add more).  This should be the name of a private method on this class.
	 * @param  bool $excludeUserSpecific Exclude user specific queries. Used for precaching
	 */
	private function process_filters(vB_Search_Criteria &$criteria, $filter_method, $excludeUserSpecific = false)
	{
		$viewTopics = $criteria->has_topic_view_filter();

		foreach ($this->filters[$filter_method] AS $field => $value)
		{
			switch ($field)
			{
				//if this is a null filter we that forces a 0-result query
				case 'prefixid':
					if (is_array($value))
					{
						$value = array_map([$this, 'crc_uint_string'], $value);
					}
					else
					{
						$value = $this->crc_uint_string($value);
					}

					$where = $this->$filter_method('prefixid', $value);
					$this->where[] = $where;
					break;
				case 'null':
					$this->where[] = "id = 0 /** field is null **/";
					break;
				case 'tag':
					$this->process_tag_filters($value);
					break;
				case 'channelid':
					$this->process_channel_filters($value, $criteria->getDepth(), $criteria->getIncludeStarter(), $criteria->getDepthExact(), $excludeUserSpecific);
					break;
				case 'exclude':
					$this->process_exclude_filters($value);
					break;
				case 'follow':
					$this->process_follow_filters($value, $criteria);
					break;
				case 'unpublishdate':
					// todo: this node. prefix is probably a bug and will likely cause a "unknown column: node" error
					$this->where[] = "(node.showpublished = 1)";
					break;
				case 'lastcontent':
				case 'publishdate':
					// skip adding a date restriction, workaround for activity stream
					if ($value != 'all')
					{
						if ($viewTopics)
						{
							$this->where[] = $this->$filter_method('starterlastcontent', $value);
						}
						else
						{
							$this->where[] = $this->$filter_method($field, $value);
						}
					}
					break;
				case 'starter_only':
					$this->process_starter_only_filter($value);
					break;
				case 'reply_only':
					$this->process_reply_only_filter($value);
					break;
				case 'comment_only':
					$this->process_comment_only_filter($value);
					break;
				case 'include_visitor_messages':
					$this->process_visitor_message_filter($value, 'include');
					break;
				case 'visitor_messages_only':
					$this->process_visitor_message_filter($value, 'only');
					break;
				case 'sentto':
					$this->process_visitor_message_filter($value, 'for');
					break;
				case 'exclude_visitor_messages':
					$this->process_visitor_message_filter($value, 'exclude');
					break;
				case 'include_private_messages':
					$this->process_private_message_filter($value, 'include');
					break;
				case 'private_messages_only':
					$this->process_private_message_filter($value, 'only');
					break;
/** @todo OR operator is not supported by sphinx*/
 				case 'OR':
// 					foreach ($value as $fld => $val)
// 					{
// 						$fld = $this->sphinxDB->clean_identifier($fld);
// 						$qbits[] = $this->make_equals_filter($fld, $val);
// 					}
// 					$this->where[''] = "(" . implode(' OR ', $qbits) . ")";
 					break;
				case 'marked':
					$this->process_marked_filter($value);
					if ($value == vB_Api_Search::FILTER_MARKED_UNREAD)
					{
						$criteria->add_post_processors('postProcessorMarkedUnreadFilter'); // only required in sphinx search, not DB search.
					}
					break;

				case 'my_channels':
					$this->process_my_channels_filter($value);
					break;
				case 'trending':
					if ($value)
					{
						$this->where['trending'] = "trendingweight > 0";
					}
					break;
				case 'eventstartdate':
				case 'eventenddate':
					if (!isset($this->where['eventdate']))
					{
						$eventContentTypeId = vB_Api::instanceInternal('contenttype')->fetchContentTypeIdFromClass('Event');
						$this->where['eventdate'] = "contenttypeid = " . intval($eventContentTypeId) . " ";
					}
					$this->where[] = $this->$filter_method($field, $value);
					break;

				default:
					$dbfield = $field;
					if (isset(self::$field_map[$field]))
					{
						$dbfield = self::$field_map[$field];
					}
					$dbfield = $this->sphinxDB->clean_identifier($dbfield);
					$where = $this->$filter_method($dbfield, $value);
					$this->where[] = $where;
					break;
			}
		}
	}

	/**************PROCESSING**************************/

	/**
	 *	Process the filters for the requested tag
	 *	@param array $tagids the ids of the tags to filter on.
	 */
	protected function process_tag_filters($tagids)
	{
		if (empty($tagids))
		{
			return;
		}
		if (is_numeric($tagids))
		{
			$tagids = [$tagids];
		}
		$this->where[] = $this->make_equals_filter('tagid', $tagids);
	}

	/**
	 *	Process the exclude filter
	 *
	 *	@param array $nodeids the ids of the nodes (and it's children) to exclude
	 */
	protected function process_exclude_filters($nodeids)
	{
		if (empty($nodeids))
		{
			return;
		}

		$nodeids = vB::getCleaner()->clean($nodeids, vB_Cleaner::TYPE_ARRAY_UINT);

		$this->where[] = $this->make_notequals_filter('closure', $nodeids);
		$this->where[] = $this->make_notequals_filter('id', $nodeids);
	}

	protected function process_starter_only_filter($starter)
	{
		if ($starter)
		{
			$this->what[] = "(starter = id) as starter_id";
			$this->where[] = "starter_id > 0 AND starter > 0";
		}
	}

	protected function process_reply_only_filter($reply)
	{
		if ($reply)
		{
			$this->what[] = "(starter = parentid) as is_reply";
			$this->where[] = "is_reply > 0";
		}
	}


	protected function process_comment_only_filter($comment)
	{
		if ($comment)
		{
			$this->what[] = "(starter <> parentid AND starter <> id AND starter <> 0) as is_comment";
			$this->where[] = "is_comment > 0";
		}
	}


	protected function process_visitor_message_filter($userid, $type)
	{
		$userid = intval($userid);
		if ($type == 'include')
		{
			$this->what[] = "(userid = $userid OR setfor = $userid) as user_setfor";
			$this->where[] = "user_setfor = 1";
		}
		else if ($type == 'only')
		{
			$this->what[] = "((userid = $userid AND  setfor <> 0) OR (setfor = $userid AND userid <> 0)) as user_setfor";
			$this->where[] = "user_setfor = 1";
		}
		else if ($type == 'for')
		{
			$this->where[] = $this->make_equals_filter('setfor', $userid);
		}
		else if ($type == 'exclude')
		{
			$this->where[] = $this->make_equals_filter('setfor', 0);
		}
	}

	protected function process_private_message_filter($userid, $type)
	{
		$userid = intval($userid);
		$pmcontentypeid = vB_Api::instanceInternal('contenttype')->fetchContentTypeIdFromClass('PrivateMessage');
		if ($type == 'include')
		{
			if (empty($userid))
			{
				$this->where[] = "contenttypeid <> $pmcontentypeid";
			}
			else
			{
				$this->what[] = "(contenttypeid <> $pmcontentypeid OR IN(sentto,$userid)) as user_sentto";
				$this->where[] = "user_sentto = 1";
			}
		}
		else if ($type == 'only')
		{
			if (empty($userid))
			{
				$this->where[] = "id=0 /** login to see private messages **/";
			}
			$this->where[] = $this->make_equals_filter('sentto', $userid);
			$this->where[] = $this->make_equals_filter('contenttypeid', $pmcontentypeid);
		}
	}

	private function get_starter_inherited_fields()
	{
		/*
			Unfortunately, we can't just do starter.$sort_field like in the
			DB search implementation because the sphinx RT index can't do
			joins, so all of the "starter" fields that we want to do sorting/filtering
			on has to be pre-saved into the node's realtime index record.

			Thus, we need to keep track of each field, and if necessary update the
			sphinx disk & realtime indices' structures to include that field.
		 */
		$inherited = [
			// these are not used for sorting, but still need to be propagated down.
			// Note that the naming convention is also slightly different (not intentionally)
			// so these are handled manually below
			//'userid',
			//'parentid',
			// these are used for sorting
			'lastcontent',
			'publishdate',
			'created',
			'textcount',
			'displayorder',
			'votes',
		];
		/*
			Uninherited fields:

			// This isn't something stored in the index, but rather something calculated via the weight() function
			// for matching.
			'rank',

			// eventstartdate only exists for starters atm.
			'eventstartdate',
			// trendingweight is associated with the node's starter & is automatically updated via cron
			// for a starter's descendants. So we can use it as it is even for title view.
			'trendingweight',
			// currently, most non-cutting-edge versions of sphinx require
			// a delete + insert instead of an update for updating a string field.
			// Skip these for now.
			'authorname',
			'titlesort',
		 */

		$return = [];
		foreach ($inherited AS $__key)
		{
			$return[$__key] = "starter" . $__key;
		}

		$return['userid'] = "starteruser";
		$return['parentid'] = "starterparent";
		$return['authorname'] = "starterauthorname";
		$return['title'] = "startertitlesort";

		return $return;
	}

	private function get_sort_field_for_topic_view($sort_field)
	{
		// Keep this in sync with get_starter_inherited_fields() &
		// $this->attributes_definition;

		/*
			userid => starteruser & parentid => starterparent aren't used for sort, but
			we don't have to unset them here since $sort_map in process_sort()
			don't have those as possible $sort_fields.
		 */
		$map = $this->get_starter_inherited_fields();
		if (isset($map[$sort_field]))
		{
			return $map[$sort_field];
		}
		else
		{
			// special case, titlesort refers to the starter.title, which is saved in
			// the index as startertitle.
			if ($sort_field == "titlesort" AND isset($map['title']))
			{
				return $map['title'];
			}

			return $sort_field;
		}
	}

	protected function getSortMap()
	{
		// keep this in sync with the database search implementation
		// Also keep this in sync with the "starter" sort fields, see get_sort_field_for_topic_view()
		return [
			'user'           => 'authorname',
			'author'         => 'authorname',
			'publishdate'    => 'publishdate',
			'created'        => 'created',
			'started'        => 'created',
			'last_post'      => 'lastcontent',
			'lastcontent'    => 'lastcontent',
			// todo: should this be startertitlesort for *all* views to keep in sync with
			// vbdbsearch behavior? ATM it maps to startertitle via get_sort_field_for_topic_view()
			// only if $viewTopics below is true.
			'title'          => 'titlesort',
			'textcount'      => 'textcount',
			'replies'        => 'textcount',
			'displayorder'   => 'displayorder',
			'rank'           => 'rank', // this is "score" in the db implementation
			'relevance'      => 'rank', // this is "score" in the db implementation
			'votes'          => 'votes',
			'eventstartdate' => 'eventstartdate',
			'trending'       => 'trendingweight',
		];
	}

	protected function process_sort(vB_Search_Criteria &$criteria)
	{
		$sort_array = $criteria->get_sort();

		$this->sort = [];

		$sort_map = $this->getSortMap();

		// Certain sort fields must be changed to use the starter.{sort_field} when
		// view == topics to make the result list *seem* correct to the user.
		// This only works as long as process_view_filters() & subsequent unset()
		// is called *after* process_sort()
		$viewTopics = $criteria->has_topic_view_filter();
		$is_starter_only = (
			$criteria->get_bool_filter_for("starter_only") === true
		);

		foreach ($sort_array AS $sort => $direction)
		{
			$direction = strtoupper($direction);
			if (!in_array($direction, ['ASC', 'DESC']))
			{
				$direction = 'ASC';
			}

			// See vbdbsearch's querydef for more details.
			// Handle sorting by "starters first"
			if ($sort == "isstarter")
			{
				if (!$is_starter_only)
				{
					$this->sort['is_starter2'] =  'is_starter2 ' . $direction;
					$this->what['is_starter2'] = '(id = starter) as is_starter2';
				}
				continue;
			}

			// if we don't have a sort, or we have an unrecognized sort type default to relevance descending
			if (!$sort OR (!isset($sort_map[$sort])))
			{
				$sort = 'relevance';
				$direction = 'DESC';
			}

			if ($sort == "eventstartdate")
			{
				/*
					VBV-17351
					If they want to sort by event start date, then it's assumed they
					only want events.
					Also see the DB search implementation.
					Since sphinx index already has the eventstartdate column, not having
					special handling will not cause an error like in the DB search, but
					having both Sphinx search & DB search return the same type of data
					as much as possible is ideal.
					So filter events only in some fashion to emulate the JOIN ... event AS event USING nodeid
					behavior.
				 */
				if (!isset($this->where['eventdate']))
				{
					$eventContentTypeId = vB_Api::instanceInternal('contenttype')->fetchContentTypeIdFromClass('Event');
					$this->where['eventdate'] = "contenttypeid = " . intval($eventContentTypeId) . " ";
				}
			}

			if ($sort == "trending")
			{
				if (!isset($this->where['trending']))
				{
					$this->where['trending'] = "trendingweight > 0";
				}
			}

			//look for a core sort option
			if (isset($sort_map[$sort]))
			{
				$sort_field = $sort_map[$sort];
				// process rank sortings
				if ($sort_field == 'rank')
				{
					// Multiple sort fields can map to 'rank'. Keyed to dedupe to avoid
					// "alias 'rank' must be unique" sphinxql error.
					$this->what['sort_rank'] = "weight() as rank";
				}
				else
				{
					if ($viewTopics)
					{
						// sort by the starter's field if search result is only going to list starters.
						$sort_field = $this->get_sort_field_for_topic_view($sort_field);
					}
					else
					{
						$sort_field = "$sort_field";
					}
				}

				$this->sort[$sort_field] = $sort_field . ' ' . $direction;
			}
		}
	}

	protected function process_marked_filter($marked) /** two stage */
	{
		$currentUserId = vB::getCurrentSession()->get('userid');

		//this can't be made to happen in the current UI because the general
		//search filter processing won't function without a valid non guest user.
		if (empty($currentUserId))
		{
			$this->where[] = "id=0 /** no user in session **/";
			return;
		}


		// FILTER_MARKED_READ is unimplemented
		if ($marked == vB_Api_Search::FILTER_MARKED_UNREAD)
		{
			$markinglimit = vB::getDatastore()->getOption('markinglimit');
			if ($markinglimit <= 0)
			{
				$this->where[] = "id=0 /** invalid markinglimit **/";
				return;
			}
			$timenow = vB::getRequest()->getTimeNow();
			$cutoff = $timenow - ($markinglimit * 86400);
			// All we can do with sphinx now is to add a filter on publishdate. The actual complex joins that decide the unread status comes
			// in at function postProcessorMarkedUnreadFilter()
			$this->where[] = $this->make_range_filter('publishdate', [$cutoff, null]);
		}
	}

	public function postProcessorMarkedUnreadFilter($nodeids, vB_Search_Criteria $criteria)
	{
		//does not need the sphinx connection.  If that changes add $this->initConnection(); here

		$nodeids = vB::getCleaner()->clean($nodeids, vB_Cleaner::TYPE_ARRAY_UINT);
		$currentUserId = vB::getCurrentSession()->get('userid');

		// After discussion with other devs, we're probably expecting 1-2k nodeids *AT MOST*. We
		// could be wrong, of course - the admin could've set the search results limit to something
		// higher, and the sphinx search might've returned enough to fill that higher limit. However,
		// going with the expected # of values in that range, using the IN() list instead of
		// creating temporary tables should be Okay. If search breaks due to hitting the max packet size or
		// is too slow due to the IN(), we may need revisit this. Please update this comment then.

		$limit = $this->postProcessorGetLimitValue($criteria);
		// if there's no limit found, let's just hard-code a limit to 1000. This is because the stored query *must*
		// have an integer for the limit of some kind or else the query will break
		if (empty($limit))
		{
			$limit = 1000;
		}

		$orderby = implode(', ', $this->sort);

		$assertor = vB::getDbAssertor();
		$nodes = $assertor->assertQuery(
			'vBDBSearch:getUnreadNodesIn',
			[
				'currentuserid'=> $currentUserId,
				'nodeids' => $nodeids,
				'limit' => $limit,
			]
		);

		/*
		 *	There are a few ways to go about the sorting.
		 *	Easiest is to add				ORDER BY FIELD(node.nodeid, {nodeids})
		 *	to the db query, but I'm not certain on its performance for lage # of nodeids in the result.
		 *	Hardest would be to make the db query a method query, pass in the criteria object and have it
		 *	reconstruct its own ORDER BY clauses (hopefully using some indices), but that involves taking
		 *	care of any addition joins/unions that might come out of it, which is very messy.
		 *	Another, easy way is to loop through the result set and sort it. IMO this is preferred at the moment
		 *	because 1) it will not take a long time to implement, 2) it takes most of performance optimizations
		 *	out of the query and into sorting in PHP, which is easier to work with (and involves less second guessing
		 *	the mysql optimizer and more bigO calculations). So I'm going to go with that
		 *
		 *	A few things helping us with this is the fact that function getResults() which calls this function ensures
		 *	that $nodeids is both keyed & valued by nodeid
		 */
		// Below should be ~O(n), assuming the array copy doesn't do anything crazy
		$notfound = $nodeids;
		foreach ($nodes AS $row)
		{
			unset($notfound[$row['nodeid']]);
		}
		foreach ($notfound AS $nodeid)
		{
			unset($nodeids[$nodeid]);
		}

		return $nodeids;
	}

	private function doShowApprovedCheck()
	{
		// no need to do this multiple times.
		if (!empty($this->where['showapprovedcheck']))
		{
			return;
		}


		$userid = (int) vB::getUserContext()->fetchUserId();
		if (empty($userid))
		{
			$this->where['showapprovedcheck'] = '(showapproved > 0)';
		}
		else
		{
			// Allow user to see their own, unapproved nodes
			// OR operator in WHERE clause is unsupported in sphinx, so we have to get around it by
			// adding a selected field then sort on that. Not great for indexed based sorting but
			// shouldn't be a problem for sphinx.
			$this->what['showapprovedcheck'] = "(showapproved > 0 OR userid = $userid) AS sa";
			$this->where['showapprovedcheck'] = 'sa = 1';
		}
	}

	protected function process_channel_filters($channelid, $depth, $include_starter, $depth_exact, $excludeUserSpecific)
	{
		//first let's see if this is valid.
		$userdata = vB::getUserContext()->getAllChannelAccess();

		// result set is being pre-cached so include all nodes from selfonly.
		// Nodes from selfonly will be filtered out on second pass
		if ($excludeUserSpecific AND !empty($userdata['selfonly']))
		{
			$userdata['canalwaysview'] = $userdata['canalwaysview'] + $userdata['selfonly'];
			$userdata['selfonly'] = [];
		}

		if (
			!empty($depth) AND ($depth == 1) AND
			is_numeric($channelid) AND
			(
				in_array($channelid, $userdata['canview']) OR
				in_array($channelid, $userdata['canalwaysview']) OR
				in_array($channelid, $userdata['canmoderate']) OR
				in_array($channelid, $userdata['selfonly'])
			)
		)
		{
			$channelid = intval($channelid);
			// The user can moderate the channel.
			if (in_array($channelid, $userdata['canmoderate']))
			{
				$this->where[] = $this->make_equals_filter('parentid', $channelid);
				$this->done_permission_check = true;
				return;
			}
			$userId = (int) vB::getUserContext()->fetchUserId();
			$gitWhere = '';

			// The user can't moderate but is a channel member.
			if (!empty($userId))
			{
				$gitInfo = vB_Api::instanceInternal('user')->getGroupInTopic($userId);

				if (is_array($gitInfo) AND array_key_exists($channelid, $gitInfo))
				{
					$this->where[] = $this->make_equals_filter('parentid', $channelid);
					if (!vB::getUserContext()->getChannelPermission('forumpermissions', 'canseedelnotice', $channelid))
					{
						$this->where[] = "showpublished > 0";
					}
					// Allow user to see their own, unapproved nodes. TODO: test this branch
					$this->doShowApprovedCheck();

					if (!vB::getUserContext()->getChannelPermission('forumpermissions', 'canviewthreads', $channelid))
					{
						$this->what[] = "(id = starter OR starter = 0) as is_starter";
						$this->where[] = "is_starter = 1";
					}
					$this->done_permission_check = true;
					return;
				}

				if (!empty($gitInfo))
				{
					$gitWhere = " OR id IN (" . implode(',', array_keys($gitInfo)) . ") ";
				}
			}

			//if the user can't moderate, and doesn't have canviewthread, they just get titles and for the starters
			if (!empty($userId) AND in_array($channelid, $userdata['canview']) AND !vB::getUserContext()->getChannelPermission('forumpermissions', 'canviewthreads', $channelid))
			{
				$this->where[] = $this->make_equals_filter('parentid', $channelid);
				if (!vB::getUserContext()->getChannelPermission('forumpermissions', 'canseedelnotice', $channelid))
				{
					$this->where[] = "showpublished > 0";
				}
				// Allow user to see their own, unapproved nodes
				$this->doShowApprovedCheck();
				$this->what[] = "(viewperms > 0 $gitWhere) AS vp";
				$this->where[] = "vp = 1";

				$this->what[] = "(id = starter OR starter = 0) AS is_starter";
				$this->where[] = "is_starter = 1";

				$this->done_permission_check = true;
				return;
			}

			// The user can't moderate, is logged in, isn't a channel member, and has forum permission "can view others".
			if (!empty($userId) AND in_array($channelid, $userdata['canview']))
			{
				$this->where[] = $this->make_equals_filter('parentid', $channelid);
				if (!vB::getUserContext()->getChannelPermission('forumpermissions', 'canseedelnotice', $channelid))
				{
					$this->where[] = "showpublished > 0";
				}
				// Allow user to see their own, unapproved nodes
				$this->doShowApprovedCheck();
				$this->what[] = "(viewperms > 0 $gitWhere) as vp";
				$this->where[] = "vp = 1";
				$this->done_permission_check = true;
				return;
			}

			// The user can't moderate, isn't a channel member,  is logged in, and has forum permission "can view posts" but not"can view others".
			if (!empty($userId) AND in_array($channelid, $userdata['selfonly']))
			{
				$this->where[] = $this->make_equals_filter('parentid', $channelid);
				if (!vB::getUserContext()->getChannelPermission('forumpermissions', 'canseedelnotice', $channelid))
				{
					$this->where[] = "showpublished > 0";
				}
				// They can only see their own nodes. Allow them to see their own nodes in moderation
				//$this->where[] = "showapproved  > 0";
				$this->where[] = "userid = $userId";
				$this->done_permission_check = true;
				return;
			}

			// The user can't moderate, isn't logged in, and has forum permission "can view others"
			if (empty($userId) AND in_array($channelid, $userdata['canview']))
			{
				$this->where[] = $this->make_equals_filter('parentid', $channelid);
				if (!vB::getUserContext()->getChannelPermission('forumpermissions', 'canseedelnotice', $channelid))
				{
					$this->where[] = "showpublished > 0";
				}
				// guests can't see any unapproved nodes since we can't distinguish guests
				$this->where[] = "showapproved > 0";
				$this->where[] = "viewperms = 2";
				$this->done_permission_check = true;
				return;
			}
		}

		$this->where[] = $this->make_equals_filter('closure', $channelid);
		//this isn't what is says on the in.  (none of this is).  The channelid is the root of the subtree (or subtrees)
		//we want to search.  It may not actually be a channel. This causes the root of the subtree to not match
		//so we don't match the channel record itself when searching a channel or the starter when searching a thread.
		//It has nothing to do with if the node being matched is a starter or not.
		if (empty($include_starter))
		{
			$this->where[] = $this->make_notequals_filter('id', $channelid);

//			$this->what['is_starter2'] = '(id = starter) as is_starter2';
//			$this->where[] = 'is_starter2 = 0';
		}

		if (!empty($depth))
		{
			$depth = intval($depth);
			$channelsIDs = (array) $channelid;
			foreach ($channelsIDs as $n)
			{
				if ($depth_exact !== false)
				{
					$this->where[] = "depth.n$n = $depth";
				}
				else
				{
					$this->where[] = "depth.n$n <= $depth";
				}
			}
		}

		$canviewthreads = true;
		$cannotviewthreadsChannelids = [];
		if (is_array($channelid))
		{
			foreach ($channelid as $chid)
			{
				if (!vB::getUserContext()->getChannelPermission('forumpermissions', 'canviewthreads', intval($chid)))
				{
					$cannotviewthreadsChannelids[] = intval($chid);
					$canviewthreads = false;
				}
			}
		}
		else if (is_numeric($channelid) AND !vB::getUserContext()->getChannelPermission('forumpermissions', 'canviewthreads', $channelid))
		{
			$canviewthreads = false;
		}
		if (!$canviewthreads)
		{
			if (empty($cannotviewthreadsChannelids))
			{
				$this->what[] = "(id = starter OR starter = 0) as is_starter";
				$this->where[] = "is_starter = 1";
			}
			else
			{
				/*
					You cannot use OR in the WHERE clause in sphinx, so we have to get a bit
					convoluted.

					Note the difference between SELECT...IN(expr, value1, value2...) function
					vs the WHERE... expr IN(value1, value2) operator.

					http://sphinxsearch.com/docs/current/comparison-functions.html
				 */
				$inCannotViewChannels = "IN (closure," . implode(',', $cannotviewthreadsChannelids) . ")";
				unset($cannotviewthreadsChannelids);

				$this->what[] = "(" .
									"{$inCannotViewChannels} AND (id = starter OR starter = 0) " .
									"OR NOT {$inCannotViewChannels}" .
								") AS canViewOrIsStarter";
				$this->where[] = "canViewOrIsStarter = 1";
			}
		}
	}

	protected function process_view_filters($view, $criteria)
	{
		if (empty($view))
		{
			return;
		}
		switch ($view)
		{
			/**
			 * only include the latest reply or comment (or the starter itself if no replies/comments yet) per starter in all the channels.
			 * Filters out the Channel nodes from the Search API nodes results.
			 * @include replies/comments in the second phase
			 */
			case vB_Api_Search::FILTER_VIEW_ACTIVITY :
				$datecheck = $criteria->has_date_filter();

				$this->what[] = "(id = starter) as id_starter2";
				$this->where[] = "id_starter2 = 1";

 				$this->where[] = "contenttypeid <> " . vB_Api::instanceInternal('contenttype')->fetchContentTypeIdFromClass('Channel');

				$channelAccess = vB::getUserContext()->getAllChannelAccess();

				if (!empty($this->filters['make_equals_filter']['starter_only']))
				{
					array_unshift($this->what, 'starter');
				}
				else if ((!empty($channelAccess['starteronly'])))
				{
					$starterOnly = implode(',', $channelAccess['starteronly']);
					array_unshift($this->what, "IF(IN(starter, $starterOnly) OR IN(starterparent, $starterOnly), starter, lastcontentid) AS last");
					$this->groupby = 'last';
				}
				else
				{
	 				array_unshift($this->what, 'lastcontentid');
	 				$this->groupby = 'lastcontentid';

					// See db search's process_view_filters() comments.
					// Inherit the ignored user exclusion to lastcontentnode.
					$neFilters = $criteria->get_notequals_filters();
					if (!empty($neFilters['userid']))
					{
						$this->where[] = $this->make_notequals_filter(
							'lastauthorid',
							$neFilters['userid']
						);
					}
				}
				unset($channelAccess);

				if (!$datecheck)
				{
					$age = vB::getDatastore()->getOption('max_age_channel');

					if (empty($age))
					{
					$age = 60;
					}

					$this->where[] = "lastcontent > " . (vB::getRequest()->getTimeNow() - ($age * 86400));
				}
				// in activity stream we don't want deleted content even if viewed by a moderator
				$this->where[] = "showpublished > 0";
			break;

				/**
				* The Topic view should only display the starter nodes for the specified channel.
				* Filters out the Channel nodes from the Search API nodes results.
				*/
			case vB_Api_Search::FILTER_VIEW_TOPIC :
				/*
					VBV-12581 Search Results are improperly ordered....
				*/
				array_unshift($this->what, 'starter');
				$this->groupby = 'starter';
				$this->where[] = "contenttypeid <> " . vB_Api::instanceInternal('contenttype')->fetchContentTypeIdFromClass('Channel');
			break;
				/**
				 * The Conversation Detail view should only display the descendant nodes of (and including) the specified starter.
				 * Filters out the Comment node from the Search API nodes results.
				 */
			case vB_Api_Search::FILTER_VIEW_CONVERSATION_THREAD :
				$this->what[] = "(id = starter OR starter = parentid) as starter_node";
				$this->where[] = "starter_node = 1";
				break;
			case vB_Api_Search::FILTER_VIEW_CONVERSATION_THREAD_SEARCH :
				array_unshift($this->what, "IF(parentid = 0 OR id = starter OR starter = parentid, id, parentid) as starter_node");
				$this->groupby = 'starter_node';
				break;
				/**
				 * The Conversation Detail view should only display the descendant nodes of (and including) the specified starter.
				 * the Comment nodes are not filtered out.
				 * This should be handled by the channel filter
				 */
			case vB_Api_Search::FILTER_VIEW_CONVERSATION_STREAM :
				break;
		}
	}

	protected function process_follow_filters($value, vB_Search_Criteria &$criteria) /** multi stage */
	{
		$type = $value['type'];
		$userid = intval($value['userid']);
		$assertor = vB::getDbAssertor();

		if (
				$type == vB_Api_Search::FILTER_FOLLOWING_BOTH OR
				$type == vB_Api_Search::FILTER_FOLLOWING_ALL OR
				$type == vB_Api_Search::FILTER_FOLLOWING_CHANNEL OR
				$type == vB_Api_Search::FILTER_FOLLOWING_CONTENT
		)
		{
			/* following nodes */
			$subscriptions = $assertor->getRows('vBForum:subscribediscussion', ['userid' => $userid], false, 'discussionid');
			$nodes = (!$subscriptions OR !empty($subscriptions['errors'])) ? [] : $subscriptions;
			$nodeids = array_keys($nodes);
			if (empty($nodeids) AND (
					$type == vB_Api_Search::FILTER_FOLLOWING_CHANNEL OR
					$type == vB_Api_Search::FILTER_FOLLOWING_CONTENT OR
					$type == vB_Api_Search::FILTER_FOLLOWING_BOTH
			)
			)
			{
				$this->where[] = "id=0 /** no subscriptions for user **/";
				return;
			}
		}
		if (($type == vB_Api_Search::FILTER_FOLLOWING_ALL) OR ($type == vB_Api_Search::FILTER_FOLLOWING_USERS))
		{
			/* following users */
			$cache = vB_Cache::instance(vB_Cache::CACHE_FAST);
			$userids = $cache->read("vbUserlistFollowFriend_$userid");
			if ($userids === false)
			{
				$userids = $assertor->getColumn('userlist', 'relationid',	['userid' => $userid, 'type' => 'follow', 'friend' => 'yes']);
				$cache->write("vbUserlistFollowFriend_$userid", $userids, 60, 'followChg_' . $userid);
			}

			if (empty($userids) AND $type == vB_Api_Search::FILTER_FOLLOWING_USERS)
			{
				$this->where[] = "id=0 /** no following for user **/";
				return;
			}
		}

		if ($type == vB_Api_Search::FILTER_FOLLOWING_ALL AND empty($nodeids) AND empty($userids))
		{
			$this->where[] = "id=0 /** no following for user **/";
			return;
		}

		if ($type == vB_Api_Search::FILTER_FOLLOWING_CHANNEL)
		{
			$this->filters['make_equals_filter']['channelid'] = $nodeids;
			$channelcontentypeid = vB_Api::instanceInternal('contenttype')->fetchContentTypeIdFromClass('Channel');
			$this->where[] = $this->make_notequals_filter('contenttypeid', $channelcontentypeid);
			return;
		}

		if ($type == vB_Api_Search::FILTER_FOLLOWING_CONTENT)
		{
			$channelcontentypeid = vB_Api::instanceInternal('contenttype')->fetchContentTypeIdFromClass('Channel');

			$this->where[] = $this->make_equals_filter('id', $nodeids);
			$this->where[] = $this->make_notequals_filter('contenttypeid', $channelcontentypeid);
			return;
		}

		if ($type == vB_Api_Search::FILTER_FOLLOWING_USERS OR ($type == vB_Api_Search::FILTER_FOLLOWING_ALL AND empty($nodeids)))
		{
			$this->where[] = $this->make_equals_filter('userid', $userids);
			return;
		}

		if ($type == vB_Api_Search::FILTER_FOLLOWING_BOTH)
		{
			$nodes = $assertor->assertQuery('vBDBSearch:getNodesWithSubChannel', ['nodeids' => $nodeids]);

			$allNodeIds = [];
			foreach ($nodes as $nodeid)
			{
				$allNodeIds[] = $nodeid['nodeid'];
			}

			//I don't think we can get here because if nodeids is empty we'll bail earlier and if it
			//isn't then we should get at least the values for nodeids back from the query.  We'll
			//leave it in as it might be useful if the data gets a little inconsistant.
			if (empty($allNodeIds))
			{
				$this->where[] = "id=0 /** no following for user **/";
				return;
			}

			$this->where[] = $this->make_equals_filter('id', $allNodeIds);
			return;
		}

		if ($type == vB_Api_Search::FILTER_FOLLOWING_ALL)
		{
			$nodes = $assertor->assertQuery('vBDBSearch:getNodesWithSubChannel', ['nodeids'=>$nodeids]);
			$allNodeIds = [];
			foreach ($nodes as $nodeid)
			{
				$allNodeIds[] = $nodeid['nodeid'];
			}

			//I don't think we can get here because if nodeids is empty we'll bail earlier and if it
			//isn't then we should get at least the values for nodeids back from the query.  We'll
			//leave it in as it might be useful if the data gets a little inconsistant.
			if (empty($allNodeIds))
			{
				$this->where[] = "id=0 /** no following for user **/";
				return;
			}

			/** @todo OR operator is not supported by sphinx*/
			// $this->filters['make_equals_filter']['OR'] = ['nodeid' => $allNodeIds, 'userid' => $userids];
			// workarond for missiong support for OR operator
			$nodeids = $assertor->getColumn('vBForum:node', 'nodeid', ['userid' => $userids]);
			$this->where[] = $this->make_equals_filter('id', $allNodeIds + $nodeids);
			return;
		}
	}



	/**
	 * The "my_channels" filter returns channels of the specified type that the current user
	 * belongs to.
	 * @see vBDBSearch_dB_MYSQL_QueryDefs->process_my_channels_filter() for more information
	 */
	protected function process_my_channels_filter($params)
	{
		switch($params['type'])
		{
			case 'blog':
				$blogChannel = vB_Library::instance('content_channel')->fetchChannelByGUID(vB_Channel::DEFAULT_BLOG_PARENT);
				$parentid = intval($blogChannel['nodeid']);
				break;
			case 'group':
				$sgChannel = vB_Library::instance('content_channel')->fetchChannelByGUID(vB_Channel::DEFAULT_SOCIALGROUP_PARENT);
				$parentid = intval($sgChannel['nodeid']);
				break;
			default:
				$parentid = false;
				break;
		}

		$userContext = vB::getUserContext();
		$channelAccess = $userContext->getAllChannelAccess();
		$channelcontentypeid = vB_Api::instanceInternal('contenttype')->fetchContentTypeIdFromClass('Channel');

		// userid isn't used in the query, but is used implicitly to reject the notion of "guest" owned channels.
		// If that ever becomes a thing we'll have to fix this, and fix any erroneous git records with userid=0
		$userid = $userContext->fetchUserId();

		if (empty($userid) OR empty($channelAccess['mychannels']) OR empty($parentid))
		{
			$this->where[] = "0 /** Missing Data for my_channels filter, or user belongs to no channels **/";
			return;
		}

		$this->where['my_channels_closure'] = $this->make_equals_filter('closure', $parentid);
		$this->where['my_channels_nodeid'] = $this->make_equals_filter('id', $channelAccess['mychannels']);
		$this->where['my_channels_contenttypeid'] = $this->make_equals_filter('contenttypeid', $channelcontentypeid);
	}


	/**
	 * Processes the include/exclude channels filter...
	 * Please note that this function itself returns nothing! It's used in conjunction with getSearchResults()
	 *
	 * @param	string[]	$param		Must have key 'filterType', 'filterChannels', 'includeChildren'.
	 *                                  See vB_Library_Widget::cleanFilterNodes()
	 */
	protected function process_inc_exc_channel_filter($params)
	{
		$filterType = $params['filterType'];
		$filterChannels = $params['filterChannels'];
		$includeChildren = $params['includeChildren'];

		//convert into an explicit list of channels we will include.
		$filterChannels = vB_Library::instance('search')->convertToParentChannels($filterType, $filterChannels, $includeChildren);

		if (empty($filterChannels))
		{
			return;
		}

		$this->what[] = 'IN(starterparent,' . implode(',', $filterChannels) . ') AS chn_filter_cond';
		$this->where[] = 'chn_filter_cond = 1';
	}

	public function PostProcessorAddComments($nodeids, vB_Search_Criteria $criteria)
	{
		$this->initConnection();

		$this->process_sort($criteria);

		$this->where[] = $this->make_equals_filter('parentid', $nodeids);
		$this->where[] = $this->make_notequals_filter('id', $nodeids);
		$keywords = $this->adjustKeywordsForSphinx($criteria->get_raw_keywords());

		//this won't matter if Sphinx case folding is set up correctly, but we lowercase the text
		//before we index it so we should do the same before we search on it just to be consistent.
		$string = vB::getString();
		$keywords = $string->strtolower($keywords);

		array_unshift($this->where, "MATCH('" . $this->sphinxEscapeKeywords($keywords) . "')");

		$groupby = '';
		if (!empty($this->groupby))
		{
			$groupby = '
			GROUP BY ' . $this->groupby;
		}

		$limit = $this->postProcessorGetLimitValue($criteria);

		//see comments in getResults regarding limits and sphinx
		$query_limit = '';
		if ($limit)
		{
			$query_limit = "LIMIT " . $limit . " OPTION max_matches=" . $limit;
		}

		$query = '
			SELECT ' . implode(', ', $this->what) . '
			FROM ' . $this->table . '

			WHERE ' . implode(' AND ', $this->where) . $groupby . '
			ORDER BY ' . implode(', ', $this->sort) . "
				$query_limit
			/**" . __FUNCTION__ . (defined('THIS_SCRIPT') ? '- ' . THIS_SCRIPT : '') . '**/';

		$res = $this->sphinxDB->query($query);
		while ($row = $this->sphinxDB->fetch_array($res))
		{
			$value = current($row);
			$nodeids[$value] = $value;
		}

		$config = vB::getConfig();

		if (!empty($config['Misc']['debug_sql']))
		{
			echo "$query;\n";
		}

		return $nodeids;
	}

	/**************HELPER FUNCTIONS**************************/

	// set to protected instead of private so that any subclass can implement their own if different behavior is desired.
	protected function adjustKeywordsForSphinx($keywords)
	{
		//Sphinx doesn't recognize 'OR' as a valid operator.  Not only does this break our
		//search tests, it will confuse users who try to use db search queries on sphinx.
		$keywords = str_ireplace(' OR ', ' | ', $keywords);

		//Also fix "NOT" as a opeartor
		$keywords = preg_replace('# NOT *#i', ' -', $keywords);

		//a trailing not operator will cause an error.  Let's strip it if we have one
		//to be consistant with the DB search.
		$keywords = preg_replace('#- *$#i', '', $keywords);

		return $keywords;
	}

	protected function compute_changes(&$node)
	{
		$existing = $this->sphinxDB->query_first($query = "SELECT * FROM " . $this->table . " WHERE id = " . $node['nodeid']);
		$assertor = vB::getDbAssertor();
		$node['tagid'] = $assertor->getColumn('vBForum:tagnode', 'tagid', ['nodeid' => $node['nodeid']],'tagid');
		$closure = $assertor->assertQUery('vBForum:closure', ['child' => $node['nodeid']],'child');
		$depth = [];
		foreach ($closure as $value)
		{
			$node['closure'][] = $value['parent'];
			$depth[] = '"n' . $value['parent'] . '":' . $value['depth'];
		}
		//building a json structure for the depth field that contains the depth of each parent
		$node['depth'] = '{' . implode(',', $depth) . '}';
		$nodeLib = vB_Library::instance('node');
		$node['starterparent'] = $nodeLib->getChannelId($node);
		if (!empty($node['starter']))
		{
			$starter = $nodeLib->getNodeBare($node['starter']);
		}
		else
		{
			$starter = [];
		}
		$map = $this->get_starter_inherited_fields();
		foreach ($map AS $__starterkey => $__childkey)
		{
			// For now, all of these are some form of integer with a default of 0.
			// If that changes (e.g. we get a string w/ a different default) we'll
			// have to handle that here case by case.
			if (!empty($starter[$__starterkey]))
			{
				$node[$__childkey] = $starter[$__starterkey];
			}
			else
			{
				// These fields are only used for topic view + sorting by lastcontent.
				// So if the search result is a channel & doesn't have a starter, this
				// sorting field shouldn't be used so it should just be 0 or ""... hypothetically.
				if ($this->attributeIsString($__childkey))
				{
					/*
						Non-int & unconventional naming cases:
						'startertitlesort'  => 'rt_attr_string',
						'starterauthorname' => 'rt_attr_string',
					 */
					$node[$__childkey] = "";
				}
				else
				{
					$node[$__childkey] = 0;
				}
			}
		}

		//populate the sentto only when the node is a private message
		if ($node['contenttypeid'] == vB_Api::instanceInternal('ContentType')->fetchContentTypeIdFromClass('PrivateMessage'))
		{
			$node['sentto'] = $assertor->getColumn('vBForum:sentto', 'userid', ['nodeid' => $node['nodeid'], 'deleted' => 0],'userid');
		}
		$update = [];
		$need_reindex = false;
		if (!empty($existing))
		{
			unset ($existing['id']);

			foreach ($existing as $attribute => $value)
			{
				//some attributes are not defined if they are not relevant: sentto
				if (!isset($node[$attribute]))
				{
					continue;
				}

				//because of the nature of sphinx and strings we need to have two title
				//fields (one to sort and one for keywords) which means we have a naming
				//conflict.
				if ($attribute == 'titlesort')
				{
					$nodevalue = $node['title'];
				}
				else
				{
					$nodevalue = $node[$attribute];
				}

				//when both old and new values are empty we don't need to update it
				if (empty($nodevalue) AND empty($value))
				{
					continue;
				}

				//multi type attributes are comma separated values, we need to convert them to arrays
				if ($this->attributes_definition[$attribute] == 'rt_attr_multi')
				{
					$value = explode(',', $value);
					$existing[$attribute] = $value;
				}

				// json values need to be matched individually
				if ($this->attributes_definition[$attribute] == 'rt_attr_json')
				{
					$old_depth = @json_decode($value, true);
					$new_depth = @json_decode($nodevalue, true);
					if (empty($new_depth))
					{
						continue;
					}

					//when both keys and values match between the old and new values, we can ignore updating this attribute
					if (!empty($new_depth) AND (count( array_intersect_assoc($old_depth, $new_depth)) == count($new_depth)))
					{
						continue;
					}
				}

				// checking array values for changes
				if (is_array($nodevalue) AND is_array($value) AND (count(array_intersect($nodevalue, $value)) == count($value)))
				{
					continue;
				}

				// this is an attribute that needs to be updated
				if ($nodevalue != $value)
				{
					$update[$attribute] = $nodevalue;
					//we need to update one of the string attributes. string attributes can't be updated using UPDATE,
					//we need to use REPLACE which means re-indexing the whole node
					//JSON attributes are treated basically like strings and need to be treated the same way.
					//There are suggestions that you can update scalar values within a JSON attribute by accessing the key directly,
					//but I couldn't get that to work and it probably doesn't help us much because typically the keys are going to change
					//and not just the values.
					if (in_array($this->attributes_definition[$attribute], ['rt_attr_string', 'rt_attr_json']))
					{
						$need_reindex = true;
					}
				}
			}
		}
		return ['changes' => $update, 'existing' => $existing, 'reindex' => $need_reindex];
	}

	private function make_equals_filter($field, $value)
	{
		$value = $this->quote_smart($field, $value);

		if (is_array($value))
		{
			return "$field IN (" . implode(',', $value) . ")";
		}
		else
		{
			return "$field = $value";
		}
	}

	private function make_notequals_filter($field, $value)
	{
		$value = $this->quote_smart($field, $value);

		if (is_array($value))
		{
			return "$field NOT IN (" . implode(',', $value) . ")";
		}
		else
		{
			return "$field <> $value";
		}

	}

	private function make_range_filter($field, $values)
	{
		//null mean infinity in a given direction
		if (!is_null($values[0]) AND !is_null($values[1]))
		{
			$values = $this->quote_smart($field, $values);
			return "($field BETWEEN $values[0] AND $values[1])";
		}
		else if (!is_null($values[0]))
		{
			$value = $this->quote_smart($field, $values[0]);
			return "$field >= $value";
		}
		else if (!is_null($values[1]))
		{
			$value = $this->quote_smart($field, $values[1]);
			return "$field <= $value";
		}
	}

	private function crc_uint_string($value)
	{
		return sprintf('%u', crc32($value));
	}

	private function quote_smart($field, $value, $is_handled = true)
	{
		if ($is_handled AND is_array($value) AND count($value) == 1)
		{
			$value = array_pop($value);
		}

		//id is a special field.  We don't to add it ot the attributes list because we assume items therein
		//are actually Sphinx attributes elsewhere and id is not.  It should act like a uint attribute
		//even though it technically isn't
		if ($field == 'id')
		{
			$type = 'rt_attr_uint';
		}
		else if (isset($this->attributes_definition[$field]))
		{
			$type = $this->attributes_definition[$field];
		}
		else
		{
			//shouldn't happen but if we're getting unknown fields we could have a problem if we aren't
			//correctly quoting/escaping them
			throw new vB_Exception_Api('invalid_data_w_x_y_z', [$field, 'field', __CLASS__, __FUNCTION__]);
		}

		if ($type == 'rt_attr_string')
		{
			if (empty($value))
			{
				return "''";
			}

			if (is_array($value))
			{
				foreach ($value as $k => $v)
				{
					$value[$k] = "'" . $this->sphinxEscapeString($v) . "'";
				}
				return $value;
			}
			else
			{
				return "'" . $this->sphinxEscapeString($value) . "'";
			}
		}

		if ($type == 'rt_attr_json')
		{
			if (empty($value))
			{
				return "''";
			}
			else
			{
				return "'$value'";
			}
		}


		if ($type == 'rt_attr_multi')
		{
			if (!$is_handled AND empty($value)){
				return '()';
			}

			if ($is_handled OR !is_array($value))
			{
				return $value;
			}

			return '(' . implode(',', $value) . ')';
		}


		if (empty($value))
		{
			return 0;
		}

		if (is_bool($value))
		{
			return $value ?  1 : 0;
		}

		return $value;
	}

	private function exec_command($command, $silent = false)
	{
		exec($command, $output);
		$haserror = false;
		foreach ($output as $string)
		{
			if (!$silent)
			{
				echo $string . "\n";
			}

			if ((strpos($string, 'ERROR') === 0) OR (strpos($string, 'FATAL') === 0))
			{
				$haserror = true;
			}
		}

		if ($haserror)
		{
			throw new vB_Exception_Api('sphinx_error_x', [implode("\n", $output)], '', false, false, false, true);
		}
	}

	private function exec_command_feedback($command, $silent = false)
	{
		$output = '';
		$fp=popen($command, "r");
		stream_set_blocking($fp, 0);
		while (!feof($fp))
		{
			$buffer = fread($fp, 100);
			if (empty($buffer))
			{
				echo '.';
				flush();
				sleep(5);
			}
			else
			{
				$output .= $buffer;
			}
		}
		pclose($fp);
		if ((strpos($output, 'ERROR') === 0) OR (strpos($output, 'FATAL') === 0))
		{
			throw new vB_Exception_Api('sphinx_error_x', $output);
		}
		else if (!$silent)
		{
			echo $output;
		}
	}

	private function restart($silent = false)
	{
		$config = vB::getConfig();
		// close the current connection before restarting the service
		$this->sphinxDB->close();

		// restart the service
		//windows
		if (stripos(@php_uname('s'), "win") !== false)
		{
			$this->exec_command('net stop SphinxSearch', $silent);
			$this->exec_command('net start SphinxSearch', $silent);
		}
		//unix
		else
		{
			$this->exec_command($config['Misc']['sphinx_path'] . '/bin/searchd --config ' . $config['Misc']['sphinx_config'] . ' --stopwait', $silent);
			$this->exec_command($config['Misc']['sphinx_path'] . '/bin/searchd --config ' . $config['Misc']['sphinx_config'], $silent);
		}
		// need to reconnect after restarting the service
		$this->connect($config);
	}

	/**
	 * Composes the terms for the flags to enforce the starter-node-specific permissions.
	 *
	 * @param	$excludeUserSpecific	bool	Exclude user specific queries. Used for precaching
	 **/
	protected function setNodePermTerms($excludeUserSpecific = false)
	{
		$userContext = vB::getUserContext();

		if (empty($userContext))
		{
			$this->where[] = 'id = 0 /* no user context */';
			return;
		}
		if ($userContext->isSuperAdmin())
		{
			return;
		}

		$userid = vB::getCurrentSession()->get('userid');
		$channelAccess = $userContext->getAllChannelAccess();

		$condition = [];

		if (!empty($channelAccess['canmoderate']))
		{
			$condition[] = '(IN(starterparent,' . implode(',', $channelAccess['canmoderate']) . ') OR IN(starter,' . implode(',', $channelAccess['canmoderate']) . '))';
		}

		$starterAnd = '';

		if (!empty($channelAccess['canseedelnotice']))
		{
			$starterAnd = 'AND (IN(starterparent, ' . implode(',', $channelAccess['canseedelnotice']) . ') OR showpublished > 0)';
		}

		$sphinxShowApprovedQuery = "showapproved > 0";
		if ($userid > 0)
		{
			$userid = intval($userid);
			// This is OK because this is only in the SELECT, not WHERE.
			// If we need to do the OR check on its own in the WHERE, use doShowApprovedCheck()
			$sphinxShowApprovedQuery = "(showapproved > 0 OR userid = $userid)";
		}

		if (!empty($channelAccess['canview']))
		{
			$showParams = [
				$sphinxShowApprovedQuery,
				$userid > 0 ? 'viewperms > 0' : 'viewperms > 1',
			];

			if (empty($channelAccess['canseedelnotice']))
			{
				$showParams[] = 'showpublished > 0';
			}

			$condition[] = '(((IN(starterparent,' . implode(',', $channelAccess['canview']) . ") $starterAnd) AND " . implode(' AND ', $showParams) . "))\n";
		}

		if (!empty($channelAccess['canalwaysview']))
		{
			$condition[] = 'IN(starterparent,' . implode(',', $channelAccess['canalwaysview']) . ')';
		}

		if (!empty($channelAccess['starteronly']))
		{
			$starterOnly = implode(',', $channelAccess['starteronly']);
			$condition[] = "(IN(id,$starterOnly) OR IN(parentid, $starterOnly))";
		}

		if (!empty($channelAccess['selfonly']))
		{
			if ($excludeUserSpecific)
			{
				$condition[] = 'IN(starterparent,' . implode(',', $channelAccess['selfonly']) . ')';
			}
			else if ($userid)
			{
				// Result set it not being pre-cached
				$condition[] = '(IN(starterparent,' . implode(',', $channelAccess['selfonly']) . ") AND starteruser = $userid)";
			}
		}

		if (!empty($channelAccess['member']))
		{
			$showParams = [
				$sphinxShowApprovedQuery,
			];

			if (empty($channelAccess['canseedelnotice']))
			{
				$showParams[] = 'showpublished > 0';
			}

			$condition[] = '((IN(starterparent,' . implode(',', $channelAccess['member']) . ") $starterAnd) AND " . implode(' AND ', $showParams) . ")\n";
		}

		//albums for which viewperms is zero.
		$following = vB_Library::instance('follow')->getFollowingParameters($userid);

		if (empty($following['user']))
		{
			$following['user'] = [$userid];
		}
		else
		{
			$following['user'][] = $userid;
		}

		$condition[] = "(starterparent = " . vB_Library::instance('node')->fetchAlbumChannel() . " AND (IN (userid," . implode(',', $following['user']) . ")))";

		if (empty($condition))
		{
			$this->where[] = 'id = 0 /* no permissions */';
			return;
		}

		$this->what[] = '(' . implode (' OR ', $condition) . ') AS permconditions';
		$this->where[] = 'permconditions = 1';
	}


	/*
	 * The mysql_real_escape_string function does not correctly handle escaping sphinx strings.  So we do it the hard way.
	 */
	public function sphinxEscapeString($toEscape)
	{
		/*
			http://sphinxsearch.com/docs/current/extended-syntax.html
			There are some more operators in that list, like
				<< (strict order operator)
				: (part of ZONE: and ZONESPAN: limit operators)
			but in some quick testing, these characters either never make it into the search core,
			or do not seem to cause problems when dangling out of context. Leaving the current list
			unchanged, but leaving the reference link here in case we hit more characters in the
			wild that we need to verify & add.
		 */

		$this->initConnection();
		//handle sphinx special chars
		$from = ['\\', '(',')','|','-','!','@','~','"','&', '/', '^', '$', '='];
		$to   = ['\\\\', '\(','\)','\|','\-','\!','\@','\~','\"', '\&', '\/', '\^', '\$', '\='];

		//we need to do both sphinx and mysql escaping see advice in http://sphinxsearch.com/bugs/view.php?id=1713
		return $this->sphinxDB->escape_string(str_replace ($from, $to, $toEscape));
	}

	/**
	 *	We need to escape some characters from the keyword string to avoid allowing the user to break
	 *	sphinx queries.  However we want to allow some things, like the | character for OR. This is
	 *	a clone of the full escape function we a few operators removed so we can let them through
	 *
	 *	Escaping is a mess and not well documented in Sphinx -- and complicated because we're passing
	 *	things through the mysql library as well.
	 *
	 *	Currently allowed operators: |
	 */
	public function sphinxEscapeKeywords($toEscape)
	{
		$this->initConnection();
		//handle sphinx special chars
		$from = ['\\', '(',')','!','@','~','"','&', '/', '^', '$', '='];
		$to   = ['\\\\', '\(','\]','\!','\@','\~','\"', '\&', '\/', '\^', '\$', '\='];

		//we need to do both sphinx and mysql escaping see advice in http://sphinxsearch.com/bugs/view.php?id=1713
		return $this->sphinxDB->escape_string(str_replace ($from, $to, $toEscape));
	}
}

/*=========================================================================*\
|| #######################################################################
|| # Downloaded: 13:27, Sun Aug 30th 2026
|| # CVS: $RCSfile$ - $Revision: 122154 $
|| #######################################################################
\*=========================================================================*/
