Version 5.0.13

This commit is contained in:
Neo committed 2025-12-19 19:04:57 -08:00
1 parent b89858b2ff
commit 5efe744c5f
1407 files changed
+69690 -30177

No files matched your search

+242 -122
View File
@@ -74,9 +74,8 @@ use OutOfRangeException;
use RuntimeException;
use SplObserver;
use SplSubject;
use UnderflowException;
use function array_reverse;
use function array_slice;
use function class_implements;
use function count;
use function defined;
use function explode;
@@ -88,7 +87,9 @@ use function is_array;
use function is_null;
use function is_string;
use function json_decode;
use const IPS\LARGE_TOPIC_LOCK;
use const IPS\LARGE_TOPIC_REPLIES;
use const IPS\LARGE_TOPIC_WARNING;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
@@ -129,6 +130,7 @@ class Topic extends Item implements Embeddable,
ReadMarkers::markRead as public _markRead;
Reactable::reactionClass as public _reactionClass;
Solvable::toggleSolveComment as protected _toggleSolveComment;
Lockable::canUnlock as protected _canUnlock;
}
/**
@@ -219,7 +221,8 @@ class Topic extends Item implements Embeddable,
'is_future_entry' => 'is_future_entry',
'future_date' => 'publish_date',
'last_vote' => 'last_vote',
'assigned' => 'assignment_id'
'assigned' => 'assignment_id',
'num_helpful' => 'helpful_count'
);
/**
@@ -919,6 +922,67 @@ class Topic extends Item implements Embeddable,
{
return static::_canCreatePoll( $member, $container ) and ( $container === NULL or $container->allow_poll );
}
/**
* Can unlock?
*
* @param Member|NULL $member The member to check for (NULL for currently logged in member)
* @return bool
*/
public function canUnlock( ?Member $member=NULL ): bool
{
/* Intentionally doing an override instead of adding to actionEnabled()
because we want to bypass any Permissions extension here. Never, ever, ever let
large topics be unlocked! */
if( $this->isLargeTopic() )
{
return false;
}
return $this->_canUnlock( $member );
}
/**
* Can Merge?
*
* @param Member|null $member The member to check for (NULL for currently logged in member)
* @return bool
*/
public function canMerge( Member|null $member=NULL ): bool
{
/* Same idea here for merging. We cannot merge into a large topic,
not even by extension override */
if( $this->isLargeTopic() )
{
return false;
}
/* And just to be safe, don't allow it if we are even approaching the threshold */
if( $this->postsToClose() )
{
return false;
}
return parent::canMerge( $member );
}
/**
* Can comment?
*
* @param Member|NULL $member The member (NULL for currently logged in member)
* @param bool $considerPostBeforeRegistering If TRUE, and $member is a guest, will return TRUE if "Post Before Registering" feature is enabled
* @return bool
*/
public function canComment( ?Member $member=NULL, bool $considerPostBeforeRegistering = TRUE ): bool
{
/* Don't care who you are, NO COMMENTS FOR YOU */
if( $this->isLargeTopic() and $this->locked() )
{
return false;
}
return parent::canComment( $member, $considerPostBeforeRegistering );
}
/**
* SplObserver notification that poll has been voted on
@@ -971,7 +1035,8 @@ class Topic extends Item implements Embeddable,
}
}
if ( Bridge::i()->featureIsEnabled( 'realtime' ) )
/* Do we need a hash for who's viewing and/or who's typing? */
if ( Bridge::i()->featureIsEnabled( 'live_full' ) )
{
$hash = [
'app' => 'forums',
@@ -1009,7 +1074,6 @@ class Topic extends Item implements Embeddable,
}
parent::move( $container, $keepLink );
Db::i()->update( 'forums_question_ratings', array( 'forum' => $container->_id ), array( 'topic=?', $this->tid ) );
/* While you can't normally move archived topics, when you mass manage content from the AdminCP by using the menu next to the forum,
this still allows topics to be moved. If we don't update the archive forum database the forum counts will be off */
@@ -1043,11 +1107,11 @@ class Topic extends Item implements Embeddable,
catch ( Exception $e ){}
}
Db::i()->delete( 'forums_question_ratings', array( 'topic=?', $this->tid ) );
Db::i()->delete( 'forums_answer_ratings', array( 'topic=?', $this->tid ) );
/* Delete any moved topic links that point to this topic - moved_on>? is for query optimisation purposes */
Db::i()->delete( 'forums_topics', array( "moved_on>? AND moved_to LIKE CONCAT( ?, '%' ) AND state=?", 0, $this->tid . '&', 'link' ) );
/* Delete any parent/child relationsihps */
Db::i()->delete( 'forums_topics_children', [ 'topic_id=? or parent_topic=?', $this->tid, $this->tid ] );
}
/**
@@ -1299,6 +1363,12 @@ class Topic extends Item implements Embeddable,
$member = $member ?: Member::loggedIn();
/* Guests can never do this */
if( !$member->member_id )
{
return false;
}
/* If we asked this question, we can set the best answer */
if ( $member === $this->author() and $this->container()->forums_bitoptions['bw_solved_set_by_member'] )
{
@@ -1327,31 +1397,6 @@ class Topic extends Item implements Embeddable,
return FALSE;
}
/**
* @brief Answer Votes
*/
protected array $answerVotes = array();
/**
* Answer Votes
*
* @param Member $member The member
* @return array
*/
public function answerVotes( Member $member ) : array
{
if ( !isset( $this->answerVotes[ $member->member_id ] ) )
{
$this->answerVotes[ $member->member_id ] = iterator_to_array(
Db::i()->select( 'post,rating', 'forums_answer_ratings', array( 'topic=? AND `member`=?', $this->tid, $member->member_id ) )
->setKeyField( 'post' )
->setValueField( 'rating' )
);
}
return $this->answerVotes[ $member->member_id ];
}
/**
* Get Best Answer
*
@@ -1370,74 +1415,6 @@ class Topic extends Item implements Embeddable,
return NULL;
}
/**
* Can the user rate answers?
*
* @param int $rating 1 for positive, -1 for negative, 0 for either
* @param Member|NULL $member The member (NULL for currently logged in member)
* @return bool
* @throws InvalidArgumentException
*/
public function canVote( int $rating=0, ?Member $member=NULL ) : bool
{
/* Is $rating valid */
if ( !in_array( $rating, array( -1, 0, 1 ) ) )
{
throw new InvalidArgumentException;
}
/* Guests can't vote */
$member = $member ?: Member::loggedIn();
if ( !$member->member_id )
{
return FALSE;
}
/* Can't vote your own answers */
if ( $member === $this->author() )
{
return FALSE;
}
return TRUE;
}
/**
* @brief Votes
*/
protected ?array $votes = NULL;
/**
* Votes
*
* @return array
*/
public function votes() : array
{
if ( $this->votes === NULL )
{
$this->votes = iterator_to_array(
Db::i()->select( '`member`,rating', 'forums_question_ratings', array( 'topic=?', $this->tid ) )
->setKeyField( 'member' )
->setValueField( 'rating' )
);
}
return $this->votes;
}
/**
* Clear Votes Cache
*
* @return void
* @note This is necessary so that when voting on a question or answer, the cached votes ($votes and $answerVotes) are reloaded properly.
*/
public function clearVotes() : void
{
$this->votes = NULL;
$this->answerVotes = array();
}
/**
* Container has assignable enabled
*
@@ -1447,17 +1424,6 @@ class Topic extends Item implements Embeddable,
{
return (bool) $this->container()->forums_bitoptions['bw_enable_assignments'];
}
/**
* [ActiveRecord] Save
*
* @return void
*/
public function save(): void
{
parent::save();
$this->clearVotes();
}
/* !Sitemap */
@@ -1698,22 +1664,33 @@ class Topic extends Item implements Embeddable,
$pagePercent = ceil( ( $page / $this->commentPageCount() ) * 100 );
$commentCount = $this->commentCount();
if ( $pagePercent > 50 )
if ( $pagePercent >= 50 )
{
$postsOnLastPage = $commentCount % static::getCommentsPerPage();
$pageFromEnd = $this->commentPageCount() - $page;
$offset = $pageFromEnd > 1 ? ( $pageFromEnd - 1 ) * static::getCommentsPerPage() : 0;
if ( $pageFromEnd > 1 )
{
$offset = ( $pageFromEnd - 1 ) * static::getCommentsPerPage();
/* We need to adjust the offset to be from the end of the topic keeping in mind the last page may have fewer posts than a full 25 */
$offset += $postsOnLastPage ?: static::getCommentsPerPage();
}
elseif ( $pageFromEnd == 1 )
{
/* $postsOnLastPage might be 0 if we have exactly the amount of comments on the last page.
So if we are one page from the end AND $postsOnLastPage is 0, then just use the number of comments per page. */
$offset = $postsOnLastPage ?: static::getCommentsPerPage();
}
else
{
$offset = 0;
}
/* Are we on the last page, if so the limit should be on the number of posts on the last page */
if ( $page == $this->commentPageCount() )
{
$limit = $postsOnLastPage;
}
else
{
/* We need to adjust the offset to be from the end of the topic keeping in mind the last page may have fewer posts than a full 25 */
$offset += $postsOnLastPage;
}
$data = $this->_comments( $class, $limit ?: $this->getCommentsPerPage(), $offset, ( isset( $class::$databaseColumnMap[$order] ) ? ( $class::$databasePrefix . $class::$databaseColumnMap[$order] ) : $order ) . ' desc', $member, $includeHiddenComments, $cutoff, $canViewWarn, $extraWhereClause, $includeDeleted );
$comments[ $_hash ] = is_array( $data ) ? array_reverse( $data, true ) : $data;
@@ -1793,7 +1770,7 @@ class Topic extends Item implements Embeddable,
try
{
$row = $class::db()->select( "{$minMax}( CONCAT( post_date, '.', pid) ) as pid", 'forums_posts', $pidWhere )->first();
$row = $class::db()->select( "{$minMax}( CONCAT( post_date, '.', pid) ) as pid", 'forums_posts', $pidWhere, flags: ( $bypassCache ) ? Db::SELECT_FROM_WRITE_SERVER : 0 )->first();
$pid = (int) explode( '.', $row )[1];
if( \is_array( $extraWhereClause ) or is_null( $extraWhereClause ) )
@@ -1808,7 +1785,7 @@ class Topic extends Item implements Embeddable,
];
}
}
catch( \UnderflowException ) { }
catch( UnderflowException ) { }
}
$comments[ $_hash ] = $this->_comments( $class, $originalLimit ?: static::getCommentsPerPage(), $originalOffset, ( isset( $class::$databaseColumnMap[$order] ) ? ( $class::$databasePrefix . $class::$databaseColumnMap[$order] ) : $order ) . ' ' . $orderDirection, $member, $includeHiddenComments, $cutoff, $includeWarnings, $extraWhereClause, $includeDeleted );
@@ -1955,6 +1932,8 @@ class Topic extends Item implements Embeddable,
* @apiresponse bool featured Topic is featured
* @apiresponse bool archived Topic is archived
* @apiresponse \IPS\Poll poll Poll data, if there is one
* @apiresponse \IPS\core\Assignments\Assignment assignment Assignment data
* @apiresponse int parentTopic If this was split from a larger topic, returns the ID of the parent
* @apiresponse string url URL
*/
public function apiOutput( Member $authorizedMember = NULL ): array
@@ -1981,7 +1960,9 @@ class Topic extends Item implements Embeddable,
'poll' => $this->poll_state ? Poll::load( $this->poll_state )->apiOutput( $authorizedMember ) : null,
'url' => (string) $this->url(),
'is_future_entry' => $this->is_future_entry,
'publish_date' => $this->publish_date ? DateTime::ts( $this->publish_date )->rfc3339() : NULL
'publish_date' => $this->publish_date ? DateTime::ts( $this->publish_date )->rfc3339() : NULL,
'assignment' => $this->assignment ? $this->assignment->apiOutput( $authorizedMember, false ) : NULL,
'parentTopic' => $this->parent()?->tid ?? null
);
}
@@ -2282,4 +2263,143 @@ class Topic extends Item implements Embeddable,
{
return (bool) Settings::i()->forum_post_use_minimal_editor;
}
// <editor-fold desc="large topics">
/**
* Is this considered a "large" topic?
*
* @return bool
*/
public function isLargeTopic() : bool
{
/* Intentionally not using commentCount because it runs a query */
return $this->mapped( 'num_comments' ) >= LARGE_TOPIC_LOCK;
}
/**
* How many more posts can we make before this will close?
* Return null if there are too many for us to care.
*
* @return int|null
*/
public function postsToClose() : ?int
{
$postsAllowed = LARGE_TOPIC_LOCK - $this->mapped( 'num_comments' );
return ( $postsAllowed < LARGE_TOPIC_WARNING ) ? $postsAllowed : null;
}
/**
* @var array
*/
protected static array $parentChildTopics = [];
/**
* Returns the parent topic, if one exists
*
* @return Topic|null
*/
public function parent() : ?Topic
{
if( $data = $this->_topicRelationships() )
{
if( $data['parent_topic'] )
{
try
{
return Topic::load( $data['parent_topic'] );
}
catch( OutOfRangeException ){}
}
}
return null;
}
/**
* What number is this in the sequence?
*
* @return int
*/
public function sequence() : int
{
if( $data = $this->_topicRelationships() )
{
return $data['sequence'];
}
return 1;
}
/**
* Returns all child topics, in sequence
*
* @return array|null
*/
public function children() : ?array
{
if( $data = $this->_topicRelationships() )
{
if( isset( $data['children'] ) )
{
$childTopics = iterator_to_array(
new ActiveRecordIterator(
Db::i()->select( '*', 'forums_topics', Db::i()->in( 'tid', $data['children'] ) )->setKeyField( 'tid' ),
Topic::class
)
);
$return = [];
/* Make sure we return them in sequence */
foreach( $data['children'] as $childId )
{
$return[] = $childTopics[ $childId ];
}
return $return;
}
}
return null;
}
/**
* Local cache for parent/child topics
* @return array|null
*/
protected function _topicRelationships() : ?array
{
if( !array_key_exists( $this->tid, static::$parentChildTopics ) )
{
/* Default to null so that we don't load this again */
static::$parentChildTopics[ $this->tid ] = null;
/* Load parent and child data in one shot */
foreach( Db::i()->select( '*', 'forums_topics_children', [ 'topic_id=? or parent_topic=?', $this->tid, $this->tid ], 'sequence' ) as $row )
{
static::$parentChildTopics[ $row['topic_id'] ] = $row;
if( $row['topic_id'] != $row['parent_topic'] )
{
if( !isset( static::$parentChildTopics[ $row['parent_topic'] ]['children'] ) )
{
static::$parentChildTopics[ $row['parent_topic'] ]['children'] = [];
}
static::$parentChildTopics[ $row['parent_topic'] ]['children'][] = $row['topic_id'];
}
}
/* If this is a child topic, load up the siblings, we may not have picked it up in the last query */
if( static::$parentChildTopics[ $this->tid ] !== null and $parentId = static::$parentChildTopics[ $this->tid ]['parent_topic'] )
{
static::$parentChildTopics[ $parentId ]['children'] = iterator_to_array(
Db::i()->select( 'topic_id', 'forums_topics_children', [ 'parent_topic=?', $parentId ] )
);
}
}
return static::$parentChildTopics[ $this->tid ];
}
// </editor-fold>
}