1349 lines
36 KiB
PHP
1349 lines
36 KiB
PHP
<?php
|
|
/**
|
|
* @brief Elasticsearch 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 9 Nov 2017
|
|
*/
|
|
|
|
namespace IPS\Content\Search\Elastic;
|
|
|
|
/* To prevent PHP errors (extending class does not exist) revealing path */
|
|
|
|
use Exception;
|
|
use IPS\Application;
|
|
use IPS\Content;
|
|
use IPS\Content\Item;
|
|
use IPS\Content\Search\Query as SearchQuery;
|
|
use IPS\Content\Search\Results;
|
|
use IPS\DateTime;
|
|
use IPS\Db;
|
|
use IPS\Http\Url;
|
|
use IPS\IPS;
|
|
use IPS\Log;
|
|
use IPS\Member;
|
|
use IPS\Member\Club;
|
|
use IPS\Node\Grouping;
|
|
use IPS\Settings;
|
|
use StdClass;
|
|
use function array_pop;
|
|
use function count;
|
|
use function defined;
|
|
use function in_array;
|
|
use function intval;
|
|
use function is_array;
|
|
|
|
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
|
{
|
|
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Elasticsearch Search Query
|
|
*/
|
|
class Query extends SearchQuery
|
|
{
|
|
/**
|
|
* @brief The server URL
|
|
*/
|
|
protected Url $url;
|
|
|
|
/**
|
|
* @brief Filters
|
|
*/
|
|
protected array $filters = array();
|
|
|
|
/**
|
|
* @brief "Must Not" filter
|
|
*/
|
|
protected array $mustNot = array();
|
|
|
|
/**
|
|
* @brief The sort clause
|
|
*/
|
|
protected array|string|null $sort = NULL;
|
|
|
|
/**
|
|
* @brief The offset
|
|
*/
|
|
protected int $offset = 0;
|
|
|
|
/**
|
|
* @brief index_hidden statuses
|
|
*/
|
|
protected int|array|null $hiddenStatuses = NULL;
|
|
|
|
/**
|
|
* @brief Item classes included
|
|
*/
|
|
protected array|null $itemClasses = NULL;
|
|
|
|
/**
|
|
* @brief If true, just fetch the title record, or the first comment if a post, etc
|
|
*/
|
|
protected bool $getTitleRecordOnly = false;
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param Member $member The member performing the search
|
|
* @param Url $url The server URL
|
|
* @return void
|
|
*/
|
|
public function __construct( Member $member, Url $url )
|
|
{
|
|
parent::__construct( $member );
|
|
$this->url = $url;
|
|
}
|
|
|
|
/**
|
|
* @var bool Stores whether or not any content type needs to be limited by the latest comment
|
|
*/
|
|
protected bool $lastCommentMustBeTrue = false;
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
public function filterByContent( array $contentFilters, bool $type = TRUE ): static
|
|
{
|
|
$contentFilterConditions = array();
|
|
if ( $type )
|
|
{
|
|
$this->itemClasses = array();
|
|
}
|
|
|
|
/* Loop through the filters... */
|
|
foreach ( $contentFilters as $filter )
|
|
{
|
|
$conditions = array();
|
|
if ( $type and !empty( $filter->itemClass ) )
|
|
{
|
|
$this->itemClasses[] = $filter->itemClass;
|
|
}
|
|
|
|
/* Start by specifying the classes */
|
|
$conditions[] = array(
|
|
'terms' => array(
|
|
'index_class' => $filter->classes
|
|
)
|
|
);
|
|
|
|
/* Container filer */
|
|
if ( $filter->containerIdFilter !== NULL )
|
|
{
|
|
if ( $filter->containerIdFilter )
|
|
{
|
|
if ( $filter->containerIds )
|
|
{
|
|
if ( $filter->itemClass )
|
|
{
|
|
$itemClass = $filter->itemClass;
|
|
if ( isset( $itemClass::$containerNodeClass ) )
|
|
{
|
|
$containerClass = $itemClass::$containerNodeClass;
|
|
$filter->containerIds = $containerClass::normalizeIds( $filter->containerIds );
|
|
}
|
|
}
|
|
}
|
|
|
|
$conditions[] = array(
|
|
'terms' => array(
|
|
'index_container_id' => $filter->containerIds
|
|
)
|
|
);
|
|
}
|
|
else
|
|
{
|
|
$conditions[] = array(
|
|
'bool' => array(
|
|
'must_not' => array(
|
|
'terms' => array(
|
|
'index_container_id' => $filter->containerIds
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/* Item filter */
|
|
if ( $filter->itemIdFilter !== NULL )
|
|
{
|
|
if ( $filter->itemIdFilter )
|
|
{
|
|
$conditions[] = array(
|
|
'terms' => array(
|
|
'index_item_id' => $filter->itemIds
|
|
)
|
|
);
|
|
}
|
|
else
|
|
{
|
|
$conditions[] = array(
|
|
'bool' => array(
|
|
'must_not' => array(
|
|
'terms' => array(
|
|
'index_item_id' => $filter->itemIds
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
if ( $filter->objectIdFilter !== NULL )
|
|
{
|
|
if ( $filter->objectIdFilter )
|
|
{
|
|
$conditions[] = array(
|
|
'terms' => array(
|
|
'index_object_id' => $filter->objectIds
|
|
)
|
|
);
|
|
}
|
|
else
|
|
{
|
|
$conditions[] = array(
|
|
'bool' => array(
|
|
'must_not' => array(
|
|
'terms' => array(
|
|
'index_object_id' => $filter->objectIds
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/* Minimum comments / reviews */
|
|
foreach ( array( 'minimumComments' => 'index_comments', 'minimumReviews' => 'index_reviews' ) as $filterKey => $indexKey )
|
|
{
|
|
if ( $filter->$filterKey )
|
|
{
|
|
$conditions[] = array(
|
|
'range' => array(
|
|
$indexKey => array( 'gte' => $filter->$filterKey )
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/* Only first comment? */
|
|
if ( $filter->onlyFirstComment )
|
|
{
|
|
$this->getTitleRecordOnly = true;
|
|
}
|
|
|
|
/* Only last comment? */
|
|
if ( $filter->onlyLastComment )
|
|
{
|
|
/* Set this for when the search JSON is built */
|
|
$this->lastCommentMustBeTrue = true;
|
|
|
|
$conditions[] = array(
|
|
'term' => array( 'index_is_last_comment' => true )
|
|
);
|
|
}
|
|
|
|
/* Put it together */
|
|
if( count( $conditions ) )
|
|
{
|
|
$contentFilterConditions[] = Index::convertConditionsToQuery( $conditions );
|
|
}
|
|
}
|
|
|
|
/* Put them together */
|
|
if ( count( $contentFilterConditions ) > 1 )
|
|
{
|
|
$this->filters[] = array(
|
|
'bool' => array(
|
|
( $type ? 'should' : 'must_not' ) => $contentFilterConditions
|
|
)
|
|
);
|
|
}
|
|
elseif ( $type )
|
|
{
|
|
if( count( $contentFilterConditions ) )
|
|
{
|
|
$this->filters[] = $contentFilterConditions[0];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if( count( $contentFilterConditions ) )
|
|
{
|
|
$this->mustNot[] = $contentFilterConditions[0];
|
|
}
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Filter by author
|
|
*
|
|
* @param Member|int|array $author The author, or an array of author IDs
|
|
* @return Query (for daisy chaining)
|
|
*/
|
|
public function filterByAuthor( Member|int|array $author ): static
|
|
{
|
|
if ( is_array( $author ) )
|
|
{
|
|
$this->filters[] = array( 'terms' => array( 'index_author' => $author ) );
|
|
}
|
|
else
|
|
{
|
|
$this->filters[] = array( 'term' => array( 'index_author' => $author instanceof Member ? $author->member_id : $author ) );
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Filter by club
|
|
*
|
|
* @param Club|int|array|null $club The club, or array of club IDs or NULL to exlude content from clubs
|
|
* @return Query (for daisy chaining)
|
|
*/
|
|
public function filterByClub( Club|int|array|null $club ): static
|
|
{
|
|
if ( $club === NULL )
|
|
{
|
|
$this->mustNot[] = array(
|
|
'exists' => array( 'field' => 'index_club_id' )
|
|
);
|
|
}
|
|
elseif ( is_array( $club ) )
|
|
{
|
|
$this->filters[] = array(
|
|
'terms' => array( 'index_club_id' => $club )
|
|
);
|
|
}
|
|
else
|
|
{
|
|
$this->filters[] = array(
|
|
'term' => array( 'index_club_id' => $club instanceof Club ? $club->id : $club )
|
|
);
|
|
}
|
|
|
|
/* Give content item classes a chance to inspect and manipulate filters */
|
|
$this->customFiltering( TRUE );
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Filter for profile
|
|
*
|
|
* @param Member $member The member whose profile is being viewed
|
|
* @return Query (for daisy chaining)
|
|
*/
|
|
public function filterForProfile( Member $member ): static
|
|
{
|
|
/* Filter by content they've posted or posts on their wall */
|
|
$this->filters[] = array(
|
|
'bool' => array(
|
|
'should' => array(
|
|
array(
|
|
'term' => array( 'index_author' => $member->member_id )
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
/* Give content item classes a chance to inspect and manipulate filters */
|
|
$this->customFiltering( TRUE );
|
|
|
|
/* Return for daisy-chaining */
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Stores the "more like this" Content object for search()
|
|
* @param string|null $moreLikeThis
|
|
*/
|
|
protected string|null $moreLikeThis = NULL;
|
|
|
|
/**
|
|
* Filter by more like this
|
|
* @param Content $object
|
|
* @return Query
|
|
*/
|
|
public function filterByMoreLikeThis( Content $object ): static
|
|
{
|
|
$index = new Index( $this->url );
|
|
$this->moreLikeThis = $index->getIndexId( ( $object instanceof Item and $object::$firstCommentRequired ) ? $object->firstComment() : $object );
|
|
|
|
/* Some container types cannot be cached */
|
|
$classes = [];
|
|
$conditions = [];
|
|
$noSimilarContentClasses = [];
|
|
|
|
foreach ( Application::allExtensions( 'core', 'ContentRouter', FALSE ) as $object )
|
|
{
|
|
$classes = array_merge( $object->classes, $classes );
|
|
|
|
if ( empty( $object->similarContent ) )
|
|
{
|
|
foreach( $object->classes as $class )
|
|
{
|
|
$noSimilarContentClasses[] = $class;
|
|
|
|
if ( is_subclass_of( $class, 'IPS\Content\Item' ) and isset( $class::$commentClass ) )
|
|
{
|
|
$noSimilarContentClasses[] = $class::$commentClass;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Some nodes have complex permissions, so avoid any permission issues by not selecting these */
|
|
foreach( $classes as $itemClass )
|
|
{
|
|
if ( isset( $itemClass::$containerNodeClass ) )
|
|
{
|
|
$containerClass = $itemClass::$containerNodeClass;
|
|
$blockIds = [];
|
|
if ( $customNodes = $containerClass::customPermissionNodes() )
|
|
{
|
|
foreach ( $customNodes as $key => $ids )
|
|
{
|
|
if ( $key !== 'count' )
|
|
{
|
|
$blockIds = array_merge( $blockIds, $ids );
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( count( $blockIds ) )
|
|
{
|
|
$conditions[] = array(
|
|
'bool' => array(
|
|
'filter' => array(
|
|
array(
|
|
'terms' => array( 'index_container_class' => [ $containerClass ] )
|
|
),
|
|
array(
|
|
'terms' => array( 'index_container_id' => $blockIds )
|
|
),
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
if ( count( $conditions ) )
|
|
{
|
|
foreach( $conditions as $condition )
|
|
{
|
|
$this->mustNot[] = $condition;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Prevent some things from being in similar content widget */
|
|
if ( count( $noSimilarContentClasses ) )
|
|
{
|
|
$this->mustNot[] = array(
|
|
'bool' => array(
|
|
'filter' => array(
|
|
array(
|
|
'terms' => array( 'index_class' => $noSimilarContentClasses )
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
/* Only show non-hidden and approved items */
|
|
$this->setHiddenFilter( static::HIDDEN_VISIBLE );
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
public function filterByContainerClasses( array $classes=array(), array $exclude=array() ): static
|
|
{
|
|
if( empty( $exclude ) )
|
|
{
|
|
$this->filters[] = array(
|
|
'bool' => array(
|
|
'must_not' => array(
|
|
'terms' => array( 'index_container_class' => $classes )
|
|
)
|
|
)
|
|
);
|
|
}
|
|
elseif ( $classes )
|
|
{
|
|
$this->filters[] = array(
|
|
'bool' => array(
|
|
'should' => array(
|
|
array(
|
|
'bool' => array(
|
|
'must_not' => array(
|
|
'terms' => array( 'index_container_class' => $classes ),
|
|
)
|
|
)
|
|
),
|
|
array(
|
|
'terms' => array( 'index_container_class' => $exclude ),
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Filter by item author
|
|
*
|
|
* @param Member $author The author
|
|
* @return Query (for daisy chaining)
|
|
*/
|
|
public function filterByItemAuthor( Member $author ): static
|
|
{
|
|
$this->filters[] = array(
|
|
'term' => array( 'index_item_author' => $author->member_id )
|
|
);
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
public function filterByFollowed( bool $includeContainers, bool $includeItems, bool $includeMembers ): static
|
|
{
|
|
$conditions = array();
|
|
$followApps = $followAreas = $case = $containerCase = array();
|
|
$followedItems = array();
|
|
$followedContainers = array();
|
|
|
|
/* Are we including items or containers? */
|
|
if ( $includeContainers or $includeItems )
|
|
{
|
|
/* Work out what classes we need to examine */
|
|
if ( $this->itemClasses !== NULL )
|
|
{
|
|
$classes = $this->itemClasses;
|
|
}
|
|
else
|
|
{
|
|
$classes = array();
|
|
foreach ( Application::allExtensions( 'core', 'ContentRouter', FALSE ) as $object )
|
|
{
|
|
$classes = array_merge( $object->classes, $classes );
|
|
}
|
|
}
|
|
|
|
/* Loop them */
|
|
foreach ( $classes as $class )
|
|
{
|
|
if( IPS::classUsesTrait( $class, 'IPS\Content\Followable' ) )
|
|
{
|
|
$followApps[ $class::$application ] = $class::$application;
|
|
$followArea = mb_strtolower( mb_substr( $class, mb_strrpos( $class, '\\' ) + 1 ) );
|
|
|
|
if ( $includeContainers and $includeItems )
|
|
{
|
|
$followAreas[] = mb_strtolower( mb_substr( $class::$containerNodeClass, mb_strrpos( $class::$containerNodeClass, '\\' ) + 1 ) );
|
|
$followAreas[] = $followArea;
|
|
}
|
|
elseif ( $includeItems )
|
|
{
|
|
$followAreas[] = $followArea;
|
|
}
|
|
elseif ( $includeContainers )
|
|
{
|
|
$followAreas[] = mb_strtolower( mb_substr( $class::$containerNodeClass, mb_strrpos( $class::$containerNodeClass, '\\' ) + 1 ) );
|
|
}
|
|
|
|
/* Work out what classes this applies to - need to specify comment and review classes */
|
|
if ( ! $class::$firstCommentRequired )
|
|
{
|
|
$case[ $followArea ][] = $class;
|
|
}
|
|
|
|
if( $includeContainers )
|
|
{
|
|
$containerCase[ $followArea ] = mb_strtolower( mb_substr( $class::$containerNodeClass, mb_strrpos( $class::$containerNodeClass, '\\' ) + 1 ) ) ;
|
|
}
|
|
|
|
if ( isset( $class::$commentClass ) )
|
|
{
|
|
$case[ $followArea ][] = $class::$commentClass;
|
|
}
|
|
if ( isset( $class::$reviewClass ) )
|
|
{
|
|
$case[ $followArea ][] = $class::$reviewClass;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Get the stuff we follow */
|
|
foreach( Db::i()->select( '*', 'core_follow', array( 'follow_member_id=? AND ' . Db::i()->in( 'follow_app', $followApps ) . ' AND ' . Db::i()->in( 'follow_area', $followAreas ), $this->member->member_id ) ) as $follow )
|
|
{
|
|
if( array_key_exists( $follow['follow_area'], $case ) )
|
|
{
|
|
$followedItems[ $follow['follow_area'] ][] = $follow['follow_rel_id'];
|
|
}
|
|
else if( in_array( $follow['follow_area'], $containerCase ) )
|
|
{
|
|
$followedContainers[ $follow['follow_area'] ][] = $follow['follow_rel_id'];
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach( $followedItems as $area => $item )
|
|
{
|
|
$conditions[] = array(
|
|
'bool' => array(
|
|
'filter' => array(
|
|
array(
|
|
'terms' => array( 'index_class' => $case[ $area ] )
|
|
),
|
|
array(
|
|
'terms' => array( 'index_item_id' => $item )
|
|
),
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
foreach( $followedContainers as $area => $container )
|
|
{
|
|
$indexClasses = array();
|
|
|
|
foreach( $containerCase as $followArea => $containerArea )
|
|
{
|
|
if( $containerArea == $area )
|
|
{
|
|
$indexClasses = $case[ $followArea ];
|
|
}
|
|
}
|
|
|
|
$conditions[] = array(
|
|
'bool' => array(
|
|
'filter' => array(
|
|
array(
|
|
'terms' => array( 'index_class' => $indexClasses )
|
|
),
|
|
array(
|
|
'terms' => array( 'index_container_id' => $container )
|
|
),
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
/* Are we including content posted by followed members? */
|
|
if ( $includeMembers and $followed = iterator_to_array( Db::i()->select( 'follow_rel_id', 'core_follow', array( 'follow_app=? AND follow_area=? AND follow_member_id=?', 'core', 'member', $this->member->member_id ), 'follow_rel_id asc' ) ) )
|
|
{
|
|
$conditions[] = array(
|
|
'terms' => array( 'index_author' => $followed )
|
|
);
|
|
}
|
|
|
|
/* Put it all together */
|
|
if ( count( $conditions ) )
|
|
{
|
|
$this->filters[] = array( 'bool' => array( 'should' => $conditions ) );
|
|
}
|
|
else
|
|
{
|
|
$this->filters[] = array( 'match_none' => new StdClass );
|
|
}
|
|
|
|
/* And return */
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Filter by content the user has posted in
|
|
*
|
|
* @return Query (for daisy chaining)
|
|
*/
|
|
public function filterByItemsIPostedIn(): static
|
|
{
|
|
$this->filters[] = array(
|
|
'term' => array(
|
|
'index_participants' => $this->member->member_id
|
|
)
|
|
);
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
public function filterByUnread(): static
|
|
{
|
|
/* Work out what classes we need to examine */
|
|
if ( $this->itemClasses !== NULL )
|
|
{
|
|
$classes = $this->itemClasses;
|
|
}
|
|
else
|
|
{
|
|
$classes = array();
|
|
foreach ( Application::allExtensions( 'core', 'ContentRouter', FALSE ) as $object )
|
|
{
|
|
$classes = array_merge( $object->classes, $classes );
|
|
}
|
|
}
|
|
|
|
/* Loop them */
|
|
$conditions = array();
|
|
$resetTimes = $this->member->markersResetTimes( NULL );
|
|
foreach ( $classes as $class )
|
|
{
|
|
if( IPS::classUsesTrait( $class, 'IPS\Content\ReadMarkers' ) )
|
|
{
|
|
$containerClass = $class::$containerNodeClass ?? NULL;
|
|
$classConditions = array();
|
|
|
|
/* Work out what classes this applies to - need to specify comment and review classes */
|
|
$_classes = array( $class );
|
|
if ( isset( $class::$commentClass ) )
|
|
{
|
|
$_classes[] = $class::$commentClass;
|
|
}
|
|
if ( isset( $class::$reviewClass ) )
|
|
{
|
|
$_classes[] = $class::$reviewClass;
|
|
}
|
|
$classConditions[] = array(
|
|
'terms' => array( 'index_class' => $_classes )
|
|
);
|
|
|
|
/* Get the reset times */
|
|
$classBits = explode( "\\", $class );
|
|
$application = $classBits[1];
|
|
$containerConditions = array();
|
|
$markers = array();
|
|
if ( isset( $resetTimes[ $application ] ) )
|
|
{
|
|
foreach( $resetTimes[ $application ] as $containerId => $timestamp )
|
|
{
|
|
/* Pages has different classes per database, but recorded as 'cms' and the container ID in the marking tables */
|
|
if ( $containerClass and method_exists( $containerClass, 'isValidContainerId' ) )
|
|
{
|
|
if ( ! $containerClass::isValidContainerId( $containerId ) )
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
/* Add a condition to exlude anything in this container since the last time we marked the whole thing read */
|
|
$timestamp = $timestamp ?: $this->member->marked_site_read;
|
|
$containerConditions[ $containerId ] = array(
|
|
'bool' => array(
|
|
'filter' => array(
|
|
array(
|
|
'term' => array( 'index_container_id' => $containerId )
|
|
),
|
|
array(
|
|
'range' => array( 'index_date_updated' => array( 'gt' => $timestamp ) )
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
/* And get the times each individual item was read for later */
|
|
$items = $this->member->markersItems( $application, $class::makeMarkerKey( $containerId ) );
|
|
if ( count( $items ) )
|
|
{
|
|
foreach( $items as $mid => $mtime )
|
|
{
|
|
if ( $mtime > $timestamp )
|
|
{
|
|
/* If an item has been moved from one container to another, the user may have a marker
|
|
in its old location, with the previously 'read' time. In this circumstance, we need
|
|
to only use more recent read time, otherwise the topic may be incorrectly included
|
|
in the results */
|
|
if ( in_array( $mid, $markers ) )
|
|
{
|
|
$_key = array_search( $mid, $markers );
|
|
$_mtime = intval( mb_substr( $_key, 0, mb_strpos( $_key, '.' ) ) );
|
|
if ( $_mtime < $mtime )
|
|
{
|
|
unset( $markers[ $_key ] );
|
|
}
|
|
/* If the existing timestamp is higher, retain that since we reset the $markers array below */
|
|
else
|
|
{
|
|
$mtime = $_mtime;
|
|
}
|
|
}
|
|
|
|
$markers[ $mtime . '.' . $mid ] = $mid;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ( $containerConditions )
|
|
{
|
|
$containerConditions[] = array(
|
|
'bool' => array(
|
|
'must_not' => array(
|
|
'terms' => array( 'index_container_id' => array_keys( $containerConditions ) )
|
|
),
|
|
'filter' => array(
|
|
'range' => array( 'index_date_updated' => array( 'gt' => $this->member->marked_site_read ) )
|
|
)
|
|
)
|
|
);
|
|
|
|
$classConditions[] = array(
|
|
'bool' => array(
|
|
'should' => array_values( $containerConditions )
|
|
)
|
|
);
|
|
}
|
|
else
|
|
{
|
|
$classConditions[] = array(
|
|
'range' => array( 'index_date_updated' => array( 'gt' => $this->member->marked_site_read ) )
|
|
);
|
|
}
|
|
|
|
$notIn = array();
|
|
if ( count( $markers ) )
|
|
{
|
|
$useIds = array_flip( $markers );
|
|
|
|
$dateColumns = array();
|
|
foreach ( array( 'updated', 'last_comment', 'last_review' ) as $k )
|
|
{
|
|
if ( isset( $class::$databaseColumnMap[ $k ] ) )
|
|
{
|
|
if ( is_array( $class::$databaseColumnMap[ $k ] ) )
|
|
{
|
|
foreach ( $class::$databaseColumnMap[ $k ] as $v )
|
|
{
|
|
$dateColumns[] = " IFNULL( " . $class::$databaseTable . '.'. $class::$databasePrefix . $v . ", 0 )";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$dateColumns[] = " IFNULL( " . $class::$databaseTable . '.'. $class::$databasePrefix . $class::$databaseColumnMap[ $k ] . ", 0 )";
|
|
}
|
|
}
|
|
}
|
|
$dateColumnExpression = count( $dateColumns ) > 1 ? ( 'GREATEST(' . implode( ',', $dateColumns ) . ')' ) : array_pop( $dateColumns );
|
|
|
|
foreach( Db::i()->select( $class::$databaseTable . '.' . $class::$databasePrefix . $class::$databaseColumnId. ' as _id, ' . $dateColumnExpression . ' as _date', $class::$databaseTable, Db::i()->in( $class::$databasePrefix . $class::$databaseColumnId, array_keys( $useIds ) ) ) as $row )
|
|
{
|
|
if ( isset( $useIds[ $row['_id'] ] ) )
|
|
{
|
|
if ( $useIds[ $row['_id'] ] >= $row['_date'] )
|
|
{
|
|
/* Still read */
|
|
$notIn[] = intval( $row['_id'] );
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Add it to the array */
|
|
$_condition = array(
|
|
'bool' => array(
|
|
'filter' => $classConditions
|
|
)
|
|
);
|
|
if ( count( $notIn ) )
|
|
{
|
|
$_condition['bool']['must_not'] = array(
|
|
'terms' => array(
|
|
'index_item_id' => $notIn
|
|
)
|
|
);
|
|
}
|
|
$conditions[] = $_condition;
|
|
}
|
|
}
|
|
|
|
/* Put it all together */
|
|
if ( count( $conditions ) )
|
|
{
|
|
$this->filters[] = array(
|
|
'bool' => array(
|
|
'should' => $conditions
|
|
)
|
|
);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Filter by solved
|
|
*
|
|
* @return Query (for daisy chaining)
|
|
*/
|
|
public function filterBySolved(): static
|
|
{
|
|
$this->filters[] = array(
|
|
'term' => array( 'index_item_solved' => 1 )
|
|
);
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Filter by Unsolved
|
|
*
|
|
* @return Query (for daisy chaining)
|
|
*/
|
|
public function filterByUnsolved(): static
|
|
{
|
|
$this->filters[] = array(
|
|
'term' => array( 'index_item_solved' => 0 )
|
|
);
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
public function filterByCreateDate( DateTime|null $start = null, DateTime|null $end = null ): static
|
|
{
|
|
$range = array();
|
|
|
|
if ( $start )
|
|
{
|
|
/* Numeric values are treated as milliseconds since epoch, not seconds https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html */
|
|
$range['gt'] = (string) $start->getTimestamp();
|
|
}
|
|
if ( $end )
|
|
{
|
|
/* Numeric values are treated as milliseconds since epoch, not seconds https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html */
|
|
$range['lt'] = (string) $end->getTimestamp();
|
|
}
|
|
|
|
if ( $range )
|
|
{
|
|
$this->filters[] = array(
|
|
'range' => array( 'index_date_created' => $range )
|
|
);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
public function filterByLastUpdatedDate( DateTime|null $start = null, DateTime|null $end = null ): static
|
|
{
|
|
$range = array();
|
|
|
|
if ( $start )
|
|
{
|
|
/* Numeric values are treated as milliseconds since epoch, not seconds https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html */
|
|
$range['gt'] = (string) $start->getTimestamp();
|
|
}
|
|
if ( $end )
|
|
{
|
|
/* Numeric values are treated as milliseconds since epoch, not seconds https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html */
|
|
$range['lt'] = (string) $end->getTimestamp();
|
|
}
|
|
|
|
if ( $range )
|
|
{
|
|
$this->filters[] = array(
|
|
'range' => array( 'index_date_updated' => $range )
|
|
);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Set hidden status
|
|
*
|
|
* @param int|array|NULL $statuses The statuses (see HIDDEN_ constants) or NULL for any
|
|
* @return Query (for daisy chaining)
|
|
*/
|
|
public function setHiddenFilter( int|array|null $statuses ): static
|
|
{
|
|
$this->hiddenStatuses = $statuses;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Set page
|
|
*
|
|
* @param int $page The page number
|
|
* @return Query (for daisy chaining)
|
|
*/
|
|
public function setPage( int $page ): static
|
|
{
|
|
$this->offset = ( $page - 1 ) * $this->resultsToGet;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Set order
|
|
*
|
|
* @param int $order Order (see ORDER_ constants)
|
|
* @return Query (for daisy chaining)
|
|
*/
|
|
public function setOrder( int $order ): static
|
|
{
|
|
switch ( $order )
|
|
{
|
|
case static::ORDER_NEWEST_UPDATED:
|
|
$this->sort = array( array( 'index_date_updated' => 'desc' ) );
|
|
break;
|
|
|
|
case static::ORDER_OLDEST_UPDATED:
|
|
$this->sort = array( array( 'index_date_updated' => 'asc' ) );
|
|
break;
|
|
|
|
case static::ORDER_NEWEST_CREATED:
|
|
$this->sort = array( array( 'index_date_created' => 'desc' ) );
|
|
break;
|
|
|
|
case static::ORDER_OLDEST_CREATED:
|
|
$this->sort = array( array( 'index_date_created' => 'asc' ) );
|
|
break;
|
|
|
|
case static::ORDER_NEWEST_COMMENTED:
|
|
$this->sort = array( array( 'index_date_commented' => 'desc' ) );
|
|
break;
|
|
|
|
case static::ORDER_RELEVANCY:
|
|
$this->sort = NULL;
|
|
break;
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Debug by itemId, used for debugging purposes only
|
|
* @note No permission checks run, do not use in production
|
|
* @param int $itemId
|
|
* @return array
|
|
*/
|
|
public function debugByItemId( int $itemId ): array
|
|
{
|
|
$array = array(
|
|
'query' => [
|
|
'term' => [
|
|
'index_item_id' => [
|
|
'value' => $itemId
|
|
]
|
|
]
|
|
],
|
|
'sort' => $this->sort ?: array(),
|
|
'from' => 0,
|
|
'size' => 50,
|
|
);
|
|
|
|
$json = json_encode( $array, JSON_PARTIAL_OUTPUT_ON_ERROR );
|
|
|
|
return Index::request( $this->url->setPath( $this->url->data[ Url::COMPONENT_PATH ] . '/_search' ) )->setHeaders( array( 'Content-Type' => 'application/json' ) )->get( $json )->decodeJson();
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
public function search( string|null $term = null, array|null $tags = null, int $method = 1, string|null $operator = null ): Results
|
|
{
|
|
/* If we're looking for more results than we can fetch, we don't need to ask ElasticSearch */
|
|
if( ( $this->offset + $this->resultsToGet ) > Settings::i()->search_index_maxresults )
|
|
{
|
|
return new Results( array(), 0 );
|
|
}
|
|
|
|
$operator = $operator ?: Settings::i()->search_default_operator;
|
|
$must = array();
|
|
$filters = $this->filters;
|
|
|
|
/* Set our conditions for this search */
|
|
if ( $term !== NULL or ( Settings::i()->tags_enabled AND $tags !== NULL ) )
|
|
{
|
|
$searchConditions = array();
|
|
$titleField = Settings::i()->search_title_boost ? ( 'index_title^' . intval( Settings::i()->search_title_boost ) ) : 'index_title';
|
|
|
|
/* Build the condition for the search term */
|
|
if ( $term !== NULL )
|
|
{
|
|
/* If term is in "quotes" handle it as a phrase */
|
|
if ( static::termIsPhrase( $term ) )
|
|
{
|
|
$term = trim( $term, '"' );
|
|
if ( $method & static::TERM_TITLES_ONLY )
|
|
{
|
|
$searchConditions[] = array( 'match_phrase' => array( 'index_title' => array( 'query' => $term ) ) );
|
|
|
|
/* Just get the title record only, or the first post if it's a topic as we don't want to show each comment as each comment will have the topic title in the index */
|
|
$this->getTitleRecordOnly = true;
|
|
}
|
|
else
|
|
{
|
|
$searchConditions[] = array( 'multi_match' => array( 'query' => $term, 'fields' => array( 'index_content', $titleField ), 'type' => 'phrase' ) );
|
|
}
|
|
}
|
|
/* If term contains * wildcard, but doesn't start with it (from elasticsearch docs: "In order to prevent extremely slow wildcard queries, a wildcard term should not start with one of the wildcards * or ?"), handle it as a wildcard search - NOTE this will not use the analyzer */
|
|
elseif ( preg_match( '/^[^\s\*].*\*.*$/', $term ) )
|
|
{
|
|
if ( $method & static::TERM_TITLES_ONLY )
|
|
{
|
|
$searchConditions[] = array( 'wildcard' => array( 'index_title' => array( 'value' => $term ) ) );
|
|
|
|
/* Just get the title record only, or the first post if it's a topic as we don't want to show each comment as each comment will have the topic title in the index */
|
|
$this->getTitleRecordOnly = true;
|
|
}
|
|
else
|
|
{
|
|
$searchConditions[] = array(
|
|
'bool' => array(
|
|
'should' => array(
|
|
array(
|
|
'wildcard' => array( 'index_title' => ( Settings::i()->search_title_boost ? array( 'value' => $term, 'boost' => intval( Settings::i()->search_title_boost ) ) : array( 'value' => $term ) ) )
|
|
),
|
|
array(
|
|
'wildcard' => array( 'index_content' => array( 'value' => $term ) )
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
/* Otherwise just do it as a match search */
|
|
else
|
|
{
|
|
if ( $method & static::TERM_TITLES_ONLY )
|
|
{
|
|
$searchConditions[] = array( 'match' => array( 'index_title' => array( 'query' => $term, 'operator' => $operator ) ) );
|
|
|
|
/* Just get the title record only, or the first post if it's a topic as we don't want to show each comment as each comment will have the topic title in the index */
|
|
$this->getTitleRecordOnly = true;
|
|
}
|
|
else
|
|
{
|
|
$searchConditions[] = array( 'multi_match' => array( 'query' => $term, 'fields' => array( 'index_content', $titleField ), 'operator' => $operator ) );
|
|
}
|
|
}
|
|
}
|
|
/* Build the condition for the tags */
|
|
if ( Settings::i()->tags_enabled and $tags !== NULL )
|
|
{
|
|
$searchConditions[] = array(
|
|
'bool' => array(
|
|
'should' => array(
|
|
array(
|
|
'terms' => array( 'index_tags' => $tags )
|
|
),
|
|
array(
|
|
'terms' => array( 'index_prefix' => $tags )
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
/* If we're not searching with a term, then just show the title record, not comments */
|
|
if ( $term === NULL and ! $this->lastCommentMustBeTrue )
|
|
{
|
|
$this->getTitleRecordOnly = true;
|
|
}
|
|
}
|
|
|
|
/* Put that with the rest of the conditions */
|
|
if ( $term !== NULL and $tags !== NULL )
|
|
{
|
|
if ( $method & static::TERM_OR_TAGS )
|
|
{
|
|
$must[] = array( 'bool' => array( 'should' => $searchConditions ) );
|
|
}
|
|
else
|
|
{
|
|
$must = $searchConditions;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$must[] = $searchConditions[0];
|
|
}
|
|
}
|
|
|
|
/* Only get stuff we have permission for */
|
|
$filters[] = array( 'terms' => array( 'index_permissions' => array_merge( $this->permissionArray(), array( '*' ) ) ) );
|
|
if ( $this->hiddenStatuses !== NULL )
|
|
{
|
|
if ( is_array( $this->hiddenStatuses ) )
|
|
{
|
|
$filters[] = array( 'terms' => array( 'index_hidden' => $this->hiddenStatuses ) );
|
|
}
|
|
else
|
|
{
|
|
$filters[] = array( 'term' => array( 'index_hidden' => $this->hiddenStatuses ) );
|
|
}
|
|
}
|
|
|
|
$searchUrl = $this->url->setPath( $this->url->data[ Url::COMPONENT_PATH ] . '/_search' );
|
|
|
|
if ( $this->moreLikeThis )
|
|
{
|
|
$must[] = [
|
|
'more_like_this' => [
|
|
'fields' => [ 'index_title' ],
|
|
'like' => [ '_index' => Settings::i()->search_elastic_index, '_id' => $this->moreLikeThis ],
|
|
'min_term_freq' => 1,
|
|
'max_query_terms' => 12,
|
|
'min_word_length' => 3,
|
|
]
|
|
];
|
|
|
|
/* Let's enforce a relatively short timeout to prevent slow ES server from dragging down topics */
|
|
$searchUrl = $searchUrl->setQueryString( 'timeout', '3s' );
|
|
}
|
|
|
|
/* Peform the search */
|
|
try
|
|
{
|
|
/* We only want the main record (of first comment) not the comments themselves, so we check to make sure _id (aka index_id) is the same as index_item_index_id */
|
|
if ( $this->getTitleRecordOnly )
|
|
{
|
|
$filters[] = [
|
|
'script' => [
|
|
'script' => [
|
|
'lang' => 'painless',
|
|
'source' => "doc['index_item_index_id'] == doc['index_id']"
|
|
]
|
|
]
|
|
];
|
|
}
|
|
|
|
/* Initial query */
|
|
$query = array(
|
|
'bool' => array(
|
|
'must' => $must,
|
|
'must_not' => $this->mustNot,
|
|
'filter' => $filters
|
|
)
|
|
);
|
|
|
|
/* Add the time decay */
|
|
if ( Settings::i()->search_decay_factor and !$this->sort )
|
|
{
|
|
$query = array(
|
|
'function_score' => array(
|
|
'query' => $query,
|
|
'linear' => array(
|
|
'index_date_updated' => array(
|
|
'scale' => intval( Settings::i()->search_decay_days ) . 'd',
|
|
'decay' => number_format( Settings::i()->search_decay_factor, 1, '.', '' ),
|
|
'origin' => time(),
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
/* Add the self boost */
|
|
if ( Settings::i()->search_elastic_self_boost and $this->member->member_id and !$this->sort and ! $this->moreLikeThis )
|
|
{
|
|
$query = array(
|
|
'function_score' => array(
|
|
'query' => $query,
|
|
'script_score' => array(
|
|
'script' => array(
|
|
'source' => "doc['index_author'].value == params.param_memberId ? ( _score * Float.parseFloat( params.param_booster ) ) : _score",
|
|
'lang' => 'painless',
|
|
'params' => array(
|
|
'param_memberId' => intval( $this->member->member_id ),
|
|
'param_booster' => number_format( Settings::i()->search_elastic_self_boost, 1, '.', '' )
|
|
)
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
/* Build the JSON and validate it. Use JSON_PARTIAL_OUTPUT_ON_ERROR in case someone has used an unencodable value as
|
|
the term, and check json_encode() didn't return FALSE (which it may do on error) as a sanity check against
|
|
sending a blank query, which would return everything in the index */
|
|
$array = array(
|
|
'query' => $query,
|
|
'sort' => $this->sort ?: array(),
|
|
'from' => $this->offset,
|
|
'size' => $this->resultsToGet,
|
|
);
|
|
|
|
$json = json_encode( $array, JSON_PARTIAL_OUTPUT_ON_ERROR );
|
|
if ( $json === FALSE )
|
|
{
|
|
return new Results( array(), 0 );
|
|
}
|
|
|
|
/* Make the call! */
|
|
$return = Index::request( $searchUrl )->setHeaders( array( 'Content-Type' => 'application/json' ) )->get( $json )->decodeJson();
|
|
if ( isset( $return['error'] ) )
|
|
{
|
|
Log::log( print_r( array_merge( $array, ['error' => $return['error'] ] ), TRUE ), 'elasticsearch' );
|
|
return new Results( array(), 0 );
|
|
}
|
|
|
|
/* Set results */
|
|
$total = $return['hits']['total']['value'] ?? $return['hits']['total'];
|
|
return new Results( array_map( function( $hit ) {
|
|
$indexData = $hit['_source'];
|
|
$indexData['index_permissions'] = implode( ',', $indexData['index_permissions'] );
|
|
$indexData['index_tags'] = $indexData['index_tags'] ? implode( ',', $indexData['index_tags'] ) : NULL;
|
|
return $indexData;
|
|
}, $return['hits']['hits'] ), $total <= Settings::i()->search_index_maxresults ? $total : (int) Settings::i()->search_index_maxresults );
|
|
}
|
|
catch ( Exception $e )
|
|
{
|
|
Log::log( $e, 'elasticsearch' );
|
|
return new Results( array(), 0 );
|
|
}
|
|
}
|
|
} |