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

@@ -13,9 +13,14 @@ namespace IPS\core\Achievements\Actions;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Application;
use IPS\cloud\Quest;
use IPS\core\Achievements\Rule;
use IPS\Helpers\Form\Node;
use IPS\Http\Url;
use IPS\Member;
use IPS\Platform\Bridge;
use IPS\Theme;
use OutOfRangeException;
use function defined;
use function get_called_class;
@@ -42,6 +47,17 @@ abstract class AchievementActionAbstract
return Application::appIsEnabled( $exploded[1] );
}
/**
* Determines whether this should be displayed in the ACP list.
* We need to override this for some rules that should remain hidden
*
* @return bool
*/
public function showInAcp() : bool
{
return true;
}
/**
* Get filter form elements
*
@@ -51,7 +67,22 @@ abstract class AchievementActionAbstract
*/
public function filters( ?array $filters, Url $url ): array
{
return [];
$return = [];
if( Bridge::i()->featureIsEnabled( 'quests' ) )
{
$classKey = explode( '\\', get_called_class() )[5];
$questField = new Node( 'achievement_filter_quest_' . $classKey, $filters['quest'] ?? null, false, [
'class' => Quest::class,
'subnodes' => false,
'multiple' => false,
'showAllNodes' => true
], null, Member::loggedIn()->language()->addToStack( 'achievement_filter_quest_prefix' ), null, 'achievement_filter_quest_' . $classKey );
$questField->label = Member::loggedIn()->language()->addToStack( 'achievement_filter_quest' );
$return['quest'] = $questField;
}
return $return;
}
/**
@@ -62,6 +93,17 @@ abstract class AchievementActionAbstract
*/
public function formatFilterValues( array $values ): array
{
if( Bridge::i()->featureIsEnabled( 'quests' ) )
{
$classKey = explode( '\\', get_called_class() )[5];
if( isset( $values['achievement_filter_quest_' . $classKey ] ) and $values['achievement_filter_quest_' . $classKey ] instanceof Quest )
{
return [
'quest' => $values['achievement_filter_quest_' . $classKey ]->_id
];
}
}
return [];
}
@@ -81,6 +123,31 @@ abstract class AchievementActionAbstract
{
return TRUE;
}
/**
* Get "description" for rule (usually a description of the rule's filters)
*
* @param Rule $rule The rule
* @return string|NULL
*/
protected function _questFilterDescription( Rule $rule ): ?string
{
if ( isset( $rule->filters['quest'] ) and Bridge::i()->featureIsEnabled( 'quests' ) )
{
try
{
$quest = Quest::load( $rule->filters['quest'] );
return Member::loggedIn()->language()->addToStack( 'achievements_title_filter_quest', false, [
'htmlsprintf' => [
Theme::i()->getTemplate( 'achievements', 'core' )->ruleDescriptionBadge( 'quest', $quest->_title )
]
]);
}
catch( OutOfRangeException ){}
}
return NULL;
}
/**
* Get the labels for the people this action might give awards to
@@ -107,6 +174,21 @@ abstract class AchievementActionAbstract
{
return [];
}
/**
* Determines if the member has already completed this rule.
* Used for retroactive rule completion.
* So far, this is only used in Quests, but may be used elsewhere at a later point.
*
* @param Member $member
* @param array $filters
* @return bool
*/
public function isRuleCompleted( Member $member, array $filters ) : bool
{
/* Default to false, so that any other rules would have to be completed again. */
return false;
}
/**
* Get identifier to prevent the member being awarded points for the same action twice
@@ -141,7 +141,8 @@ abstract class ContentAchievementActionAbstract extends AchievementActionAbstrac
$nthFilter = new Number( 'achievement_filter_' . $classKey . '_nth', ( $filters and isset( $filters['milestone'] ) and $filters['milestone'] ) ? $filters['milestone'] : 0, FALSE, [], NULL, Member::loggedIn()->language()->addToStack('achievement_filter_nth_their'), Member::loggedIn()->language()->addToStack('achievement_filter_' . $classKey . '_nth_suffix') );
$nthFilter->label = Member::loggedIn()->language()->addToStack('achievement_filter_NewContentItem_nth');
$return = [ 'type' => $typeFilter ];
$return = parent::filters( $filters, $url );
$return['type'] = $typeFilter;
foreach ( $_return as $k => $v )
{
$return[ $k ] = $v;
@@ -159,9 +160,10 @@ abstract class ContentAchievementActionAbstract extends AchievementActionAbstrac
*/
public function formatFilterValues( array $values ): array
{
$return = parent::formatFilterValues( $values );
$classKey = explode( '\\', get_called_class() )[5];
$return = [];
if ( isset( $values['achievement_filter_' . $classKey . '_type'] ) )
{
$return['type'] = $values['achievement_filter_' . $classKey . '_type'];
@@ -293,7 +295,7 @@ abstract class ContentAchievementActionAbstract extends AchievementActionAbstrac
{
return Member::loggedIn()->language()->addToStack( 'achievements_title_filter_location', FALSE, [
'htmlsprintf' => [
Theme::i()->getTemplate( 'achievements' )->ruleDescriptionBadge( 'location',
Theme::i()->getTemplate( 'achievements', 'core' )->ruleDescriptionBadge( 'location',
count( $nodeNames ) === 1 ? $nodeNames[0] : Member::loggedIn()->language()->addToStack( 'achievements_title_filter_location_val', FALSE, [ 'sprintf' => [
count( $nodeNames ),
Member::loggedIn()->language()->addToStack( $nodeClass::$nodeTitle, FALSE )
@@ -87,7 +87,8 @@ abstract class NodeAchievementActionAbstract extends AchievementActionAbstract
$nthFilter = new Number( 'achievement_filter_Node_nth', ( $filters and isset( $filters['milestone'] ) and $filters['milestone'] ) ? $filters['milestone'] : 0, FALSE, [], NULL, Member::loggedIn()->language()->addToStack('achievement_filter_nth_their'), Member::loggedIn()->language()->addToStack('achievement_filter_Node_nth_suffix') );
$nthFilter->label = Member::loggedIn()->language()->addToStack('achievement_filter_Node_nth');
$return = [ 'type' => $typeFilter ];
$return = parent::filters( $filters, $url );
$return['type'] = $typeFilter;
foreach ( $_return as $k => $v )
{
$return[ $k ] = $v;
@@ -105,7 +106,7 @@ abstract class NodeAchievementActionAbstract extends AchievementActionAbstract
*/
public function formatFilterValues( array $values ): array
{
$return = [];
$return = parent::formatFilterValues( $values );
if ( isset( $values['achievement_filter_Node_type'] ) )
{
$return['type'] = $values['achievement_filter_Node_type'];
@@ -197,7 +198,7 @@ abstract class NodeAchievementActionAbstract extends AchievementActionAbstract
{
return Member::loggedIn()->language()->addToStack( 'achievements_title_filter_location', FALSE, [
'htmlsprintf' => [
Theme::i()->getTemplate( 'achievements' )->ruleDescriptionBadge( 'location',
Theme::i()->getTemplate( 'achievements', 'core' )->ruleDescriptionBadge( 'location',
count( $nodeNames ) === 1 ? $nodeNames[0] : Member::loggedIn()->language()->addToStack( 'achievements_title_filter_location_val', FALSE, [ 'sprintf' => [
count( $nodeNames ),
Member::loggedIn()->language()->addToStack( $nodeClass::$nodeTitle, FALSE, [ 'strtolower' => TRUE ] )
@@ -268,6 +268,11 @@ class Badge extends Model
{
if ( !$this->badge_use_image and $badge = $this->getRecordBadge() )
{
if( !$badge->file() )
{
$badge->save();
}
return $badge->file()->url;
}
@@ -128,12 +128,12 @@ class Rule extends Model
/**
* Get the extension
*
* @return AchievementActionAbstract
* @return AchievementActionAbstract|null
*/
public function extension(): AchievementActionAbstract
public function extension(): ?AchievementActionAbstract
{
$exploded = explode( '_', $this->action );
return Application::load( $exploded[0] )->extensions( 'core', 'AchievementAction' )[ $exploded[1] ];
return Application::load( $exploded[0] )->extensions( 'core', 'AchievementAction' )[ $exploded[1] ] ?? null;
}
/**
@@ -14,19 +14,27 @@ namespace IPS\core;
use Exception;
use IPS\Application;
use IPS\Content;
use IPS\Db;
use IPS\Dispatcher;
use IPS\Email;
use IPS\Extensions\AdvertisementLocationsAbstract;
use IPS\File;
use IPS\Helpers\Form\CheckboxSet;
use IPS\Helpers\Form\Node;
use IPS\Helpers\Form\Number;
use IPS\Helpers\Form\Radio;
use IPS\Helpers\Form\Select;
use IPS\Lang;
use IPS\Member;
use IPS\Member\Group;
use IPS\Output;
use IPS\Patterns\ActiveRecord;
use IPS\Redis;
use IPS\Request;
use IPS\Settings;
use IPS\Theme;
use OutOfRangeException;
use function count;
use function defined;
@@ -102,11 +110,245 @@ class Advertisement extends ActiveRecord
* @brief List all default location codes
*/
public static array $defaultLocations = [
'ad_global_header',
'ad_global_footer',
'ad_sidebar'
'ad_global_header' => array( 'ad_apps' ),
'ad_global_footer' => array( 'ad_apps' ),
'ad_sidebar' => array( 'ad_apps' )
];
/**
* Return an array of form elements that will handle location-based settings
*
* @param array $currentValues
* @param array $currentLocations
* @return array
*/
public static function locationFields( array $currentValues, array $currentLocations ) : array
{
$settingFields = [];
$applications = [];
$appToggles = [];
$nodeFields = [];
$defaultLocations = static::$defaultLocations;
foreach( Application::enabledApplications() as $app )
{
$applications[ $app->directory ] = $app->_title;
foreach( $app->extensions( 'core', 'ContentRouter' ) as $router )
{
if( !isset( $appToggles[ $app->directory ] ) )
{
$appToggles[ $app->directory ] = [];
}
foreach( $router->classes as $itemClass )
{
if( isset( $itemClass::$containerNodeClass ) )
{
$nodeClass = $itemClass::$containerNodeClass;
/* Skip nodes that can be created by users, such as blogs */
if( !empty( $nodeClass::$ownerTypes ) )
{
continue;
}
$key = str_replace( '\\', '_', $nodeClass );
$field = new Node( $key, $currentValues[ $key ] ?? 0, false, [
'class' => $nodeClass,
'multiple' => true,
'zeroVal' => 'all',
'nodeGroups' => true
], null, null, null, $key );
$field->label = Member::loggedIn()->language()->addToStack( $nodeClass::$nodeTitle );
$nodeFields[] = $field;
$appToggles[ $app->directory ][] = $key;
}
}
}
}
/* Add a field to restrict by app */
$settingFields['ad_apps'] = new Select( 'ad_apps', $currentValues['ad_apps'] ?? -1, false, array(
'options' => $applications,
'multiple' => true,
'unlimitedLang' => 'all',
'toggles' => $appToggles
), null, null, null, 'ad_apps' );
foreach( $nodeFields as $field )
{
$settingFields[ $field->htmlId ] = $field;
}
/* Now grab ad location extensions */
foreach ( Application::allExtensions( 'core', 'AdvertisementLocations', FALSE, 'core' ) as $key => $extension )
{
/* @var AdvertisementLocationsAbstract $extension */
$result = $extension->getSettings( $currentValues );
$defaultLocations = array_merge( $defaultLocations, $result['locations'] );
/* If this is a list view, add setting fields for interaval and repeat */
if( $extension::$listView )
{
$settingFields['ad_view_type'] = new Radio( 'ad_view_type', $currentValues['ad_view_type'] ?? null, true, [
'options' => [
'interval' => 'ad_view_type__interval',
'fixed' => 'ad_view_type__fixed'
],
'toggles' => [
'interval' => [ 'ad_view_number', 'ad_view_repeat' ],
'fixed' => [ 'ad_view_position' ]
]
], null, null, null, 'ad_view_type' );
$settingFields['ad_view_number'] = new Number( 'ad_view_number', ( isset( $currentValues['ad_view_number'] ) AND $currentValues['ad_view_number'] ) ? $currentValues['ad_view_number'] : 1, null, array( 'min' => 1 ), NULL, NULL, Member::loggedIn()->language()->addToStack('ad_view_number_suffix'), 'ad_view_number' );
$settingFields['ad_view_repeat'] = new Number( 'ad_view_repeat', $currentValues['ad_view_repeat'] ?? 1, FALSE, array( 'unlimited' => -1 ), NULL, NULL, Member::loggedIn()->language()->addToStack('ad_view_repeat_suffix'), 'ad_view_repeat' );
$settingFields['ad_view_position'] = new Number( 'ad_view_position', $currentValues['ad_view_position'] ?? 1, null, id: 'ad_view_position' );
foreach( $result['locations'] as $location => $toggles )
{
$defaultLocations[ $location ][] = 'ad_view_type';
}
}
if( isset( $result['settings'] ) )
{
$settingFields = $settingFields + $result['settings'];
}
}
/* Make location optional so that you can have an active ad for widgets only */
$locationField = new CheckboxSet( 'ad_location',
array_intersect( array_keys( $defaultLocations ), $currentLocations ),
false,
array(
'options' => array_combine( array_keys( $defaultLocations ), array_keys( $defaultLocations ) ),
'toggles' => $defaultLocations,
'noDefault' => true
),
NULL,
NULL,
NULL,
'ad_location'
);
return [ 'ad_location' => $locationField ] + $settingFields;
}
/**
* Format the location settings for saving
*
* @param array $values
* @return array
*/
public static function processLocationFields( array $values ) : array
{
/* Any additional settings to save? */
$additionalSettings = array(
'ad_apps' => ( isset( $values['ad_apps'] ) AND is_array( $values['ad_apps'] ) ) ? array_values( $values['ad_apps'] ) : 0
);
if( is_array( $additionalSettings['ad_apps'] ) )
{
foreach( $additionalSettings['ad_apps'] as $app )
{
foreach( Application::load( $app )->extensions( 'core', 'ContentRouter' ) as $router )
{
foreach( $router->classes as $itemClass )
{
if( isset( $itemClass::$containerNodeClass ) )
{
$key = str_replace( '\\', '_', $itemClass::$containerNodeClass );
if( !empty( $values[ $key ] ) and is_array( $values[ $key ] ) )
{
$additionalSettings[ $key ] = array_keys( $values[ $key ] );
}
}
}
}
}
}
foreach ( Application::allExtensions( 'core', 'AdvertisementLocations', FALSE, 'core' ) as $key => $extension )
{
if( $extension::$listView )
{
$additionalSettings['ad_view_type'] = $values['ad_view_type'] ?? 'interval';
if( $additionalSettings['ad_view_type'] == 'fixed' )
{
$additionalSettings['ad_view_position'] = $values['ad_view_position'] ?? 1;
}
else
{
$additionalSettings['ad_view_number'] = $values['ad_view_number'] ?? 1;
$additionalSettings['ad_view_repeat'] = $values['ad_view_repeat'] ?? 0;
}
}
$settings = $extension->parseSettings( $values );
$additionalSettings = array_merge( $additionalSettings, $settings );
}
return $additionalSettings;
}
/**
* Array of form elements for email advertisement configuration
*
* @param array $currentValues
* @return array
*/
public static function emailFields( array $currentValues ) : array
{
$fields = [];
/* Container restrictions for email ads */
$containerClasses = array();
$containerToggles = array();
foreach( Content::routedClasses( FALSE, FALSE, TRUE ) as $contentItemClass )
{
if( isset( $contentItemClass::$containerNodeClass ) AND $contentItemClass::$containerNodeClass AND !isset( $containerClasses[ $contentItemClass::$containerNodeClass ] ) )
{
$containerClass = $contentItemClass::$containerNodeClass;
$containerClasses[ $containerClass ] = $contentItemClass::$title . '_pl';
$containerToggles[ $containerClass ] = array( 'node_' . md5( $containerClass ) );
}
}
if( count( $containerClasses ) )
{
$fields['_ad_email_container'] = new Select( '_ad_email_container', $currentValues['email_container'] ?? '*', FALSE, array( 'options' => $containerClasses, 'multiple' => FALSE, 'unlimited' => '*', 'unlimitedLang' => 'unrestricted', 'toggles' => $containerToggles ), NULL, NULL, NULL, '_ad_email_container' );
foreach( $containerClasses as $classname => $lang )
{
$containerField = new Node( 'ad_node_' . md5( $classname ), $currentValues['email_node'] ?? 0, FALSE, array( 'class' => $classname, 'zeroVal' => 'any', 'multiple' => TRUE, 'forceOwner' => FALSE ), NULL, NULL, NULL, 'node_' . md5( $classname ) );
$containerField->label = Member::loggedIn()->language()->addToStack( $classname::$nodeTitle );
$fields[ 'node_' . md5( $classname ) ] = $containerField;
}
}
return $fields;
}
/**
* Format email fields for saving
*
* @param array $values
* @return array
*/
public static function processEmailFields( array $values ) : array
{
return [
'email_container' => $values['_ad_email_container'],
'email_node' => ( $values['_ad_email_container'] != '*' ) ? ( ( $values['ad_node_' . md5( $values['_ad_email_container'] ) ] == 0 ) ? 0 : array_keys( $values['ad_node_' . md5( $values['_ad_email_container'] ) ] ) ) : 0
];
}
/**
* Fetch advertisements and return the appropriate one to display
*
@@ -162,17 +404,55 @@ class Advertisement extends ActiveRecord
}
/* Weed out any ads that we don't see due to the application settings */
if ( in_array( $adLocation, static::$defaultLocations ) and isset( $ad->_additional_settings['ad_apps'] ) and is_array( $ad->_additional_settings['ad_apps'] ) and $ad->_additional_settings != 0 )
if ( array_key_exists( $adLocation, static::$defaultLocations ) and isset( $ad->_additional_settings['ad_apps'] ) and ( is_array( $ad->_additional_settings['ad_apps'] ) and count( $ad->_additional_settings['ad_apps'] ) ) and $ad->_additional_settings != 0 )
{
if ( !in_array( Dispatcher::i()->application->directory, $ad->_additional_settings['ad_apps'] ) )
{
unset( static::$advertisements[ $adLocation ][ $index ] );
continue;
}
/* Check if we have a content class defined */
if( isset( Output::i()->bodyAttributes['contentClass'] ) )
{
$contentId = Request::i()->id ?? 0;
$class = Output::i()->bodyAttributes['contentClass'];
if( isset( $class::$containerNodeClass ) )
{
if( isset( Request::i()->id ) )
{
try
{
$item = $class::load( Request::i()->id );
$contentId = $item->mapped( 'container' );
}
catch( OutOfRangeException ){}
}
$class = $class::$containerNodeClass;
}
$key = str_replace( '\\', '_', $class );
if( isset( $ad->_additional_settings[ $key ] ) and is_array( $ad->_additional_settings[ $key ] ) and !in_array( $contentId, $ad->_additional_settings[ $key ] ) )
{
unset( static::$advertisements[ $adLocation ][ $index ] );
}
}
else
{
/* If we defined specific nodes, and we are on a general page (e.g. an index page), skip this ad */
foreach( $ad->_additional_settings as $k => $v )
{
if( str_starts_with( $k, 'IPS_' . Dispatcher::i()->application->directory ) )
{
unset( static::$advertisements[ $adLocation ][ $index ] );
}
}
}
}
}
if( !in_array( $adLocation, static::$defaultLocations ) )
if( !array_key_exists( $adLocation, static::$defaultLocations ) )
{
foreach ( $ads as $index => $ad )
{
@@ -461,8 +741,23 @@ class Advertisement extends ActiveRecord
break;
}
$indexNumber = $ad->_additional_settings[ 'ad_view_number' ] ?? false;
$repeat = $ad->_additional_settings[ 'ad_view_repeat' ] ?? false;
/* Fixed position ads have a set position and do not repeat */
if( isset( $ad->_additional_settings['ad_view_type'] ) and $ad->_additional_settings['ad_view_type'] == 'fixed' )
{
$indexNumber = $ad->_additional_settings['ad_view_position'] ?? 1;
$repeat = 1;
/* Ignore this entirely if we are not in the correct position */
if( $position != $indexNumber )
{
continue;
}
}
else
{
$indexNumber = $ad->_additional_settings[ 'ad_view_number' ] ?? false;
$repeat = $ad->_additional_settings[ 'ad_view_repeat' ] ?? false;
}
if( $indexNumber !== false )
{
@@ -724,6 +1019,4 @@ class Advertisement extends ActiveRecord
Db::i()->update( 'core_advertisements', "ad_impressions=ad_impressions+" . $by . ", ad_daily_impressions=ad_daily_impressions+" . $by, "ad_id IN(" . implode( ',', $ids ) . ")" );
}
}
}
}
+1 -1
View File
@@ -615,7 +615,7 @@ class Alert extends Item
$member = $member?: Member::loggedIn();
if ( !$this->anonymous and $this->reply and $this->author()->member_id and $member->canUseMessenger())
{
$form = \IPS\core\Messenger\Conversation::create();
$form = \IPS\core\Messenger\Conversation::create( showError: false );
$form->class = 'ipsForm ipsForm--fullWidth';
$form->elements['']['messenger_content']->defaultValue = '<blockquote class="ipsQuote">' . $this->content . "</blockquote><br>";
$form->elements['']['messenger_content']->setValue(TRUE , FALSE );
@@ -20,6 +20,7 @@ use IPS\Data\Store;
use IPS\DateTime;
use IPS\Db;
use IPS\Dispatcher;
use IPS\Extensions\AnnouncementsAbstract;
use IPS\File;
use IPS\Helpers\Form;
use IPS\Helpers\Form\CheckboxSet;
@@ -207,10 +208,13 @@ class Announcement extends Item
/* App and container specific announcements */
foreach ( $extensions as $key => $extension )
{
/* @var AnnouncementsAbstract $extension */
$id = $extension::$idField;
if ( $announcement->ids AND isset( Request::i()->$id ) )
{
$idsToCheck = $extension->getAnnouncementIds( $announcement->ids );
/* Are we viewing a content item */
if ( Dispatcher::i()->dispatcherController instanceof Controller )
{
@@ -220,7 +224,7 @@ class Announcement extends Item
{
try
{
if( $announcement->location == $key AND in_array( $class::load( Request::i()->$id )->mapped('container'), explode( ',', $announcement->ids ) ) )
if( $announcement->location == $key AND in_array( $class::load( Request::i()->$id )->mapped('container'), $idsToCheck ) )
{
$return[] = $announcement;
}
@@ -234,7 +238,7 @@ class Announcement extends Item
{
try
{
if( $announcement->location == $key AND in_array( Request::i()->$id , explode( ',', $announcement->ids ) ) )
if( $announcement->location == $key AND in_array( Request::i()->$id , $idsToCheck ) )
{
$return[] = $announcement;
}
@@ -222,6 +222,7 @@ class Approval extends ActiveRecord
if ( Application::appIsEnabled( 'cloud' ) )
{
$reasons[] = 'image';
$reasons[] = 'spam';
}
foreach( Application::allExtensions( 'core', 'ApprovalReason' ) AS $ext )
@@ -413,4 +413,64 @@ class Assignment extends ActiveRecord
parent::delete();
}
/**
* Get output for API
*
* @param Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials
* @param bool $includeItem Should the assigned item be attached to the response?
* @return array
* @apiresponse int id ID
* @apiresponse int date Date of assignment
* @apiresponse \IPS\Member assigned_by Member who assigned the item
* @apiresponse string time_first_reply Time for the first reply
* @apiresponse [\IPS\Member] assigned_to Assigned Members
* @apiresponse string item_class Content Class
* @apiresponse int item_id Content ID
* @apiresponse [\IPS\Content\Item|\IPS\Content\Comment] content Content Object
*/
public function apiOutput( Member $authorizedMember = NULL, bool $includeItem = true ): array
{
$item = $this->item;
$response = [];
$response['id'] = $this->id;
$response['date'] = DateTime::ts( $this->date )->rfc3339();
$response['assigned_by'] = Member::load($this->by )->apiOutput( $authorizedMember);
$response['time_first_reply'] = $this->timeToFirstReply();
$assignedTo = $this->assignedTo();
if( $assignedTo )
{
$members = [];
if( $assignedTo instanceof Team )
{
foreach( $assignedTo->members() as $member )
{
$members[] = $member->apiOutput( $authorizedMember );
}
}
else
{
$members[] = $assignedTo->apiOutput( $authorizedMember );
}
}
else
{
$members = [];
}
$response['assigned_to'] = $members;
$response['item_class'] = $this->item_class;
$response['item_id'] = $this->item_id;
if( $includeItem and $item )
{
$response['content'] = $item->apiOutput( $authorizedMember );
}
else
{
$response['content'] = NULL;
}
return $response;
}
}
@@ -49,13 +49,13 @@ class Table extends \IPS\Helpers\Table\Db
*/
public function __construct( $table, Url $baseUrl, $where=NULL, $forceIndex=NULL )
{
parent::__construct( $table, $baseUrl, $where, $forceIndex );
/* Do any multi-mod */
if ( isset( Request::i()->modaction ) )
{
$this->multiMod();
}
parent::__construct( $table, $baseUrl, $where, $forceIndex );
}
/**
@@ -154,6 +154,6 @@ class Table extends \IPS\Helpers\Table\Db
}
}
Output::i()->redirect( Url::internal( 'app=core&module=system&controller=attachments', 'front', 'attachments' ), 'deleted' );
Output::i()->redirect( $this->baseUrl, 'deleted' );
}
}
@@ -125,6 +125,7 @@ class CustomBadge extends ActiveRecord
$file->delete();
}
File::$safeFileExtensions[] = 'svg';
$this->file = (string) File::create( 'core_CustomBadges', 'custombadge-' . $this->id . '.svg', $this->raw );
}
@@ -137,7 +138,7 @@ class CustomBadge extends ActiveRecord
{
$foreground = $this->foreground ?: 'currentColor';
$icon = $this->compileIconElement();
$numberOverlay = static::getBadgeNumberOverlay( $this->number_overlay );
$numberOverlay = $this->getBadgeNumberOverlay( $this->number_overlay );
return <<<SVG
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" height="48">
@@ -170,7 +171,7 @@ SVG;
{
$points = static::getRadialPoints( $this->sides, ( $this->sides === 4 and $this->rotation % 90 === 45) ? sqrt( 47.4*47.4*2 ) : 47.4 );
$icon = $this->compileIconElement();
$numberOverlay = static::getBadgeNumberOverlay( $this->number_overlay );
$numberOverlay = $this->getBadgeNumberOverlay( $this->number_overlay );
$foreground = $this->foreground ?: 'currentColor';
$path = "";
@@ -198,7 +199,7 @@ SVG;
protected function generateSVG_circle() : string
{
$icon = $this->compileIconElement();
$numberOverlay = static::getBadgeNumberOverlay( $this->number_overlay );
$numberOverlay = $this->getBadgeNumberOverlay( $this->number_overlay );
$foreground = $this->foreground ?: 'currentColor';
return <<<SVG
@@ -221,7 +222,7 @@ SVG;
protected function generateSVG_star() : string
{
$icon = $this->compileIconElement();
$numberOverlay = static::getBadgeNumberOverlay( $this->number_overlay );
$numberOverlay = $this->getBadgeNumberOverlay( $this->number_overlay );
$foreground = $this->foreground ?: 'currentColor';
$innerPoints = static::getRadialPoints( $this->sides, (0.75 + (0.095 * ( $this->sides - 4 ) / 8)) * ( 45 * ( cos( pi() / $this->sides ) ) ), 0.5 );
$outerPoints = static::getRadialPoints( $this->sides, 47.4 );
@@ -250,7 +251,7 @@ SVG;
protected function generateSVG_flower() : string
{
$icon = $this->compileIconElement();
$numberOverlay = static::getBadgeNumberOverlay( $this->number_overlay );
$numberOverlay = $this->getBadgeNumberOverlay( $this->number_overlay );
$foreground = $this->foreground ?: 'currentColor';
$r = 45 * ((sin(pi() / $this->sides)) / (sin(pi() / $this->sides) + 1));
@@ -354,16 +355,10 @@ SVG;
{
// complicated, but emojis don't center well. This was tested on MacOS using Apple Emoji font and accounts for whether the badge uses a background shape or not. Might be worth adding a note that emojis might not look correct on all devices (as the font is device-specific)
$size = 50 + round( $sizeOffset * 2, 2 );
$offset = 50 + round( ($this->shape === "no_shape" ? 24 : 18) + (($this->iconSize - 1) * ($this->shape === 'no_shape' ? 12.5 : 8.75) / 4), 2 );
$offset = 50 + round( $size / 10, 2 );// - round( $size / 2, 2 );//round( ($this->shape === "no_shape" ? 24 : 18) + (($this->iconSize - 1) * ($this->shape === 'no_shape' ? 12.5 : 8.75) / 4), 2 );
return <<<SVG
<style>
svg text.svg__text_icon:not(#x) {
line-height: {$size}px;
vertical-align: middle;
}
</style>
<text text-anchor="middle" font-size="{$size}" x="50" y="{$offset}" class="svg__text_icon">{$iconData['raw']}</text>
<text text-anchor="middle" dominant-baseline="middle" font-size="{$size}" x="50" y="{$offset}" style="line-height: {$size}px;" class="svg__text_icon">{$iconData['raw']}</text>
SVG;
}
}
@@ -375,8 +370,24 @@ SVG;
*
* @return string
*/
protected static function getBadgeNumberOverlay( int $number ) : string
protected function getBadgeNumberOverlay( int $number ) : string
{
/* Do nothing if the number is empty */
if( $number == 0 )
{
return '';
}
/* Check if the class using this badge uses the overlay */
$class = $this->class;
if( $class )
{
if( isset( $class::$customBadgeNumberOverlay ) and !$class::$customBadgeNumberOverlay )
{
return "";
}
}
return <<<SVG
<style>
svg text.svg__text_overlay:not(#x) {
+156 -28
View File
@@ -12,6 +12,7 @@ namespace IPS\core;
/* To prevent PHP errors (extending class does not exist) revealing path */
use Exception;
use InvalidArgumentException;
use IPS\core\modules\admin\settings\dataLayer as DataLayerController;
use IPS\Data\Store;
@@ -23,6 +24,7 @@ use IPS\Member;
use IPS\Member\Group;
use IPS\Output;
use IPS\Patterns\Singleton;
use IPS\Platform\Bridge;
use IPS\Request;
use IPS\Session;
use IPS\Settings;
@@ -284,6 +286,16 @@ HTML,
'type' => 'string',
'page_level' => true,
],
'content_is_followed' => [
'event_keys' => ['content_*', 'filter_*', 'sort', 'file_download'],
'pii' => false,
'formatted_name' => 'content_is_followed',
'description' => 'Whether the user if following the content item.',
'short' => 'Content item followed',
'enabled' => true,
'type' => 'boolean',
'page_level' => true,
],
'comment_type' => [
'event_keys' => ['content_comment', 'content_react', 'content_quote'],
'pii' => false,
@@ -294,6 +306,17 @@ HTML,
'type' => 'string',
'page_level' => false,
],
'content_anonymous' => [
'event_keys' => ['content_*', 'filter_*', 'sort', 'file_download'],
'pii' => false,
'formatted_name' => 'content_anonymous',
'description' => 'Whether the relevant content (item/comment/review) was created anonymously. This attribute is only possible for certain content types, and when true the author ID and Name will be obfuscated',
'short' => 'Content is anonymous',
'enabled' => true,
'type' => 'boolean',
'page_level' => true,
'default' => false,
],
'content_container_id' => [
'event_keys' => ['content_*', 'filter_*', 'sort', 'file_download'],
'pii' => false,
@@ -334,6 +357,17 @@ HTML,
'type' => 'string',
'page_level' => true,
],
'message_recipient_count' => [
'event_keys' => ['content_*', 'filter_*', 'sort', 'file_download'],
'pii' => false,
'formatted_name' => 'message_recipient_count',
'description' => 'The number of recipients a message was sent to. If the content event or page context is not relevant to a message, this will be <code>null</code>',
'short' => 'PM Recipient Count',
'enabled' => true,
'type' => 'number | null',
'default' => null,
'page_level' => true,
],
'ips_key' => [
'event_keys' => ['*'],
'pii' => false,
@@ -700,7 +734,7 @@ HTML,
$piiProperties = array_merge( $piiProperties, $this->relatedPiiProperties( $propertyKey ) );
continue;
}
$value = $this->getSsoId( $value );
$value = $value ? $this->getSsoId( $value ) : $value;
}
$_values[$propertyKey] = $value;
@@ -728,31 +762,28 @@ HTML,
{
$output = array();
$piiExcludes = array();
$pii = Settings::i()->core_datalayer_include_pii;
foreach ( $this->propertiesConfiguration as $key => $property )
{
if ( $key === 'content_anonymous' and @$properties[$key] )
{
$piiExcludes = array_merge( $piiExcludes, $this->relatedPiiProperties( 'author_id' ) );
}
if ( !$property['enabled'] OR !isset( $properties[$key] ) )
{
continue;
}
if ( !$pii AND ( $property['pii'] ?? 0 ) )
if ( !Settings::i()->core_datalayer_include_pii AND ( $property['pii'] ?? 0 ) )
{
continue;
}
/* Check the member's pii preference. If we can verify the member's PII should NOT be exposed, don't allow it to be added */
if ( Settings::i()->core_datalayer_member_pii_choice AND
@$property['replace_with_sso'] AND
( $originalID = array_search( $properties[$key], $this->ssoIds ) )
)
if ( @$property['replace_with_sso'] and ( $originalID = array_search( $properties[$key], $this->ssoIds ) ) and !$this->includeSSOForMember( $originalID ) )
{
$member = Member::load( $originalID );
if ( $member->member_id AND $member->members_bitoptions['datalayer_pii_optout'] )
{
$piiExcludes = array_merge( $piiExcludes, $this->relatedPiiProperties( $key ) );
continue;
}
$piiExcludes = array_merge( $piiExcludes, $this->relatedPiiProperties( $key ) );
continue;
}
$output[$key] = $properties[$key];
@@ -832,13 +863,13 @@ HTML,
}
/* If this has PII, is PII allowed? */
if ( ( $properties[$key]['pii'] ?? 0 ) AND ! Settings::i()->core_datalayer_include_pii )
if ( @$properties[$key]['pii'] AND !Settings::i()->core_datalayer_include_pii )
{
return;
}
/* Replace with sso id if needed */
if ( $properties[$key]['replace_with_sso'] ?? 0 )
if ( @$properties[$key]['replace_with_sso'] )
{
$value = $this->getSsoId( $value );
}
@@ -942,7 +973,7 @@ HTML,
}
/* Logged In Member PII */
if ( Settings::i()->core_datalayer_include_pii AND ( !Settings::i()->core_datalayer_member_pii_choice OR !Member::loggedIn()->members_bitoptions['datalayer_pii_optout'] ) )
if ( $this->includeSSOForMember() )
{
if ( $properties['member_id']['enabled'] )
{
@@ -1002,6 +1033,11 @@ HTML,
$piiExcludes = array();
foreach ( $properties as $key => $property )
{
if ( $key === 'content_anonymous' and @$_context[$key] )
{
$piiExcludes = array_merge( $piiExcludes, $this->relatedPiiProperties( 'author_id' ) );
}
/* Make sure all are page level, enabled, and valid */
if ( !$property['enabled'] or !$property['page_level'] )
{
@@ -1010,20 +1046,11 @@ HTML,
$fkey = $property['formatted_name'];
/* If we can determine a member based on this property's PII, only include the related PII if we know that member hasn't opted out? */
if (
( $property['replace_with_sso'] ?? 0 ) AND
Settings::i()->core_datalayer_include_pii AND
Settings::i()->core_datalayer_member_pii_choice AND
isset( $_context[ $key ] ) AND
( $originalId = array_search( $_context[ $key ], $this->ssoIds ) )
if ( @$property['replace_with_sso'] AND isset( $_context[ $key ] ) AND ( $originalId = array_search( $_context[ $key ], $this->ssoIds ) AND !( $this->includeSSOForMember( $originalId ) ) )
)
{
$member = Member::load( $originalId );
if ( !( $member->member_id AND !$member->members_bitoptions['datalayer_pii_optout'] ) )
{
$piiExcludes = array_merge( $piiExcludes, $this->relatedPiiProperties( $key ) );
continue;
}
$piiExcludes = array_merge( $piiExcludes, $this->relatedPiiProperties( $key ) );
continue;
}
/* Add context properties set by the system */
@@ -1063,6 +1090,52 @@ HTML,
return str_replace( ["\u{2028}", "\u{2029}"], ['', ''], $out );
}
/**
* @param Member|int|null $member
* @return bool
*/
public function includeSSOForMember( Member|int|null $member=null ) :bool
{
/* Can we determine using settings? */
if ( !Settings::i()->core_datalayer_include_pii )
{
return false;
}
if ( Settings::i()->core_datalayer_include_pii and !Settings::i()->core_datalayer_member_pii_choice )
{
return true;
}
if ( is_null( $member ) )
{
$member = Member::loggedIn();
}
static $res = [];
$memberId = ( is_int( $member ) ? $member : $member->member_id ) ?: 0;
if ( array_key_exists( 'member_' . $memberId, $res ) )
{
return $res['member_'.$memberId];
}
if ( is_int( $member ) )
{
try
{
$member = Member::load( $memberId );
}
catch ( OutOfRangeException )
{
$res['member_'.$memberId] = false;
return false; // edge case, but if the member doesn't exist, let's not expose PII on the grounds that there technically shouldn't really be PII to expose
}
}
$res['member_' . $memberId] = !$member->members_bitoptions['datalayer_pii_optout'];
return $res['member_' . $memberId];
}
public static string $configCacheKey = 'dataLayerConfig';
@@ -1731,4 +1804,59 @@ HTML,
return array_merge( $properties, $additionalProperties );
}
/**
* Method to determine whether
*
* @param string[]|string $features Features that must be available for this site's package for the data layer to be enabled.
*
* @return bool
*/
public static function enabled( array|string $features=[] ) : bool
{
if ( !Settings::i()->core_datalayer_enabled or Output::i()->bypassDataLayer )
{
return false;
}
else if ( empty( $features ) )
{
return true;
}
/* Now let's determine if the data layer is enabled for the provided features. We'll cache the result */
static $results = [];
if ( is_string( $features ) )
{
$cacheKey = $features;
$features = [ $features];
}
else
{
$cacheKey = implode( ',', sort( $features ) );
}
if ( !array_key_exists( $cacheKey, $results ) )
{
/* assume it's enabled till we find one that's not */
$results[$cacheKey] = true;
foreach ( $features as $feature )
{
try
{
if ( !Bridge::i()->featureIsEnabled( $feature ) )
{
$results[$cacheKey] = false;
break;
}
}
catch ( Exception )
{
$results[$cacheKey] = false;
break;
}
}
}
return $results[$cacheKey];
}
}
@@ -13,11 +13,14 @@ namespace IPS\core\Feature;
/* To prevent PHP errors (extending class does not exist) revealing path */
use Exception;
use IPS\Content;
use IPS\Content\Featurable;
use IPS\core\Feature;
use IPS\Db;
use IPS\Helpers\Table\Table;
use IPS\Content\Tag as SystemTag;
use IPS\Http\Url;
use IPS\IPS;
use IPS\Member;
use IPS\Theme;
use function defined;
@@ -125,6 +128,18 @@ class PublicTable extends Table
}
$where[] = array( '( promote_hide=0 )' );
/* Get only the valid classes. We may have disabled applications or legacy content that doesn't use the right trait */
$classes = [];
foreach( Content::routedClasses() as $_class )
{
if( IPS::classUsesTrait( $_class, Featurable::class ) )
{
$classes[] = $_class;
}
}
$where[] = [ Db::i()->in( 'promote_class', $classes ) ];
/* Get Count */
$count = Db::i()->select( 'COUNT(*) as cnt', 'core_content_promote', $where )->first();
+3 -5
View File
@@ -107,11 +107,9 @@ class Table extends TableHelper
$it = Db::i()->select( '*', 'core_content_promote', $where, $sortBy, array( ( $this->limit * ( $this->page - 1 ) ), $this->limit ) );
$rows = iterator_to_array( $it );
if ( ! count( $rows ) )
{
static::$rows = [];
}
else
static::$rows = [];
if ( count( $rows ) )
{
foreach ( $rows as $index => $row )
{
+157 -2
View File
@@ -14,13 +14,19 @@ namespace IPS\core\Followed;
/* To prevent PHP errors (extending class does not exist) revealing path */
use InvalidArgumentException;
use IPS\Api\Webhook;
use IPS\Application;
use IPS\Content\Tag;
use IPS\DateTime;
use IPS\Db;
use IPS\Events\Event;
use IPS\IPS;
use IPS\Member;
use IPS\Member\Club;
use IPS\Node\Model;
use IPS\Patterns\ActiveRecord;
use OutOfRangeException;
use UnderflowException;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
@@ -59,6 +65,17 @@ class Follow extends ActiveRecord
*/
protected static array $multitonMap = array();
/**
* Set Default Values (overriding $defaultValues)
*
* @return void
*/
protected function setDefaultValues() : void
{
$this->notify_meta = '';
$this->visible = 1;
}
/**
* Save
*
@@ -66,16 +83,24 @@ class Follow extends ActiveRecord
*/
public function save(): void
{
$sendWebhook = false;
if( $this->_new )
{
$this->visible = 1;
$this->notify_sent = 0;
$this->added = time();
$sendWebhook = true;
}
$this->id = md5( $this->app . ';' . $this->area . ';' . $this->rel_id . ';' . $this->member_id );
parent::save();
if ( $sendWebhook )
{
Webhook::fire( 'content_followed', $this);
}
$this->clearFollowCache();
}
/**
@@ -93,6 +118,7 @@ class Follow extends ActiveRecord
* @apiresponse datetime|null followSent Date and time the last notification was sent, or NULL if none has been sent
* @apiresponse string followName Textual representation of the content that was followed (title, name, etc.)
* @apiresponse string followUrl URL to the content that was followed
* @apiresponse \IPS\Member followMember Member following the item
*/
public function apiOutput( Member $authorizedMember = NULL ): array|null
{
@@ -186,6 +212,7 @@ class Follow extends ActiveRecord
'followSent' => $this->notify_sent ? DateTime::ts( $this->notify_sent )->rfc3339() : NULL,
'followName' => $name ?? '',
'followUrl' => ( isset( $url ) ? (string) $url : '' ),
'followMember' => Member::load($this->member_id)->apiOutput($authorizedMember)
);
}
@@ -197,9 +224,116 @@ class Follow extends ActiveRecord
'core' => [
'member' => Member::class,
'club' => Club::class,
'tag' => Tag::class
],
];
/**
* Return the app and area parameters
*
* @param object $object
* @return array
*/
public static function getFollowParameters( object $object ) : array
{
$objectClass = get_class( $object );
foreach( static::$specialFollowClasses as $app => $areas )
{
foreach( $areas as $area => $class )
{
if( $class == $objectClass )
{
return [
'app' => $app,
'area' => $area
];
}
}
}
$itemClass = ( $object instanceof Model ) ? $object::$contentItemClass : get_class( $object );
return [
'app' => $itemClass::$application,
'area' => mb_strtolower( mb_substr( $objectClass, mb_strrpos( $objectClass, '\\' ) + 1 ) )
];
}
/**
* Find a record based on an object and a member
*
* @param object $object
* @param Member|null $member
* @return static|null
* @throws OutOfRangeException
*/
public static function loadByObject( object $object, ?Member $member=null ) : static|null
{
$member = $member ?: Member::loggedIn();
/* Guests can never follow anything */
if ( !$member->member_id )
{
throw new OutOfRangeException;
}
$params = static::getFollowParameters( $object );
$idColumn = $object::$databaseColumnId;
try
{
$row = Db::i()->select( '*', 'core_follow', [ 'follow_app=? and follow_area=? and follow_rel_id=? and follow_member_id=?', $params['app'], $params['area'], $object->$idColumn, $member->member_id ] )->first();
return static::constructFromData( $row );
}
catch( UnderflowException )
{
throw new OutOfRangeException;
}
}
/**
* @return string
*/
public function get__class() : string
{
if( isset( static::$specialFollowClasses[ $this->app ][ $this->area ] ) )
{
return static::$specialFollowClasses[ $this->app ][ $this->area ];
}
return 'IPS\\' . $this->app . '\\' . IPS::mb_ucfirst( $this->area );
}
/**
* @return ActiveRecord|null
*/
public function get__item() : ActiveRecord|null
{
$class = $this->_class;
try
{
return $class::load( $this->rel_id );
}
catch( OutOfRangeException )
{
return null;
}
}
/**
* Set as many properties as we can get from the object
*
* @param object $object
* @return void
*/
public function set_object( object $object ) : void
{
$params = static::getFollowParameters( $object );
$this->app = $params['app'];
$this->area = $params['area'];
$idColumn = $object::$databaseColumnId;
$this->rel_id = $object->$idColumn;
}
/**
* Get the class to follow
*
@@ -225,4 +359,25 @@ class Follow extends ActiveRecord
return $classToFollow;
}
/**
* Delete corresponding record from the follow cache
*
* @return void
*/
protected function clearFollowCache() : void
{
Db::i()->delete( 'core_follow_count_cache', array( 'id=? AND class=?', $this->rel_id, $this->_class ) );
}
public function delete(): void
{
Webhook::fire( 'content_unfollowed', $this );
Event::fire( 'onUnfollow', Member::load( $this->member_id ), [ $this->_item ] );
parent::delete();
/* Delete from the cache */
$this->clearFollowCache();
}
}
@@ -64,12 +64,12 @@ class SocialProfiles extends KeyValue
*
* @param string $name Form helper name
* @param mixed|null $defaultValue Default value for the helper
* @param bool $required Helper is required (TRUE) or not (FALSE)
* @param bool|null $required Helper is required (TRUE) or not (FALSE)
* @param array $options Options for the helper instance
* @return void
*@see \IPS\Helpers\Form\Abstract::__construct
*/
public function __construct(string $name, mixed $defaultValue=NULL, bool $required=FALSE, array $options=array() )
public function __construct(string $name, mixed $defaultValue=NULL, ?bool $required=FALSE, array $options=array() )
{
$options = array_merge( $this->defaultOptions, $options );
@@ -0,0 +1,316 @@
<?php
/**
* @brief Mailchimp
* @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
* @since 8/13/2025
*/
namespace IPS\core;
/* To prevent PHP errors (extending class does not exist) revealing path */
use DomainException;
use IPS\Http\Request\Exception as RequestException;
use IPS\Http\Url;
use IPS\Log;
use IPS\Member;
use IPS\Settings;
use function str_replace;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Mailchimp
{
/**
* @var string
*/
protected string $apiKey = '';
/**
* @var string
*/
protected string $prefix = '';
/**
* @var string
*/
protected static string $apiEndpoint = 'https://<prefix>.api.mailchimp.com/3.0';
public function __construct()
{
/* Are we testing a new API key? */
$this->apiKey = Settings::i()->mailchimp_api_key;
$this->prefix = Settings::i()->mailchimp_server_prefix;
}
/**
* @var Mailchimp|null
*/
protected static ?Mailchimp $instance = null;
/**
* @return static
*/
public static function i() : static
{
if( static::$instance === null )
{
static::$instance = new static();
}
return static::$instance;
}
/**
* Test the connection
*
* @param string|null $apiKey
* @param string|null $prefix
* @return bool
*/
public function test( ?string $apiKey=null, ?string $prefix=null ) : bool
{
/* Are we overriding the settings? */
if( $apiKey !== null )
{
$this->apiKey = $apiKey;
}
if( $prefix !== null )
{
$this->prefix = $prefix;
}
if( $result = $this->makeRequest( 'ping', [], 'GET' ) )
{
if( isset( $result['health_status'] ) )
{
return true;
}
throw new DomainException( $result['title'] );
}
throw new DomainException;
}
/**
* Return all available lists
*
* @return array
*/
public function getLists() : array
{
$lists = [];
if( $result = $this->makeRequest( "lists", [], 'GET' ) )
{
if( !empty( $result['lists'] ) )
{
foreach( $result['lists'] as $list )
{
$lists[ $list['id'] ] = $list['name'];
}
}
}
return $lists;
}
/**
* Subscribe a member to a list
*
* @param Member|null $member
* @param bool $subscribe
* @return bool
*/
public function subscribeMember( ?Member $member=null, bool $subscribe=true ) : bool
{
if( !$this->getListId() )
{
return false;
}
$member = $member ?? Member::loggedIn();
/* Did we unsubscribe emails? */
if( !$member->allow_admin_mails and $subscribe )
{
return false;
}
if( $result = $this->makeRequest( "lists/" . $this->getListId(), [
'members' => [
[
'email_address' => $member->email,
'status' => ( $subscribe ? 'subscribed' : 'unsubscribed' )
]
],
'update_existing' => true
] ) )
{
/* Did we have any errors? */
if( !empty( $result['errors'] ) )
{
/* Make sure it's for this email address */
foreach( $result['errors'] as $error )
{
if( $error['email_address'] == $member->email )
{
$member->logHistory( 'core', 'mailchimp_subscribed', [
'error_code' => $error['error_code'],
'error_message' => $error['error']
]);
return false;
}
}
}
/* Log a successful subscription */
$member->logHistory( 'core', 'mailchimp_subscribed', [ 'list' => $this->getListName(), 'action' => ( $subscribe ? 'subscribed' : 'unsubscribed' ) ] );
return true;
}
return false;
}
/**
* Update a member's email address
*
* @param Member|null $member
* @param string $oldEmail
* @return bool
*/
public function updateMemberEmail( ?Member $member , string $oldEmail ) : bool
{
if( !$this->getListId() )
{
return false;
}
/* Did we unsubscribe emails? */
if( !$member->allow_admin_mails )
{
return false;
}
if( $result = $this->makeRequest( "lists/" . $this->getListId() . "/members/" . $this->subscriberHash( $oldEmail ), [
'email_address' => $member->email,
'status_if_new' => 'subscribed'
], 'PUT' ) )
{
/* Did we have any errors? */
if( !empty( $result['errors'] ) )
{
/* Make sure it's for either the old or new email address */
foreach( $result['errors'] as $error )
{
if( $error['email_address'] == $member->email or $error['email_address'] == $oldEmail )
{
$member->logHistory( 'core', 'mailchimp_subscribed', [
'error_code' => $error['error_code'],
'error_message' => $error['error']
]);
return false;
}
}
}
/* Log a successful subscription */
$member->logHistory( 'core', 'mailchimp_subscribed', [ 'list' => $this->getListName(), 'action' => 'email_update' ] );
return true;
}
return false;
}
/**
* Return the ID of the list to which we are subscribing
*
* @return string
*/
protected function getListId() : string
{
if( $value = json_decode( Settings::i()->mailchimp_lists, true ) )
{
return $value['id'];
}
return '';
}
/**
* Return the name of the list to which we are subscribing
*
* @return string
*/
protected function getListName() : string
{
if( $value = json_decode( Settings::i()->mailchimp_lists, true ) )
{
return $value['name'];
}
return '';
}
/**
* Generate a hash that mailchimp will recognize
*
* @param string $email
* @return string
*/
protected function subscriberHash( string $email ) : string
{
return md5( strtolower( $email ) );
}
/**
* Make an API request to Mailchimp
*
* @param string $endpoint
* @param array $params
* @param string $method
* @return array|null
*/
protected function makeRequest( string $endpoint, array $params=array(), string $method='POST' ) : ?array
{
/* Make sure we have an API key */
if( empty( $this->apiKey ) or empty( $this->prefix ) )
{
return null;
}
$method = strtolower( $method );
$url = Url::external( str_replace( "<prefix>", $this->prefix, static::$apiEndpoint ) . '/' . ltrim( $endpoint, '/' ) );
if( $method == 'get' and !empty( $params ) )
{
$url = $url->setQueryString( $params );
$params = null;
}
try
{
$response = $url->request()
->login( 'apiKey', $this->apiKey )
->setHeaders([
'Accept' => 'application/json',
'Content-Type' => 'application/json'
])->$method( !empty( $params ) ? json_encode( $params ) : null );
return $response->decodeJson();
}
catch( RequestException $e )
{
Log::log( $e->getMessage(), 'mailchimp' );
return null;
}
}
}
@@ -18,7 +18,9 @@ use InvalidArgumentException;
use IPS\Application\Module;
use IPS\Content\Comment;
use IPS\Content\Item;
use IPS\Content\Permissions;
use IPS\core\Alerts\Alert;
use IPS\core\DataLayer;
use IPS\core\Reports\Report;
use IPS\core\Warnings\Warning;
use IPS\DateTime;
@@ -26,6 +28,7 @@ use IPS\Db;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Member as FormMember;
use IPS\Http\Url;
use IPS\Lang;
use IPS\Member;
use IPS\Node\Model;
use IPS\Output;
@@ -180,10 +183,28 @@ class Conversation extends Item
* @param Member $member The member
* @param Model|null $container Container (e.g. forum) ID, if appropriate
* @param bool $showError If TRUE, rather than returning a boolean value, will display an error
* @param Alert|null $alert Is this a reply to an alert?
* @return bool
*/
public static function canCreate( Member $member, ?Model $container=NULL, bool $showError=FALSE ) : bool
public static function canCreate( Member $member, ?Model $container=NULL, bool $showError=FALSE, Alert $alert = null ) : bool
{
/* If this conversation is associated with an alert, skip the rest of the permission checks, the user should be able to reply */
if ( ( $alert and $alert instanceof Alert ) or isset( Request::i()->alert ) )
{
if( isset( Request::i()->alert ) )
{
$alert = Alert::load( Request::i()->alert );
}
try
{
if( $alert->forMember( Member::loggedIn() ) AND $alert->reply == Alert::REPLY_REQUIRED )
{
return TRUE;
}
}
catch ( OutOfRangeException $e ) {}
}
/* Can we access the module? */
if ( !parent::canCreate( $member, $container, $showError ) )
{
@@ -200,22 +221,6 @@ class Conversation extends Item
return FALSE;
}
/* If this conversation is associated with an alert, skip the
rest of the permission checks, the user should be able to reply */
if ( isset( Request::i()->alert ) )
{
try
{
$alert = Alert::load( Request::i()->alert );
if( $alert->forMember( Member::loggedIn() ) AND $alert->reply == Alert::REPLY_REQUIRED )
{
return TRUE;
}
}
catch ( OutOfRangeException $e ) {}
}
/* Have we exceeded our limit for the day/minute? */
if ( $member->group['g_pm_perday'] !== -1 )
@@ -401,8 +406,13 @@ class Conversation extends Item
{
return FALSE;
}
/* Extensions are last because all of the above needs to be honored */
if( $permCheck = Permissions::can( 'receive', new static, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
return TRUE;
}
@@ -475,6 +485,8 @@ class Conversation extends Item
}
}
/**
* Does a member have permission to access?
*
@@ -485,6 +497,12 @@ class Conversation extends Item
{
$member = $member ?: Member::loggedIn();
/* Extensions check */
if( $permCheck = Permissions::can( 'view', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
/* Is the user part of the conversation? */
foreach ( $this->maps() as $map )
{
@@ -993,4 +1011,59 @@ class Conversation extends Item
return $form;
}
/**
* 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|null title Title ( if available )
* @apiresponse \IPS\Member author The member that created the item
* @apiresponse datetime date Date
* @apiresponse string content Content
* @apiresponse string url URL
* @apiresponse [\IPS\Member] participants Active users in this conversation
*/
public function apiOutput( Member $authorizedMember = NULL ): array
{
$return = [
'id' => $this->id,
'title' => $this->mapped( 'title' ),
'author' => $this->author()->apiOutput( $authorizedMember ),
'date' => DateTime::ts( $this->mapped('date') )->rfc3339(),
'content' => $this->content(),
'url' => (string) $this->url(),
'participants' => []
];
foreach( $this->maps() as $map )
{
if( $map['map_user_active'] )
{
$return['participants'][] = Member::load( $map['map_user_id'] )->apiOutput( $authorizedMember );
}
}
return $return;
}
/**
* @param Comment|null $comment
* @param array $createOrEditValues=[]
* @param bool $clearCache=false
* @return array
*/
public function getDataLayerProperties ( ?Comment $comment = null, array $createOrEditValues = [], bool $clearCache=false ): array
{
$properties = parent::getDataLayerProperties( $comment, $createOrEditValues, $clearCache );
if ( isset( $properties['content_area'] ) )
{
$properties['content_area'] = Lang::load( Lang::defaultLanguage() )->addToStack( 'personal_conversations' );
}
$updates = DataLayer::i()->filterProperties([ 'message_recipient_count' => $this->activeParticipants ]);
return array_replace( $properties, $updates );
}
}
@@ -20,6 +20,7 @@ use IPS\Content\Item;
use IPS\Content\Reportable;
use IPS\DateTime;
use IPS\Db;
use IPS\IPS;
use IPS\Member;
use IPS\Node\Model;
use IPS\Notification;
@@ -229,4 +230,32 @@ class Message extends Comment
parent::move( $item );
}
/**
* 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 int item_id The ID number of the item this belongs to
* @apiresponse \IPS\Member author Author
* @apiresponse datetime date Date
* @apiresponse string content The content
* @apiresponse string url URL to content
*/
public function apiOutput( Member|null $authorizedMember = NULL ): array
{
$idColumn = static::$databaseColumnId;
$itemColumn = static::$databaseColumnMap['item'];
$return = array(
'id' => $this->$idColumn,
'item_id' => $this->$itemColumn,
'author' => $this->author()->apiOutput( $authorizedMember ),
'date' => DateTime::ts( $this->mapped('date') )->rfc3339(),
'content' => $this->content(),
'url' => (string) $this->url()
);
return $return;
}
}
@@ -669,8 +669,14 @@ class Profanity extends ActiveRecord implements JsonSerializable
}
}
}
/* If we have tags, and it's still null, that means none of the tags were worth checking (e.g. local attachments) */
if( count( $tags ) and $good === null )
{
$good = true;
}
if ( $good !== TRUE AND Settings::i()->ipb_url_filter_option == 'white' )
if ( count($tags) and $good !== TRUE AND Settings::i()->ipb_url_filter_option == 'white' )
{
return $good;
}
+48 -2
View File
@@ -23,6 +23,7 @@ use IPS\core\Reports\Comment as ReportComment;
use IPS\DateTime;
use IPS\Db;
use IPS\Email;
use IPS\Events\Event;
use IPS\Http\Url;
use IPS\IPS;
use IPS\Log;
@@ -149,6 +150,8 @@ class Report extends Item
*/
public static bool $skipIndexNow = TRUE;
protected static array|bool $_bypassDataLayerEvents = true;
/**
* Get all the report types for this report index
@@ -189,6 +192,9 @@ class Report extends Item
$this->status = $status;
$this->save();
/* Fire an event */
Event::fire( 'onCreateOrEdit', $this, [ array(), false ] );
/* We only need to notify if there is an outcome */
if ( $status == static::STATUS_CLOSED or static::STATUS_REJECTED )
{
@@ -311,6 +317,9 @@ class Report extends Item
$comment = ReportComment::create( $this, $content, TRUE, NULL, NULL, $member, new DateTime );
$comment->save();
/* And fire an event for the comment */
Event::fire( 'onCreateOrEdit', $comment, [ array(), true ] );
/* And add to the moderator log */
Session::i()->modLog( 'modlog__action_update_report_status', array( $this->url()->__toString() => FALSE ) );
}
@@ -908,8 +917,6 @@ class Report extends Item
}
}
$extensionWhereClause = '';
/* Skip this if we have global permissions */
if( is_array( $perms ) and !$perms['can_view_reports'] )
{
@@ -1001,4 +1008,43 @@ class Report extends Item
return FALSE;
}
/**
* Get output for API
*
* @param Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials
* @param bool $includeItem Should the assigned item be attached to the response?
* @return array
* @apiresponse int id ID
* @apiresponse int date Date of report
* @apiresponse \IPS\Member reported_by Member who reported the item
* @apiresponse string item_class Content Class
* @apiresponse int item_id Content ID
* @apiresponse [\IPS\Content\Item|\IPS\Content\Comment] content Content Object
*/
public function apiOutput( Member $authorizedMember = NULL ): array
{
$className = $this->class;
try
{
$reportedContent = $className::load( $this->content_id );
}
catch ( OutOfRangeException $ex )
{
/* No content, no moderation, no cry */
$reportedContent = FALSE;
}
$response = [];
$response['id'] = $this->id;
$response['date'] = $this->mapped('date');
$response['reported_by'] = $this->author()->apiOutput($authorizedMember);
$response['item_class'] = $this->class;
$response['item_id'] = $this->content_id;
$response['content'] = $reportedContent ? $reportedContent->apiOutput($authorizedMember) : NULL;
return $response;
}
}
+3 -13
View File
@@ -33,6 +33,7 @@ use IPS\Http\Request\Exception;
use IPS\Http\Url;
use IPS\Http\Url\Exception as UrlException;
use IPS\Image;
use IPS\IPS;
use IPS\Member;
use IPS\Node\Model;
use IPS\Settings;
@@ -602,20 +603,9 @@ class Import extends Model
'rss_imported_import_id' => $this->id
), TRUE );
if ( $this->auto_follow )
if ( $this->auto_follow and IPS::classUsesTrait( $item, Content\Followable::class ) )
{
Db::i()->insert( 'core_follow', array(
'follow_id' => md5( $class::$application . ';' . $followArea . ';' . $item->$idColumn . ';' . $member->member_id ),
'follow_app' => $class::$application,
'follow_area' => $followArea,
'follow_rel_id' => $item->$idColumn,
'follow_member_id' => $member->member_id,
'follow_is_anon' => 0,
'follow_added' => time(),
'follow_notify_do' => 1,
'follow_notify_freq' => 'immediate',
'follow_visible' => 1,
) );
$item->follow( 'immediate', true, $member );
}
/* Re-index to pick up any changes to hidden, etc */
+275 -13
View File
@@ -12,16 +12,31 @@ namespace IPS\core\Statistics;
/* To prevent PHP errors (extending class does not exist) revealing path */
use DateInterval;
use InvalidArgumentException;
use IPS\Application;
use IPS\DateTime;
use IPS\Db;
use IPS\Helpers\Form;
use IPS\Helpers\Form\DateRange;
use IPS\Helpers\Form\Select;
use IPS\Http\Url;
use IPS\Member;
use IPS\Output;
use IPS\Platform\Bridge;
use IPS\Request;
use IPS\Settings;
use IPS\Theme;
use OutOfRangeException;
use UnderflowException;
use function array_keys;
use function defined;
use function in_array;
use function is_array;
use function is_int;
use function is_numeric;
use function strval;
use function time;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
@@ -37,7 +52,14 @@ abstract class Chart
/**
* @brief Controller
*/
public ?string $controller = NULL;
public ?string $controller = 'NULL';
/**
* Whether this chart can be saved so that extra tabs exist. When this is true, the chart can only be saved to reports
*
* @var bool
*/
public bool $noTabs = false;
/**
* Get Chart
@@ -112,9 +134,9 @@ abstract class Chart
{
if ( !is_array( $data ) )
{
$data = Db::i()->select( '*', 'core_saved_charts', array( "chart_id=? AND chart_member=?", $data, $check->member_id ) )->first();
$data = Db::i()->select( '*', 'core_saved_charts', array( "chart_id=? AND (chart_member=? OR chart_member IS NULL)", $data, $check->member_id ) )->first();
}
else if ( $data['chart_member'] !== $check->member_id )
else if ( $data['chart_member'] !== $check->member_id and $data['chart_member'] !== null and $data['chart_member'] !== 0 )
{
throw new UnderflowException;
}
@@ -123,9 +145,9 @@ abstract class Chart
{
if ( !is_array( $data ) )
{
$data = Db::i()->select( '*','core_saved_charts', array( "chart_id=? AND chart_member=?", $data, Member::loggedIn()->member_id ) )->first();
$data = Db::i()->select( '*', 'core_saved_charts', array( "chart_id=? AND (chart_member=? OR chart_member IS NULL OR chart_member=0)", $data, Member::loggedIn()->member_id ) )->first();
}
else if ( $data['chart_member'] !== Member::loggedIn()->member_id )
else if ( $data['chart_member'] !== Member::loggedIn()->member_id and $data['chart_member'] !== null and $data['chart_member'] !== 0 )
{
throw new UnderflowException;
}
@@ -157,10 +179,14 @@ abstract class Chart
$chart->showFilterTabs = FALSE;
$chart->showIntervals = FALSE;
$chart->showDateRange = FALSE;
$dateRange = static::getChartDateRanges( is_numeric( Request::i()->report_id ) ? (int) Request::i()->report_id : null );
$chart->start = $dateRange['start'];
$chart->end = $dateRange['end'];
return $chart;
}
catch( UnderflowException $e )
catch( UnderflowException )
{
/* Chart doesn't exist, or isn't owned by $member */
throw new OutOfRangeException;
@@ -180,15 +206,34 @@ abstract class Chart
public static function getChartsForMember( Url $url, bool $idsOnly = FALSE, ?Member $member = NULL ): array
{
$member ??= Member::loggedIn();
if ( !$member->member_id )
{
throw new InvalidArgumentException;
}
$return = [];
foreach( Db::i()->select( '*', 'core_saved_charts', array( "chart_member=?", $member->member_id ) ) AS $chart )
if ( Bridge::i()->featureIsEnabled( "community_health_stats" ) and !empty( $url->queryString['communityhealth'] ) )
{
$charts = Bridge::i()->getCommunityHealthCharts();
}
else
{
$return = [];
$reportID = is_numeric( Request::i()->report_id ) ? (int) Request::i()->report_id : 0;
$where = [];
if ( $reportID > 0 )
{
$where[] = array( "(chart_member IS NULL OR chart_member=? OR chart_member=0) AND chart_report_id=?", $member->member_id, $reportID );
}
else
{
$where[] = array( "(chart_member IS NULL OR chart_member=0) AND chart_report_id IS NULL", $member->member_id );
}
$charts = Db::i()->select( '*', 'core_saved_charts', $where );
}
foreach( $charts AS $chart )
{
if ( $idsOnly )
{
@@ -198,13 +243,12 @@ abstract class Chart
{
try
{
$controller = explode( '_', $chart['chart_controller'] );
$return[$chart['chart_id']] = array(
'chart' => static::constructMemberChartFromData( $chart, $url, $member ),
'data' => $chart
);
}
catch( OutOfRangeException )
catch( OutOfRangeException $e )
{
continue;
}
@@ -213,4 +257,222 @@ abstract class Chart
return $return;
}
/**
* Get the saved blocks for the current page.
*
* @return array
*/
public static function getSavedBlocks() : array
{
static $toDisplay = null;
if ( !is_array( $toDisplay ) )
{
$where = [[ "(stat_member IS NULL OR stat_member=?)", Member::loggedIn()->member_id ]];
if ( Request::i()->report_id )
{
$where[] = [ "stat_report_id=?", Request::i()->report_id ];
}
else
{
$where[] = [ "stat_report_id IS NULL" ];
}
$allExtensions = Application::allExtensions( 'core', 'OverviewStatistics', TRUE );
$toDisplay = [];
foreach ( Db::i()->select( "*", "core_saved_overview_stats", $where ) as $savedStat )
{
$key = $savedStat['stat_app'] . "_" . $savedStat['stat_extension'];
if ( isset( $allExtensions[ $key ] ) and in_array( $savedStat['stat_subblock'], $allExtensions[ $key ]->getBlocks() ) )
{
$details = $allExtensions[ $key ]->getBlockDetails( $savedStat['stat_subblock'] );
$details['title'] = $savedStat['stat_title'];
$toDisplay[] = [
"blockKey" => $key,
"subblock" => $savedStat['stat_subblock'],
"saved_stat_id" => $savedStat['id'],
"details" => $details,
'page' => $allExtensions[ $key ]->page,
'extension' => $allExtensions[$key],
];
}
}
}
return $toDisplay;
}
/**
* Get the date range selector that is at the top of the saved reports acp page
* @return string
*/
public static function getBlockDateRangeForm( ?int $reportId=null ) : string
{
$options = array(
'7' => 'last_week',
'30' => 'last_month',
'90' => 'last_three_months',
'180' => 'last_six_months',
'365' => 'last_year',
'0' => 'alltime',
'-1' => 'custom'
);
$defaultRange = '7';
$dateRange = null;
if ( ( is_numeric( Request::i()->predate ) and isset( $options[Request::i()->predate] ) ) or ( is_numeric( @Request::i()->date['start'] ) and is_numeric( @Request::i()->date['end'] ) ) )
{
if ( is_numeric( @Request::i()->date['start'] ) and is_numeric( @Request::i()->date['end'] ) )
{
$dateRange = [
'start' => Request::i()->date['start'],
'end' => Request::i()->date['end']
];
$defaultRange = '-1';
}
else
{
$defaultRange = Request::i()->predate;
}
}
else if ( is_int( $reportId ) )
{
try
{
$currentReport = Db::i()->select( '*', 'core_reports', ['id=?', $reportId] )->first();
$defaultRange = strval($currentReport['report_range_timescale'] ?? '7');
if ( isset( $currentReport['report_range_start'] ) or isset( $currentReport['report_range_end'] ) )
{
$dateRange = [
'start'=> $currentReport['report_range_start'],
'end' => $currentReport['report_range_end'],
];
}
}
catch ( \UnderflowException ) {}
}
$form = new Form( 'posts', 'update' );
$form->add( new Select( 'predate', $defaultRange, FALSE, array( 'options' => $options, 'toggles' => array( '-1' => array( 'dateFilterInputs' ) ) ) ) );
$form->add( new DateRange( 'date', $dateRange, FALSE, array(), NULL, NULL, NULL, 'dateFilterInputs' ) );
$form->class .= " ipsBox ipsPull i-padding_2";
if ( is_int( $reportId ) and $values = $form->values() )
{
$update = [
'report_range_timescale' => (int) $values['predate'],
'report_range_start' => @$values['date']['start']?->getTimestamp() ?: null,
'report_range_end' => @$values['date']['end']?->getTimestamp() ?: null
];
Db::i()->update( 'core_reports', $update, ['id=?', $reportId] );
if ( Request::i()->isAjax() )
{
Output::i()->json( ['message' => 'ok'], 200 );
}
Output::i()->redirect( (string) Url::internal( 'app=core&module=overview&controller=mycharts' ) );
}
return $form->customTemplate( array( Theme::i()->getTemplate( 'stats' ), 'filtersOverviewForm' ) );
}
/**
* @return array
*/
public static function getAppsToFilterBy()
{
static $apps = null;
if ( !is_array( $apps ) )
{
$blocks = static::getSavedBlocks();
$apps = [];
foreach ( $blocks as $block )
{
if ( isset( $block['details']['app'] ) and !isset( $apps[ $block['details']['app'] ] ) )
{
$apps[ $block['details']['app'] ] = Application::load( $block['details']['app'] );
}
}
}
return $apps;
}
/**
* @param int|null $reportId The ID of the report, or leave null to use the default saved charts function
* @return array{start: DateTime|null, end: DateTime|null}
*/
public static function getChartDateRanges( ?int $reportId=null )
{
// It is possible this is included right in the request
if ( isset( Request::i()->predate ) and is_numeric( Request::i()->predate ) and intval( Request::i()->predate ) >= 0 )
{
$days = (int) Request::i()->predate;
if ( $days <= 0 )
{
return [
'start' => null,
'end' => null,
];
}
return [
'end' => DateTime::ts( time() )->setTime( 23, 59, 59 ),
'start' => DateTime::ts( time() )->setTime( 0, 0, 0 )->sub( new DateInterval( "P{$days}D" ) )
];
}
else if ( isset( Request::i()->date['start'] ) and isset( Request::i()->date['end'] ) ) // the way the JS works, the start and end are sent even if it just relies on the predate.
{
$start = is_numeric( Request::i()->date['start'] ) ? DateTime::ts( (int) Request::i()->date['start'] ) : null;
$end = is_numeric( Request::i()->date['end'] ) ? DateTime::ts( (int) Request::i()->date['end'] ) : null;
return [
'start' => $start,
'end' => $end,
];
}
if ( is_null( $reportId ) )
{
if ( isset( Settings::i()->core_saved_charts_range_timescale ) and is_numeric( Settings::i()->core_saved_charts_range_timescale ) and intval( Settings::i()->core_saved_charts_range_timescale ) >= 0 )
{
$days = (int) Settings::i()->core_saved_charts_range_timescale;
if ( $days <= 0 )
{
return [
'start' => null,
'end' => null,
];
}
return [
'end' => DateTime::ts( time() )->setTime( 23, 59, 59 ),
'start' => DateTime::ts( time() )->setTime( 0, 0, 0 )->sub( new DateInterval( "P{$days}D" ) )
];
}
$start = is_numeric( Settings::i()->core_saved_charts_range_start ) ? DateTime::ts( (int) Settings::i()->core_saved_charts_range_start ) : null;
$end = is_numeric( Settings::i()->core_saved_charts_range_end ) ? DateTime::ts( (int) Settings::i()->core_saved_charts_range_end ) : null;
}
else
{
try
{
$row = Db::i()->select( "*", "core_reports", [ "id=?", $reportId ?: 0 ] )->first();
$start = is_int( $row['report_range_start'] ) ? DateTime::ts( $row['report_range_start'] ) : null;
$end = is_int( $row['report_range_end'] ) ? DateTime::ts( $row['end'] ) : null;
}
catch ( UnderflowException $e )
{
$start = null;
$end = null;
}
}
return [
"start" => $start,
"end" => $end
];
}
}
+9 -1
View File
@@ -441,7 +441,15 @@ class Stream extends Model
$value = $containers->$class ?? 0;
/* @var Item $class */
$field = new Node( 'stream_containers_' . $class::$title, $value, FALSE, array( 'class' => $nodeClass, 'clubs' => TRUE, 'multiple' => TRUE, 'zeroVal' => 'all', 'forceOwner' => FALSE, 'subnodes' => FALSE ), NULL, NULL, NULL, 'stream_containers_' . $class::$title );
$field = new Node( 'stream_containers_' . $class::$title, $value, FALSE, array(
'class' => $nodeClass,
'clubs' => TRUE,
'multiple' => TRUE,
'zeroVal' => 'all',
'forceOwner' => FALSE,
'subnodes' => FALSE,
'nodeGroups' => TRUE,
), NULL, NULL, NULL, 'stream_containers_' . $class::$title );
$field->label = Member::loggedIn()->language()->addToStack( $nodeClass::$nodeTitle );
$form->add( $field );
}
+46 -5
View File
@@ -21,6 +21,7 @@ use IPS\Application;
use IPS\Content;
use IPS\Content\Comment;
use IPS\Content\Item;
use IPS\core\DataLayer;
use IPS\core\Messenger\Conversation;
use IPS\core\Messenger\Message;
use IPS\DateTime;
@@ -33,11 +34,13 @@ use IPS\Helpers\Form\Editor;
use IPS\Helpers\Form\Number;
use IPS\Helpers\Form\Select;
use IPS\Http\Url;
use IPS\Lang;
use IPS\Log;
use IPS\Member;
use IPS\Node\Model;
use IPS\Notification;
use IPS\Output;
use IPS\Platform\Bridge;
use IPS\Request;
use IPS\Session;
use IPS\Settings;
@@ -65,6 +68,7 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
class Warning extends Item
{
/* !\IPS\Patterns\ActiveRecord */
protected static array|bool $_bypassDataLayerEvents = true;
/**
* @brief Database Table
@@ -371,7 +375,8 @@ class Warning extends Item
{
$reasons['other'] = 'core_warn_reason_other';
}
$first = null;
foreach( $roots AS $root )
{
$first = $root;
@@ -379,15 +384,15 @@ class Warning extends Item
}
/* Build the form */
$elements[] = new Select( 'warn_reason', NULL, TRUE, array( 'options' => $reasons ) );
$elements[] = new Number( 'warn_points', 0, FALSE, array( 'valueToggles' => array( 'warn_remove' ), 'disabled' => ( !$first->points_override ) ) );
$elements[] = new Select( 'warn_reason', NULL, !empty( $reasons ), array( 'options' => $reasons ) );
$elements[] = new Number( 'warn_points', 0, FALSE, array( 'valueToggles' => array( 'warn_remove' ), 'disabled' => ( $first and !$first->points_override ) ) );
$elements[] = new Date( 'warn_remove', -1, FALSE, array( 'time' => TRUE, 'unlimited' => -1, 'unlimitedLang' => 'never' ), NULL, NULL, NULL, 'warn_remove' );
$elements[] = new Number( 'warn_cheeve_point_reduction', $first->cheev_point_reduction, FALSE, array( 'disabled' => ( !$first->cheev_override ) ) );
$elements[] = new Number( 'warn_cheeve_point_reduction', $first?->cheev_point_reduction, FALSE, array( 'disabled' => ( !$first?->cheev_override ) ) );
$elements[] = new Editor( 'warn_member_note', NULL, FALSE, array( 'app' => 'core', 'key' => 'Modcp', 'autoSaveKey' => "warn-member-" . Request::i()->id, 'attachIds' => ( $item === NULL ? NULL : array( $item->id, NULL, 'member' ) ), 'minimize' => 'warn_member_note_placeholder' ) );
$elements[] = new Editor( 'warn_mod_note', NULL, FALSE, array( 'app' => 'core', 'key' => 'Modcp', 'autoSaveKey' => "warn-mod-" . Request::i()->id, 'attachIds' => ( $item === NULL ? NULL : array( $item->id, NULL, 'mod' ) ), 'minimize' => 'warn_mod_note_placeholder' ) );
$elements[] = new CheckboxSet( 'warn_punishment', array(), FALSE, array(
'options' => array( 'mq' => 'warn_mq', 'rpa' => 'warn_rpa', 'suspend' => 'warn_suspend' ),
'toggles' => array( 'mq' => array( 'form_warn_mq' ), 'rpa' => array( 'form_warn_rpa' ), 'suspend' => array( 'form_warn_suspend' ) ),
'toggles' => array( 'mq' => array( 'warn_mq' ), 'rpa' => array( 'warn_rpa' ), 'suspend' => array( 'warn_suspend' ) ),
) );
$elements[] = new Date( 'warn_mq', -1, FALSE, array( 'time' => TRUE, 'unlimited' => -1, 'unlimitedLang' => 'indefinitely' ), function( $val )
{
@@ -523,6 +528,12 @@ class Warning extends Item
$member->save();
$member->logHistory( 'core', 'warning', array( 'wid' => $this->id, 'points' => $this->points, 'reason' => $this->reason, 'consequences' => $consequences ) );
/* Data Layer */
if ( DataLayer::enabled( 'analytics_full' ) )
{
DataLayer::i()->addEvent( 'warning_created', $this->getDataLayerProperties() );
}
/* Moderator log */
Session::i()->modLog( 'modlog__action_warn_user', array( $member->name => FALSE ) );
@@ -531,6 +542,36 @@ class Warning extends Item
parent::processAfterCreate( $comment, $values );
}
/**
* @param Comment|null $comment
* @param array $createOrEditValues
* @param bool $clearCache=false
* @return array
*/
public function getDataLayerProperties( ?Comment $comment = null, array $createOrEditValues = [], bool $clearCache=false ): array
{
if ( $comment !== null )
{
return parent::getDataLayerProperties( $comment, $createOrEditValues, $clearCache );
}
static $properties = null;
if ( $clearCache or $properties === null )
{
$lang = Lang::load( Lang::defaultLanguage() );
$reasonString = $lang->addToStack( ( $this->reason and $lang->checkKeyExists( Reason::$titleLangPrefix . $this->reason ) ) ? ( Reason::$titleLangPrefix . $this->reason ) : 'core_warn_reason_other' );
$lang->parseOutputForDisplay( $reasonString );
$properties = DataLayer::i()->filterProperties([
'warn_reason' => $reasonString,
'warn_reason_id' => $this->reason,
'warn_points' => $this->points,
'warn_member_id' => DataLayer::i()->getSsoId( $this->member ),
'warn_mod_id' => DataLayer::i()->getSsoId( $this->moderator ),
]);
}
return $properties;
}
/**
* Process the warning values
*