Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @subpackage * @since 2/14/2024 */ namespace IPS\Theme\Editor; /* To prevent PHP errors (extending class does not exist) revealing path */ use InvalidArgumentException; use IPS\Application; use IPS\Dispatcher; use IPS\Helpers\Form; use IPS\Helpers\Form\Icon; use IPS\Helpers\Form\Node; use IPS\Helpers\Form\Text; use IPS\Http\Url; use IPS\Lang; use IPS\Node\Model; use IPS\Request; use IPS\Settings; use IPS\Theme; use OutOfRangeException; use const IPS\IN_DEV; use function substr; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } class Category extends Model { /** * @brief [ActiveRecord] Multiton Store * @note This needs to be declared in any child classes as well, only declaring here for editor code-complete/error-check functionality */ protected static array $multitons = array(); /** * @brief [ActiveRecord] Database Prefix */ public static string $databasePrefix = 'cat_'; /** * @brief [ActiveRecord] Database table * @note This MUST be over-ridden */ public static ?string $databaseTable = 'core_theme_editor_categories'; /** * @brief [ActiveRecord] Database ID Fields * @note If using this, declare a static $multitonMap = array(); in the child class to prevent duplicate loading queries */ protected static array $databaseIdFields = array( 'cat_key' ); /** * @brief [Node] Order Database Column */ public static ?string $databaseColumnOrder = 'position'; /** * @brief [Node] Parent ID Database Column */ public static ?string $databaseColumnParent = 'parent'; /** * @brief [Node] Show forms modally? */ public static bool $modalForms = true; /** * @brief [ActiveRecord] Multiton Map */ protected static array $multitonMap = array(); /** * @brief [Node] Node Title */ public static string $nodeTitle = 'theme_editor_categories'; /** * @brief [Node] Subnode class */ public static ?string $subnodeClass = Setting::class; /** * [Node] Get Title * * @return string */ protected function get__title(): string { return $this->name ?? ''; } /** * Get the title for a node using the specified language object * This is commonly used where we cannot use the logged in member's language, such as sending emails * * @param Lang $language Language object to fetch the title with * @param array $options What options to use for language parsing * @return string */ public function getTitleForLanguage( Lang $language, array $options=array() ): string { return $this->name; } /** * [Node] Get Title language key, not added to a language stack * * @return string|null */ protected function get__titleLanguageKey(): ?string { return $this->name; } /** * [Node] Get Icon for tree * * @note Return the class for the icon (e.g. 'globe', the 'fa fa-' is added automatically so you do not need this here) * @return mixed */ protected function get__icon(): mixed { if( $this->icon ) { $icon = json_decode( $this->icon, true ); $suffix = substr( $icon['key'], -3 ); switch( $suffix ) { case 'fab': $icon['title'] = 'fa-brands fa-' . $icon['title']; break; case 'far': $icon['title'] = 'fa-regular fa-' . $icon['title']; break; } if( isset( $icon['extra'] ) ) { $icon['title'] .= ' fa-' . $icon['extra']; } return $icon['title']; } return ''; } /** * @return string */ public function icon() : string { if( $this->icon ) { $icon = json_decode( $this->icon, true ); $bits = explode( ":", $icon['key'] ); $return = 'fa-'; switch( $bits[1] ) { case 'fas': $return .= 'solid'; break; case 'far': $return .= 'regular'; break; case 'fab': $return .= 'brands'; break; } $return .= ' fa-' . $bits[0]; if( isset( $icon['extra'] ) ) { $return .= ' ' . $icon['extra']; } return $return; } return ''; } /** * Generate icon data from the icon string * * @param string $key * @return string[] */ public static function buildIconData( string $key ) : array { $suffix = null; $icon = [ 'type' => 'fa', 'raw' => "" ]; foreach( explode( "fa-", $key ) as $string ) { $string = trim( $string ); switch( $string ) { case 'solid': $suffix = 'fas'; break; case 'brands': $suffix = 'fab'; break; case 'regular': $suffix = 'far'; break; default: if( !isset( $icon['title'] ) or empty( $icon['title'] ) ) { $icon['title'] = $string; } else { $icon['extra'] = $string; } break; } } $icon['key'] = $icon['title'] . ':' . ( $suffix ?? 'fas' ); $icon['html'] = Theme::i()->getTemplate( 'global', 'core', 'global' )->icon( $icon ); return $icon; } /** * [Node] Get buttons to display in tree * Example code explains return value * * @code * array( * array( * 'icon' => 'plus-circle', // Name of FontAwesome icon to use * 'title' => 'foo', // Language key to use for button's title parameter * 'link' => \IPS\Http\Url::internal( 'app=foo...' ) // URI to link to * 'class' => 'modalLink' // CSS Class to use on link (Optional) * ), * ... // Additional buttons * ); * @endcode * @param Url $url Base URL * @param bool $subnode Is this a subnode? * @return array */ public function getButtons( Url $url, bool $subnode=FALSE ): array { $buttons = parent::getButtons( $url, $subnode ); if( $this->hasChildren( null, null, false ) or !$this->hasContents() ) { $buttons['child'] = [ 'icon' => 'plus', 'title' => 'themeeditor_cat_add', 'link' => $url->setQueryString( array( 'do' => 'categoryForm', 'parent' => $this->id ) ) ]; } /* Don't allow modification of 1st party settings if we're in theme designer mode */ if( !IN_DEV and Settings::i()->theme_designer_mode and !$this->set_id ) { unset( $buttons['edit'] ); unset( $buttons['delete'] ); } return $buttons; } /** * [Node] Does the currently logged in user have permission to add a child node to this node? * * @return bool */ public function canAdd(): bool { if( !parent::canAdd() ) { return false; } if( $this->hasChildren( null, null, false ) ) { return false; } return true; } /** * [Node] Add/Edit Form * * @param Form $form The form * @return void */ public function form( Form &$form ) : void { $form->add( new Text( 'themeeditor_cat_name', $this->name, true ) ); $form->add( new Text( 'themeeditor_cat_key', $this->key, true, array( 'regex' => '/[A-Z]+/i', 'maxLength' => 150 ), function( $val ){ try { $test = static::load( $val, 'cat_key' ); if( $test->id != $this->id ) { throw new InvalidArgumentException( "err__duplicate_cat_key" ); } } catch( OutOfRangeException ){} }) ); $iconData = $this->icon ? json_decode( $this->icon, true ) : []; $form->add( new Icon( 'themeeditor_cat_icon', count( $iconData ) ? array( $iconData ) : null, true, array( 'emoji' => false, 'maxIcons' => 1 ) ) ); $form->add( new Text( 'themeeditor_cat_icon_extra', $iconData['extra'] ?? null, false ) ); $form->add( new Node( 'themeeditor_cat_parent', $this->parent, false, array( 'class' => Category::class, 'subnodes' => false, 'autoPopulate' => false, 'multiple' => false, 'permissionCheck' => function( $val ){ if( $val instanceof Category and ( $val->hasSettings() or $val->hasColors() ) ) { return false; } return true; } ) ) ); if( Dispatcher::i()->module->key == 'customization' ) { $form->add( new Node( 'themeeditor_cat_app', $this->app ? Application::load( $this->app ) : Application::load( 'core' ), true, array( 'class' => Application::class, 'multiple' => false, 'subnodes' => false ) ) ); } } /** * [Node] Format form values from add/edit form for save * * @param array $values Values from the form * @return array */ public function formatFormValues( array $values ): array { $icon = $values['themeeditor_cat_icon'][0]; if( $values['themeeditor_cat_icon_extra'] ) { $icon['extra'] = $values['themeeditor_cat_icon_extra']; } $return = [ 'cat_name' => $values['themeeditor_cat_name'], 'cat_key' => $values['themeeditor_cat_key'], 'cat_icon' => json_encode( $icon ), 'cat_parent' => ( $values['themeeditor_cat_parent'] instanceof Model ? $values['themeeditor_cat_parent']->id : 0 ), 'cat_set_id' => Request::i()->set_id ?? 0 ]; if( isset( $values['themeeditor_cat_app'] ) and $values['themeeditor_cat_app'] instanceof Application ) { $return['cat_app'] = $values['themeeditor_cat_app']->directory; } return $return; } /** * Does the category have anything in it? * * @return bool */ public function hasContents() : bool { return ( $this->hasChildren() or $this->hasSettings() or $this->hasColors() ); } /** * @var array|null */ protected ?array $_settings = null; /** * Load category settings * * @return void */ protected function loadSettings() : void { if( $this->_settings === null ) { $this->_settings = []; $this->_colors = []; foreach( Setting::loadByCategory( $this ) as $setting ) { if( $setting->type == Setting::SETTING_COLOR ) { $this->_colors[] = $setting; } else { $this->_settings[] = $setting; } } } } /** * Return all settings in this category * * @return array */ public function settings() : array { $this->loadSettings(); return $this->_settings; } /** * Determines if this category has any settings * * @return bool */ public function hasSettings() : bool { return (bool) ( count( $this->settings() ) ); } /** * @var array|null */ protected ?array $_colors = null; /** * Return all color settings in this category * * @return array */ public function colors() : array { $this->loadSettings(); return $this->_colors; } /** * Determines if this category has any color settings * * @return bool */ public function hasColors() : bool { return (bool) ( count( $this->colors() ) ); } /** * @return string */ public function editorHtml() : string { return ( $this->hasContents() ) ? Theme::i()->getTemplate( 'themeeditor', 'core', 'front' )->editorPanel( $this ) : ''; } /** * Return only the editor categories that should be visible in the * theme editor. Some may be related to disabled apps, * some may be related to another theme. * * @return array */ public static function themeEditorCategories() : array { $appKeys = array_keys( Application::enabledApplications() ); $return = []; foreach( static::roots( null ) as $cat ) { if( $cat->app and !in_array( $cat->app, $appKeys ) ) { continue; } if( $cat->set_id and !in_array( $cat->set_id, array( Theme::$defaultFrontendThemeSet, Theme::i()->id ) ) ) { continue; } $return[] = $cat; } return $return; } }