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

+135
View File
@@ -0,0 +1,135 @@
<?php
/**
* @brief Search results paginated response
* @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 17 Oct 2017
*/
namespace IPS\Content\Search;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* Abstract Search Query
*/
class _ApiResponse extends \IPS\Api\PaginatedResponse
{
/**
* Data to output
*
* @return array
*/
public function getOutput()
{
/* Get the query object from the stored select */
$queryObject = $this->select[0];
/* Set pagination */
$queryObject->setLimit( $this->resultsPerPage );
$queryObject->setPage( $this->page );
/* Run query */
$searchResults = $queryObject->search(
$this->select[2],
$this->select[3],
$this->select[1] + \IPS\Content\Search\Query::TAGS_MATCH_ITEMS_ONLY,
$this->select[4]
);
$this->count = $searchResults->count( TRUE );
$results = array();
foreach ( $searchResults as $result )
{
$resultData = $result->asArray();
$indexClass = $resultData['indexData']['index_class'];
$itemClass = ( in_array( 'IPS\Content\Comment', class_parents( $indexClass ) ) ) ? $indexClass::$itemClass : $indexClass;
/* Container details */
$containerUrl = NULL;
$containerTitle = NULL;
if ( isset( $itemClass::$containerNodeClass ) )
{
$containerClass = $itemClass::$containerNodeClass;
$containerTitle = \IPS\Member::loggedIn()->language()->addToStack( $containerClass::$titleLangPrefix . $resultData['indexData']['index_container_id'], 'NULL', array( 'escape' => true ) );
$containerUrl = $containerClass::urlFromIndexData( $resultData['indexData'], $resultData['itemData'], $resultData['containerData'] );
}
/* Reputation - if we are showing the total value, then we need to load them up and total up all of the values */
if ( \IPS\Settings::i()->reaction_count_display == 'count' )
{
$repCount = 0;
foreach( $resultData['reputationData'] AS $memberId => $reactionId )
{
try
{
$repCount += \IPS\Content\Reaction::load( $reactionId )->value;
}
catch( \OutOfRangeException $e ) {}
}
}
else
{
$repCount = count( $resultData['reputationData'] );
}
$commentCount = NULL;
if( isset( $itemClass::$databaseColumnMap['num_comments'] ) )
{
$commentCount = $resultData['itemData'][ $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['num_comments'] ];
if( $itemClass::$firstCommentRequired === TRUE )
{
$commentCount -= 1;
}
}
$reviewCount = NULL;
if( isset( $itemClass::$databaseColumnMap['num_reviews'] ) )
{
$reviewCount = $resultData['itemData'][ $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['num_reviews'] ];
}
$results[] = array(
'title' => $resultData['itemData'][ $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['title'] ],
'content' => $resultData['indexData']['index_content'],
'class' => $indexClass,
'objectId' => $resultData['indexData']['index_object_id'],
'itemClass' => $itemClass,
'itemId' => $resultData['indexData']['index_item_id'],
'started' => $result->createdDate->rfc3339(),
'updated' => $result->lastUpdatedDate->rfc3339(),
'itemUrl' => (string) $itemClass::urlFromIndexData( $resultData['indexData'], $resultData['itemData'] ),
'objectUrl' => (string) $itemClass::urlFromIndexData( $resultData['indexData'], $resultData['itemData'] )->setQueryString( array( 'do' => 'findComment', 'comment' => $resultData['indexData']['index_object_id'] ) ),
'reputation' => $repCount,
'comments' => $commentCount,
'reviews' => $reviewCount,
'container' => $containerTitle,
'containerUrl' => (string) $containerUrl,
'author' => $resultData['authorData']['name'],
'authorUrl' => $resultData['authorData']['member_id'] ? (string) \IPS\Http\Url::internal( "app=core&module=members&controller=profile&id={$resultData['authorData']['member_id']}", 'front', 'profile', $resultData['authorData']['members_seo_name'] ) : NULL,
'authorPhoto' => \IPS\Member::photoUrl( $resultData['authorData'], FALSE ),
'authorPhotoThumbnail' => \IPS\Member::photoUrl( $resultData['authorData'] ),
'tags' => ( !empty( $resultData['indexData']['index_tags'] ) ) ? explode( ',', $resultData['indexData']['index_tags'] ) : array()
);
}
return array(
'page' => $this->page,
'perPage' => $this->resultsPerPage,
'totalResults' => $this->count,
'totalPages' => ceil( $this->count / $this->resultsPerPage ),
'results' => $results
);
}
}
+24
View File
@@ -58,6 +58,16 @@ class _ContentFilter
*/
public $itemIds = array();
/**
* @brief Object ID filter
*/
public $objectIdFilter = NULL;
/**
* @brief Object IDs
*/
public $objectIds = array();
/**
* @brief Minimum comments
*/
@@ -157,6 +167,20 @@ class _ContentFilter
return $this;
}
/**
* Only include results with specific IDs
*
* @param array $ids Acceptable object IDs
* @return \IPS\Content\Search\ContentFilter (for daisy chaining)
*/
public function onlyInIds( array $ids )
{
$this->objectIdFilter = TRUE;
$this->objectIds = $ids;
return $this;
}
/**
* Exclude results in items
*
+999
View File
@@ -0,0 +1,999 @@
<?php
/**
* @brief Elasticsearch Search Index
* @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 31 Oct 2017
*/
namespace IPS\Content\Search\Elastic;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* Elasticsearch Search Index
*/
class _Index extends \IPS\Content\Search\Index
{
/**
* @brief The server URL
*/
protected $url;
/**
* Constructor
*
* @param \IPS\Http\Url $url The server URL
* @return void
*/
public function __construct( \IPS\Http\Url $url )
{
$this->url = $url;
}
/**
* Initalize when first setting up
*
* @return void
*/
public function init()
{
$this->url->request()->delete();
$this->url->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->put( json_encode( array(
'mappings' => array(
'content' => array(
'properties' => array(
'index_class' => array( 'type' => 'keyword' ),
'index_object_id' => array( 'type' => 'long' ),
'index_item_id' => array( 'type' => 'long' ),
'index_container_class' => array( 'type' => 'keyword' ),
'index_container_id' => array( 'type' => 'long' ),
'index_title' => array(
'type' => 'text',
'analyzer' => \IPS\Settings::i()->search_elastic_analyzer,
),
'index_content' => array(
'type' => 'text',
'analyzer' => \IPS\Settings::i()->search_elastic_analyzer,
),
'index_permissions' => array( 'type' => 'keyword' ),
'index_date_created' => array(
'type' => 'date',
'format' => 'epoch_second',
),
'index_date_updated' => array(
'type' => 'date',
'format' => 'epoch_second',
),
'index_date_commented' => array(
'type' => 'date',
'format' => 'epoch_second',
),
'index_author' => array( 'type' => 'long' ),
'index_tags' => array( 'type' => 'keyword' ),
'index_prefix' => array( 'type' => 'keyword' ),
'index_hidden' => array( 'type' => 'byte' ),
'index_item_index_id' => array( 'type' => 'keyword' ),
'index_item_author' => array( 'type' => 'long' ),
'index_is_last_comment' => array( 'type' => 'boolean' ),
'index_club_id' => array( 'type' => 'long' ),
'index_class_type_id_hash' => array( 'type' => 'keyword' ),
'index_views' => array( 'type' => 'long' ),
'index_comments' => array( 'type' => 'long' ),
'index_reviews' => array( 'type' => 'long' ),
'index_participants' => array( 'type' => 'long' ),
)
)
)
) ) );
}
/**
* Get index data
*
* @param \IPS\Content\Searchable $object Item to add
* @return array|NULL
*/
public function indexData( \IPS\Content\Searchable $object )
{
if ( $indexData = parent::indexData( $object ) )
{
$indexData['index_permissions'] = explode( ',', $indexData['index_permissions'] );
$indexData['index_tags'] = $indexData['index_tags'] ? explode( ',', $indexData['index_tags'] ) : array();
$indexData['index_is_last_comment'] = (bool) $indexData['index_is_last_comment'];
if ( $object instanceof \IPS\Content\Item )
{
$indexData += $this->metaCounts( $object );
}
else
{
$indexData += $this->metaCounts( $object->item() );
}
return $indexData;
}
return NULL;
}
/**
* Index an item
*
* @param \IPS\Content\Searchable $object Item to add
* @return void
*/
public function index( \IPS\Content\Searchable $object )
{
if ( $indexData = $this->indexData( $object ) )
{
/* If nobody has permission to access it, just remove it */
if ( !$indexData['index_permissions'] )
{
$this->removeFromSearchIndex( $object );
}
/* Otherwise, go ahead... */
else
{
try
{
$class = get_class( $object );
/* Do not allow hidden comments to be flagged as latest */
if ( $object instanceof \IPS\Content\Comment and $indexData['index_is_last_comment'] and $indexData['index_item_id'] and $indexData['index_hidden'] !== 0 ) //
{
$indexData['index_is_last_comment'] = false;
}
/* Insert into index */
$r = $this->url
->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/' . $this->getIndexId( $object ) )
->request()
->setHeaders( array( 'Content-Type' => 'application/json' ) )
->put( json_encode( $indexData ) );
/* Views / Comments / Reviews */
if ( $object instanceof \IPS\Content\Item )
{
$item = $object;
}
elseif ( $object instanceof \IPS\Content\Comment )
{
$item = $object->item();
}
$this->rebuildMetaCounts( $item, $indexData['index_is_last_comment'] ? $this->getIndexId( $object ) : NULL );
}
catch ( \IPS\Http\Request\Exception $e )
{
\IPS\Log::log( $e, 'elasticsearch' );
}
}
}
}
/**
* Update view count
*
* @param string $class Class to update
* @param int $id ID of item
* @param int $count Count to update
* @throws \OutOfRangeException When table to update no longer exists
*/
public function updateViewCounts( $class, $id, $count )
{
$classes = array( $class );
if ( isset( $class::$commentClass ) )
{
$classes[] = $class::$commentClass;
}
if ( isset( $class::$reviewClass ) )
{
$classes[] = $class::$reviewClass;
}
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_update_by_query' )->setQueryString( 'conflicts', 'proceed' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'script' => array(
'inline' => "ctx._source.index_views += ". intval( $count ) . ";",
'lang' => 'painless'
),
'query' => array(
'bool' => array(
'must' => array(
array(
'terms' => array(
'index_class' => $classes
)
),
array(
'term' => array(
'index_item_id' => $id
)
),
)
)
)
) ) );
}
/**
* Get the comment / review counts for an item
*
* @param \IPS\Content\Searchable $item The content item
* @return void
*/
protected function metaCounts( $item )
{
$databaseColumnId = $item::$databaseColumnId;
$participants = array( $item->mapped('author') );
if ( isset( $item::$commentClass ) )
{
$commentClass = $item::$commentClass;
$participants += iterator_to_array( \IPS\Db::i()->select( 'DISTINCT ' . $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['author'], $commentClass::$databaseTable, array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['item'] . '=?', $item->$databaseColumnId ) ) );
}
if ( isset( $item::$reviewClass ) )
{
$reviewClass = $item::$reviewClass;
$participants += iterator_to_array( \IPS\Db::i()->select( 'DISTINCT ' . $reviewClass::$databasePrefix . $reviewClass::$databaseColumnMap['author'], $reviewClass::$databaseTable, array( $reviewClass::$databasePrefix . $reviewClass::$databaseColumnMap['item'] . '=?', $item->$databaseColumnId ) ) );
}
$participants = array_values( array_unique( $participants ) );
return array(
'index_views' => $item->mapped('views'),
'index_comments' => $item->mapped('num_comments'),
'index_reviews' => $item->mapped('num_reviews'),
'index_participants' => $participants
);
}
/**
* Rebuild the comment / review counts for an item
*
* @param \IPS\Content\Searchable $item The content item
* @param string $lastCommentIndexId The index ID of the last comment, if you want to reset others
* @return void
*/
protected function rebuildMetaCounts( $item, $lastCommentIndexId = NULL )
{
$databaseColumnId = $item::$databaseColumnId;
$class = get_class( $item );
$classes = array( $class );
if ( isset( $class::$commentClass ) )
{
$classes[] = $class::$commentClass;
}
if ( isset( $class::$reviewClass ) )
{
$classes[] = $class::$reviewClass;
}
try
{
$updates = array();
foreach ( $this->metaCounts( $item ) as $k => $v )
{
if ( is_array( $v ) )
{
$updates[] = "ctx._source.{$k} = [" . implode( ',', array_map( 'intval', $v ) ) . "];";
}
elseif ( is_null( $v ) )
{
$updates[] = "ctx._source.{$k} = null;";
}
else
{
$updates[] = "ctx._source.{$k} = " . intval( $v ) . ';';
}
}
if ( !is_null( $lastCommentIndexId ) )
{
$updates[] = "ctx._source.index_is_last_comment = ( ctx._id == '{$lastCommentIndexId}' );";
}
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_update_by_query' )->setQueryString( 'conflicts', 'proceed' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'script' => array(
'inline' => implode( ' ', $updates ),
'lang' => 'painless'
),
'query' => array(
'bool' => array(
'must' => array(
array(
'terms' => array(
'index_class' => $classes
)
),
array(
'term' => array(
'index_item_id' => $item->$databaseColumnId
)
),
)
)
)
) ) );
}
catch ( \IPS\Http\Request\Exception $e )
{
\IPS\Log::log( $e, 'elasticsearch' );
}
}
/**
* Retrieve the search ID for an item
*
* @param \IPS\Content\Searchable $object Item to add
* @return void
*/
public function getIndexId( \IPS\Content\Searchable $object )
{
$databaseColumnId = $object::$databaseColumnId;
return \strtolower( str_replace( '\\', '_', \substr( get_class( $object ), 4 ) ) ) . '-' . $object->$databaseColumnId;
}
/**
* Remove item
*
* @param \IPS\Content\Searchable $object Item to remove
* @return void
*/
public function removeFromSearchIndex( \IPS\Content\Searchable $object )
{
try
{
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/' . $this->getIndexId( $object ) )->request()->delete();
if ( $object instanceof \IPS\Content\Item )
{
$class = get_class( $object );
$idColumn = $class::$databaseColumnId;
if ( isset( $class::$commentClass ) )
{
$commentClass = $class::$commentClass;
$response = $this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_delete_by_query' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'query' => array(
'bool' => array(
'must' => array(
array(
'term' => array(
'index_class' => $commentClass
)
),
array(
'term' => array(
'index_item_id' => $object->$idColumn
)
),
)
)
)
) ) );
}
if ( isset( $class::$reviewClass ) )
{
$reviewClass = $class::$reviewClass;
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_delete_by_query' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'query' => array(
'bool' => array(
'must' => array(
array(
'term' => array(
'index_class' => $reviewClass
)
),
array(
'term' => array(
'index_item_id' => $object->$idColumn
)
),
)
)
)
) ) );
}
}
else
{
$this->rebuildMetaCounts( $object->item() );
}
}
catch ( \IPS\Http\Request\Exception $e )
{
\IPS\Log::log( $e, 'elasticsearch' );
}
}
/**
* Removes all content for a classs
*
* @param string $class The class
* @param int|NULL $containerId The container ID to delete, or NULL
* @param int|NULL $authorId The author ID to delete, or NULL
* @return void
*/
public function removeClassFromSearchIndex( $class, $containerId=NULL, $authorId=NULL )
{
try
{
if ( $containerId or $authorId )
{
$query = array(
'bool' => array(
'must' => array(
array(
'term' => array(
'index_class' => $class
)
)
)
)
);
if ( $containerId )
{
$query['bool']['must'][] = array(
'term' => array(
'index_container_id' => $containerId
)
);
}
if ( $authorId )
{
$query['bool']['must'][] = array(
'term' => array(
'index_author' => $authorId
)
);
}
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_delete_by_query' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'query' => $query
) ) );
}
else
{
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_delete_by_query' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'query' => array(
'term' => array(
'index_class' => $class
)
)
) ) );
}
}
catch ( \IPS\Http\Request\Exception $e )
{
\IPS\Log::log( $e, 'elasticsearch' );
}
}
/**
* Mass Update (when permissions change, for example)
*
* @param string $class The class
* @param int|NULL $containerId The container ID to update, or NULL
* @param int|NULL $itemId The item ID to update, or NULL
* @param string|NULL $newPermissions New permissions (if applicable)
* @param int|NULL $newHiddenStatus New hidden status (if applicable) special value 2 can be used to indicate hidden only by parent
* @param int|NULL $newContainer New container ID (if applicable)
* @param int|NULL $authorId The author ID to update, or NULL
* @param int|NULL $newItemId The new item ID (if applicable)
* @param int|NULL $newItemAuthorId The new item author ID (if applicable)
* @param bool $addAuthorToPermissions If true, the index_author_id will be added to $newPermissions - used when changing the permissions for a node which allows access only to author's items
* @return void
*/
public function massUpdate( $class, $containerId = NULL, $itemId = NULL, $newPermissions = NULL, $newHiddenStatus = NULL, $newContainer = NULL, $authorId = NULL, $newItemId = NULL, $newItemAuthorId = NULL, $addAuthorToPermissions = FALSE )
{
try
{
$conditions = array();
$conditions['must'][] = array(
'term' => array(
'index_class' => $class
)
);
if ( $containerId !== NULL )
{
$conditions['must'][] = array(
'term' => array(
'index_container_id' => $containerId
)
);
}
if ( $itemId !== NULL )
{
$conditions['must'][] = array(
'term' => array(
'index_item_id' => $itemId
)
);
}
if ( $authorId !== NULL )
{
$conditions['must'][] = array(
'term' => array(
'index_item_author' => $authorId
)
);
}
$updates = array();
if ( $newPermissions !== NULL )
{
$updates[] = "ctx._source.index_permissions = " . json_encode( explode( ',', $newPermissions ) ) . ";";
}
if ( $newContainer )
{
$updates[] = "ctx._source.index_container_id = " . intval( $newContainer ) . ";";
if ( $itemClass = ( in_array( 'IPS\Content\Item', class_parents( $class ) ) ? $class : $class::$itemClass ) and $containerClass = $itemClass::$containerNodeClass and \IPS\IPS::classUsesTrait( $containerClass, 'IPS\Content\ClubContainer' ) and $clubIdColumn = $containerClass::clubIdColumn() )
{
try
{
$updates[] = "ctx._source.index_club_id = " . intval( $containerClass::load( $newContainer )->$clubIdColumn ) . ";";
}
catch ( \OutOfRangeException $e )
{
$updates[] = "ctx._source.index_club_id = null;";
}
}
}
if ( $newItemId )
{
$updates[] = "ctx._source.index_item_id = " . intval( $newItemId ) . ";";
}
if ( $newItemAuthorId )
{
$updates[] = "ctx._source.index_item_author = " . intval( $newItemAuthorId ) . ";";
}
if ( count( $updates ) )
{
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_update_by_query' )->setQueryString( 'conflicts', 'proceed' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'script' => array(
'inline' => implode( ' ', $updates ),
'lang' => 'painless'
),
'query' => array(
'bool' => $conditions
)
) ) );
}
if ( $addAuthorToPermissions )
{
$addAuthorToPermissionsConditions = $conditions;
$addAuthorToPermissionsConditions['must_not'][] = array(
'term' => array(
'index_author' => 0
)
);
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_update_by_query' )->setQueryString( 'conflicts', 'proceed' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'script' => array(
'inline' => "ctx._source.index_permissions.add( 'm.' + ctx._source.index_author );",
'lang' => 'painless'
),
'query' => array(
'bool' => $conditions
)
) ) );
}
if ( $newHiddenStatus !== NULL )
{
if ( $newHiddenStatus === 2 )
{
$conditions['must'][] = array(
'term' => array(
'index_item_author' => '0'
)
);
}
else
{
$conditions['must'][] = array(
'term' => array(
'index_item_author' => '2'
)
);
}
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_update_by_query' )->setQueryString( 'conflicts', 'proceed' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'script' => array(
'inline' => "ctx._source.index_permissions = " . intval( $newHiddenStatus ) . ";",
'lang' => 'painless'
),
'query' => array(
'bool' => $conditions
)
) ) );
}
}
catch ( \IPS\Http\Request\Exception $e )
{
\IPS\Log::log( $e, 'elasticsearch' );
}
}
/**
* Convert an arbitary number of elasticsearch conditions into a query
*
* @param array $conditions Conditions
* @return array
*/
public static function convertConditionsToQuery( $conditions )
{
if ( count( $conditions ) == 1 )
{
return $conditions[0];
}
elseif ( count( $conditions ) == 0 )
{
return array( 'match_all' => new \StdClass );
}
else
{
return array(
'bool' => array(
'must' => array( $conditions )
)
);
}
}
/**
* Update data for the first and last comment after a merge
* Sets index_is_last_comment on the last comment, and, if this is an item where the first comment is indexed rather than the item, sets index_title and index_tags on the first comment
*
* @param \IPS\Content\Item $item The item
* @return void
*/
public function rebuildAfterMerge( \IPS\Content\Item $item )
{
if ( $item::$commentClass )
{
$firstComment = $item->comments( 1, 0, 'date', 'asc', NULL, FALSE, NULL, NULL, TRUE, FALSE, FALSE );
$lastComment = $item->comments( 1, 0, 'date', 'desc', NULL, FALSE, NULL, NULL, TRUE, FALSE, FALSE );
$idColumn = $item::$databaseColumnId;
$update = array( 'index_is_last_comment' => false );
if ( $item::$firstCommentRequired )
{
$update['index_title'] = NULL;
}
try
{
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/' . $this->getIndexId( $item ) . '/_update' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'doc' => $update
) ) );
if ( $firstComment )
{
$this->index( $firstComment );
}
if ( $lastComment )
{
$this->index( $lastComment );
}
}
catch ( \IPS\Http\Request\Exception $e )
{
\IPS\Log::log( $e, 'elasticsearch' );
}
}
}
/**
* Prune search index
*
* @param \IPS\DateTime|NULL $cutoff The date to delete index records from, or NULL to delete all
* @return void
*/
public function prune( \IPS\DateTime $cutoff = NULL )
{
if ( $cutoff )
{
try
{
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_delete_by_query' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'query' => array(
'range' => array(
'index_date_updated' => array(
'lt' => $cutoff->getTimestamp()
)
)
)
) ) );
}
catch ( \IPS\Http\Request\Exception $e )
{
\IPS\Log::log( $e, 'elasticsearch' );
}
}
else
{
$this->init();
}
}
/**
* Reset the last comment flag in any given class/index_item_id
*
* @param array $classes The classes (when first post is required, this is typically just \IPS\forums\Topic\Post but for others, it will be both item and comment classes)
* @param int|NULL $indexItemId The index item ID
* @return void
*/
public function resetLastComment( $classes, $indexItemId )
{
try
{
/* Remove the flag */
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_update_by_query' )->setQueryString( 'conflicts', 'proceed' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'script' => array(
'inline' => "ctx._source.index_is_last_comment = false;",
'lang' => 'painless'
),
'query' => array(
'bool' => array(
'must' => array(
array(
'terms' => array(
'index_class' => $classes
)
),
array(
'term' => array(
'index_item_id' => $indexItemId
)
),
)
)
)
) ) );
/* Get the latest comment */
$latest = $this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_search' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->get( json_encode( array(
'query' => array(
'bool' => array(
'must' => array(
array(
'terms' => array(
'index_class' => $classes
)
),
array(
'term' => array(
'index_item_id' => $indexItemId
)
),
array(
'term' => array(
'index_hidden' => 0
)
),
)
)
),
'sort' => array(
array(
'index_date_created' => 'desc'
)
),
'size' => 1
) ) )->decodeJson();
/* If we got it... */
if ( $latest['hits']['total'] )
{
$result = $latest['hits']['hits'][0];
/* Set that it is the latest comment */
$r = $this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/' . $result['_type'] . '/' . $result['_id'] . '/_update' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'doc' => array(
'index_is_last_comment' => true
)
) ) );
/* And set the updated time on the main item (done as _update_by_query because it might not exist if the first comment is required) */
$this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_update_by_query' )->setQueryString( 'conflicts', 'proceed' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->post( json_encode( array(
'script' => array(
'inline' => "ctx._source.index_date_updated = " . intval( $latest['_source']['index_date_updated'] ) ."; ctx._source.index_date_commented = " . intval( $latest['_source']['index_date_commented'] ) .";",
'lang' => 'painless'
),
'query' => array(
'bool' => array(
'must' => array(
array(
'terms' => array(
'index_class' => $classes
)
),
array(
'term' => array(
'index_item_id' => $indexItemId
)
),
array(
'term' => array(
'index_object_id' => $indexItemId
)
),
)
)
)
) ) );
}
}
catch ( \Exception $e )
{
\IPS\Log::log( $e, 'elasticsearch' );
}
}
/**
* Given a list of item index IDs, return the ones that a given member has participated in
*
* @param array $itemIndexIds Item index IDs
* @param \IPS\Member $member The member
* @return array
*/
public function iPostedIn( array $itemIndexIds, \IPS\Member $member )
{
try
{
$results = $this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_search' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->get( json_encode( array(
'query' => array(
'bool' => array(
'filter' => array(
array(
'terms' => array(
'index_item_index_id' => $itemIndexIds
),
),
array(
'term' => array(
'index_author' => $member->member_id
)
)
)
)
)
) ) )->decodeJson();
}
catch ( \Exception $e )
{
\IPS\Log::log( $e, 'elasticsearch' );
return array();
}
$iPostedIn = array();
foreach ( $results['hits']['hits'] as $result )
{
$iPostedIn[] = $result['_source']['index_item_index_id'];
}
return $iPostedIn;
}
/**
* Given a list of "index_class_type_id_hash"s, return the ones that a given member has permission to view
*
* @param array $itemIndexIds Item index IDs
* @param \IPS\Member $member The member
* @return array
*/
public function hashesWithPermission( array $hashes, \IPS\Member $member )
{
try
{
$results = $this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_search' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->get( json_encode( array(
'query' => array(
'bool' => array(
'filter' => array(
array(
'terms' => array(
'index_class_type_id_hash' => $hashes
)
),
array(
'terms' => array(
'index_permissions' => array_merge( $member->permissionArray(), array( '*' ) )
)
),
array(
'term' => array(
'index_hidden' => 0
)
)
)
)
)
) ) )->decodeJson();
}
catch ( \Exception $e )
{
\IPS\Log::log( $e, 'elasticsearch' );
return array();
}
$hashesWithPermission = array();
foreach ( $results['hits']['hits'] as $result )
{
$hashesWithPermission[ $result['_source']['index_class_type_id_hash'] ] = $result['_source']['index_class_type_id_hash'];
}
return $hashesWithPermission;
}
/**
* Get timestamp of oldest thing in index
*
* @return int|null
*/
public function firstIndexDate()
{
try
{
$results = $this->url->setPath( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ] . '/content/_search' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->get( json_encode( array(
'size' => 1,
'sort' => array( array( 'index_date_updated' => 'asc' ) )
) ) )->decodeJson();
if ( isset( $results['hits']['hits'][0] ) )
{
return $results['hits']['hits'][0]['_source']['index_date_updated'];
}
return NULL;
}
catch ( \Exception $e )
{
\IPS\Log::log( $e, 'elasticsearch' );
return NULL;
}
}
/**
* Convert terms into stemmed terms for the highlighting JS
*
* @param array $terms Terms
* @return array
*/
public function stemmedTerms( $terms )
{
try
{
$results = $this->url->setPath( '/_analyze' )->request()->setHeaders( array( 'Content-Type' => 'application/json' ) )->get( json_encode( array(
'analyzer' => \IPS\Settings::i()->search_elastic_analyzer,
'text' => implode( ' ', $terms )
) ) )->decodeJson();
if ( isset( $results['tokens'] ) )
{
$stemmed = array();
foreach ( $results['tokens'] as $token )
{
$stemmed[] = $token['token'];
}
return $stemmed;
}
return $terms;
}
catch ( \Exception $e )
{
return $terms;
}
}
}
@@ -0,0 +1,76 @@
<?php
/**
* @brief Elasticsearch Mass Indexer
* @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 15 Nov 2017
*/
namespace IPS\Content\Search\Elastic;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* Elasticsearch Mass Indexer
*/
class _MassIndexer extends \IPS\Content\Search\Elastic\Index
{
protected $data = array();
/**
* Index an item
*
* @param \IPS\Content\Searchable $object Item to add
* @return void
*/
public function index( \IPS\Content\Searchable $object )
{
if ( $indexData = $this->indexData( $object ) and $indexData['index_permissions'] )
{
if ( $object instanceof \IPS\Content\Item )
{
$parent = $object;
}
elseif ( $object instanceof \IPS\Content\Comment )
{
$parent = $object->item();
}
$this->data[] = array(
'index' => array(
'_index' => trim( $this->url->data[ \IPS\Http\Url::COMPONENT_PATH ], '/' ),
'_type' => 'content',
'_id' => $this->getIndexId( $object ),
)
);
$this->data[] = $indexData;
}
}
/**
* Commit at the end of the request
*
* @return void
*/
public function __destruct()
{
if ( $this->data )
{
$json = array();
foreach ( $this->data as $data )
{
$json[] = json_encode( $data );
}
$json = implode( "\n", $json );
$this->url->setPath( '/_bulk' )->request()->setHeaders( array( 'Content-Type' => 'application/x-ndjson' ) )->post( $json . "\n" );
}
}
}
File diff suppressed because it is too large. Load diff
+140 -27
View File
@@ -30,18 +30,53 @@ abstract class _Index extends \IPS\Patterns\Singleton
/**
* Get instance
*
* @param bool $skipCache Do not use the cached instance if one exists
* @return static
*/
public static function i()
public static function i( $skipCache=FALSE )
{
if( static::$instance === NULL )
if( static::$instance === NULL OR $skipCache === TRUE )
{
static::$instance = new \IPS\Content\Search\Mysql\Index;
if ( \IPS\Settings::i()->search_method == 'elastic' )
{
static::$instance = new \IPS\Content\Search\Elastic\Index( \IPS\Http\Url::external( \IPS\Settings::i()->search_elastic_server . '/' . \IPS\Settings::i()->search_elastic_index ) );
}
else
{
static::$instance = new \IPS\Content\Search\Mysql\Index;
}
}
return static::$instance;
}
/**
* Get mass indexer
*
* @return static
*/
public static function massIndexer()
{
if ( \IPS\Settings::i()->search_method == 'elastic' )
{
return new \IPS\Content\Search\Elastic\MassIndexer( \IPS\Http\Url::external( \IPS\Settings::i()->search_elastic_server . '/' . \IPS\Settings::i()->search_elastic_index ) );
}
else
{
return static::i();
}
}
/**
* Initalize when first setting up
*
* @return void
*/
public function init()
{
// Does nothing by default
}
/**
* Clear and rebuild search index
*
@@ -96,14 +131,14 @@ abstract class _Index extends \IPS\Patterns\Singleton
}
/* Or if this *is* the first comment, add the title and replace the tags */
$title = $object->mapped('title');
$title = $object->searchIndexTitle();
$isForItem = FALSE;
if ( $object instanceof \IPS\Content\Comment )
{
$itemClass = $class::$itemClass;
if ( $itemClass::$firstCommentRequired and $object->isFirst() )
{
$title = $object->item()->mapped('title');
$title = $object->item()->searchIndexTitle();
$tags = ( $object->item() instanceof \IPS\Content\Tags and \IPS\Settings::i()->tags_enabled ) ? implode( ',', array_filter( $object->item()->tags() ) ) : NULL;
$prefix = ( $object->item() instanceof \IPS\Content\Tags and \IPS\Settings::i()->tags_enabled ) ? $object->item()->prefix() : NULL;
$isForItem = TRUE;
@@ -114,10 +149,29 @@ abstract class _Index extends \IPS\Patterns\Singleton
if ( $isForItem )
{
$dateUpdated = $object->item()->mapped('last_comment');
$dateCommented = $object->item()->mapped('last_comment');
}
else
{
$dateUpdated = ( $object instanceof \IPS\Content\Item ) ? $object->mapped('last_comment') : $object->mapped('date');
$dateUpdated = $object->mapped('date');
$dateCommented = $object->mapped('date');
if ( $object instanceof \IPS\Content\Item )
{
foreach ( array( 'last_comment', 'last_review', 'updated' ) as $k )
{
if ( $val = $object->mapped( $k ) )
{
if ( $val > $dateUpdated )
{
$dateUpdated = $val;
}
if ( $k != 'updated' and $val > $dateCommented )
{
$dateCommented = $val;
}
}
}
}
}
/* Is the the latest content? */
@@ -182,7 +236,7 @@ abstract class _Index extends \IPS\Patterns\Singleton
}
/* Strip spoilers */
$content = $object->mapped('content');
$content = $object->searchIndexContent();
if ( preg_match( '#<div\s+?class=["\']ipsSpoiler["\']#', $content ) )
{
$content = \IPS\Text\Parser::removeElements( $content, array( 'div[class=ipsSpoiler]' ) );
@@ -211,6 +265,12 @@ abstract class _Index extends \IPS\Patterns\Singleton
{
try
{
/* If the first comment is required and there is no first comment, this is a broken piece of content - do not try to index */
if( !$object->item()->firstComment() )
{
return NULL;
}
$itemIndexId = $this->getIndexId( $object->item()->firstComment() );
}
catch ( \Exception $e ) { }
@@ -271,24 +331,27 @@ abstract class _Index extends \IPS\Patterns\Singleton
/* Return */
return array(
'index_class' => $class,
'index_object_id' => $object->$idColumn,
'index_item_id' => ( $object instanceof \IPS\Content\Item ) ? $object->$idColumn : $object->mapped('item'),
'index_container_class' => ( $object instanceof \IPS\Content\Item ) ? get_class( $object->searchIndexContainerClass() ) : get_class( $object->item()->searchIndexContainerClass() ),
'index_container_id' => ( $object instanceof \IPS\Content\Item ) ? (int) $object->searchIndexContainer() : (int) $object->item()->mapped('container'),
'index_title' => $title,
'index_content' => $content,
'index_permissions' => $object->searchIndexPermissions(),
'index_date_created' => intval( $object->mapped('date') ),
'index_date_updated' => intval( $dateUpdated ?: $object->mapped('date') ),
'index_author' => (int) $object->mapped('author'),
'index_tags' => $tags,
'index_prefix' => $prefix,
'index_hidden' => $hiddenStatus,
'index_item_index_id' => $itemIndexId,
'index_item_author' => intval( ( $object instanceof \IPS\Content\Item ) ? $object->mapped('author') : $object->item()->mapped('author') ),
'index_is_last_comment' => $isLastComment,
'index_club_id' => $clubId
'index_class' => $class,
'index_object_id' => $object->$idColumn,
'index_item_id' => ( $object instanceof \IPS\Content\Item ) ? $object->$idColumn : $object->mapped('item'),
'index_container_class' => ( $object instanceof \IPS\Content\Item ) ? ( $object->searchIndexContainerClass() ? get_class( $object->searchIndexContainerClass() ) : NULL ) : get_class( $object->item()->searchIndexContainerClass() ),
'index_container_id' => ( $object instanceof \IPS\Content\Item ) ? (int) $object->searchIndexContainer() : (int) $object->item()->mapped('container'),
'index_title' => $title,
'index_content' => $content,
'index_permissions' => $object->searchIndexPermissions(),
'index_date_created' => intval( $object->mapped('date') ),
'index_date_updated' => intval( $dateUpdated ),
'index_date_commented' => intval( $dateCommented ),
'index_author' => (int) $object->mapped('author'),
'index_tags' => $tags,
'index_prefix' => $prefix,
'index_hidden' => $hiddenStatus,
'index_item_index_id' => $itemIndexId,
'index_item_author' => intval( ( $object instanceof \IPS\Content\Item ) ? $object->mapped('author') : $object->item()->mapped('author') ),
'index_is_last_comment' => $isLastComment,
'index_club_id' => $clubId,
'index_class_type_id_hash' => md5( $class . ':' . $object->$idColumn )
);
}
@@ -300,6 +363,19 @@ abstract class _Index extends \IPS\Patterns\Singleton
*/
abstract public function index( \IPS\Content\Searchable $object );
/**
* Update view count
*
* @param string $class Class to update
* @param int $id ID of item
* @param int $count Count to update
* @throws \OutOfRangeException When table to update no longer exists
*/
public function updateViewCounts( $class, $id, $count )
{
// Do nothing by default
}
/**
* Retrieve the search ID for an item
*
@@ -320,10 +396,11 @@ abstract class _Index extends \IPS\Patterns\Singleton
* Removes all content for a classs
*
* @param string $class The class
* @param int|NULL $containerId The container ID to update, or NULL
* @param int|NULL $containerId The container ID to delete, or NULL
* @param int|NULL $authorId The author ID to delete, or NULL
* @return void
*/
abstract public function removeClassFromSearchIndex( $class, $containerId=NULL );
abstract public function removeClassFromSearchIndex( $class, $containerId=NULL, $authorId=NULL );
/**
* Removes all content for a specific application from the index (for example, when uninstalling).
@@ -405,4 +482,40 @@ abstract class _Index extends \IPS\Patterns\Singleton
* @return void
*/
abstract public function resetLastComment( $class, $indexItemId );
/**
* Given a list of item index IDs, return the ones that a given member has participated in
*
* @param array $itemIndexIds Item index IDs
* @param \IPS\Member $member The member
* @return array
*/
abstract public function iPostedIn( array $itemIndexIds, \IPS\Member $member );
/**
* Given a list of "index_class_type_id_hash"s, return the ones that a given member has permission to view
*
* @param array $itemIndexIds Item index IDs
* @param \IPS\Member $member The member
* @return array
*/
abstract public function hashesWithPermission( array $hashes, \IPS\Member $member );
/**
* Get timestamp of oldest thing in index
*
* @return int|null
*/
abstract public function firstIndexDate();
/**
* Convert terms into stemmed terms for the highlighting JS
*
* @param array $terms Terms
* @return array
*/
public function stemmedTerms( $terms )
{
return $terms;
}
}
+70 -14
View File
@@ -22,6 +22,30 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
*/
class _Index extends \IPS\Content\Search\Index
{
/**
* Get index data
*
* @param \IPS\Content\Searchable $object Item to add
* @return array|NULL
*/
public function indexData( \IPS\Content\Searchable $object )
{
$indexData = parent::indexData( $object );
if( $indexData === NULL )
{
return $indexData;
}
/* The index_title in core_search_index is varchar(255) so we have to limit to this length */
if( $indexData['index_title'] !== NULL )
{
$indexData['index_title'] = mb_substr( $indexData['index_title'], 0, 255 );
}
return $indexData;
}
/**
* Index an item
*
@@ -37,9 +61,6 @@ class _Index extends \IPS\Content\Search\Index
/* If we got the data... */
if( $indexData )
{
/* MySQL specific look up hash for easy referencing across tables */
$indexData['index_class_type_id_hash'] = md5( $indexData['index_class'] . ':' . $indexData['index_object_id'] );
/* If nobody has permission to access it, just remove it */
if ( !$indexData['index_permissions'] )
{
@@ -188,13 +209,6 @@ class _Index extends \IPS\Content\Search\Index
\IPS\Db::i()->update( 'core_search_index', array( 'index_item_index_id' => $newIndexId ), array( \IPS\Db::i()->in( 'index_class', array( $class, $class::$itemClass ) ) . ' AND index_item_id=?', $object->$itemColumnId ) );
}
}
/* And also set core_follow.follow_index_id */
if ( $object instanceof \IPS\Content\Comment )
{
$class = $class::$itemClass;
}
\IPS\Db::i()->update( 'core_follow', array( 'follow_index_id' => $newIndexId ), array( 'follow_app=? AND follow_area=? AND follow_rel_id=?', $object::$application, mb_strtolower( mb_substr( $class, mb_strrpos( $class, '\\' ) + 1 ) ), $object->$databaseColumnId ) );
}
}
@@ -318,17 +332,22 @@ class _Index extends \IPS\Content\Search\Index
/**
* Removes all content for a classs
*
* @param string $class The class
* @param int|NULL $containerId The container ID to update, or NULL
* @param string $class The class
* @param int|NULL $containerId The container ID to delete, or NULL
* @param int|NULL $authorId The author ID to delete, or NULL
* @return void
*/
public function removeClassFromSearchIndex( $class, $containerId=NULL )
public function removeClassFromSearchIndex( $class, $containerId=NULL, $authorId=NULL )
{
$where = array( array( 'index_class=?', $class ) );
if ( $containerId !== NULL )
{
$where[] = array( 'index_container_id=?', $containerId );
}
if ( $authorId !== NULL )
{
$where[] = array( 'index_author=?', $authorId );
}
\IPS\Db::i()->delete( 'core_search_index_item_map', array( 'index_class=? and index_item_id IN( ? )', $class, \IPS\Db::i()->select( 'index_item_id', 'core_search_index', $where ) ) );
\IPS\Db::i()->delete( 'core_search_index_tags', array( 'index_id IN( ? )', \IPS\Db::i()->select( 'index_id', 'core_search_index', $where ) ) );
@@ -494,8 +513,45 @@ class _Index extends \IPS\Content\Search\Index
\IPS\Db::i()->update( 'core_search_index', array( 'index_is_last_comment' => 1 ), array( 'index_class=? AND index_object_id=?', $latest['index_class'], $latest['index_object_id'] ) );
/* Now reset the item index with the latest comment time */
\IPS\Db::i()->update( 'core_search_index', array( 'index_date_updated' => $latest['index_date_updated'] ), array( \IPS\Db::i()->in( 'index_class', $classes ) . ' AND index_item_id=? AND index_object_id=?', $indexItemId, $indexItemId ) );
\IPS\Db::i()->update( 'core_search_index', array( 'index_date_updated' => $latest['index_date_updated'], 'index_date_commented' => $latest['index_date_commented'] ), array( \IPS\Db::i()->in( 'index_class', $classes ) . ' AND index_item_id=? AND index_object_id=?', $indexItemId, $indexItemId ) );
}
catch( \Exception $ex ) { }
}
/**
* Given a list of item index IDs, return the ones that a given member has participated in
*
* @param array $itemIndexIds Item index IDs
* @param \IPS\Member $member The member
* @return array
*/
public function iPostedIn( array $itemIndexIds, \IPS\Member $member )
{
return iterator_to_array( \IPS\Db::i()->select( 'index_item_index_id', 'core_search_index', array( array( \IPS\Db::i()->in( 'index_item_index_id', $itemIndexIds ) ), array( 'index_author=?', $member->member_id ) ) )->setKeyField('index_item_index_id') );
}
/**
* Given a list of "index_class_type_id_hash"s, return the ones that a given member has permission to view
*
* @param array $itemIndexIds Item index IDs
* @param \IPS\Member $member The member
* @return array
*/
public function hashesWithPermission( array $hashes, \IPS\Member $member )
{
return iterator_to_array( \IPS\Db::i()->select( 'index_class_type_id_hash', array( 'core_search_index', 'si' ), array(
array( "( si.index_permissions = '*' OR " . \IPS\Db::i()->findInSet( 'si.index_permissions', \IPS\Member::loggedIn()->permissionArray() ) . ' AND si.index_hidden=0 )' ),
array( \IPS\Db::i()->in( 'si.index_class_type_id_hash', $hashes ) )
) )->setKeyField('index_class_type_id_hash') );
}
/**
* Get timestamp of oldest thing in index
*
* @return int|null
*/
public function firstIndexDate()
{
return \IPS\Db::i()->select( 'MIN(index_date_updated)', 'core_search_index' )->first();
}
}
+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 );
}
}
+39 -72
View File
@@ -22,12 +22,14 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
*/
abstract class _Query
{
const TERM_OR_TAGS = 1;
const TERM_AND_TAGS = 2;
const TERM_OR_MODE = 4;
const TERM_TITLES_ONLY = 8;
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;
@@ -38,6 +40,7 @@ abstract class _Query
const ORDER_RELEVANCY = 3;
const ORDER_OLDEST_UPDATED = 4;
const ORDER_OLDEST_CREATED = 5;
const ORDER_NEWEST_COMMENTED = 6;
const SUPPORTS_JOIN_FILTERS = TRUE;
@@ -49,7 +52,14 @@ abstract class _Query
*/
public static function init( \IPS\Member $member = NULL )
{
return new \IPS\Content\Search\Mysql\Query( $member ?: \IPS\Member::loggedIn() );
if ( \IPS\Settings::i()->search_method == 'elastic' )
{
return new \IPS\Content\Search\Elastic\Query( $member ?: \IPS\Member::loggedIn(), \IPS\Http\Url::external( \IPS\Settings::i()->search_elastic_server . '/' . \IPS\Settings::i()->search_elastic_index ) );
}
else
{
return new \IPS\Content\Search\Mysql\Query( $member ?: \IPS\Member::loggedIn() );
}
}
/**
@@ -131,6 +141,16 @@ abstract class _Query
*/
abstract public function filterByItemAuthor( \IPS\Member $author );
/**
* 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 \IPS\Content\Search\Query (for daisy chaining)
*/
abstract public function filterByContainerClasses( $classes=array(), $exclude=array() );
/**
* Filter by content the user follows
*
@@ -223,12 +243,13 @@ abstract class _Query
/**
* 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
* @return \Traversable
* @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 \IPS\Content\Search\Results
*/
abstract public function search( $term = NULL, $tags = NULL, $method = 1 );
abstract public function search( $term = NULL, $tags = NULL, $method = 1, $operator = NULL );
/**
* Get the default date cut off
@@ -253,14 +274,15 @@ abstract class _Query
/**
* 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 \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 int
*/
public function count( $term = NULL, $tags = NULL, $method = 1 )
public function count( $term = NULL, $tags = NULL, $method = 1, $operator = NULL )
{
return $this->search( $term, $tags, $method )->count( TRUE );
return $this->search( $term, $tags, $method, $operator )->count( TRUE );
}
/**
@@ -285,63 +307,8 @@ abstract class _Query
*/
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;
}
}
$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 ) )
+54
View File
@@ -22,6 +22,29 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
*/
abstract class _Result
{
/**
* Pre-Display logic for search index content
*
* @param bool $createdDate If TRUE, uses $createdDate, otherwise uses $lastUpdatedDate
* @return string
*/
public static function preDisplay( $content )
{
/* We cannot use images (or *anything* other than text) in search results as it messes with the truncation
logic. All HTML (including emoticons) are already stripped before they go into the search index
for this reason, but Emoji (because they're text) won't be. We can't swap the Emoji back out
with one of the alternative Emoji style images though because that re-introduces images, so we
just strip them out (making them behave like emoticons) */
if ( \IPS\Settings::i()->emoji_style == 'twemoji' or \IPS\Settings::i()->emoji_style == 'emojione' )
{
return preg_replace( '/(?:' . \IPS\Text\Parser::EMOJI_REGEX . '|\x{200d})+/u', '', $content );
}
else
{
return $content;
}
}
/**
* @brief Created Date
*/
@@ -68,4 +91,35 @@ abstract class _Result
return 'earlier';
}
}
/**
* Get output for API
*
* @param \IPS\Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials
* @return array
* @apiresponse string title Title of search result
* @apiresponse string content Content of search result
* @apiresponse string class Content class of search result
* @apiresponse int objectId Content ID of search result
* @apiresponse string itemClass Content item class of search result (if search result is of a content item, this will match class)
* @apiresponse int itemId Content item ID of search result (if search result is of a content item, this will match objectId)
* @apiresponse datetime started Datetime search result was submitted
* @apiresponse datetime updated Datetime search result was last updated
* @apiresponse string itemUrl URL to content item
* @apiresponse string objectUrl URL to search result item (if search result is of a content item, this will match itemUrl)
* @apiresponse int reputation Number of reputation points for search result
* @apiresponse int|null comments Number of comments or replies for search result, or NULL if commenting is not supported
* @apiresponse int|null reviews Number of reviews for search result, or NULL if reviewing is not supported
* @apiresponse string container Title of container of search result
* @apiresponse string containerUrl URL to container of search result
* @apiresponse string author Author name of search result
* @apiresponse string|null authorUrl URL to author's profile, or NULL if search result was submitted by a guest
* @apiresponse string authorPhoto URL to author's profile photo
* @apiresponse string authorPhotoThumbnail URL to author's profile photo thumbnail
* @apiresponse array tags Array of tags associated with the search result
*/
public function apiOutput( \IPS\Member $authorizedMember = NULL )
{
/* @note This is only here to populate the AdminCP reference tab - the array of search result entries is built manually */
}
}
+21 -14
View File
@@ -171,7 +171,7 @@ class _Results extends \ArrayIterator
{
$this->itemData[ $itemClass ][ $itemId ] = $itemData;
if ( isset( $itemClass::$databaseColumnMap ) and isset( $itemData[ $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['author'] ] ) )
if ( isset( $itemClass::$databaseColumnMap ) and isset( $itemClass::$databaseColumnMap['author'] ) and isset( $itemData[ $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['author'] ] ) )
{
$memberId = $itemData[ $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['author'] ];
$membersToLoad[ $memberId ] = $memberId;
@@ -236,16 +236,16 @@ class _Results extends \ArrayIterator
/* Load data for the "I posted in this" stars */
if ( count( $itemIndexIds ) and \IPS\Member::loggedIn()->member_id )
{
$this->iPostedIn = iterator_to_array( \IPS\Db::i()->select( 'index_item_index_id', 'core_search_index', array( array( \IPS\Db::i()->in( 'index_item_index_id', $itemIndexIds ) ), array( 'index_author=?', \IPS\Member::loggedIn()->member_id ) ) )->setKeyField('index_item_index_id') );
$this->iPostedIn = \IPS\Content\Search\Index::i()->iPostedIn( $itemIndexIds, \IPS\Member::loggedIn() );
}
/* Load tags and prefixes */
if ( count( $itemIndexIds ) )
if ( count( $itemIndexIds ) and \IPS\Settings::i()->search_method == 'mysql' )
{
$this->tags = iterator_to_array( \IPS\Db::i()->select( 'index_id, GROUP_CONCAT(index_tag) as index_tags', 'core_search_index_tags', array( array( \IPS\Db::i()->in( 'index_id', $itemIndexIds ) ), array( 'index_is_prefix = 0' ) ), NULL, NULL, 'index_id' )->setKeyField('index_id') );
$this->prefixes = iterator_to_array( \IPS\Db::i()->select( 'index_id, index_tag as index_prefix', 'core_search_index_tags', array( array( \IPS\Db::i()->in( 'index_id', $itemIndexIds ) ), array( 'index_is_prefix = 1' ) ) )->setKeyField('index_id') );
}
/* Set that we're initiated */
$this->initated = TRUE;
}
@@ -280,7 +280,8 @@ class _Results extends \ArrayIterator
{
/* This should not happen in production, so debug log and move on to the next item */
\IPS\Log::debug( "index_item_id ID " . $data['index_item_id'] . " for " . $itemClass . " has no itemData" , 'searchResults' );
return static::next();
static::next();
return static::current();
}
$itemData = $this->itemData[ $itemClass ][ $data['index_item_id'] ];
@@ -288,7 +289,7 @@ class _Results extends \ArrayIterator
{
$itemData['profile'] = $this->authorData[ $data['index_container_id'] ];
}
if ( isset( $itemData[ $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['author'] ] ) )
if ( isset( $itemClass::$databaseColumnMap['author'] ) and isset( $itemData[ $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['author'] ] ) )
{
$memberId = $itemData[ $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['author'] ];
if ( isset( $this->authorData[ $memberId ] ) )
@@ -301,7 +302,7 @@ class _Results extends \ArrayIterator
{
$data['index_tags'] = $this->tags[ $data['index_item_index_id'] ]['index_tags'];
}
if ( isset( $this->prefixes[ $data['index_item_index_id'] ]) )
{
$data['index_prefix'] = $this->prefixes[ $data['index_item_index_id'] ]['index_prefix'];
@@ -432,7 +433,7 @@ class _Results extends \ArrayIterator
/* Users registered */
if ( in_array( 'register', $extra ) )
{
$where = array( array( 'joined>?', $lastTime ), array( 'name<>?', '' ) );
$where = array( array( 'joined>?', $lastTime ), array( '( name<>? and email<>? )', '', '' ), array( 'temp_ban!=?', '-1' ) );
if ( $firstTime )
{
$where[] = array( 'joined<?', $firstTime );
@@ -441,12 +442,18 @@ class _Results extends \ArrayIterator
{
$where[] = array( 'core_members.member_id=?', $author->member_id );
}
foreach ( \IPS\Db::i()->select( 'core_members.member_id, core_members.name, core_members.members_seo_name, core_members.joined, core_validating.new_reg', 'core_members', $where, 'joined DESC', 10 )
->join( 'core_validating', 'core_validating.member_id=core_members.member_id' ) as $member )
{
foreach (
\IPS\Db::i()->select( implode( ', ', array_merge( array_map( function( $c ) { return "core_members.{$c}"; }, \IPS\Member::columnsForPhoto() ), array( 'core_members.joined', 'core_validating.new_reg' ) ) ), 'core_members', $where, 'joined DESC', 10 )
->join( 'core_validating', 'core_validating.member_id=core_members.member_id' )
as $member ) {
if ( empty( $member['new_reg'] ) )
{
$extraItems[] = new Result\Custom( \IPS\DateTime::ts( $member['joined'] ), \IPS\Member::loggedIn()->language()->addToStack( 'activity_member_joined', FALSE, array( 'htmlsprintf' => \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' )->userLinkFromData( $member['member_id'], $member['name'], $member['members_seo_name'] ) ) ) );
$extraItems[] = new Result\Custom(
\IPS\DateTime::ts( $member['joined'] ),
\IPS\Member::loggedIn()->language()->addToStack( 'activity_member_joined', FALSE, array( 'htmlsprintf' => \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' )->userLinkFromData( $member['member_id'], $member['name'], $member['members_seo_name'] ) ) ),
\IPS\Theme::i()->getTemplate( 'global', 'core', 'front' )->userPhotoFromData( $member['member_id'], $member['name'], $member['members_seo_name'], \IPS\Member::photoUrl( $member ), 'tiny' )
);
}
}
}
@@ -454,7 +461,7 @@ class _Results extends \ArrayIterator
/* Users changed photo */
if ( in_array( 'photo', $extra ) )
{
$where = array( array( 'photo_last_update>?', $lastTime ) );
$where = array( array( 'photo_last_update>? AND photo_last_update>(joined+300)', $lastTime ), array( '( name<>? and email<>? )', '', '' ) );
if ( $firstTime )
{
$where[] = array( 'photo_last_update<?', $firstTime );
@@ -525,7 +532,7 @@ class _Results extends \ArrayIterator
}
else
{
$class = 'IPS\\' . $follow['follow_app'] . '\\' . ucfirst( $follow['follow_area'] );
$class = 'IPS\\' . $follow['follow_app'] . '\\' . mb_ucfirst( $follow['follow_area'] );
try
{