Version 4.7.0

This commit is contained in:
Neo committed 2025-12-19 16:00:35 -08:00
1 parent 517a5e1f70
commit 8ba7d43898
1933 files changed
+65613 -11332

No files matched your search

@@ -77,22 +77,25 @@ abstract class _AbstractAchievementAction
/**
* Get the labels for the people this action might give awards to
*
* @param array|NULL $filters Current filter values
*
* @return array
*/
public function awardOptions(): array
public function awardOptions( ?array $filters ): array
{
return [
'subject' => '',
];
}
/**
* Get the "other" people we need to award =stuff to
*
* @param mixed $extra Any additional information about what is happening (e.g. if a post is being made: the post object)
* @param mixed $extra Any additional information about what is happening (e.g. if a post is being made: the post object)
* @param array|NULL $filters Current filter values
* @return array
*/
public function awardOther( $extra = NULL ): array
public function awardOther( $extra = NULL, ?array $filters = NULL ): array
{
return [];
}
@@ -122,7 +122,7 @@ abstract class _AbstractContentAchievementAction extends AbstractAchievementActi
$return[ $k ] = $v;
}
$return['milestone'] = $nthFilter;
return $return;
}
@@ -185,7 +185,7 @@ abstract class _AbstractContentAchievementAction extends AbstractAchievementActi
{
$item = $extra->item();
}
if ( isset( $filters[ 'nodes_' . str_replace( '\\', '-', \get_class( $item ) ) ] ) )
{
if ( !\in_array( $item->container()->_id, $filters[ 'nodes_' . str_replace( '\\', '-', \get_class( $item ) ) ] ) )
@@ -193,9 +193,36 @@ abstract class _AbstractContentAchievementAction extends AbstractAchievementActi
return FALSE;
}
}
return TRUE;
}
return TRUE;
else
{
/* Make sure the class is a contentRouter approved class, mostly to stop private messages being awarded points */
foreach ( \IPS\Application::allExtensions( 'core', 'ContentRouter', FALSE ) as $extension )
{
foreach ( $extension->classes as $class )
{
if ( \get_class( $extra ) == $class )
{
return TRUE;
}
elseif ( isset( $class::$commentClass ) and \get_class( $extra ) == $class::$commentClass )
{
return TRUE;
}
elseif ( isset( $class::$reviewClass ) and \get_class( $extra ) == $class::$reviewClass )
{
return TRUE;
}
}
}
return FALSE;
}
/* Stop PHPStorm from complaining about incorrect return types >_> */
return FALSE;
}
/**
@@ -68,6 +68,7 @@ class _Badge extends \IPS\Node\Model
'app' => 'core',
'module' => 'achievements',
'prefix' => 'badges_',
'all' => 'badges_manage',
);
/**
@@ -135,8 +136,11 @@ class _Badge extends \IPS\Node\Model
*/
public function form( &$form )
{
/* Allow SVGs without the obscure hash removing the file extension */
\IPS\File::$safeFileExtensions[] = 'svg';
$form->add( new \IPS\Helpers\Form\Translatable( 'badge_name', NULL, TRUE, array( 'app' => 'core', 'key' => ( $this->id ? "core_badges_{$this->id}" : NULL ) ) ) );
$form->add( new \IPS\Helpers\Form\Upload( 'badge_image', $this->image ? \IPS\File::get( 'core_Badges', $this->image ) : NULL, TRUE, array( 'obscure' => FALSE, 'checkImage' => TRUE, 'allowedFileTypes' => array_merge( \IPS\Image::$imageExtensions, ['svg'] ), 'storageExtension' => 'core_Badges', 'allowStockPhotos' => FALSE ), function( $val ) {
$form->add( new \IPS\Helpers\Form\Upload( 'badge_image', $this->image ? \IPS\File::get( 'core_Badges', $this->image ) : NULL, TRUE, array( 'obscure' => TRUE, 'checkImage' => TRUE, 'allowedFileTypes' => array_merge( \IPS\Image::supportedExtensions(), ['svg'] ), 'storageExtension' => 'core_Badges', 'allowStockPhotos' => FALSE ), function( $val ) {
if( !$val )
{
throw new \DomainException('achievements_bad_image');
@@ -228,12 +232,47 @@ class _Badge extends \IPS\Node\Model
return FALSE;
}
$stats = $this->getBadgeStats();
return ( 100 / $stats['memberCount'] * $stats['badgeCount'][ $this->id ] ) < \IPS\Settings::i()->rare_badge_percent;
}
/**
* Return number of people who have this badge
*
* @param bool|null $roundUp Round number to nearest 100 to be less specific
* @return int
*/
public function ownedByCount( bool $roundUp=FALSE )
{
if ( $stats = $this->getBadgeStats() and isset( $stats['badgeCount'][ $this->id ] ) )
{
if ( $roundUp )
{
return ceil( $stats['badgeCount'][$this->id] / 100 ) * 100;
}
else
{
return $stats['badgeCount'][ $this->id ];
}
}
return NULL;
}
/**
* Get statistics for this badge
*
* @return array|null
*/
public function getBadgeStats(): ?array
{
/* Fetch the counts we need from the datastore. If they are old or non-existent, refresh and store in datastore for next time. */
$stats = NULL;
if( isset( \IPS\Data\Store::i()->badgeRarityStats ) )
if( isset( \IPS\Data\Store::i()->badgeStats ) )
{
$stats = json_decode( \IPS\Data\Store::i()->badgeRarityStats, true );
$stats = json_decode( \IPS\Data\Store::i()->badgeStats, true );
if( !isset( $stats['expiration'] ) OR time() > $stats['expiration'] OR !isset( $stats['badgeCount'][ $this->id ] ) )
{
@@ -243,21 +282,38 @@ class _Badge extends \IPS\Node\Model
if( $stats === NULL )
{
$exclude = json_decode( \IPS\Settings::i()->rules_exclude_groups, TRUE );
$where = [ [ 'completed=?', true ] ];
$subQuery = NULL;
if ( \is_array( $exclude ) and \count( $exclude ) )
{
$subQuery = \IPS\Db::i()->select( 'member_id', 'core_members', [ \IPS\Db::i()->in( 'member_group_id', $exclude ) ] );
$where[] = [ \IPS\Db::i()->in( 'member_group_id', $exclude, TRUE ) ];
}
$stats = array(
'expiration' => time() + 3600, // Cache for 60 minutes
'memberCount' => \IPS\Db::i()->select( 'COUNT(*)', 'core_members', [ 'completed=?', true ] )->first(),
'expiration' => time() + ( 3600 * 6 ), // Cache for 6 hours
'memberCount' => \IPS\Db::i()->select( 'COUNT(*)', 'core_members', $where )->first(),
'badgeCount' => array(),
);
foreach( \IPS\Db::i()->select( 'id', 'core_badges' ) as $badgeId )
{
$stats['badgeCount'][ $badgeId ] = \IPS\Db::i()->select( 'COUNT(*)', 'core_member_badges', [ 'badge=?', $badgeId ] )->first();
$where = [ [ 'badge=?', $badgeId ] ];
if ( \is_array( $exclude ) and \count( $exclude ) )
{
$where[] = [ \IPS\Db::i()->in( 'core_member_badges.member', $subQuery, TRUE ) ];
}
$stats['badgeCount'][ $badgeId ] = \IPS\Db::i()->select( 'COUNT(*)', 'core_member_badges', $where )->first();
}
\IPS\Data\Store::i()->badgeRarityStats = json_encode( $stats );
\IPS\Data\Store::i()->badgeStats = json_encode( $stats );
}
return ( 100 / $stats['memberCount'] * $stats['badgeCount'][ $this->id ] ) < \IPS\Settings::i()->rare_badge_percent;
return $stats;
}
/**
@@ -444,4 +500,24 @@ class _Badge extends \IPS\Node\Model
}
}
}
/**
* Get output for API
*
* @param \IPS\Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials
* @return array
* @apiresponse int id ID number
* @apiresponse string name Name
* @apiresponse array statistcs Badge Statistics
*/
public function apiOutput( \IPS\Member $authorizedMember = NULL )
{
$return = array(
'id' => $this->id,
'name' => $this->_title,
'statistics' => $this->getBadgeStats(),
);
return $return;
}
}
@@ -128,7 +128,7 @@ class _Rank extends \IPS\Node\Model
{
if ( $this->icon )
{
return \IPS\File::get( 'core_Badges', $this->icon );
return \IPS\File::get( 'core_Ranks', $this->icon );
}
else
{
@@ -197,9 +197,12 @@ class _Rank extends \IPS\Node\Model
*/
public function form( &$form )
{
/* Allow SVGs without the obscure hash removing the file extension */
\IPS\File::$safeFileExtensions[] = 'svg';
$form->add( new \IPS\Helpers\Form\Translatable( 'member_ranks_word_custom', NULL, TRUE, array( 'app' => 'core', 'key' => ( $this->id ? "core_member_rank_{$this->id}" : NULL ) ) ) );
$form->add( new \IPS\Helpers\Form\Number( 'member_ranks_points', $this->points ?: 0, TRUE, array( 'min' => 0 ) ) );
$form->add( new \IPS\Helpers\Form\Upload( 'member_ranks_icon', $this->icon ? \IPS\File::get( 'core_Badges', $this->icon ) : NULL, TRUE, array( 'obscure' => FALSE, 'allowedFileTypes' => array_merge( \IPS\Image::$imageExtensions, ['svg'] ), 'checkImage' => TRUE, 'storageExtension' => 'core_Badges' ), function( $val ) {
$form->add( new \IPS\Helpers\Form\Upload( 'member_ranks_icon', $this->icon ? \IPS\File::get( 'core_Ranks', $this->icon ) : NULL, TRUE, array( 'obscure' => TRUE, 'allowedFileTypes' => array_merge( \IPS\Image::supportedExtensions(), ['svg'] ), 'checkImage' => TRUE, 'storageExtension' => 'core_Ranks' ), function( $val ) {
if( !$val )
{
throw new \DomainException('achievements_bad_image');
@@ -285,8 +288,8 @@ class _Rank extends \IPS\Node\Model
{
try
{
$image = \IPS\File::get( 'core_Badges', $oldImage );
$newImage = \IPS\File::create( 'core_Badges', $image->originalFilename, $image->contents() );
$image = \IPS\File::get( 'core_Ranks', $oldImage );
$newImage = \IPS\File::create( 'core_Ranks', $image->originalFilename, $image->contents() );
$this->icon = (string) $newImage;
}
catch ( \Exception $e )
@@ -309,7 +312,7 @@ class _Rank extends \IPS\Node\Model
{
try
{
\IPS\File::get( 'core_Badges', $this->icon )->delete();
\IPS\File::get( 'core_Ranks', $this->icon )->delete();
}
catch( \Exception $ex ) { }
}
@@ -332,7 +335,7 @@ class _Rank extends \IPS\Node\Model
return array(
'id' => $this->id,
'name' => $this->_title,
'icon' => $this->icon ? $this->_icon->url : $this->_icon,
'icon' => $this->icon ? (string) $this->_icon->url : $this->_icon,
'points' => $this->points
);
}
@@ -419,7 +422,7 @@ class _Rank extends \IPS\Node\Model
if ( ! empty( $insert['icon_name'] ) and ! empty( $insert['icon_data'] ) )
{
$insert['icon'] = (string) \IPS\File::create( 'core_Badges', $insert['icon_name'], $insert['icon_data'], NULL, TRUE, NULL, FALSE );
$insert['icon'] = (string) \IPS\File::create( 'core_Ranks', $insert['icon_name'], $insert['icon_data'], NULL, TRUE, NULL, FALSE );
unset( $insert['icon_name'] );
unset( $insert['icon_data'] );
@@ -102,7 +102,7 @@ class _Recognize extends \IPS\Patterns\ActiveRecord
$member->awardPoints( $points, 0, [], ['subject'], $recognize->id );
}
$notification = new \IPS\Notification( \IPS\Application::load( 'core' ), 'new_recognize', $member, [ $recognize ], [ $recognize->id ] );
$notification = new \IPS\Notification( \IPS\Application::load( 'core' ), 'new_recognize', $member, [ $member, $recognize ], [ $recognize->id ] );
$notification->recipients->attach( $member );
$notification->send();
}
@@ -161,7 +161,7 @@ class _Rule extends \IPS\Node\Model
$extraElements[] = $filterElement;
}
$awardTo = $achievementAction->awardOptions();
$awardTo = $achievementAction->awardOptions( $this->filters );
$awardSubjectField = new \IPS\Helpers\Form\Custom( "{$exploded[1]}_{$exploded[5]}_award_subject", [ 'points' => $this->points_subject, 'badge' => $this->badge_subject ], FALSE, [
'getHtml' => function( $field ) {
return \IPS\Theme::i()->getTemplate('achievements')->awardField( $field->name, $field->value );
@@ -228,7 +228,7 @@ class _Rule extends \IPS\Node\Model
{
if ( isset( $values[$values['achievement_rule_action'] . '_award_' . $type . '_badge'] ) )
{
if ( $values[$values['achievement_rule_action'] . '_award_subject']['badge'] )
if ( $values[$values['achievement_rule_action'] . '_award_' . $type]['badge'] )
{
\IPS\Lang::saveCustom( 'core', "core_award_" . $type . "_badge_{$this->id}", $values[$values['achievement_rule_action'] . '_award_' . $type . '_badge'] );
}
@@ -244,7 +244,7 @@ class _Rule extends \IPS\Node\Model
$return = [
'action' => $values['achievement_rule_action'],
'filters' => $filters ? json_encode( $filters ) : NULL,
'milestone' => isset( $filters['milestone'] ) ? $filters['milestone'] : NULL,
'milestone' => ( isset( $filters['milestone'] ) and \is_numeric( $filters['milestone'] ) ) ? $filters['milestone'] : NULL,
'points_subject'=> \intval( $values[ $values['achievement_rule_action'] . '_award_subject' ]['points'] ),
'badge_subject' => $values[ $values['achievement_rule_action'] . '_award_subject' ]['badge'] ? \intval( $values[ $values['achievement_rule_action'] . '_award_subject' ]['badge'] ): NULL,
'points_other' => \intval( isset( $values[ $values['achievement_rule_action'] . '_award_other' ] ) ? $values[ $values['achievement_rule_action'] . '_award_other' ]['points'] : 0 ),
@@ -322,10 +322,33 @@ class _Rule extends \IPS\Node\Model
{
parent::delete();
\IPS\Db::i()->delete( 'core_achievements_log_milestones', array( 'milestone_rule=?', $this->id ) );
\IPS\Lang::deleteCustom( 'core', "core_award_subject_badge_{$this->id}" );
\IPS\Lang::deleteCustom( 'core', "core_award_other_badge_{$this->id}" );
}
/**
* [Node] Clone the rule
*
* @return void
*/
public function __clone()
{
if ( $this->skipCloneDuplication === TRUE ) {
return;
}
$oldId = $this->id;
parent::__clone();
foreach ( array( 'subject' => "core_award_subject_badge_{$this->id}", 'other' => "core_award_other_badge_{$this->id}") as $fieldKey => $langKey ) {
$oldLangKey = str_replace( $this->id, $oldId, $langKey );
\IPS\Lang::saveCustom( 'core', $langKey, iterator_to_array(\IPS\Db::i()->select( 'word_custom, lang_id', 'core_sys_lang_words', array('word_key=?', $oldLangKey ) )->setKeyField('lang_id')->setValueField('word_custom' ) ) );
}
}
/**
* Get details about the rebuild progress so far
*
@@ -360,6 +383,7 @@ class _Rule extends \IPS\Node\Model
public static function rebuildAllAchievements( ?\IPS\DateTime $time )
{
\IPS\Db::i()->delete( 'core_achievements_log' );
\IPS\Db::i()->delete( 'core_achievements_log_milestones' );
\IPS\Db::i()->delete( 'core_points_log' );
\IPS\Db::i()->delete( 'core_member_badges', [ 'rule > 0' ] ); /* 0 is a manually awarded badge. Don't hate the coder, hate the player */
\IPS\Db::i()->update( 'core_members', [ 'achievements_points' => 0 ] );
@@ -410,7 +434,12 @@ class _Rule extends \IPS\Node\Model
{
foreach( $rules as $ruleId => $rule )
{
static::load( $ruleId )->delete();
try
{
static::load( $ruleId )->delete();
}
catch( \OutOfRangeException $e ){}
}
}
}
@@ -450,6 +479,12 @@ class _Rule extends \IPS\Node\Model
continue;
}
/* Skip the forum related rule if we don't have forums installed */
if ( $xml->name == 'action' AND !static::canImport( $xml->readString() ) )
{
continue 2;
}
switch ( $xml->name )
{
case 'action':
@@ -566,4 +601,49 @@ class _Rule extends \IPS\Node\Model
unset( \IPS\Data\Store::i()->achievementRules );
}
/**
* Only import the node if the application is installed
*
* @param string $value Actionname
* @return bool
*/
public static function canImport( string $value ) : bool
{
$appKey = explode( '_', $value );
return \IPS\Application::appIsEnabled( $appKey[0] );
}
/**
* Does this rule have a milestone set?
*
* @param array|null $filters Actionname
* @return bool
*/
public static function ruleHasMilestone( ?array $filters ): bool
{
if ( \is_array( $filters ) and \count( $filters ) )
{
foreach( $filters as $key => $value )
{
if ( mb_substr( $key, 0, 9 ) == 'milestone' )
{
return TRUE;
}
}
}
return FALSE;
}
/**
* [Node] Get the title to store in the log
*
* @return string|null
*/
public function titleForLog()
{
$exploded = explode( '_', $this->action );
return \IPS\Lang::load( \IPS\Lang::defaultLanguage() )->get( 'AchievementAction__' . $exploded[1] . '_title' ) . " #" . $this->id;
}
}