Files
IPB/system/Content/Search/Query.php
T
2025-12-19 19:04:57 -08:00

465 lines
13 KiB
PHP

<?php
/**
* @brief Abstract Search Query
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @since 21 Aug 2014
*/
namespace IPS\Content\Search;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Content\Item;
use IPS\DateTime;
use IPS\Db;
use IPS\Db\Select;
use IPS\Http\Url;
use IPS\Member;
use IPS\Member\Club;
use IPS\Settings;
use function defined;
use function is_null;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* Abstract Search Query
*/
abstract class Query
{
const TERM_OR_TAGS = 1; // If both tags and a term are provided, match results where either the tags or the term match
const TERM_AND_TAGS = 2; // If both tags and a term are provided, match only results where BOTH the tags or the term match
const TERM_TITLES_ONLY = 8; // If this flag is set, only titles will be searched
const TAGS_MATCH_ITEMS_ONLY = 16;
const OPERATOR_OR = 'or';
const OPERATOR_AND = 'and';
const HIDDEN_VISIBLE = 0;
const HIDDEN_UNAPPROVED = 1;
const HIDDEN_HIDDEN = -1;
const HIDDEN_PARENT_HIDDEN = 2;
const ORDER_NEWEST_UPDATED = 1;
const ORDER_NEWEST_CREATED = 2;
const ORDER_RELEVANCY = 3;
const ORDER_OLDEST_UPDATED = 4;
const ORDER_OLDEST_CREATED = 5;
const ORDER_NEWEST_COMMENTED = 6;
const SUPPORTS_JOIN_FILTERS = TRUE;
/**
* Create new query
*
* @param Member|null $member The member performing the search (NULL for currently logged in member)
* @return Query
*/
public static function init( Member|null $member = NULL ): static
{
if ( Settings::i()->search_method == 'elastic' )
{
$search = new Elastic\Query( $member ?: Member::loggedIn(), Url::external( rtrim( Settings::i()->search_elastic_server, '/' ) . '/' . Settings::i()->search_elastic_index ) );
}
else
{
$search = new Mysql\Query( $member ?: Member::loggedIn() );
}
/* Don't include any club content if we have disabled clubs */
if( !Settings::i()->clubs )
{
$search->filterByClub( NULL );
}
return $search;
}
/**
* @brief Number of results to get
*/
public int $resultsToGet = 25;
/**
* @brief The member performing the search
*/
protected Member $member;
/**
* Constructor
*
* @param Member $member The member performing the search
* @return void
*/
public function __construct( Member $member )
{
$this->member = $member;
/* Exclude hidden items */
if ( !$member->modPermission('can_view_hidden_content') )
{
$this->setHiddenFilter( static::HIDDEN_VISIBLE );
}
$filters = [];
/* Exclude disabled applications */
foreach( SearchContent::searchableClasses() as $class )
{
/* We only want items here */
if( is_subclass_of( $class, Item::class ) )
{
$filters[] = ContentFilter::init( $class );
}
}
if ( !empty( $filters ) )
{
$this->filterByContent( $filters );
}
}
/**
* Filter by multiple content types
*
* @param array $contentFilters Array of \IPS\Content\Search\ContentFilter objects
* @param bool $type TRUE means only include results matching the filters, FALSE means exclude all results matching the filters
* @return Query (for daisy chaining)
*/
abstract public function filterByContent( array $contentFilters, bool $type = TRUE ): static;
/**
* Filter by author
*
* @param Member|int|array $author The author, or an array of author IDs
* @return Query (for daisy chaining)
*/
abstract public function filterByAuthor( Member|int|array $author ): static;
/**
* Filter by club
*
* @param Club|int|array|null $club The club, or array of club IDs
* @return Query (for daisy chaining)
*/
abstract public function filterByClub( Club|int|array|null $club ): static;
/**
* Filter for profile
*
* @param Member $member The member whose profile is being viewed
* @return Query (for daisy chaining)
*/
abstract public function filterForProfile( Member $member ): static;
/**
* Filter by item author
*
* @param Member $author The author
* @return Query (for daisy chaining)
*/
abstract public function filterByItemAuthor( Member $author ): static;
/**
* Filter by container class
*
* @param array $classes Container classes to exclude from results.
* @param array $exclude Content classes to exclude from the filter. For cases where multiple content classes may have the same container class such as Gallery images, comments and reviews.
* @return Query (for daisy chaining)
*/
abstract public function filterByContainerClasses( array $classes=array(), array $exclude=array() ): static;
/**
* Filter by content the user follows
*
* @param bool $includeContainers Include content in containers the user follows?
* @param bool $includeItems Include items and comments/reviews on items the user follows?
* @param bool $includeMembers Include content posted by members the user follows?
* @return Query (for daisy chaining)
*/
abstract public function filterByFollowed( bool $includeContainers, bool $includeItems, bool $includeMembers ): static;
/**
* Filter by content the user has posted in
*
* @return Query (for daisy chaining)
*/
abstract public function filterByItemsIPostedIn(): static;
/**
* Filter by content the user has not read
*
* @note If applicable, it is more efficient to call filterByContent() before calling this method
* @return Query (for daisy chaining)
*/
abstract public function filterByUnread(): static;
/**
* Filter by content the user has not read
*
* @return Query (for daisy chaining)
*/
abstract public function filterBySolved(): static;
/**
* Filter by content the user has not read
*
* @return Query (for daisy chaining)
*/
abstract public function filterByUnsolved(): static;
/**
* Filter by start date
*
* @param DateTime|NULL $start The start date (only results AFTER this date will be returned)
* @param DateTime|NULL $end The end date (only results BEFORE this date will be returned)
* @return Query (for daisy chaining)
*/
abstract public function filterByCreateDate( DateTime|null $start = NULL, DateTime|null $end = NULL ): static;
/**
* Filter by last updated date
*
* @param DateTime|NULL $start The start date (only results AFTER this date will be returned)
* @param DateTime|NULL $end The end date (only results BEFORE this date will be returned)
* @return Query (for daisy chaining)
*/
abstract public function filterByLastUpdatedDate( DateTime|null $start = NULL, DateTime|null $end = NULL ): static;
/**
* Set hidden status
*
* @param int|array|NULL $statuses The statuses (see HIDDEN_ constants) or NULL for any
* @return Query (for daisy chaining)
*/
abstract public function setHiddenFilter( int|array|null $statuses ): static;
/**
* Set limit
*
* @param int $limit Number per page
* @return Query (for daisy chaining)
*/
public function setLimit( int $limit ): static
{
$this->resultsToGet = $limit;
return $this;
}
/**
* Set page
*
* @param int $page The page number
* @return Query (for daisy chaining)
*/
abstract public function setPage( int $page ): static;
/**
* Set order
*
* @param int $order Order (see ORDER_ constants)
* @return Query (for daisy chaining)
*/
abstract public function setOrder( int $order ): static;
/**
* Permission Array
*
* @return array
*/
public function permissionArray(): array
{
return $this->member->permissionArray();
}
/**
* Search
*
* @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
* @return Results
*/
abstract public function search( string|null $term = NULL, array|null $tags = NULL, int $method = 1, string|null $operator = NULL ): Results;
/**
* Get the default date cut off
*
* @return string
*/
public function getDefaultDateCutOff(): string
{
return 'any';
}
/**
* Get the default sort method
*
* @return string
*/
public function getDefaultSortMethod(): string
{
return 'relevancy';
}
/**
* Can use relevancy?
*
* @return boolean
*/
public function canUseRelevancy(): bool
{
return true;
}
/**
* 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 $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 Select|int
*/
public function count( string|null $term = NULL, array|null $tags = NULL, int $method = 1, string|null $operator = NULL ): Select|int
{
return $this->search( $term, $tags, $method, $operator )->count( TRUE );
}
/**
* Is this term a phrase?
*
* @param string $term The term to search for
* @return boolean
*/
public static function termIsPhrase( string $term ): bool
{
return (bool) preg_match( '#^".*"$#', $term );
}
/**
* 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( string $term, bool $ignorePhrase=FALSE, int|null $minLength=NULL, int|null $maxLength=NULL ): array
{
$minLength = is_null( $minLength ) ? 0 : $minLength;
$maxLength = is_null( $maxLength ) ? 255 : $maxLength;
/* Sub quotes with smaller than min word strings can cause issues for larger tables, so we convert the sub quote into normal words for a boolean search */
if ( ! static::termIsPhrase( $term ) )
{
preg_match_all( '#"([^"]+?)"#', $term, $matches, PREG_SET_ORDER );
foreach( $matches as $match )
{
$allWordsInPhraseAreGood = true;
$subWords = array();
foreach( explode( ' ', $match[1] ) as $subWord )
{
if ( mb_strlen( $subWord ) <= $minLength or mb_strlen( $subWord ) >= $maxLength )
{
$allWordsInPhraseAreGood = false;
}
else
{
$subWords[] = $subWord;
}
}
if ( ! $allWordsInPhraseAreGood )
{
$term = str_replace( $match[0], implode( ' ', $subWords ), $term );
}
}
}
/* Parse */
$words = array();
$currentWord = '';
$inQuote = false;
$termLength = mb_strlen( $term );
for ( $i = 0; $i < $termLength; $i++ )
{
$c = mb_substr( $term, $i, 1 );
if ( $c == '"' )
{
if ( $ignorePhrase )
{
continue;
}
$inQuote = !$inQuote;
}
elseif ( $c == ' ' and !$inQuote )
{
$words[] = trim( $currentWord );
$currentWord = '';
}
$currentWord .= $c;
}
$words[] = trim( $currentWord );
/* Now check each of the words is acceptable */
$finalWords = array();
foreach( $words as $word )
{
if ( mb_strlen( $word ) >= $minLength and mb_strlen( $word ) <= $maxLength )
{
$finalWords[] = $word;
}
}
/* And return */
return $finalWords;
}
/**
* Is a rebuild task running?
*
* @return boolean
*/
public static function isRebuildRunning(): bool
{
return (bool) Db::i()->select( 'COUNT(*)', 'core_queue', array( '`key`=?', 'RebuildSearchIndex' ) )->first();
}
/**
* Run custom search engine filters
*
* @param bool $itemsOnly
* @return void
*/
public function customFiltering( bool $itemsOnly=FALSE ) : void
{
/* Get the list of valid classes */
$classesChecked = array();
foreach( SearchContent::searchableClasses() as $class )
{
if ( $itemsOnly AND 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 )
{
if( $extension = SearchContent::extension( $itemClass ) )
{
$extension::searchEngineFiltering( $filters, $this );
}
}
}
}