Files
IPB/applications/forums/sources/Topic/Topic.php
T
2025-12-19 17:18:38 -08:00

2235 lines
64 KiB
PHP

<?php
/**
* @brief Topic Model
* @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
* @subpackage Forums
* @since 8 Jan 2014
*/
namespace IPS\forums;
/* To prevent PHP errors (extending class does not exist) revealing path */
use BadMethodCallException;
use DateInterval;
use DomainException;
use InvalidArgumentException;
use IPS\cms\Databases;
use IPS\cms\Records;
use IPS\Content\Anonymous;
use IPS\Content\Assignable;
use IPS\Content\Comment;
use IPS\Content\EditHistory;
use IPS\Content\Embeddable;
use IPS\Content\Filter;
use IPS\Content\Followable;
use IPS\Content\FuturePublishing;
use IPS\Content\Helpful;
use IPS\Content\Hideable;
use IPS\Content\Item;
use IPS\Content\Lockable;
use IPS\Content\MetaData;
use IPS\Content\Featurable;
use IPS\Content\Pinnable;
use IPS\Content\Polls;
use IPS\Content\Reactable;
use IPS\Content\Reaction;
use IPS\Content\ReadMarkers;
use IPS\Content\Reportable;
use IPS\Content\Shareable;
use IPS\Content\Solvable;
use IPS\Content\Statistics;
use IPS\Content\Taggable;
use IPS\Content\ViewUpdates;
use IPS\DateTime;
use IPS\Db;
use IPS\Db\Exception;
use IPS\Dispatcher;
use IPS\File;
use IPS\forums\tasks\unarchive;
use IPS\forums\Topic\ArchivedPost;
use IPS\forums\Topic\LiveTopic;
use IPS\forums\Topic\Post;
use IPS\Helpers\Badge;
use IPS\Helpers\Form\CheckboxSet;
use IPS\Helpers\Form\Date;
use IPS\Helpers\Form\Password;
use IPS\Http\Url;
use IPS\Http\Url\Friendly;
use IPS\Member;
use IPS\Node\Model;
use IPS\Output;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Platform\Bridge;
use IPS\Poll;
use IPS\Request;
use IPS\Session;
use IPS\Settings;
use IPS\Theme;
use OutOfRangeException;
use RuntimeException;
use SplObserver;
use SplSubject;
use function array_slice;
use function count;
use function defined;
use function func_get_args;
use function get_class;
use function in_array;
use function intval;
use function is_array;
use function json_decode;
use const IPS\LARGE_TOPIC_REPLIES;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* Topic Model
*/
class Topic extends Item implements Embeddable,
Filter,
SplObserver
{
use LiveTopic,
Reactable,
Reportable,
Statistics,
Pinnable,
Anonymous,
Solvable,
Followable,
Lockable,
FuturePublishing,
MetaData,
Polls,
Shareable,
Taggable,
EditHistory,
ReadMarkers,
Helpful,
Hideable,
ViewUpdates,
Featurable,
Assignable
{
Polls::canCreatePoll as public _canCreatePoll;
ReadMarkers::markRead as public _markRead;
Reactable::reactionClass as public _reactionClass;
Solvable::toggleSolveComment as protected _toggleSolveComment;
}
/**
* @brief Not archived
*/
const ARCHIVE_NOT = 0;
/**
* @brief Archiving completed
*/
const ARCHIVE_DONE = 1;
/**
* @brief In the process of being archived
*/
const ARCHIVE_WORKING = 2;
/**
* @brief Excluded from archiving
*/
const ARCHIVE_EXCLUDE = 3;
/**
* @brief Flagged to restore from archive
*/
const ARCHIVE_RESTORE = 4;
/**
* @brief Multiton Store
*/
protected static array $multitons;
/**
* @brief [ActiveRecord] ID Database Column
*/
public static string $databaseColumnId = 'tid';
/**
* @brief Application
*/
public static string $application = 'forums';
/**
* @brief Module
*/
public static string $module = 'forums';
/**
* @brief Database Table
*/
public static ?string $databaseTable = 'forums_topics';
/**
* @brief Database Prefix
*/
public static string $databasePrefix = '';
/**
* @brief Database Column Map
*/
public static array $databaseColumnMap = array(
'author' => 'starter_id',
'author_name' => 'starter_name',
'container' => 'forum_id',
'date' => 'start_date',
'title' => 'title',
'num_comments' => 'posts',
'unapproved_comments' => 'topic_queuedposts',
'hidden_comments' => 'topic_hiddenposts',
'first_comment_id' => 'topic_firstpost',
'last_comment' => array( 'last_post', 'last_real_post' ),
'last_comment_by' => 'last_poster_id',
'last_comment_name' => 'last_poster_name',
'views' => 'views',
'approved' => 'approved',
'pinned' => 'pinned',
'poll' => 'poll_state',
'status' => 'state',
'moved_to' => 'moved_to',
'moved_on' => 'moved_on',
'featured' => 'featured',
'state' => 'state',
'updated' => 'last_post',
'meta_data' => 'topic_meta_data',
'solved_comment_id' => 'topic_answered_pid',
'is_anon' => 'is_anon',
'last_comment_anon' => 'last_poster_anon',
'is_future_entry' => 'is_future_entry',
'future_date' => 'publish_date',
'last_vote' => 'last_vote',
'assigned' => 'assignment_id'
);
/**
* @brief Title
*/
public static string $title = 'topic';
/**
* @brief Node Class
*/
public static ?string $containerNodeClass = 'IPS\forums\Forum';
/**
* @brief [Content\Item] Comment Class
*/
public static ?string $commentClass = 'IPS\forums\Topic\Post';
/**
* @brief Archived comment class
*/
public static string $archiveClass = 'IPS\forums\Topic\ArchivedPost';
/**
* @brief [Content\Item] First "comment" is part of the item?
*/
public static bool $firstCommentRequired = TRUE;
/**
* @brief [Content\Comment] Language prefix for forms
*/
public static string $formLangPrefix = 'topic_';
/**
* @brief Icon
*/
public static string $icon = 'comment';
/**
* @brief [Content] Key for hide reasons
*/
public static ?string $hideLogKey = 'topic';
/**
* @brief Hover preview
*/
public ?bool $tableHoverUrl = true;
/**
* @brief Setting name for show_meta
*/
public static ?string $showMetaSettingKey = 'forums_topics_show_meta';
/**
* Callback from \IPS\Http\Url\Inernal::correctUrlFromVerifyClass()
*
* This is called when verifying the *the URL currently being viewed* is correct, before calling self::loadFromUrl()
* Can be used if there is a more effecient way to load and cache the objects that will be used later on that page
*
* @param Url $url The URL of the page being viewed, which belongs to this class
* @return void
*/
public static function preCorrectUrlFromVerifyClass( Url $url ) : void
{
Forum::loadIntoMemory();
}
/**
* Check the request for legacy parameters we may need to redirect to
*
* @return NULL|Url
*/
public function checkForLegacyParameters(): ?Url
{
/* Check for any changes in the parent, i.e. st=20 */
$url = parent::checkForLegacyParameters();
$paramsToSet = array();
$paramsToUnset = array();
/* view=findpost needs to go to do=findComment */
if( isset( Request::i()->view ) AND Request::i()->view == 'findpost' )
{
$paramsToSet['do'] = 'findComment';
$paramsToUnset[] = 'view';
}
/* p=123 needs to go to comment=123 */
if( isset( Request::i()->p ) )
{
$paramsToSet['do'] = 'findComment';
$paramsToSet['comment'] = Request::i()->p;
$paramsToUnset[] = 'p';
}
/* Did we have any? */
if( count( $paramsToSet ) )
{
if( $url === NULL )
{
$url = $this->url();
}
if( count( $paramsToUnset ) )
{
$url = $url->stripQueryString( $paramsToUnset );
}
return $url->setQueryString( $paramsToSet );
}
return $url;
}
/**
* Set custom posts per page setting
*
* @return int
*/
public static function getCommentsPerPage(): int
{
return Settings::i()->forums_posts_per_page;
}
/**
* Should posting this increment the poster's post count?
*
* @param Model|NULL $container Container
* @return boolean
* @see Post::incrementPostCount
*/
public static function incrementPostCount( Model $container = NULL ): bool
{
return FALSE;
}
/**
* Get items with permission check
*
* @param array $where Where clause
* @param string|null $order MySQL ORDER BY clause (NULL to order by date)
* @param int|array|null $limit Limit clause
* @param string $permissionKey A key which has a value in the permission map (either of the container or of this class) matching a column ID in core_permission_index or NULL to ignore permissions
* @param int|bool|null $includeHiddenItems Include hidden items? NULL to detect if currently logged in member has permission, -1 to return public content only, TRUE to return unapproved content and FALSE to only return unapproved content the viewing member submitted
* @param int $queryFlags Select bitwise flags
* @param Member|null $member The member (NULL to use currently logged in member)
* @param bool $joinContainer If true, will join container data (set to TRUE if your $where clause depends on this data)
* @param bool $joinComments If true, will join comment data (set to TRUE if your $where clause depends on this data)
* @param bool $joinReviews If true, will join review data (set to TRUE if your $where clause depends on this data)
* @param bool $countOnly If true will return the count
* @param array|null $joins Additional arbitrary joins for the query
* @param bool|Model $skipPermission If you are getting records from a specific container, pass the container to reduce the number of permission checks necessary or pass TRUE to skip conatiner-based permission. You must still specify this in the $where clause
* @param bool $joinTags If true, will join the tags table
* @param bool $joinAuthor If true, will join the members table for the author
* @param bool $joinLastCommenter If true, will join the members table for the last commenter
* @param bool $showMovedLinks If true, moved item links are included in the results
* @param array|null $location Array of item lat and long
* @return ActiveRecordIterator|int
*/
public static function getItemsWithPermission( array $where=array(), string $order=null, int|array|null $limit=10, string $permissionKey='read', int|bool|null $includeHiddenItems= Filter::FILTER_AUTOMATIC, int $queryFlags=0, Member $member=null, bool $joinContainer=FALSE, bool $joinComments=FALSE, bool $joinReviews=FALSE, bool $countOnly=FALSE, array|null $joins=null, bool|Model $skipPermission=FALSE, bool $joinTags=TRUE, bool $joinAuthor=TRUE, bool $joinLastCommenter=TRUE, bool $showMovedLinks=FALSE, array|null $location=null ): ActiveRecordIterator|int
{
$where = static::getItemsWithPermissionWhere( $where, $permissionKey, $member, $joinContainer, $skipPermission );
return parent::getItemsWithPermission( $where, $order, $limit, $permissionKey, $includeHiddenItems, $queryFlags, $member, $joinContainer, $joinComments, $joinReviews, $countOnly, $joins, $skipPermission, $joinTags, $joinAuthor, $joinLastCommenter, $showMovedLinks );
}
/**
* Additional WHERE clauses for Follow view
*
* @param bool $joinContainer If true, will join container data (set to TRUE if your $where clause depends on this data)
* @param array $joins Other joins
* @return array
*/
public static function followWhere( bool &$joinContainer, array &$joins ): array
{
$joinContainer = FALSE;
return array_merge( parent::followWhere( $joinContainer, $joins ), static::getItemsWithPermissionWhere( array(), 'read', NULL, $joinContainer ) );
}
/**
* WHERE clause for getItemsWithPermission
*
* @param array $where Current WHERE clause
* @param string $permissionKey A key which has a value in the permission map (either of the container or of this class) matching a column ID in core_permission_index
* @param Member|null $member The member (NULL to use currently logged in member)
* @param bool $joinContainer If true, will join container data (set to TRUE if your $where clause depends on this data)
* @param bool $skipPermission If you are getting records from a specific container, pass the container to reduce the number of permission checks necessary or pass TRUE to skip container-based permission. You must still specify this in the $where clause
* @return array
*/
public static function getItemsWithPermissionWhere( array $where, string $permissionKey, ?Member $member, bool &$joinContainer, bool|Model $skipPermission=FALSE ): array
{
/* Don't show topics in password protected forums */
if ( !$skipPermission and in_array( $permissionKey, array( 'view', 'read' ) ) )
{
$joinContainer = TRUE;
$member = $member ?: Member::loggedIn();
/* @var Forum $containerClass */
$containerClass = static::$containerNodeClass;
if ( $containerClass::customPermissionNodes() )
{
$whereString = 'forums_forums.password=? OR ' . Db::i()->findInSet( 'forums_forums.password_override', $member->groups );
$whereParams = array( NULL );
if ( Dispatcher::hasInstance() AND $member->member_id === Member::loggedIn()->member_id )
{
foreach ( Request::i()->cookie as $k => $v )
{
if ( mb_substr( $k, 0, 13 ) === 'ipbforumpass_' )
{
$whereString .= ' OR ( forums_forums.id=? AND MD5(forums_forums.password)=? )';
$whereParams[] = (int) mb_substr( $k, 13 );
$whereParams[] = $v;
}
}
}
$where['container'][] = array_merge( array( '( ' . $whereString . ' )' ), $whereParams );
}
}
/* Don't show topics from forums in which topics only show to the poster */
if ( $skipPermission !== TRUE and in_array( $permissionKey, array( 'view', 'read' ) ) )
{
$member = $member ?: Member::loggedIn();
if ( $skipPermission instanceof Forum)
{
if ( !$skipPermission->can_view_others )
{
if ( !$member->member_id )
{
return array( '1=0' );
}
if ( $club = $skipPermission->club() )
{
if ( !$club->isModerator( $member ) )
{
$where['item'][] = array( 'forums_topics.starter_id=?', $member->member_id );
}
}
elseif ( !$member->modPermission('can_read_all_topics') or ( is_array( $member->modPermission('forums') ) and !in_array( $skipPermission->_id, $member->modPermission('forums') ) ) )
{
$where['item'][] = array( 'forums_topics.starter_id=?', $member->member_id );
}
}
}
elseif ( !$member->modPermission('can_read_all_topics') or is_array( $member->modPermission('forums') ) or !$member->modPermission('can_access_all_clubs') )
{
$joinContainer = TRUE;
if ( !$member->member_id )
{
$where[] = array( 'forums_forums.can_view_others=1' );
}
else
{
$whereClause = array( 'forums_forums.can_view_others=1 OR forums_topics.starter_id=?', (int) $member->member_id );
$ors = array();
if ( $member->modPermission('can_read_all_topics') )
{
$forums = $member->modPermission('forums');
if ( isset( $forums ) and is_array( $forums ) )
{
$ors[] = '( forums_forums.club_id IS NULL AND ' . Db::i()->in( 'forums_topics.forum_id', $forums ) . ')';
}
else
{
$ors[] = 'forums_forums.club_id IS NULL';
}
}
if ( $member->modPermission('can_access_all_clubs') )
{
$ors[] = 'forums_forums.club_id IS NOT NULL';
}
elseif ( $moderatorClubIds = $member->clubs( FALSE, TRUE ) )
{
$ors[] = Db::i()->in( 'forums_forums.club_id', $moderatorClubIds );
}
if ( $ors )
{
$whereClause[0] = "( {$whereClause[0]} OR " . implode( ' OR ', $ors ) . ' )';
}
else
{
$whereClause[0] = "( {$whereClause[0]} )";
}
$where[] = $whereClause;
}
}
}
/* Return */
return $where;
}
/**
* Total item \count(including children)
*
* @param Model $container The container
* @param bool $includeItems If TRUE, items will be included (this should usually be true)
* @param bool $includeComments If TRUE, comments will be included
* @param bool $includeReviews If TRUE, reviews will be included
* @param int $depth Used to keep track of current depth to avoid going too deep
* @return int|NULL|string When depth exceeds 10, will return "NULL" and initial call will return something like "100+"
* @note This method may return something like "100+" if it has lots of children to avoid exahusting memory. It is intended only for display use
* @note This method includes counts of hidden and unapproved content items as well
*/
public static function contentCount( Model $container, bool $includeItems=TRUE, bool $includeComments=FALSE, bool $includeReviews=FALSE, int $depth=0 ): int|NULL|string
{
return parent::contentCount( $container, FALSE, TRUE, $includeReviews, $depth );
}
/**
* Total item, items only \count(including children)
*
* @param Model $container The container
* @return int|NULL|string When depth exceeds 10, will return "NULL" and initial call will return something like "100+"
* @note This method may return something like "100+" if it has lots of children to avoid exahusting memory. It is intended only for display use
* @note This method includes counts of hidden and unapproved content items as well
*/
public static function contentCountItemsOnly( Model $container ) : int|string|null
{
return parent::contentCount( $container );
}
/**
* Get elements for add/edit form
*
* @param Item|NULL $item The current item if editing or NULL if creating
* @param Model|NULL $container Container (e.g. forum), if appropriate
* @return array
*/
public static function formElements( Item $item=NULL, Model $container=NULL ): array
{
$formElements = parent::formElements( $item, $container );
/* Password protected */
if ( $container !== NULL AND !$container->loggedInMemberHasPasswordAccess() )
{
$password = $container->password;
$formElements['password'] = new Password( 'password', NULL, TRUE, array(), function( $val ) use ( $password )
{
if ( $val != $password )
{
throw new DomainException( 'forum_password_bad' );
}
} );
}
/* Build the topic state toggles */
$options = array();
$toggles = array();
$current = array();
if ( static::modPermission( 'lock', NULL, $container ) )
{
$options['lock'] = 'create_topic_locked';
$toggles['lock'] = array( 'create_topic_locked' );
if( $item and $item->locked() )
{
$current[] = 'lock';
}
}
if ( static::modPermission( 'pin', NULL, $container ) )
{
$options['pin'] = 'create_topic_pinned';
$toggles['pin'] = array( 'create_topic_pinned' );
if( $item and $item->mapped('pinned') )
{
$current[] = 'pin';
}
}
$canHide = ( $item ) ? $item->canHide() : ( Member::loggedIn()->group['g_hide_own_posts'] == '1' or in_array( 'IPS\forums\Topic', explode( ',', Member::loggedIn()->group['g_hide_own_posts'] ) ) );
if ( static::modPermission( 'hide', NULL, $container ) or $canHide )
{
$options['hide'] = 'create_topic_hidden';
$toggles['hide'] = array( 'create_topic_hidden' );
if( $item and $item->hidden() === -1 )
{
$current[] = 'hide';
}
}
if ( count( $options ) or count( $toggles ) )
{
$formElements['topic_state'] = new CheckboxSet( 'topic_create_state', $current, FALSE, array(
'options' => $options,
'toggles' => $toggles,
'multiple' => TRUE
) );
}
if ( static::modPermission( 'lock', NULL, $container ) )
{
/* Add lock/unlock options */
if ( static::modPermission( 'unlock', NULL, $container ) )
{
$formElements['topic_open_time'] = new Date( 'topic_open_time', ( $item and $item->topic_open_time ) ? DateTime::ts( $item->topic_open_time ) : NULL, FALSE, array( 'time' => TRUE ) );
}
$formElements['topic_close_time'] = new Date( 'topic_close_time', ( $item and $item->topic_close_time ) ? DateTime::ts( $item->topic_close_time ) : NULL, FALSE, array( 'time' => TRUE ) );
}
/* Poll always needs to go on the end */
if ( isset( $formElements['poll'] ) )
{
$poll = $formElements['poll'];
unset( $formElements['poll'] );
$formElements['poll'] = $poll;
}
return $formElements;
}
/**
* Process create/edit form
*
* @param array $values Values from form
* @return void
*/
public function processForm( array $values ): void
{
parent::processForm( $values );
if ( isset( $values['password'] ) )
{
/* Set Cookie */
$this->container()->setPasswordCookie( $values['password'] );
}
/* Moderator actions */
if ( isset( $values['topic_create_state'] ) )
{
if ( static::modPermission( 'lock', NULL, $this->container() ) )
{
$this->state = ( in_array( 'lock', $values['topic_create_state'] ) ) ? 'closed' : 'open';
}
if ( static::modPermission( 'pin', NULL, $this->container() ) )
{
$this->pinned = ( in_array( 'pin', $values['topic_create_state'] ) ) ? 1 : 0;
}
}
if ( static::modPermission( 'lock', NULL, $this->container() ) )
{
/* Set open/close time */
$this->topic_open_time = !empty( $values['topic_open_time'] ) ? $values['topic_open_time']->getTimestamp() : 0;
$this->topic_close_time = !empty( $values['topic_close_time'] ) ? $values['topic_close_time']->getTimestamp() : 0;
if( isset( $values['topic_create_state'] ) and !in_array( 'lock', $values['topic_create_state'] ) )
{
$this->state = 'open';
}
/* If open time is before close time, close now */
if ( $this->topic_open_time and $this->topic_close_time and $this->topic_open_time < $this->topic_close_time )
{
$this->state = 'closed';
}
/* If we specified an unlock time, but no lock time, make sure the topic is locked */
if ( $this->topic_open_time and !$this->topic_close_time )
{
$this->state = 'closed';
}
}
}
/**
* Process created object AFTER the object has been created
*
* @param Comment|NULL $comment The first comment
* @param array $values Values from form
* @return void
*/
protected function processAfterCreate( Comment|null $comment, array $values ): void
{
$this->processAfterCreateOrEdit( $values );
parent::processAfterCreate( $comment, $values );
}
/**
* Process after the object has been edited on the front-end
*
* @param array $values Values from form
* @return void
*/
public function processAfterEdit( array $values ): void
{
$this->processAfterCreateOrEdit( $values );
/* Initial Comment */
parent::processAfterEdit( $values );
/* Topic changed? */
if ( ! $this->hidden() )
{
$this->container()->setLastComment();
$this->container()->save();
foreach( $this->container()->parents() AS $parent )
{
$this->container()->setLastComment();
$parent->save();
}
}
}
/**
* Process after the object has been edited or created on the front-end
*
* @param array $values Values from form
* @return void
*/
protected function processAfterCreateOrEdit( array $values ) : void
{
/* Moderator actions */
if ( isset( $values['topic_create_state'] ) )
{
if( in_array( 'hide', $values['topic_create_state'] ) )
{
if ( $this->canHide() )
{
$this->hide( NULL );
}
}
elseif( $this->hidden() and $this->hidden() !== 1 and $this->canUnhide() )
{
$this->unhide( NULL );
}
}
}
/**
* @brief Cached URLs
*/
protected mixed $_url = array();
/**
* @brief URL Base
*/
public static string $urlBase = 'app=forums&module=forums&controller=topic&id=';
/**
* @brief URL Template
*/
public static string $urlTemplate = 'forums_topic';
/**
* @brief SEO Title Column
*/
public static string $seoTitleColumn = 'title_seo';
/**
* Stats for table view
*
* @param bool $includeFirstCommentInCommentCount Determines whether the first comment should be inlcluded in the comment \count(e.g. For "posts", use TRUE. For "replies", use FALSE)
* @return array
*/
public function stats( bool $includeFirstCommentInCommentCount=TRUE ): array
{
$stats = parent::stats( $includeFirstCommentInCommentCount );
if( !$includeFirstCommentInCommentCount )
{
if( isset( $stats['comments'] ) )
{
$stats = array_reverse( $stats );
$stats['forums_comments'] = $stats['comments'];
unset( $stats['comments'] );
$stats = array_reverse( $stats );
}
}
return $stats;
}
/**
* Set name
*
* @param string $title Title
* @return void
*/
public function set_title( string $title ) : void
{
$this->_data['title'] = $title;
$this->_data['title_seo'] = Friendly::seoTitle( $title );
}
/**
* Get SEO name
*
* @return string
*/
public function get_title_seo(): string
{
if( !$this->_data['title_seo'] )
{
$this->title_seo = Friendly::seoTitle( $this->title );
$this->save();
}
return $this->_data['title_seo'] ?: Friendly::seoTitle( $this->title );
}
/**
* Check if a specific action is available for this Content.
* Default to TRUE, but used for overrides in individual Item/Comment classes.
*
* @param string $action
* @param Member|null $member
* @return bool
*/
public function actionEnabled( string $action, ?Member $member=null ) : bool
{
$skipOnArchive = array(
'comment', 'feature', 'unfeature', 'lock', 'unlock', 'hide', 'unhide', 'move', 'featureComment', 'onMessage', 'toggleItemModeration', 'merge'
);
if( $this->isArchived() and in_array( $action, $skipOnArchive ) )
{
return false;
}
switch( $action )
{
case 'merge':
if( $this->moved_to )
{
return false;
}
break;
case 'edit':
if( Application::appIsEnabled( 'cms' ) and Records::getLinkedRecord( $this ) )
{
return false;
}
break;
case 'feature':
if( !$this->container()->can_view_others )
{
return false;
}
break;
}
return parent::actionEnabled( $action, $member );
}
/**
* Can view?
*
* @param Member|NULL $member The member to check for or NULL for the currently logged in member
* @return bool
*/
public function canView( Member $member=null ): bool
{
if( !parent::canView( $member ) )
{
return FALSE;
}
$member = $member ?: Member::loggedIn();
if ( $member !== $this->author() and !$this->container()->memberCanAccessOthersTopics( $member ) )
{
return FALSE;
}
/* Whitelist which types of do we allow */
$do = array( 'editComment' );
if( Application::appIsEnabled( 'cms' ) )
{
/* Check to see if it's attached to a database record and we are not a guest */
if ( isset( Request::i()->do ) and in_array( Request::i()->do, $do ) and $record = Records::getLinkedRecord( $this ) and $member->member_id )
{
return $record->canView();
}
}
return TRUE;
}
/**
* Can create polls?
*
* @param Member|NULL $member The member to check for (NULL for currently logged in member)
* @param Model|NULL $container The container to check if tags can be used in, if applicable
* @return bool
*/
public static function canCreatePoll( Member $member = NULL, Model $container = NULL ) : bool
{
return static::_canCreatePoll( $member, $container ) and ( $container === NULL or $container->allow_poll );
}
/**
* SplObserver notification that poll has been voted on
*
* @param SplSubject $subject Subject
* @return void
*/
public function update( SplSubject $subject ): void
{
$this->updateLastVote( $subject );
}
/**
* Get template for content tables
*
* @return array
*/
public static function contentTableTemplate(): array
{
Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'forums.css', 'forums', 'front' ) );
return array( Theme::i()->getTemplate( 'global', 'forums', 'front' ), 'rows' );
}
/**
* Table: Get rows
*
* @param array $rows Rows to show
* @return void
*/
public static function tableGetRows( array $rows ): void
{
$openIds = array();
$closeIds = array();
$timeNow = time();
foreach ( $rows as $topic )
{
if ( $topic->state != 'link' )
{
$locked = $topic->locked();
if ( $locked and $topic->topic_open_time and $topic->topic_open_time < $timeNow )
{
$openIds[] = $topic->tid;
$topic->state = 'open';
}
if ( !$locked and $topic->topic_close_time and $topic->topic_close_time < $timeNow )
{
$closeIds[] = $topic->tid;
$topic->state = 'closed';
}
}
if ( Bridge::i()->featureIsEnabled( 'realtime' ) )
{
$hash = [
'app' => 'forums',
'module' => 'forums',
'controller' => 'topic',
'id' => $topic->tid
];
Bridge::i()->addAdditionalLocation( $hash );
$topic->locationHash = Bridge::i()->getLocationHash( $hash );
}
}
if ( !empty( $openIds ) )
{
Db::i()->update( 'forums_topics', array( 'state' => 'open', 'topic_open_time' => 0 ), Db::i()->in( 'tid', $openIds ) );
}
if ( !empty( $closeIds ) )
{
Db::i()->update( 'forums_topics', array( 'state' => 'closed', 'topic_close_time' => 0 ), Db::i()->in( 'tid', $closeIds ) );
}
}
/**
* Move
*
* @param Model $container Container to move to
* @param bool $keepLink If TRUE, will keep a link in the source
* @return void
*/
public function move( Model $container, bool $keepLink=FALSE ): void
{
if( !$container->sub_can_post or $container->redirect_url )
{
throw new InvalidArgumentException;
}
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 */
if( $this->isArchived() )
{
try
{
ArchivedPost::db()->update( 'forums_archive_posts', array( 'archive_forum_id' => $container->_id ), array( 'archive_topic_id=?', $this->tid ) );
}
/* catch db exceptions if e.g. if the connection credentials didn't work or if the database doesn't exist anymore */
catch ( Exception $e ){}
}
}
/**
* Delete Record
*
* @return void
*/
public function delete(): void
{
parent::delete();
if( in_array( $this->archive_status, array( static::ARCHIVE_DONE, static::ARCHIVE_WORKING, static::ARCHIVE_RESTORE ) ) )
{
try
{
ArchivedPost::db()->delete( 'forums_archive_posts', array( 'archive_topic_id=?', $this->tid ) );
}
/* catch db exceptions if e.g. if the connection credentials didn't work or if the database doesn't exist anymore */
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' ) );
}
/**
* Merge other items in (they will be deleted, this will be kept)
*
* @param array $items Items to merge in
* @param bool $keepLinks Retain redirect links for the items that were merge in
* @return void
*/
public function mergeIn( array $items, bool $keepLinks=FALSE ): void
{
/* If mark solved is enabled we need to make sure we only have one best answer (at most) post-merge */
if( $this->container()->forums_bitoptions['bw_solved_set_by_member'] or $this->container()->forums_bitoptions['bw_solved_set_by_moderator'] )
{
/* Does this topic already have a best answer? */
if( $this->topic_answered_pid )
{
/* Then we need to make sure none of the items also has a best answer */
foreach( $items as $item )
{
/* Reset best answer for this topic */
if( $item->topic_answered_pid )
{
try
{
$post = Post::load( $item->topic_answered_pid );
$post->post_bwoptions['best_answer'] = FALSE;
$post->save();
}
catch( OutOfRangeException $e ){}
}
}
}
/* The topic doesn't have a best answer, but we still need to make sure we only have one best answer total post-merge */
else
{
$bestAnswerSeen = FALSE;
foreach( $items as $item )
{
if( $item->topic_answered_pid )
{
/* Have we seen a best answer yet? If not, then we're ok. */
if( $bestAnswerSeen === FALSE )
{
/* This topic had no best answer flag set, so set it now */
$this->topic_answered_pid = $item->topic_answered_pid;
$this->save();
$bestAnswerSeen = TRUE;
continue;
}
/* If we have though, reset any others */
try
{
$post = Post::load( $item->topic_answered_pid );
$post->post_bwoptions['best_answer'] = FALSE;
$post->save();
}
catch( OutOfRangeException $e ){}
}
}
}
}
parent::mergeIn( $items, $keepLinks );
}
/* !Saved Actions */
/**
* Get available saved actions for this topic
*
* @param Member|NULL $member The member (NULL for currently logged in)
* @return array
*/
public function availableSavedActions( Member $member = NULL ) : array
{
return SavedAction::actions( $this->container(), $member );
}
/**
* Do Moderator Action
*
* @param string $action The action
* @param Member|NULL $member The member doing the action (NULL for currently logged in member)
* @param string|NULL $reason Reason (for hides)
* @param bool $immediately Delete Immediately
* @return void
* @throws OutOfRangeException|InvalidArgumentException|RuntimeException
*/
public function modAction( string $action, Member $member = NULL, mixed $reason = NULL, bool $immediately=FALSE ): void
{
if ( mb_substr( $action, 0, 12 ) === 'savedAction-' )
{
$action = SavedAction::load( intval( mb_substr( $action, 12 ) ) );
$action->runOn( $this );
/* Log */
Session::i()->modLog( 'modlog__saved_action', array( 'forums_mmod_' . $action->mm_id => TRUE, $this->url()->__toString() => FALSE, $this->mapped( 'title' ) => FALSE ), $this );
}
else
{
if ( Application::appIsEnabled( 'cms' ) and $action === 'delete' )
{
/* We used to restrict by forum ID but if you move a topic to a new forum then the forum ID will no longer match */
foreach( Db::i()->select( '*', 'cms_database_categories', array( 'category_forum_record=? AND category_forum_comments=?', 1, 1 ) ) as $category )
{
try
{
$class = '\IPS\cms\Records' . $category['category_database_id'];
if( class_exists( $class ) )
{
$class::load( $this->tid, 'record_topicid' );
$database = Databases::load( $category['category_database_id'] );
Member::loggedIn()->language()->words['cms_delete_linked_topic'] = sprintf( Member::loggedIn()->language()->get('cms_delete_linked_topic'), $database->recordWord( 1 ) );
Output::i()->error( 'cms_delete_linked_topic', '1T281/1', 403, '' );
}
}
catch( OutOfRangeException | Exception $ex ) { }
}
foreach( Db::i()->select( '*', 'cms_databases', array( 'database_forum_record=? AND database_forum_comments=?', 1, 1 ) ) as $database )
{
try
{
/* @var Records $class */
$class = '\IPS\cms\Records' . $database['database_id'];
$class::load( $this->tid, 'record_topicid' );
$database = Databases::constructFromData( $database );
Member::loggedIn()->language()->words['cms_delete_linked_topic'] = sprintf( Member::loggedIn()->language()->get('cms_delete_linked_topic'), $database->recordWord( 1 ) );
Output::i()->error( 'cms_delete_linked_topic', '1T281/1', 403, '' );
}
catch( OutOfRangeException | Exception $ex ) { }
}
}
parent::modAction( $action, $member, $reason, $immediately );
/* Prevent topics with an open time re-opening again after being locked */
if ( $action == 'lock' )
{
$this->topic_open_time = 0;
$this->save();
}
/* And prevent it from relocking if we are unlocking */
if( $action == 'unlock' )
{
$this->topic_close_time = 0;
$this->save();
}
if ( Application::appIsEnabled( 'cms' ) and ( $action === 'lock' or $action === 'unlock' ) )
{
foreach( Db::i()->select( '*', 'cms_databases', array( 'database_forum_record=? AND database_forum_comments=?', 1, 1 ) ) as $database )
{
try
{
/* @var Records $class */
$class = '\IPS\cms\Records' . $database['database_id'];
$record = $class::load( $this->tid, 'record_topicid' );
$record->record_locked = ( $action === 'lock' ) ? 1 : 0;
$record->save();
}
catch(OutOfRangeException $ex ) { }
}
}
}
}
/* !Questions & Answers */
/**
* Any container has solvable enabled?
*
* @return boolean
*/
public static function anyContainerAllowsSolvable() : bool
{
return (bool) Db::i()->select( 'COUNT(*)', 'forums_forums', '( ' . Db::i()->bitwiseWhere( Forum::$bitOptions['forums_bitoptions'], 'bw_solved_set_by_moderator' ) . ' )' )->first();
}
/**
* Container has solvable enabled
*
* @return bool
*/
public function containerAllowsSolvable() : bool
{
return $this->container()->forums_bitoptions['bw_solved_set_by_moderator'];
}
/**
* Container has solvable enabled
*
* @return bool
*/
public function containerAllowsMemberSolvable() : bool
{
return ( $this->containerAllowsSolvable() AND $this->container()->forums_bitoptions['bw_solved_set_by_member'] );
}
/**
* Toggle the solve value of a comment
*
* @param int $commentId The comment ID
* @param boolean $value TRUE/FALSE value
* @param Member|null $member The member (null for currently logged in member)
*
* @return void
*/
public function toggleSolveComment( int $commentId, bool $value, ?Member $member = NULL ): void
{
if ( $value )
{
$post = Post::load( $commentId );
$post->author()->achievementAction( 'forums', 'AnswerMarkedBest', [ 'post' => $post, 'type' => 'solved' ] );
}
$this->_toggleSolveComment( $commentId, $value, $member );
}
/**
* Can user set the best answer?
*
* @param Member|null $member The member (null for currently logged in member)
* @return bool
*/
public function canSetBestAnswer( Member $member = NULL ): bool
{
/* Archived topics cannot be modified */
if ( $this->isArchived() )
{
return FALSE;
}
$member = $member ?: Member::loggedIn();
/* 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'] )
{
return TRUE;
}
/* Or if we're a moderator */
if
(
$member->modPermission( 'can_set_best_answer' )
and
(
( $member->modPermission( Forum::$modPerm ) === TRUE or $member->modPermission( Forum::$modPerm ) === -1 )
or
(
is_array( $member->modPermission( Forum::$modPerm ) )
and
in_array( $this->container()->_id, $member->modPermission( Forum::$modPerm ) )
)
)
) {
return TRUE;
}
/* Otherwise no */
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
*
* @return Post|NULL
*/
public function bestAnswer() : ?Post
{
if ( $this->topic_answered_pid )
{
try
{
return Post::load( $this->topic_answered_pid );
}
catch ( OutOfRangeException $e ){}
}
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
*
* @return bool
*/
public function containerAllowsAssignable(): bool
{
return (bool) $this->container()->forums_bitoptions['bw_enable_assignments'];
}
/**
* [ActiveRecord] Save
*
* @return void
*/
public function save(): void
{
parent::save();
$this->clearVotes();
}
/* !Sitemap */
/**
* WHERE clause for getting items for sitemap (permissions are already accounted for)
*
* @return array
*/
public static function sitemapWhere(): array
{
return array( array( 'forums_forums.ipseo_priority<>?', 0 ) );
}
/**
* Sitemap Priority
*
* @return int|null NULL to use default
*/
public function sitemapPriority(): ?int
{
$priority = $this->container()->ipseo_priority;
if ( $priority === NULL or $priority == -1 )
{
return NULL;
}
return $priority;
}
/* !Archiving */
/**
* Is archived?
*
* @return bool
*/
public function isArchived() : bool
{
return in_array( $this->topic_archive_status, array( static::ARCHIVE_DONE, static::ARCHIVE_WORKING, static::ARCHIVE_RESTORE ) );
}
/**
* Can unarchive?
*
* @param Member|NULL $member The member (NULL for currently logged in member)
* @return bool
*/
public function canUnarchive( ?Member $member=NULL ) : bool
{
if ( $this->isArchived() and $this->topic_archive_status !== static::ARCHIVE_RESTORE )
{
$member = $member ?: Member::loggedIn();
return $member->hasAcpRestriction( 'forums', 'forums', 'archive_manage' );
}
return FALSE;
}
/**
* Should this topic be archived again?
*
* @param Member|NULL $member The member (NULL for currently logged in member)
* @return bool
*/
public function canRemoveArchiveExcludeFlag( ?Member $member=NULL ) : bool
{
$member = $member ?: Member::loggedIn();
if ( $member->hasAcpRestriction( 'forums', 'forums', 'archive_manage' ) AND $this->topic_archive_status == static::ARCHIVE_EXCLUDE )
{
return TRUE;
}
return FALSE;
}
/**
* Unarchive confirm message
*
* @return string
*/
public function unarchiveBlurb() : string
{
$taskData = Db::i()->select( '*', 'core_tasks', array( '`key`=? AND app=?', 'archive', 'forums' ) )->first();
$time = DateTime::ts( $taskData['next_run'] );
$postsToBeUnarchived = Db::i()->select( 'SUM(posts) + count(*)', 'forums_topics', array( 'topic_archive_status=?', static::ARCHIVE_RESTORE ) )->first();
if ( $postsToBeUnarchived AND $postsToBeUnarchived > unarchive::PROCESS_PER_BATCH )
{
$total = $postsToBeUnarchived / unarchive::PROCESS_PER_BATCH;
$interval = new DateInterval( $taskData['frequency'] );
foreach ( range( 1, $total ) as $i )
{
$time->add( $interval );
}
}
return Member::loggedIn()->language()->addToStack( 'unarchive_confirm', FALSE, array( 'pluralize' => array( ceil( ( $time->getTimestamp() - time() ) / 60 ) ) ) );
}
/**
* Adjust based on the theme preferences
*
* @return int
*/
public function commentCount(): int
{
$count = parent::commentCount();
if ( Member::loggedIn()->getLayoutValue( 'forum_topic_view_firstpost' ) )
{
$count--;
}
return $count;
}
/**
* Actions to show in comment multi-mod
*
* @param Member|NULL $member Member (NULL for currently logged in member)
* @return array
*/
public function commentMultimodActions( ?Member $member = NULL ): array
{
if ( $this->isArchived() )
{
return array();
}
return parent::commentMultimodActions( $member );
}
/**
* Get comments
*
* @param int|NULL $limit The number to get (NULL to use static::getCommentsPerPage())
* @param int|NULL $offset The number to start at (NULL to examine \IPS\Request::i()->page)
* @param string $order The column to order by
* @param string $orderDirection "asc" or "desc"
* @param Member|NULL $member If specified, will only get comments by that member
* @param bool|NULL $includeHiddenComments Include hidden comments or not? NULL to base of currently logged in member's permissions
* @param DateTime|NULL $cutoff If an \IPS\DateTime object is provided, only comments posted AFTER that date will be included
* @param mixed $extraWhereClause Additional where clause(s) (see \IPS\Db::build for details)
* @param bool|NULL $bypassCache Used in cases where comments may have already been loaded i.e. splitting comments on an item.
* @param bool $includeDeleted Include Deleted Content
* @param bool|NULL $canViewWarn TRUE to include Warning information, NULL to determine automatically based on moderator permissions.
* @return array|NULL|Comment If $limit is 1, will return \IPS\Content\Comment or NULL for no results. For any other number, will return an array.
*/
public function comments( int|null $limit=NULL, int|null $offset=NULL, string $order='date', string $orderDirection='asc', Member|null $member=NULL, bool|null $includeHiddenComments=NULL, DateTime|null $cutoff=NULL, mixed $extraWhereClause=NULL, bool|null $bypassCache=FALSE, bool $includeDeleted=FALSE, bool|null $canViewWarn=NULL ): array|NULL|Comment
{
static $comments = array();
$idField = static::$databaseColumnId;
$_hash = md5( $this->$idField . json_encode( func_get_args() ) );
$expectingSingleComment = $limit === 1;
if( !$bypassCache and isset( $comments[ $_hash ] ) )
{
return $comments[ $_hash ];
}
$includeWarnings = $canViewWarn;
$commentClass = NULL;
if ( $this->isArchived() )
{
/* We need to set $commentClass to the archive class, otherwise the includeHidden checks in _comments fail, as they verify $class == static::$commentClass */
$class = static::$archiveClass;
$commentClass = static::$commentClass;
static::$commentClass = $class;
$includeWarnings = FALSE;
if( $extraWhereClause !== NULL )
{
if( is_array( $extraWhereClause ) )
{
foreach( $extraWhereClause as $k => $v )
{
$extraWhereClause[ $k ] = preg_replace( "/^author_id /", "archive_author_id ", $v );
}
}
else
{
$extraWhereClause = preg_replace( "/^author_id /", "archive_author_id ", $extraWhereClause );
}
}
}
else
{
$class = static::$commentClass;
}
try
{
$alreadyDone = false;
$originalLimit = $limit;
$originalOffset = $offset;
if ( $this->isArchived() and $limit === 1 and $order === 'date' )
{
$minMax = ( $orderDirection === 'asc' ) ? 'MIN' : 'MAX';
/* Do some mojo to get the lowest comment and highest comment for archived topics */
$row = ArchivedPost::db()->select( "{$minMax}( CONCAT( archive_content_date, '.', archive_id) ) as pid", 'forums_archive_posts', array( [ 'archive_topic_id=?', $this->tid, ArchivedPost::db()->in( 'archive_queued', [0,2] ) ] ) )->first();
$pid = (int) explode( '.', $row )[1];
if( is_array( $extraWhereClause ) or is_null( $extraWhereClause ) )
{
$extraWhereClause[] = [ 'archive_id=?', $pid ];
}
else if ( is_string( $extraWhereClause ) )
{
$extraWhereClause = [
[ $extraWhereClause ],
[ 'archive_id=?', $pid ]
];
}
$data = $this->_comments( $class, 1, 0, ( isset( $class::$databaseColumnMap[$order] ) ? ( $class::$databasePrefix . $class::$databaseColumnMap[$order] ) : $order ) . ' ' . $orderDirection, $member, $includeHiddenComments, $cutoff, $canViewWarn, $extraWhereClause, $includeDeleted );
$comments[ $_hash ] = is_array( $data ) ? array_reverse( $data, true ) : $data;
$alreadyDone = true;
}
else if ( $this->mapped('num_comments') > LARGE_TOPIC_REPLIES and $order === 'date' and $orderDirection === 'asc' and ( $originalLimit === null or $originalLimit === static::getCommentsPerPage() ) )
{
/* This is a large topic, if we're requesting the last 50% of pages, we will reverse sort to reduce the offset */
$page = ( Request::i()->page ? intval( Request::i()->page ) : 1 );
$pagePercent = ceil( ( $page / $this->commentPageCount() ) * 100 );
$commentCount = $this->commentCount();
if ( $pagePercent > 50 )
{
$postsOnLastPage = $commentCount % static::getCommentsPerPage();
$pageFromEnd = $this->commentPageCount() - $page;
$offset = $pageFromEnd > 1 ? ( $pageFromEnd - 1 ) * static::getCommentsPerPage() : 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;
/* When we set an implicit limit of 1, we just want a Post object but if the sql returns just 1 item but we could take more, then we want an array
yeah this is bad code but it's been here for years so... */
if ( ! is_array( $data ) and ! $expectingSingleComment )
{
$comments[ $_hash ] = [ $data->pid => $data ];
}
$alreadyDone = true;
}
else if ( $this->isArchived() )
{
/* When viewing the first handful of pages in archived topics with hundreds of thousands of replies, it's more efficient to get the ID first */
$archiveIds = [];
if ( $offset === NULL )
{
$_pageValue = ( Request::i()->page ? intval( Request::i()->page ) : 1 );
if( $_pageValue < 1 )
{
$_pageValue = 1;
}
$offset = ( $_pageValue - 1 ) * static::getCommentsPerPage();
}
foreach( ArchivedPost::db()->select( "archive_id", 'forums_archive_posts', array( [ 'archive_topic_id=?', $this->tid, ArchivedPost::db()->in( 'archive_queued', [0,2] ) ] ), 'archive_id ' . $orderDirection, [ $offset, static::getCommentsPerPage() ] ) as $row )
{
$archiveIds[] = $row;
}
if( is_array( $extraWhereClause ) or is_null( $extraWhereClause ) )
{
$extraWhereClause[] = [ ArchivedPost::db()->in( 'archive_id', $archiveIds ) ];
}
else if ( is_string( $extraWhereClause ) )
{
$extraWhereClause = [
[ $extraWhereClause ],
[ ArchivedPost::db()->in( 'archive_id', $archiveIds ) ]
];
}
$comments[ $_hash ] = $this->_comments( $class, static::getCommentsPerPage(), 0, ( isset( $class::$databaseColumnMap[$order] ) ? ( $class::$databasePrefix . $class::$databaseColumnMap[$order] ) : $order ) . ' ' . $orderDirection, $member, $includeHiddenComments, $cutoff, $includeWarnings, $extraWhereClause, $includeDeleted );
$alreadyDone = true;
}
}
if ( ! $alreadyDone )
{
$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 );
}
}
catch( Exception $e )
{
$post = new Post;
$post->topic_id = $this->tid;
$post->post = '<p><em>' . Member::loggedIn()->language()->addToStack('archived_topic_missing_posts') . '</em></p>';
$post->post_date = $this->start_date;
$post->author_id = $this->starter_id;
if ( Member::loggedIn()->isAdmin() )
{
$post->post .= "<p>" . Member::loggedIn()->language()->addToStack('archived_topic_missing_posts_admin') . "</p><p><strong>{$e->getMessage()}<br><textarea>" . var_export( $e, TRUE ) . '</textarea></p>';
}
$comments[ $_hash ] = $expectingSingleComment ? $post : array( $post );
}
/* Restore comment class now */
if( $commentClass )
{
static::$commentClass = $commentClass;
}
return $comments[ $_hash ];
}
/**
* Resync the comments/unapproved comment counts
*
* @param string|null $commentClass Override comment class to use
* @return void
*/
public function resyncCommentCounts( string $commentClass=NULL ): void
{
parent::resyncCommentCounts( $this->isArchived() ? static::$archiveClass : NULL );
}
/**
* Return the first comment on the item
*
* @return Comment|NULL
*/
public function firstComment(): Comment|null
{
if ( $this->isArchived() )
{
try
{
return parent::firstComment();
}
catch( Exception $e )
{
}
}
else
{
return parent::firstComment();
}
return null;
}
/**
* Check Moderator Permission
*
* @param string $type 'edit', 'hide', 'unhide', 'delete', etc.
* @param Member|NULL $member The member to check for or NULL for the currently logged in member
* @param Model|NULL $container The container
* @return bool
*/
public static function modPermission( string $type, ?Member $member = NULL, ?Model $container = NULL ): bool
{
/* Load Member */
$member = $member ?: Member::loggedIn();
/* Compatibility checks */
if ( in_array( $type, array( 'use_saved_actions', 'set_best_answer' ) ) )
{
/* @var Forum $containerClass */
$containerClass = get_class( $container );
$title = static::$title;
if
(
$member->modPermission( $containerClass::$modPerm ) === -1
or
(
is_array( $member->modPermission( $containerClass::$modPerm ) )
and
in_array( $container->_id, $member->modPermission( $containerClass::$modPerm ) )
)
)
{
return TRUE;
}
}
return parent::modPermission( $type, $member, $container );
}
/**
* Mark as read
*
* @param Member|NULL $member The member (NULL for currently logged in member)
* @param int|NULL $time The timestamp to set (or NULL for current time)
* @param mixed $extraContainerWhere Additional where clause(s) (see \IPS\Db::build for details)
* @param bool $force Mark as unread even if we already appear to be unread?
* @return void
*/
public function markRead( ?Member $member = NULL, ?int $time = NULL, mixed $extraContainerWhere = NULL, bool $force = FALSE ): void
{
$member = $member ?: Member::loggedIn();
$time = $time ?: time();
if ( !$this->container()->memberCanAccessOthersTopics( $member ) )
{
$extraContainerWhere = array( 'starter_id = ?', $member->member_id );
}
$this->_markRead( $member, $time, $extraContainerWhere, $force );
}
/**
* Get output for API
*
* @param Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials
* @return array
* @apiresponse int id ID number
* @apiresponse string title Title
* @apiresponse \IPS\forums\Forum forum Forum
* @apiresponse int posts Number of posts
* @apiresponse int views Number of views
* @apiresponse string prefix The prefix tag, if there is one
* @apiresponse [string] tags The tags
* @apiresponse \IPS\forums\Topic\Post firstPost The first post in the topic
* @apiresponse \IPS\forums\Topic\Post lastPost The last post in the topic
* @apiresponse \IPS\forums\Topic\Post bestAnswer The best answer, if this is a question and there is one
* @apiresponse bool locked Topic is locked
* @apiresponse bool hidden Topic is hidden
* @apiresponse bool pinned Topic is pinned
* @apiresponse bool featured Topic is featured
* @apiresponse bool archived Topic is archived
* @apiresponse \IPS\Poll poll Poll data, if there is one
* @apiresponse string url URL
*/
public function apiOutput( Member $authorizedMember = NULL ): array
{
$firstPost = $this->comments( 1, 0, 'date', 'asc' );
$lastPost = $this->comments( 1, 0, 'date', 'desc' );
$bestAnswer = $this->bestAnswer();
return array(
'id' => $this->tid,
'title' => $this->title,
'forum' => $this->container()->apiOutput( $authorizedMember ),
'posts' => $this->posts,
'views' => $this->views,
'prefix' => $this->prefix(),
'tags' => $this->tags(),
'firstPost' => $firstPost?->apiOutput( $authorizedMember ),
'lastPost' => $lastPost?->apiOutput( $authorizedMember ),
'bestAnswer' => $bestAnswer?->apiOutput( $authorizedMember ),
'locked' => $this->locked(),
'hidden' => (bool) $this->hidden(),
'pinned' => (bool) $this->mapped('pinned'),
'featured' => (bool) $this->mapped('featured'),
'archived' => $this->isArchived(),
'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
);
}
/**
* Returns the content
*
* @return string
* @throws BadMethodCallException
* @note This is overridden for performance reasons - selecting a post by a PID is more efficient than select * from posts order by date desc limit 1
*/
public function content(): string
{
$firstComment = $this->firstComment();
return $firstComment ? $firstComment->content() : '';
}
/**
* Supported Meta Data Types
*
* @return array
*/
public static function supportedMetaDataTypes(): array
{
return array( 'core_FeaturedComments', 'core_ContentMessages', 'core_ItemModeration' );
}
/**
* Get content for embed
*
* @param array $params Additional parameters to add to URL
* @return string
*/
public function embedContent( array $params ): string
{
Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'embed.css', 'forums', 'front' ) );
return Theme::i()->getTemplate( 'global', 'forums' )->embedTopic( $this, $this->url()->setQueryString( $params ) );
}
/* ! Reactions */
/**
* Reaction type
*
* @return string
*/
public static function reactionType(): string
{
/* Because of firstCommentRequired, the reaction on the item will always be the reaction for a comment */
return Post::reactionType();
}
/**
* Reaction class
*
* @return string
*/
public static function reactionClass(): string
{
return Post::class;
}
/**
* Reaction Where Clause [needs to overload reactable as it's the only item level reaction type]
*
* @param Reaction|array|int|NULL $reactions This can be any one of the following: An \IPS\Content\Reaction object, an array of \IPS\Content\Reaction objects, an integer, or an array of integers, or NULL
* @param bool $enabledTypesOnly If TRUE, only reactions of the enabled reaction types will be included (must join core_reactions)
* @return array
*/
public function getReactionWhereClause( Reaction|array|int|null $reactions = NULL, bool $enabledTypesOnly=TRUE ) : array
{
$idColumn = static::$databaseColumnId;
$where = array( array( 'rep_class=? and item_id=?', static::$commentClass, $this->$idColumn ) );
if ( $enabledTypesOnly )
{
$where[] = array( 'reaction_enabled=1' );
}
if ( $reactions !== NULL )
{
if ( !is_array( $reactions ) )
{
$reactions = array( $reactions );
}
$in = array();
foreach( $reactions AS $reaction )
{
if ( $reaction instanceof Reaction )
{
$in[] = $reaction->id;
}
else
{
$in[] = $reaction;
}
}
if ( count( $in ) )
{
$where[] = array( Db::i()->in( 'reaction', $in ) );
}
}
return $where;
}
/**
* Show the topic summary feature?
*
* @param $key string Key to check (topPost, popularDays, uploads)
* @return boolean
*/
public function showSummaryFeature( string $key ) : bool
{
if ( Settings::i()->forums_topic_activity_features )
{
$features = json_decode( Settings::i()->forums_topic_activity_features, TRUE );
if ( $features and in_array( $key, $features ) )
{
return TRUE;
}
}
return FALSE;
}
/**
* Show the topic summary on desktop? (if so where)
*
* @return string|bool [sidebar,post]
*/
public function showSummaryOnDesktop() : string|bool
{
/* Hide the summary for future topics */
if ( $this->isFutureDate() )
{
return FALSE;
}
if ( ! Settings::i()->forums_topics_activity_pages_show OR ( (int) Settings::i()->forums_topics_activity_pages_show <= $this->commentPageCount() ) )
{
$viewSettings = json_decode( Settings::i()->forums_topic_activity, TRUE );
if ( $viewSettings and in_array( 'desktop', $viewSettings ) and isset( Settings::i()->forums_topic_activity_desktop ) )
{
return Settings::i()->forums_topic_activity_desktop;
}
}
return FALSE;
}
/**
* Show the topic summary on mobile?
*
* @return boolean
*/
public function showSummaryOnMobile() : bool
{
/* Hide the summary for future topics */
if ( $this->isFutureDate() )
{
return FALSE;
}
if ( ! Settings::i()->forums_topics_activity_pages_show OR ( (int) Settings::i()->forums_topics_activity_pages_show <= $this->commentPageCount() ) )
{
$viewSettings = json_decode( Settings::i()->forums_topic_activity, TRUE );
if ( $viewSettings and in_array( 'mobile', $viewSettings ) )
{
return TRUE;
}
}
return FALSE;
}
/**
* We need to force an index so that super long topics load when fetching IDs before running the main query
*
* @return string|null
*/
protected function forceIndexForPaginatedIds() : ?string
{
if ( ! $this->isArchived() )
{
return 'first_post';
}
else
{
return 'archive_topic_id';
}
}
/**
* WHERE clause for getting items for ACP overview statistics
*
* @return array
*/
public static function overviewStatisticsWhere() : array
{
return array( array( Db::i()->in( 'state', array( 'link', 'merged' ), TRUE ) ) );
}
public function badges(): array
{
$return = parent::badges();
if( $this->topic_open_time and $this->topic_open_time > time() )
{
$return['unlock'] = new Badge( 'ipsBadge--unlocks', Member::loggedIn()->language()->addToStack( 'topic_unlocks_at_short', true, array(
'sprintf' => array( DateTime::ts( $this->topic_open_time )->relative( 1 ) )
) ), '', '', array( 'ipsBadge--text' ) );
}
elseif( !$this->locked() and $this->topic_close_time and $this->topic_close_time > time() )
{
$return['lock'] = new Badge('ipsBadge--locks', Member::loggedIn()->language()->addToStack('topic_locks_at_short', true, array(
'sprintf' => array( DateTime::ts($this->topic_close_time)->relative(1))
)), '', 'clock', array('ipsBadge--text'));
}
return $return;
}
/**
* Get the post summary blurb (returns empty string when not on cloud/when no summary exists)
*
* @return string
*/
public function get_postSummaryBlurb() : string
{
return Bridge::i()->topicPostSummaryBlurb( $this );
}
/**
* Get the estimated read time in MINUTES for this topic
*
* @return int|null
*/
public function get_estimatedReadTime() : int|null
{
static $readTime = false;
if ( $readTime === false )
{
$readTime = Bridge::i()->topicEstimatedReadTimeMinutes( $this );
}
return $readTime;
}
/**
* Get the estimated time in MINUTES to read the summary of this topic
*
* @return int|null
*/
public function get_estimatedSummaryReadTime() : int|null
{
static $readSTime = false;
if ( $readSTime === false )
{
$readSTime = Bridge::i()->topicEstimatedReadTimeMinutes( $this, true );
}
return $readSTime;
}
/**
* Check if this topic has a summary
*
* @return bool
*/
public function hasSummary() : bool
{
return Bridge::i()->_topicHasSummary( $this );
}
/**
* @return int|null
*/
public function get_summarySize() : int|null
{
return Bridge::i()->topicSummarySize( $this );
}
/**
* Allow for individual classes to override and
* specify a primary image. Used for grid views, etc.
*
* @return File|null
*/
public function primaryImage() : ?File
{
if( $contentImage = $this->contentImages(1) )
{
$attachType = key( $contentImage[0] );
try
{
return File::get( $attachType, $contentImage[0][ $attachType ] );
}
catch( \Exception ){}
}
return parent::primaryImage();
}
/**
* Overrides the parent method so we respect the ACP setting
*
* @return bool
*/
public function commentsUseCommentEditor (): bool
{
return (bool) Settings::i()->forum_post_use_minimal_editor;
}
}