Version 4.3.0

This commit is contained in:
Neo committed 2025-12-19 05:47:12 -08:00
1 parent 96997ddd8e
commit fe1acf984a
1349 files changed
+116159 -67711

No files matched your search

+185 -41
View File
@@ -139,6 +139,10 @@ class _Query extends \IPS\Content\Search\Query
{
$clause[] = \IPS\Db::i()->in( 'index_item_id', $filter->itemIds, $filter->itemIdFilter === FALSE );
}
if ( $filter->objectIdFilter !== NULL )
{
$clause[] = \IPS\Db::i()->in( 'index_object_id', $filter->objectIds, $filter->objectIdFilter === FALSE );
}
/* Minimum comments/reviews/views? */
if ( $filter->minimumComments or $filter->minimumReviews or $filter->minimumViews )
@@ -263,12 +267,33 @@ class _Query extends \IPS\Content\Search\Query
$this->where[] = array( '( index_author=? OR (' . \IPS\Db::i()->in( 'index_id', iterator_to_array( \IPS\Db::i()->select( 'index_id', 'core_search_index', array( 'index_class=? AND index_container_id=?', 'IPS\core\Statuses\Status', $member->member_id ) ) ) ) . ') )', $member->member_id );
}
return $this;
$filterResult = $this;
}
else
{
return $this->filterByAuthor( $member );
$filterResult = $this->filterByAuthor( $member );
}
/* Get the list of valid classes */
foreach ( \IPS\Application::allExtensions( 'core', 'ContentRouter', FALSE ) as $object )
{
foreach ( $object->classes as $class )
{
if ( in_array( 'IPS\Content\Item', class_parents( $class ) ) )
{
$classesChecked[] = $class;
}
}
}
/* Give content item classes a chance to inspect and manipulate filters */
$filters = array();
foreach( $classesChecked as $itemClass )
{
$itemClass::searchEngineFiltering( $filters, $filterResult );
}
return $filterResult;
}
/**
@@ -296,7 +321,7 @@ class _Query extends \IPS\Content\Search\Query
{
if( empty( $exclude ) )
{
$this->where[] = \IPS\Db::i()->in( 'index_container_class', $classes, TRUE );
$this->where[] = '( index_container_class IS NULL OR ' . \IPS\Db::i()->in( 'index_container_class', $classes, TRUE ) . ')';
}
else
{
@@ -309,7 +334,7 @@ class _Query extends \IPS\Content\Search\Query
$exclude[$i] = "'" . \IPS\DB::i()->real_escape_string( $class ) . "'";
}
$this->where[] = '( index_container_class NOT IN(' . implode( ',', $classes ) . ') OR index_class IN(' . implode( ',', $exclude ) . ') )';
$this->where[] = '( index_container_class IS NULL OR index_container_class NOT IN(' . implode( ',', $classes ) . ') OR index_class IN(' . implode( ',', $exclude ) . ') )';
}
return $this;
@@ -767,7 +792,7 @@ class _Query extends \IPS\Content\Search\Query
* @return \IPS\Content\Search\Query (for daisy chaining)
*/
public function setOrder( $order )
{
{
switch ( $order )
{
case static::ORDER_NEWEST_UPDATED:
@@ -785,6 +810,10 @@ class _Query extends \IPS\Content\Search\Query
case static::ORDER_OLDEST_CREATED:
$this->order = 'index_date_created ASC';
break;
case static::ORDER_NEWEST_COMMENTED:
$this->order = 'index_date_commented DESC';
break;
case static::ORDER_RELEVANCY:
$this->order = 'calcscore DESC';
@@ -797,12 +826,13 @@ class _Query extends \IPS\Content\Search\Query
/**
* Build where
*
* @param string|null $term The term to search for
* @param array|null $tags The tags to search for
* @param int $method \IPS\Content\Search\Index::i()->TERM_OR_TAGS or \IPS\Content\Search\Index::i()->TERM_AND_TAGS
* @param string|null $term The term to search for
* @param array|null $tags The tags to search for
* @param int $method See \IPS\Content\Search\Query::TERM_* contants
* @param string $operator If $term contains more than one word, determines if searching for both ("and") or any ("or") of those terms
* @return array
*/
protected function _searchWhereClause( $term = NULL, $tags = NULL, $method = 1 )
protected function _searchWhereClause( $term = NULL, $tags = NULL, $method = 1, $operator = 'and' )
{
$tagWhere = NULL;
$termWhere = NULL;
@@ -850,7 +880,7 @@ class _Query extends \IPS\Content\Search\Query
/* Do we have a term? */
if ( $term !== NULL )
{
$termWhere = static::matchClause( $method & static::TERM_TITLES_ONLY ? 'index_title' : 'index_content,index_title', $term, $method & static::TERM_OR_MODE ? '' : '+' );
$termWhere = static::matchClause( $method & static::TERM_TITLES_ONLY ? 'index_title' : 'index_content,index_title', $term, $operator === static::OPERATOR_AND ? '+' : '' );
}
/* Put those two together */
@@ -1072,7 +1102,7 @@ class _Query extends \IPS\Content\Search\Query
{
$extra = $columns . ' LIKE \'%' . \IPS\Db::i()->escape_string( $term ) . '%\'';
}
return array( "MATCH({$columns}) AGAINST (? IN BOOLEAN MODE) AND (" . $extra . ")", $booleanTerm );
}
else
@@ -1082,6 +1112,7 @@ class _Query extends \IPS\Content\Search\Query
}
else
{
$likeWhere = array();
foreach ( static::termAsWordsArray( $term ) as $word )
{
/* Add the default operator */
@@ -1102,9 +1133,16 @@ class _Query extends \IPS\Content\Search\Query
if ( !static::termIsPhrase( $word ) )
{
/* We can't have any other operators as MySQL will interpret them as a separate word. If they exist, wrap the word in quotes */
if ( preg_match( '/^.+[\+\-~<>]/', $word ) )
if ( preg_match( '/^.+[\+\-~<>\.]/', $word ) )
{
$word = "\"" . str_replace( '"', '', ltrim( $word, $defaultOperator ) ) . "\"";
$trimmedWord = \IPS\Db::i()->escape_string( str_replace( '"', '', ltrim( $word, $defaultOperator ) ) );
$likes = array();
foreach( explode( ',', $columns ) AS $column )
{
$likes[] = $column . ' LIKE \'%' . $trimmedWord . '%\'';
}
$likeWhere[] = '( ' . implode( ' or ', $likes ) . ' )';
continue;
}
/* Otherwise carry on... */
else
@@ -1114,75 +1152,107 @@ class _Query extends \IPS\Content\Search\Query
/* Nor is @ or parenthesis (while paranthesis can be used to group words and apply operators to the group,
(e.g. "+apple +(>turnover <strudel)") - it's unlikely a user intends this behaviour) */
$word = str_replace( array( '(', ')' ), '', str_replace( '@', ' ', $word ) );
$word = str_replace( array( '(', ')' ), '', str_replace( '@', ' ', $word ) );
preg_match( '#^(\+|\-|\*)#', $word, $matches );
if ( mb_stripos( $word, "'" ) and isset( $matches[1] ) )
{
/* Due to MySQL bug (https://bugs.mysql.com/bug.php?id=69932) apostrophes confuse things */
preg_match( '#^(\+|\-|\*)#', $word, $matches );
$word = $matches[1] . '(' . str_replace( $matches[1], '', $word ) . ')';
}
}
}
/* Add it */
$words[] = $word;
}
$term = implode( ' ', $words );
/* Return */
$return = array();
if ( $prepared )
{
if ( count( $words ) > 1 or mb_strstr( $term, '*' ) )
/* Force newest as of 4.2.6 to test search results */
if ( count( $likeWhere ) and count( $words ) )
{
return array( "MATCH({$columns}) AGAINST (? IN BOOLEAN MODE)", $term );
$return = array( implode( ' AND ', $likeWhere ) . " AND MATCH({$columns}) AGAINST (? IN BOOLEAN MODE)", $term );
}
else if ( count( $likeWhere ) )
{
$return = array( implode( ' AND ', $likeWhere ) );
}
else
{
return array( "MATCH({$columns}) AGAINST (?)", ltrim( $term, $defaultOperator ) );
$return = array( "MATCH({$columns}) AGAINST (? IN BOOLEAN MODE)", $term );
}
}
else
{
if ( count( $words ) > 1 or mb_strstr( $term, '*' ) )
/* Force newest as of 4.2.6 to test search results */
if ( count( $likeWhere ) )
{
return "MATCH({$columns}) AGAINST ('" . \IPS\Db::i()->escape_string( $term ) . "' IN BOOLEAN MODE)";
}
else
{
return "MATCH({$columns}) AGAINST ('" . \IPS\Db::i()->escape_string( ltrim( $term, $defaultOperator ) ) . "')";
$return[] = implode( ' AND ', $likeWhere );
}
$return[] = "MATCH({$columns}) AGAINST ('" . \IPS\Db::i()->escape_string( $term ) . "' IN BOOLEAN MODE)";
$return = implode( ' AND ', $return );
}
}
return $return;
}
/**
* Search
*
* @param string|null $term The term to search for
* @param array|null $tags The tags to search for
* @param int $method \IPS\Content\Search\Index::i()->TERM_OR_TAGS or \IPS\Content\Search\Index::i()->TERM_AND_TAGS
* @param string|null $term The term to search for
* @param array|null $tags The tags to search for
* @param int $method See \IPS\Content\Search\Query::TERM_* contants - controls where to search
* @param string|null $operator If $term contains more than one word, determines if searching for both ("and") or any ("or") of those terms. NULL will go to admin-defined setting
* @return \IPS\Content\Search\Results
*/
public function search( $term = NULL, $tags = NULL, $method = 1 )
public function search( $term = NULL, $tags = NULL, $method = 1, $operator = NULL )
{
/* What's our operator? */
$operator = $operator ?: \IPS\Settings::i()->search_default_operator;
/* Set the select clause */
$select = implode( ', ', $this->select );
/* Get the where clause */
$where = array_merge( $this->where, $this->_searchWhereClause( $term, $tags, $method ) );
$where = array_merge( $this->where, $this->_searchWhereClause( $term, $tags, $method, $operator ) );
/* Set order clause */
$order = $this->order;
/* But we're sorting by relevancy, we need to actually select that value with our fancy algorithm */
if ( mb_substr( $this->order, 0, 9 ) === 'calcscore' )
{
/* But we can only do that if there's a term (rather than tag-only) */
if ( $term !== NULL )
{
if ( \IPS\Settings::i()->search_title_boost )
{
$titleField = '(' . static::matchClause( 'index_title', $term, $operator === static::OPERATOR_AND ? '+' : '', FALSE ) . '*' . intval( \IPS\Settings::i()->search_title_boost ) . ')'; // The title score times multiplier
}
else
{
$titleField = '(' . static::matchClause( 'index_title', $term, $operator === static::OPERATOR_AND ? '+' : '', FALSE ) . ')';
}
$select .= ', '
. '('
. '(' . static::matchClause( 'index_title', $term, $method & static::TERM_OR_MODE ? '' : '+', FALSE ) . '*5)' // The title score times 5
. $titleField
. '+'
. '(' . static::matchClause( 'index_content,index_title', $term, $method & static::TERM_OR_MODE ? '' : '+', FALSE ) . ')' // Plus the content score times 1
. '(' . static::matchClause( 'index_content,index_title', $term, $operator === static::OPERATOR_AND ? '+' : '', FALSE ) . ')' // Plus the content score times 1
. ')'
. '/' // Divided by
. 'POWER('
. '( ( UNIX_TIMESTAMP( NOW() ) - ( CASE WHEN index_date_updated <= UNIX_TIMESTAMP( NOW() ) THEN index_date_updated ELSE 0 END )) / 3600 ) + 2' // The number of days between now and the updated date, plus 2
. '( ( UNIX_TIMESTAMP( NOW() ) - ( CASE WHEN index_date_updated <= UNIX_TIMESTAMP( NOW() ) THEN index_date_updated ELSE 0 END )) / 3600 ) + 2' // The number of days between now and the updated date, plus 2
. ',1.5)' // To the power of 1.5
. ' AS calcscore';
}
@@ -1205,25 +1275,26 @@ class _Query extends \IPS\Content\Search\Query
{
$query->forceIndex( $this->forceIndex );
}
/* Return */
$count = $this->count( $term, $tags, $method, FALSE );
$count = $this->count( $term, $tags, $method, $operator, FALSE );
return new \IPS\Content\Search\Results( iterator_to_array( $query ), $count );
}
/**
* Get count
*
* @param string|null $term The term to search for
* @param array|null $tags The tags to search for
* @param int $method \IPS\Content\Search\Index::i()->TERM_OR_TAGS or \IPS\Content\Search\Index::i()->TERM_AND_TAGS
* @param string|null $term The term to search for
* @param array|null $tags The tags to search for
* @param int $method See \IPS\Content\Search\Query::TERM_* contants
* @param string|null $operator If $term contains more than one word, determines if searching for both ("and") or any ("or") of those terms. NULL will go to admin-defined setting
* @param boolean $returnCountAsInt If TRUE, it will return the count as an integer, when FALSE it will return the \IPS\Db\Select object
* @return \IPS\Db\Query|int
*/
public function count( $term = NULL, $tags = NULL, $method = 1, $returnCountAsInt=TRUE )
public function count( $term = NULL, $tags = NULL, $method = 1, $operator = NULL, $returnCountAsInt=TRUE )
{
/* Get the where clause */
$where = array_merge( $this->where, $this->_searchWhereClause( $term, $tags, $method ) );
$where = array_merge( $this->where, $this->_searchWhereClause( $term, $tags, $method, $operator ) );
/* Construct the query */
$query = \IPS\Db::i()->select( 'COUNT(*)', array( 'core_search_index', 'main' ), $where );
@@ -1263,7 +1334,7 @@ class _Query extends \IPS\Content\Search\Query
return 'newest';
}
return 'relevancy';
return parent::getDefaultSortMethod();
}
protected static $tableCount = NULL;
@@ -1282,4 +1353,77 @@ class _Query extends \IPS\Content\Search\Query
return static::$tableCount;
}
/**
* Convert the term into an array of words
*
* @param string $term The term to search for
* @param boolean $ignorePhrase When true, phrases are stripped of quotes and treated as normal words
* @param int|NULL $minLength The minimum length a sequence of characters has to be before it is considered a word. If null, ft_min_word_len/innodb_ft_min_token_size is used.
* @param int|NULL $maxLength The maximum length a sequence of characters can be for it to be considered a word. If null, ft_max_word_len/innodb_ft_max_token_size is used.
* @return array
*/
public static function termAsWordsArray( $term, $ignorePhrase=FALSE, $minLength=NULL, $maxLength=NULL )
{
/* If we haven't set a preferred min/max length, use the MySQL configuration */
if ( $minLength === NULL or $maxLength === NULL )
{
/* If we don't already know what they are, get the values from the MySQL configuration */
if ( ( $minLength === NULL and !isset( \IPS\Data\Store::i()->mysqlMinWord ) ) or ( $maxLength === NULL and !isset( \IPS\Data\Store::i()->mysqlMaxWord ) ) )
{
/* The variable we need depends on whether the table is MyISAM or InnoDB */
$tableDefinition = \IPS\Db::i()->getTableDefinition('core_search_index');
if ( $tableDefinition['engine'] == 'InnoDB' )
{
$minVariable = 'innodb_ft_min_token_size';
$maxVariable = 'innodb_ft_max_token_size';
}
else
{
$minVariable = 'ft_min_word_len';
$maxVariable = 'ft_max_word_len';
}
/* Now fetch those */
try
{
foreach ( new \IPS\Db\Select( 'SHOW VARIABLES WHERE Variable_Name=? OR Variable_Name=?', array( $minVariable, $maxVariable ), \IPS\Db::i() ) as $row )
{
if ( $row['Variable_name'] === $minVariable )
{
\IPS\Data\Store::i()->mysqlMinWord = intval( $row['Value'] );
}
elseif ( $row['Variable_name'] === $maxVariable )
{
\IPS\Data\Store::i()->mysqlMaxWord = intval( $row['Value'] );
}
}
}
catch( \IPS\Db\Exception $e ) { }
/* If we weren't able to get them, set sensible defaults */
if ( !isset( \IPS\Data\Store::i()->mysqlMinWord ) )
{
\IPS\Data\Store::i()->mysqlMinWord = 3;
}
if ( !isset( \IPS\Data\Store::i()->mysqlMaxWord ) )
{
\IPS\Data\Store::i()->mysqlMaxWord = 84;
}
}
/* Set */
if ( $minLength === NULL )
{
$minLength = \IPS\Data\Store::i()->mysqlMinWord;
}
if ( $maxLength === NULL )
{
$maxLength = \IPS\Data\Store::i()->mysqlMaxWord;
}
}
/* And then pass up */
return parent::termAsWordsArray( $term, $ignorePhrase, $minLength, $maxLength );
}
}