Files
2025-12-19 19:04:57 -08:00

374 lines
10 KiB
PHP

<?php
/**
* @brief Tags trait for Content Models
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @since 14 Nov 2013
*/
namespace IPS\Content;
use DomainException;
use IPS\Member;
use IPS\Node\Model;
use IPS\Request;
use IPS\Settings;
use IPS\Db;
use IPS\Helpers\Form\Text;
use IPS\IPS;
use OutOfRangeException;
use function defined;
use function header;
use function get_called_class;
use function rawurlencode;
use function is_array;
use function time;
use function json_encode;
use function md5;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ($_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0') . ' 403 Forbidden' );
exit;
}
/**
* Tags trait for Content Models
*
* @note Content classes will gain special functionality by implementing this interface
*/
trait Taggable
{
/**
* Can tag?
*
* @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 canTag( ?Member $member = NULL, ?Model $container = NULL ): bool
{
/* Commenting this out until we decide whether we are allowing
nodes to have tags enabled/disabled */
/*if( $container and !$container->checkAction( 'tags' ) )
{
return FALSE;
}*/
/* Are tags enabled? */
if( !Settings::i()->tags_enabled )
{
return false;
}
/* Do we have at least one tag set up? */
if( !count( Tag::getStore() ) )
{
return false;
}
$member = $member ?: Member::loggedIn();
return !( $member->group['gbw_disable_tagging'] ) and !( $member->members_bitoptions['bw_disable_tagging'] );
}
/**
* Can use prefixes?
*
* @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 canPrefix( ?Member $member = NULL, ?Model $container = NULL ): bool
{
/* Commenting this out until we decide whether we are allowing
nodes to have tags enabled/disabled */
/*if( $container and !$container->checkAction( 'prefix' ) )
{
return FALSE;
}*/
$member = $member ?: Member::loggedIn();
return Settings::i()->tags_enabled and Settings::i()->tags_can_prefix and !( $member->group['gbw_disable_tagging'] ) and !( $member->group['gbw_disable_prefixes'] ) and !( $member->members_bitoptions['bw_disable_tagging'] ) and !( $member->members_bitoptions['bw_disable_prefixes'] );
}
/**
* Defined Tags
*
* @return array
*/
public static function definedTags(): array
{
return array_values( Tag::getStore() );
}
/**
* @brief Tags cache
*/
protected ?array $tags = NULL;
/**
* Get prefix
*
* @param bool $encode Encode returned value
* @return string|NULL
*/
public function prefix( bool $encode=FALSE ): string|NULL
{
if ( $this->tags === NULL )
{
$this->tags();
}
return isset( $this->tags['prefix'] ) ? ( ( $encode ) ? rawurlencode( $this->tags['prefix'] ) : $this->tags['prefix'] ) : NULL;
}
/**
* Get tags
*
* @return array
*/
public function tags(): array
{
if ( Settings::i()->tags_enabled )
{
if ( $this->tags === NULL )
{
$idColumn = static::$databaseColumnId;
$this->tags = array( 'tags' => array(), 'prefix' => NULL );
foreach ( Db::i()->select( '*', 'core_tags', array( 'tag_meta_app=? AND tag_meta_area=? AND tag_meta_id=?', static::$application, static::$module, $this->$idColumn ), 'tag_text' ) as $tag )
{
$tagText = Settings::i()->tags_force_lower ? mb_strtolower( $tag['tag_text'] ) : $tag['tag_text'];
if ( $tag['tag_prefix'] )
{
$this->tags['prefix'] = $tagText;
}
else
{
$this->tags['tags'][] = $tagText;
}
}
}
return ($this->tags['tags'] ?? []);
}
else
{
return [];
}
}
/**
* Set tags
*
* @param array|string $set The tags (if one has the key "prefix", it will be set as the prefix)
* @param Member|NULL $member The member saving the tags, or NULL for currently logged in member
* @return void
*/
public function setTags( array|string $set, ?Member $member=NULL ): void
{
if( $member === NULL )
{
$member = Member::loggedIn();
}
/* Grab the current tags so that we can update those later */
$currentTags = array( 'tags' => $this->tags(), 'prefix' => $this->prefix() );
$aaiLookup = $this->tagAAIKey();
$aapLookup = $this->tagAAPKey();
$idColumn = static::$databaseColumnId;
$this->tags = array( 'tags' => array(), 'prefix' => NULL );
Db::i()->delete( 'core_tags', array( 'tag_aai_lookup=?', $aaiLookup ) );
if ( !is_array( $set ) )
{
$set = array( $set );
}
/* Weed out duplicates */
$set = array_unique( $set );
$insert = [];
foreach ( $set as $key => $tag )
{
$insert[] = array(
'tag_aai_lookup' => $aaiLookup,
'tag_aap_lookup' => $aapLookup,
'tag_meta_app' => static::$application,
'tag_meta_area' => static::$module,
'tag_meta_id' => $this->$idColumn,
'tag_meta_parent_id' => ( isset( static::$containerNodeClass ) ? $this->container()->_id : 0 ),
'tag_member_id' => $member->member_id ?: 0,
'tag_added' => time(),
'tag_prefix' => $key == 'prefix',
'tag_text' => $tag
);
if ( $key == 'prefix' )
{
$this->tags['prefix'] = $tag;
}
else
{
$this->tags['tags'][] = $tag;
}
}
if( count( $insert ) )
{
Db::i()->insert( 'core_tags', $insert, true );
}
Db::i()->insert( 'core_tags_cache', array(
'tag_cache_key' => $aaiLookup,
'tag_cache_text' => json_encode( array( 'tags' => $this->tags['tags'], 'prefix' => $this->tags['prefix'] ) ),
'tag_cache_date' => time()
), TRUE );
if( isset( static::$containerNodeClass ) )
{
$containerClass = static::$containerNodeClass;
if ( isset( $containerClass::$permissionMap['read'] ) )
{
$permissions = $containerClass::load( $this->container()->_id )->permissions();
if ( isset( $permissions[ 'perm_' . $containerClass::$permissionMap['read'] ] ) )
{
Db::i()->insert( 'core_tags_perms', array(
'tag_perm_aai_lookup' => $aaiLookup,
'tag_perm_aap_lookup' => $aapLookup,
'tag_perm_text' => $permissions[ 'perm_' . $containerClass::$permissionMap['read'] ],
'tag_perm_visible' => ( $this->hidden() OR ( IPS::classUsesTrait( $this, 'FuturePublishing' ) AND $this->isFutureDate() ) ) ? 0 : 1,
), TRUE );
}
}
}
/* Update the tag totals for the new and old tags */
Tag::updateTagTotals( $this, $currentTags );
/* Callback once tags are updated */
$this->processAfterTagUpdate();
}
/**
* Get tag AAI key
*
* @return string
*/
public function tagAAIKey(): string
{
$idColumn = static::$databaseColumnId;
return md5( static::$application . ';' . static::$module . ';' . $this->$idColumn );
}
/**
* Get tag AAP key
*
* @return string
*/
public function tagAAPKey(): string
{
if( isset( static::$containerNodeClass ) )
{
$containerClass = static::$containerNodeClass;
return md5( $containerClass::$permApp . ';' . $containerClass::$permType . ';' . $this->container()->_id );
}
return md5( static::$application . ';' . static::$module );
}
/**
* Generate the tags form element
*
* @note It is up to the calling code to verify the tag input field should be shown
* @param Item|null $item Item, if editing
* @param string|Model|null $container Container
* @param bool $minimized If the form field should be minimized by default
* @return Text|NULL
*/
public static function tagsFormField( ?Item $item, Model|string|null $container = null, bool $minimized = FALSE ): Text|NULL
{
$options = array(
'autocomplete' => array(
'unique' => TRUE,
'source' => static::definedTags(),
'resultItemTemplate' => 'core.autocomplete.tagsResultItem',
'freeChoice' => false,
'minimized' => $minimized,
'addTokenText' => Member::loggedIn()->language()->get( 'tags_optional' ),
'alphabetical' => false
) );
if ( Settings::i()->tags_force_lower )
{
$options['autocomplete']['forceLower'] = TRUE;
}
if ( Settings::i()->tags_min )
{
$options['autocomplete']['minItems'] = Settings::i()->tags_min;
}
if ( Settings::i()->tags_max )
{
$options['autocomplete']['maxItems'] = Settings::i()->tags_max;
}
$options['autocomplete']['prefix'] = static::canPrefix( NULL, $container );
$options['autocomplete']['disallowedCharacters'] = array( '#' ); // @todo Pending \IPS\Http\Url rework, hashes cannot be used in URLs
if ( count( $options['autocomplete']['source'] ) )
{
$containerClass = static::$containerNodeClass ?? null;
$containerFieldName = static::$formLangPrefix . 'container';
$thisClass = get_called_class();
return new Text( static::$formLangPrefix . 'tags', $item ? ( $item->prefix() ? array_merge( array( 'prefix' => $item->prefix() ), $item->tags() ) : $item->tags() ) : array(), ( Settings::i()->tags_min and Settings::i()->tags_min_req ) ? ( $container ? TRUE : NULL ) : FALSE, $options, function ( $val ) use ( $container, $containerClass, $containerFieldName, $thisClass ) {
if ( empty( $val ) and Settings::i()->tags_min and Settings::i()->tags_min_req )
{
if ( !$container )
{
if( $containerClass !== null )
{
try
{
$container = $containerClass::load( Request::i()->$containerFieldName );
}
catch ( OutOfRangeException )
{
return TRUE;
}
}
/* @var Item $thisClass */
if ( $thisClass::canTag( NULL, $container ) )
{
throw new DomainException('form_required');
}
}
}
return TRUE;
} );
}
return NULL;
}
/**
* Callback to execute when tags are edited
*
* @return void
*/
public function processAfterTagUpdate(): void
{
if( IPS::classUsesTrait( $this, 'IPS\Content\ItemTopic' ) )
{
$this->itemTagsUpdated();
}
}
}