Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 18 Feb 2013 */ namespace IPS; /* To prevent PHP errors (extending class does not exist) revealing path */ if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * @brief Abstract class that applications extend and use to handle application data */ class _Application extends \IPS\Node\Model { /** * @brief [ActiveRecord] Multiton Store */ protected static $multitons; /** * @brief Have fetched all? */ protected static $gotAll = FALSE; /** * @brief Defined versions */ protected $definedVersions = NULL; /** * @brief [Node] Title prefix. If specified, will look for a language key with "{$key}_title" as the key */ public static $titleLangPrefix = '__app_'; /** * @brief Defined theme locations for the theme system */ public $themeLocations = array('admin', 'front', 'global'); /** * @brief [ActiveRecord] Caches * @note Defined cache keys will be cleared automatically as needed */ protected $caches = array( 'updatecount_applications' ); /** * Set default * * @return void */ public function setAsDefault() { /* Update any FURL customizations */ if ( \IPS\Settings::i()->furl_configuration ) { $furlCustomizations = json_decode( \IPS\Settings::i()->furl_configuration, TRUE ); try { /* Add the top-level directory to all the FURLs for the old default app */ $previousDefaultApp = static::constructFromData( \IPS\Db::i()->select( '*', 'core_applications', 'app_default=1' )->first() ); if( file_exists( $previousDefaultApp->getApplicationPath() . "/data/furl.json" ) ) { $oldDefaultAppDefinition = json_decode( preg_replace( '/\/\*.+?\*\//s', '', \file_get_contents( $previousDefaultApp->getApplicationPath() . "/data/furl.json" ) ), TRUE ); if ( $oldDefaultAppDefinition['topLevel'] ) { foreach ( $oldDefaultAppDefinition['pages'] as $k => $data ) { if ( isset( $furlCustomizations[ $k ] ) ) { $furlCustomizations[ $k ] = \IPS\Http\Url\Friendly::buildFurlDefinition( $furlCustomizations[ $k ]['friendly'], $furlCustomizations[ $k ]['real'], $oldDefaultAppDefinition['topLevel'], FALSE, isset( $furlCustomizations[ $k ]['alias'] ) ? $furlCustomizations[ $k ]['alias'] : NULL, isset( $furlCustomizations[ $k ]['custom'] ) ? $furlCustomizations[ $k ]['custom'] : FALSE, isset( $furlCustomizations[ $k ]['verify'] ) ? $furlCustomizations[ $k ]['verify'] : NULL ); } } } } } catch ( \UnderflowException $e ){} /* And remove it from the new */ if( file_exists( $this->getApplicationPath() . "/data/furl.json" ) ) { $newDefaultAppDefinition = json_decode( preg_replace( '/\/\*.+?\*\//s', '', \file_get_contents( $this->getApplicationPath() . "/data/furl.json" ) ), TRUE ); if ( $newDefaultAppDefinition['topLevel'] ) { foreach ( $newDefaultAppDefinition['pages'] as $k => $data ) { if ( isset( $furlCustomizations[ $k ] ) ) { $furlCustomizations[ $k ] = \IPS\Http\Url\Friendly::buildFurlDefinition( rtrim( preg_replace( '/^' . preg_quote( $newDefaultAppDefinition['topLevel'], '/' ) . '\/?/', '', $furlCustomizations[ $k ]['friendly'] ), '/' ), $furlCustomizations[ $k ]['real'], $newDefaultAppDefinition['topLevel'], TRUE, isset( $furlCustomizations[ $k ]['alias'] ) ? $furlCustomizations[ $k ]['alias'] : NULL, isset( $furlCustomizations[ $k ]['custom'] ) ? $furlCustomizations[ $k ]['custom'] : FALSE, isset( $furlCustomizations[ $k ]['verify'] ) ? $furlCustomizations[ $k ]['verify'] : NULL ); } } } } /* Save the new FURL customisation */ \IPS\Settings::i()->changeValues( array( 'furl_configuration' => json_encode( $furlCustomizations ) ) ); } foreach( \IPS\Application::applications() as $directory => $application ) { if( $application->default ) { static::removeMetaPrefix( $application ); break; } } static::addMetaPrefix( $this ); /* Actually update the database */ \IPS\Db::i()->update( 'core_applications', array( 'app_default' => 0 ) ); \IPS\Db::i()->update( 'core_applications', array( 'app_default' => 1 ), array( 'app_id=?', $this->id ) ); /* Clear cached data */ unset( \IPS\Data\Store::i()->applications ); unset( \IPS\Data\Store::i()->furl_configuration ); \IPS\Member::clearCreateMenu(); /* Clear guest page caches */ \IPS\Data\Cache::i()->clearAll(); } /** * Get Applications * * @return array<\IPS\Application> */ public static function applications(): array { if( static::$gotAll === FALSE ) { static::$multitons = array(); foreach ( static::getStore() as $row ) { try { static::$multitons[ $row['app_directory'] ] = static::constructFromData( $row ); } catch( \UnexpectedValueException $e ) { if ( mb_stristr( $e->getMessage(), 'Missing:' ) ) { /* Ignore this, the app is in the table, but not 4.0 compatible */ continue; } } } static::$gotAll = TRUE; } return static::$multitons; } /** * Get data store * * @return array */ public static function getStore() { if ( !isset( \IPS\Data\Store::i()->applications ) ) { \IPS\Data\Store::i()->applications = iterator_to_array( \IPS\Db::i()->select( '*', 'core_applications', NULL, 'app_position' ) ); } return \IPS\Data\Store::i()->applications; } /** * Get enabled applications * * @return array<\IPS\Application> */ public static function enabledApplications(): array { $applications = static::applications(); $enabled = array(); foreach( $applications as $key => $application ) { if( $application->enabled ) { $enabled[ $key ] = $application; } } return $enabled; } /** * Does an application exist and is it enabled? Note: does not check if offline for a particular member * * @see \IPS\Application::canAccess() * @param string $key Application key * @return bool */ public static function appIsEnabled( $key ) { $applications = static::applications(); if ( !array_key_exists( $key, $applications ) ) { return FALSE; } return $applications[ $key ]->enabled; } /** * Load Record * * @see \IPS\Db::build * @param int|string $id ID * @param string $idField The database column that the $id parameter pertains to (NULL will use static::$databaseColumnId) * @param mixed $extraWhereClause Additional where clause(s) (see \IPS\Db::build for details) * @return static * @throws \InvalidArgumentException * @throws \OutOfRangeException */ public static function load( $id, $idField=NULL, $extraWhereClause=NULL ) { static::applications(); // Load all applications so we can grab the data from the cache return parent::load( $id, $idField, $extraWhereClause ); } /** * Fetch All Root Nodes * * @param string|NULL $permissionCheck The permission key to check for or NULl to not check permissions * @param \IPS\Member|NULL $member The member to check permissions for or NULL for the currently logged in member * @param mixed $where Additional WHERE clause * @param array|NULL $limit Limit/offset to use, or NULL for no limit (default) * @note This is overridden to prevent UnexpectedValue exceptions when there is an old application record in core_applications without an Application.php file * @return array */ public static function roots( $permissionCheck='view', $member=NULL, $where=array(), $limit=NULL ) { return static::applications(); } /** * Get all extensions * * @param \IPS\Application|string $app The app key of the application which owns the extension * @param string $extension Extension Type * @param \IPS\Member|\IPS\Member\Group|bool $checkAccess Check access permission for application against supplied member/group (or logged in member, if TRUE) before including extension * @param string|NULL $firstApp If specified, the application with this key will be returned first * @param string|NULL $firstExtensionKey If specified, the extension with this key will be returned first * @param bool $construct Should an object be returned? (If false, just the classname will be returned) * @return array */ public static function allExtensions( $app, $extension, $checkAccess=TRUE, $firstApp=NULL, $firstExtensionKey=NULL, $construct=TRUE ) { $extensions = array(); /* Get applications */ $apps = static::applications(); if ( $firstApp !== NULL ) { $apps = static::$multitons; usort( $apps, function( $a, $b ) use ( $firstApp ) { if ( $a->directory === $firstApp ) { return -1; } if ( $b->directory === $firstApp ) { return 1; } return 0; } ); } /* Get extensions */ foreach ( $apps as $application ) { if ( !static::appIsEnabled( $application->directory ) ) { continue; } if( $checkAccess !== FALSE ) { if( !$application->canAccess( $checkAccess === TRUE ? NULL : $checkAccess ) ) { continue; } } $_extensions = array(); foreach ( $application->extensions( $app, $extension, $construct, $checkAccess ) as $key => $class ) { $_extensions[ $application->directory . '_' . $key ] = $class; } if ( $firstExtensionKey !== NULL AND array_key_exists( $application->directory . '_' . $firstExtensionKey, $_extensions ) ) { uksort( $_extensions, function( $a, $b ) use ( $application, $firstExtensionKey ) { if ( $a === $application->directory . '_' . $firstExtensionKey ) { return -1; } if ( $b === $application->directory . '_' . $firstExtensionKey ) { return 1; } return 0; } ); } $extensions = array_merge( $extensions, $_extensions ); } /* Return */ return $extensions; } /** * Retrieve a list of applications that contain a specific type of extension * * @param \IPS\Application|string $app The app key of the application which owns the extension * @param string $extension Extension Type * @param \IPS\Member|bool $checkAccess Check access permission for application against supplied member (or logged in member, if TRUE) before including extension * @return array */ public static function appsWithExtension( $app, $extension, $checkAccess=TRUE ) { $_apps = array(); foreach( static::applications() as $application ) { if ( static::appIsEnabled( $application->directory ) ) { /* If $checkAccess is false we don't verify access to the app */ if( $checkAccess !== FALSE ) { /* If we passed true, we want to check current member, otherwise pass the member in directly */ if( $application->canAccess( ( $checkAccess === TRUE ) ? NULL : $checkAccess ) !== TRUE ) { continue; } } if( \count( $application->extensions( $app, $extension ) ) ) { $_apps[ $application->directory ] = $application; } } } return $_apps; } /** * Get available version for an application * Used by the installer/upgrader * * @param string $appKey The application key * @param bool $human Return the human-readable version instead * @return int|null */ public static function getAvailableVersion( $appKey, $human=FALSE ) { $versionsJson = static::getRootPath( $appKey ) . "/applications/{$appKey}/data/versions.json"; if( file_exists( $versionsJson ) and $content = json_decode( file_get_contents( $versionsJson ), TRUE ) ) { $_versions = $human ? array_values( $content ) : array_keys( $content ); /* Set the variable so that we return the newest version */ if ( $versionsJson = $_versions ) { return array_pop( $versionsJson ); } } return NULL; } /** * Get all defined versions for an application * * @return array */ public function getAllVersions() { if( $this->definedVersions !== NULL ) { return $this->definedVersions; } $this->definedVersions = array(); $versionsJson = $this->getApplicationPath() . "/data/versions.json"; if ( file_exists( $versionsJson ) ) { $this->definedVersions = json_decode( file_get_contents( $versionsJson ), TRUE ); } return $this->definedVersions; } /** * Return the human version of an INT long version * * @param int $longVersion Long version (10001) * @return string|false Long Version (1.1.1 Beta 1) */ public function getHumanVersion( $longVersion ) { $this->getAllVersions(); if ( isset( $this->definedVersions[ $longVersion ] ) ) { return $this->definedVersions[ (int) $longVersion ]; } return false; } /** * The available version we can upgrade to * * @param bool $latestOnly If TRUE, will return the latest version only * @param bool $skipSameHumanVersion If TRUE, will not include any versions with the same "human" version number as the current version * @return array */ public function availableUpgrade( $latestOnly=FALSE, $skipSameHumanVersion=TRUE ) { $update = array(); if( ( $versions = json_decode( $this->update_version, TRUE ) ) AND is_iterable( $versions ) ) { if ( \is_array( $versions ) and !isset( $versions[0] ) and isset( $versions['longversion'] ) ) { $versions = array( $versions ); } $update = array(); foreach ( $versions as $data ) { if( !empty( $data['longversion'] ) and $data['longversion'] > $this->long_version and ( !$skipSameHumanVersion or $data['version'] != $this->version ) ) { if( $data['released'] AND ( (int) $data['released'] != $data['released'] OR \strlen($data['released']) != 10 ) ) { $data['released'] = strtotime( $data['released'] ); } $update[] = $data; } } } if ( !empty( $update ) and $latestOnly ) { $update = array_pop( $update ); } return $update; } /** * The latest new feature ID * * @return int|null */ public function newFeature() { if( $this->update_version ) { $versions = json_decode( $this->update_version, TRUE ); if ( \is_array( $versions ) and !isset( $versions[0] ) and isset( $versions['longversion'] ) ) { $versions = array( $versions ); } $latestVersion = NULL; foreach ( $versions as $data ) { if( isset( $data['latestNewFeature'] ) AND $data['latestNewFeature'] AND $data['latestNewFeature'] > $latestVersion ) { $latestVersion = $data['latestNewFeature']; } } return $latestVersion; } return NULL; } /** * Is the application up to date with security patches? * * @return bool */ public function missingSecurityPatches() { $updates = $this->availableUpgrade(); if( !empty( $updates ) ) { foreach( $updates as $update ) { if( $update['security'] ) { return TRUE; } } } return FALSE; } /** * @brief [ActiveRecord] Database Table */ public static $databaseTable = 'core_applications'; /** * @brief [ActiveRecord] Database Prefix */ public static $databasePrefix = 'app_'; /** * @brief [ActiveRecord] ID Database Column */ public static $databaseColumnId = 'directory'; /** * @brief [ActiveRecord] Database ID Fields */ protected static $databaseIdFields = array( 'app_id' ); /** * @brief [ActiveRecord] Multiton Map */ protected static $multitonMap = array(); /** * @brief [Node] Subnode class */ public static $subnodeClass = 'IPS\Application\Module'; /** * @brief [Node] Node Title */ public static $nodeTitle = 'applications_and_modules'; /** * @brief [Node] Order Database Column */ public static $databaseColumnOrder = 'position'; /** * @brief [Node] ACP Restrictions */ protected static $restrictions = array( 'app' => 'core', 'module' => 'applications', 'prefix' => 'app_' ); /** * Construct ActiveRecord from database row * * @param array $data Row from database table * @param bool $updateMultitonStoreIfExists Replace current object in multiton store if it already exists there? * @return static */ public static function constructFromData( $data, $updateMultitonStoreIfExists = TRUE ) { /* Load class */ if( !file_exists( static::getRootPath( $data['app_directory'] ) . '/applications/' . $data['app_directory'] . '/Application.php' ) ) { /* If you are upgrading and you have an application "123flashchat" this causes a PHP error, so just die out now */ if( !\in_array( mb_strtolower( mb_substr( $data['app_directory'], 0, 1 ) ), range( 'a', 'z' ) ) ) { throw new \UnexpectedValueException( "Missing: " . '/applications/' . $data['app_directory'] . '/Application.php' ); } if( !\IPS\Dispatcher::hasInstance() OR \IPS\Dispatcher::i()->controllerLocation !== 'setup' ) { throw new \UnexpectedValueException( "Missing: " . '/applications/' . $data['app_directory'] . '/Application.php' ); } else { $className = "\\IPS\\{$data['app_directory']}\\Application"; if( !class_exists( $className ) ) { $code = <<_new = FALSE; /* Import data */ if ( static::$databasePrefix ) { $databasePrefixLength = \strlen( static::$databasePrefix ); } foreach ( $data as $k => $v ) { if( static::$databasePrefix ) { $k = \substr( $k, $databasePrefixLength ); } $obj->_data[ $k ] = $v; } $obj->changed = array(); /* Return */ return $obj; } /** * @brief Modules Store */ protected $modules = NULL; /** * Get Modules * * @see static::$modules * @param string $location Location (e.g. "admin" or "front") * @return array */ public function modules( $location=NULL ) { /* Don't have an instance? */ if( $this->modules === NULL ) { $modules = \IPS\Application\Module::modules(); $this->modules = array_key_exists( $this->directory, $modules ) ? $modules[ $this->directory ] : array(); } /* Return */ return isset( $this->modules[ $location ] ) ? $this->modules[ $location ] : array(); } /** * Returns the ACP Menu JSON for this application. * * @return array */ public function acpMenu() { return json_decode( file_get_contents( $this->getApplicationPath() . "/data/acpmenu.json" ), TRUE ); } /** * ACP Menu Numbers * * @param array $queryString Query String * @return int */ public function acpMenuNumber( $queryString ) { return 0; } /** * Get Extensions * * @param \IPS\Application|string $app The app key of the application which owns the extension * @param string $extension Extension Type * @param bool $construct Should an object be returned? (If false, just the classname will be returned) * @param \IPS\Member|\IPS\Member\Group|bool $checkAccess Check access permission for extension against supplied member/group (or logged in member, if TRUE) * @return array */ public function extensions( $app, $extension, $construct=TRUE, $checkAccess=FALSE ) { $app = ( \is_string( $app ) ? $app : $app->directory ); $classes = array(); $jsonFile = $this->getApplicationPath() . "/data/extensions.json"; /* New extensions.json based approach */ if ( file_exists( $jsonFile ) and $json = @json_decode( \file_get_contents( $jsonFile ), TRUE ) ) { if ( isset( $json[ $app ] ) and isset( $json[ $app ][ $extension ] ) ) { foreach ( $json[ $app ][ $extension ] as $name => $classname ) { if ( method_exists( $classname, 'generate' ) ) { $classes = array_merge( $classes, $classname::generate() ); } elseif ( !$construct ) { $classes[ $name ] = $classname; } else { try { $classes[ $name ] = new $classname( $checkAccess === TRUE ? \IPS\Member::loggedIn() : ( $checkAccess === FALSE ? NULL : $checkAccess ) ); } catch( \RuntimeException | \OutOfRangeException $e ){} } } } } return $classes; } /** * [Node] Get Node Title * * @return string */ protected function get__title() { $key = "__app_{$this->directory}"; return \IPS\Member::loggedIn()->language()->addToStack( $key ); } /** * [Node] Get Node Icon * * @return string */ protected function get__icon() { return 'cubes'; } /** * [Node] Does this node have children? * * @param string|NULL $permissionCheck The permission key to check for or NULl to not check permissions * @param \IPS\Member|NULL $member The member to check permissions for or NULL for the currently logged in member * @param bool $subnodes Include subnodes? * @param array $_where Additional WHERE clause * @return bool */ public function hasChildren( $permissionCheck='view', $member=NULL, $subnodes=TRUE, $_where=array() ) { return $subnodes; } /** * [Node] Does the currently logged in user have permission to delete this node? * * @return bool */ public function canDelete() { if( \IPS\NO_WRITES or !static::restrictionCheck( 'delete' ) ) { return FALSE; } if( $this->_data['protected'] ) { return FALSE; } else { return TRUE; } } /** * @brief Cached URL */ protected $_url = NULL; /** * Get URL * * @return \IPS\Http\Url */ public function url() { if( $this->_url === NULL ) { $this->_url = \IPS\Http\Url::internal( "app={$this->directory}" ); } return $this->_url; } /** * [Node] Get buttons to display in tree * Example code explains return value * * @code array( array( 'icon' => array( 'icon.png' // Path to icon 'core' // Application icon belongs to ), '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 string $url Base URL * @param bool $subnode Is this a subnode? * @return array */ public function getButtons( $url, $subnode=FALSE ) { /* Get normal buttons */ $buttons = parent::getButtons( $url ); $edit = NULL; $uninstall = NULL; if( \IPS\IN_DEV and isset( $buttons['edit'] ) ) { $edit = $buttons['edit']; } unset( $buttons['edit'] ); unset( $buttons['copy'] ); if( isset( $buttons['delete'] ) ) { $buttons['delete']['title'] = 'uninstall'; $buttons['delete']['data'] = array( 'delete' => '', 'delete-warning' => \IPS\Member::loggedIn()->language()->addToStack( \IPS\IN_DEV ? 'app_files_indev_uninstall' : 'app_files_delete_uninstall') ); $uninstall = $buttons['delete']; unset( $buttons['delete'] ); } /* Default */ if( $this->enabled AND \count( $this->modules( 'front' ) ) ) { $buttons['default'] = array( 'icon' => $this->default ? 'star' : 'star-o', 'title' => 'make_default_app', 'link' => \IPS\Http\Url::internal( "app=core&module=applications&controller=applications&appKey={$this->_id}&do=setAsDefault" )->csrf(), ); } /* Online/offline */ if( !$this->protected ) { $buttons['offline'] = array( 'icon' => 'lock', 'title' => 'permissions', 'link' => \IPS\Http\Url::internal( "app=core&module=applications&controller=applications&id={$this->_id}&do=permissions" ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-forceReload' => 'true', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack('permissions') ) ); } /* View Details */ $buttons['details'] = array( 'icon' => 'search', 'title' => 'app_view_details', 'link' => \IPS\Http\Url::internal( "app=core&module=applications&controller=applications&do=details&id={$this->_id}" ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack('app_view_details') ) ); /* Upgrade */ if( !$this->protected AND !\IPS\DEMO_MODE AND \IPS\IPS::canManageResources() ) { $buttons['upgrade'] = array( 'icon' => 'upload', 'title' => 'upload_new_version', 'link' => \IPS\Http\Url::internal( "app=core&module=applications&controller=applications&appKey={$this->_id}&do=upload" ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack('upload_new_version') ) ); } /* Uninstall */ if ( $uninstall ) { $buttons['delete'] = $uninstall; $buttons['delete']['link'] = $buttons['delete']['link']->csrf(); if ( $this->default ) { $buttons['delete']['data'] = array( 'ipsDialog' => '', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack('uninstall') ); } if ( !isset( $buttons['delete']['data'] ) ) { $buttons['delete']['data'] = array(); } $buttons['delete']['data'] = $buttons['delete']['data'] + array( 'noajax' => '' ); } /* Developer */ if( \IPS\IN_DEV ) { if ( $edit ) { $buttons['edit'] = $edit; } $buttons['compilejs'] = array( 'icon' => 'cog', 'title' => 'app_compile_js', 'link' => \IPS\Http\Url::internal( "app=core&module=applications&controller=applications&appKey={$this->_id}&do=compilejs" )->csrf() ); $buttons['build'] = array( 'icon' => 'cog', 'title' => 'app_build', 'link' => \IPS\Http\Url::internal("app=core&module=applications&controller=applications&appKey={$this->_id}&do=build"), 'data' => array('ipsDialog' => '', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack('app_build')) ); $buttons['export'] = array( 'icon' => 'download', 'title' => 'download', 'link' => \IPS\Http\Url::internal("app=core&module=applications&controller=applications&appKey={$this->_id}&do=download"), 'data' => array('ipsDialog' => '', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack('download'), 'ipsDialog-remoteVerify' => 'false') ); $buttons['developer'] = array( 'icon' => 'cogs', 'title' => 'developer_mode', 'link' => \IPS\Http\Url::internal( "app=core&module=applications&controller=developer&appKey={$this->_id}" ), ); } if( !\IPS\IN_DEV ) { $buttons['export'] = array( 'icon' => 'download', 'title' => 'download', 'link' => \IPS\Http\Url::internal("app=core&module=applications&controller=applications&appKey={$this->_id}&do=downloadMafia") ); } return $buttons; } /** * [Node] Get whether or not this node is enabled * * @note Return value NULL indicates the node cannot be enabled/disabled * @return bool|null */ protected function get__enabled() { if ( $this->directory == 'core' ) { return TRUE; } return $this->enabled and ( !\in_array( $this->directory, \IPS\IPS::$ipsApps ) or $this->version == \IPS\Application::load('core')->version ); } /** * [Node] Set whether or not this node is enabled * * @param bool|int $enabled Whether to set it enabled or disabled * @return void */ protected function set__enabled( $enabled ) { if ( \IPS\NO_WRITES ) { throw new \RuntimeException; } $this->enabled = $enabled; $this->save(); \IPS\Plugin\Hook::writeDataFile(); /* Clear templates to rebuild automatically */ \IPS\Theme::deleteCompiledTemplate(); /* Invalidate disk templates */ \IPS\Theme::resetAllCacheKeys(); /* Enable queue task in case there are pending items */ if( $this->enabled ) { $queueTask = \IPS\Task::load( 'queue', 'key' ); $queueTask->enabled = TRUE; $queueTask->save(); } /* Update other app specific task statuses */ \IPS\Db::i()->update( 'core_tasks', array( 'enabled' => (int) $this->enabled ), array( 'app=?', $this->directory ) ); } /** * [Node] Get whether or not this node is locked to current enabled/disabled status * * @note Return value NULL indicates the node cannot be enabled/disabled * @return bool|null */ protected function get__locked() { if ( $this->directory == 'core' ) { return TRUE; } if ( !$this->_enabled and \in_array( $this->directory, \IPS\IPS::$ipsApps ) and $this->version != \IPS\Application::load('core')->version ) { return TRUE; } if ( $this->requires_manual_intervention ) { return TRUE; } return FALSE; } /** * [Node] Lang string for the tooltip when this is locked * * @return string */ protected function get__lockedLang() { return $this->requires_manual_intervention ? 'invalid_php8_customization' : null; } /** * [Node] Get Node Description * * @return string|null */ protected function get__description() { return \IPS\Theme::i()->getTemplate( 'applications', 'core' )->appRowDescription( $this ); } /** * Get the Application State Description ( Offline , Offline for specific groups or all ) * @return mixed */ public function get__disabledMessage() { if ( $this->_locked and $this->directory != 'core' AND \in_array( $this->directory, \IPS\IPS::$ipsApps ) ) { return \IPS\Member::loggedIn()->language()->addToStack('app_force_disabled'); } elseif ( $this->disabled_groups ) { $groups = array(); if ( $this->disabled_groups != '*' ) { foreach ( explode( ',', $this->disabled_groups ) as $groupId ) { try { $groups[] = \IPS\Member\Group::load( $groupId )->name; } catch ( \OutOfRangeException $e ) { } } } if ( empty( $groups ) ) { return \IPS\Member::loggedIn()->language()->addToStack('app_offline_to_all'); } else { return \IPS\Member::loggedIn()->language()->addToStack( 'app_offline_to_groups', FALSE, array( 'sprintf' => array( \IPS\Member::loggedIn()->language()->formatList( $groups ) ) ) ); } } } /** * Get the authors website * * @return \IPS\Http\Url|null */ public function website() { if ( $this->_data['website'] ) { return \IPS\Http\Url::createFromString( $this->_data['website'] ); } return NULL; } /** * Return the custom badge for each row * * @return NULL|array Null for no badge, or an array of badge data (0 => CSS class type, 1 => language string, 2 => optional raw HTML to show instead of language string) */ public function get__badge() { if ( \IPS\CIC AND \IPS\IPS::isManaged() AND \in_array( $this->directory, \IPS\IPS::$ipsApps ) ) { return NULL; } if ( $availableUpgrade = $this->availableUpgrade( TRUE ) ) { return array( 0 => 'new', 1 => '', 2 => \IPS\Theme::i()->getTemplate( 'global', 'core' )->updatebadge( $availableUpgrade['version'], isset( $availableUpgrade['updateurl'] ) ? $availableUpgrade['updateurl'] : '', (string) \IPS\DateTime::ts( $availableUpgrade['released'] )->localeDate() ) ); } return NULL; } /** * [Node] Does the currently logged in user have permission to add a child node? * * @return bool * @note Modules are added via the developer center and should not be added by a regular admin via the standard node controller */ public function canAdd() { return false; } /** * [Node] Does the currently logged in user have permission to add aa root node? * * @return bool * @note If IN_DEV is on, the admin can create a new application */ public static function canAddRoot() { return ( \IPS\IN_DEV ) ? true : false; } /** * [Node] Does the currently logged in user have permission to edit permissions for this node? * * @return bool * @note We don't allow permissions to be set for applications - they are handled by modules and by the enabled/disabled mode */ public function canManagePermissions() { return false; } /** * Add or edit an application * * @param \IPS\Helpers\Form $form Form object we can add our fields to * @return void */ public function form( &$form ) { if ( !$this->directory ) { $form->add( new \IPS\Helpers\Form\Text( 'app_title', NULL, FALSE, array( 'app' => 'core', 'key' => ( !$this->directory ) ? NULL : "__app_{$this->directory}" ) ) ); } $form->add( new \IPS\Helpers\Form\Text( 'app_directory', $this->directory, TRUE, array( 'disabled' => $this->id ? TRUE : FALSE, 'regex' => '/^[a-zA-Z][a-zA-Z0-9]+$/', 'maxLength' => 80 ) ) ); $form->add( new \IPS\Helpers\Form\Text( 'app_author', $this->author ) ); $form->add( new \IPS\Helpers\Form\Url( 'app_website', $this->website ) ); $form->add( new \IPS\Helpers\Form\Url( 'app_update_check', $this->update_check ) ); $form->add( new \IPS\Helpers\Form\YesNo( 'app_protected', $this->protected, FALSE ) ); $form->add( new \IPS\Helpers\Form\YesNo( 'app_hide_tab', !$this->hide_tab, FALSE ) ); } /** * [Node] Format form values from add/edit form for save * * @param array $values Values from the form * @return array */ public function formatFormValues( $values ) { /* New application stuff */ if ( !$this->id ) { /* Check dir is writable */ if( !is_writable( \IPS\ROOT_PATH . '/applications/' ) ) { \IPS\Output::i()->error( 'app_dir_not_write', '4S134/2', 403, '' ); } /* Check key isn't in use */ $values['app_directory'] = mb_strtolower( $values['app_directory'] ); try { $test = \IPS\Application::load( $values['app_directory'] ); \IPS\Output::i()->error( 'app_error_key_used', '1S134/1', 403, '' ); } catch ( \OutOfRangeException $e ) { } /* Attempt to create the basic directory structure for the developer */ if( is_writable( \IPS\ROOT_PATH . '/applications/' ) ) { /* If we can make the root dir, we can create the subfolders */ if( @mkdir( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] ) ) { @chmod( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'], \IPS\FOLDER_PERMISSION_NO_WRITE ); /* Create directories */ foreach ( array( 'data', 'dev', 'dev/css', 'dev/email', 'dev/html', 'dev/resources', 'dev/js', 'extensions', 'extensions/core', 'hooks', 'interface', 'modules', 'modules/admin', 'modules/front', 'setup', '/setup/upg_working', 'sources', 'tasks' ) as $f ) { @mkdir( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/' . $f ); @chmod( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/' . $f, \IPS\FOLDER_PERMISSION_NO_WRITE ); \file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/' . $f . '/index.html', '' ); } /* Create files */ @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/data/schema.json', '[]' ); @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/data/settings.json', '[]' ); @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/data/tasks.json', '[]' ); @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/data/themesettings.json', '[]' ); @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/data/acpmenu.json', '[]' ); @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/data/modules.json', '[]' ); @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/data/widgets.json', '[]' ); @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/data/acpsearch.json', '{}' ); @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/data/hooks.json', '[]' ); @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/data/versions.json', json_encode( array() ) ); @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/dev/lang.php', ' \"{$values['app_title']}\"\n);\n" ); @\file_put_contents( \IPS\ROOT_PATH . '/applications/' . $values['app_directory'] . '/dev/jslang.php', ' $values['app_title'], 'app_author' => $values['app_author'], 'app_directory' => $values['app_directory'], 'app_protected' => $values['app_protected'], 'app_website' => $values['app_website'], 'app_update_check' => $values['app_update_check'], 'app_hide_tab' => $values['app_hide_tab'] ) ) ); } } /* Enable it */ $values['enabled'] = TRUE; $values['app_added'] = time(); } $values['app_hide_tab'] = !$values['app_hide_tab']; if( isset( $values['app_title'] ) ) { unset( $values['app_title'] ); } return $values; } /** * [Node] Perform actions after saving the form * * @param array $values Values from the form * @return void */ public function postSaveForm( $values ) { /* Clear out member's cached "Create Menu" contents */ \IPS\Member::clearCreateMenu(); unset( \IPS\Data\Store::i()->applications ); \IPS\Settings::i()->clearCache(); } /** * Install database changes from the schema.json file * * @param bool $skipInserts Skip inserts * @throws \Exception */ public function installDatabaseSchema( $skipInserts=FALSE ) { if( file_exists( $this->getApplicationPath() . "/data/schema.json" ) ) { $schema = json_decode( file_get_contents( $this->getApplicationPath() . "/data/schema.json" ), TRUE ); foreach( $schema as $table => $definition ) { /* Look for missing tables first */ if( !\IPS\Db::i()->checkForTable( $table ) ) { \IPS\Db::i()->createTable( $definition ); } else { /* If the table exists, look for missing columns */ if( \is_array( $definition['columns'] ) AND \count( $definition['columns'] ) ) { /* Get the table definition first */ $tableDefinition = \IPS\Db::i()->getTableDefinition( $table ); foreach( $definition['columns'] as $column ) { /* Column does not exist in the table definition? Add it then. */ if( empty($tableDefinition['columns'][ $column['name'] ]) ) { \IPS\Db::i()->addColumn( $table, $column ); } } } } if ( isset( $definition['inserts'] ) AND !$skipInserts ) { foreach ( $definition['inserts'] as $insertData ) { $adminName = \IPS\Member::loggedIn()->name; try { \IPS\Db::i()->insert( $definition['name'], array_map( function( $column ) use( $adminName ) { if( !\is_string( $column ) ) { return $column; } $column = str_replace( '<%TIME%>', time(), $column ); $column = str_replace( '<%ADMIN_NAME%>', $adminName, $column ); $column = str_replace( '<%IP_ADDRESS%>', $_SERVER['REMOTE_ADDR'], $column ); return $column; }, $insertData ) ); } catch( \IPS\Db\Exception $e ) {} } } } } if( file_exists( $this->getApplicationPath() . "/setup/install/queries.json" ) ) { $schema = json_decode( file_get_contents( $this->getApplicationPath() . "/setup/install/queries.json" ), TRUE ); ksort($schema); foreach( $schema as $instruction ) { if ( $instruction['method'] === 'addColumn' ) { /* Check to see if it exists first */ $tableDefinition = \IPS\Db::i()->getTableDefinition( $instruction['params'][0] ); if ( ! empty( $tableDefinition['columns'][ $instruction['params'][1]['name'] ] ) ) { /* Run an alter instead */ \IPS\Db::i()->changeColumn( $instruction['params'][0], $instruction['params'][1]['name'], $instruction['params'][1] ); continue; } } try { if( isset( $instruction['params'][1] ) and \is_array( $instruction['params'][1] ) ) { $groups = array_filter( iterator_to_array( \IPS\Db::i()->select( 'g_id', 'core_groups' ) ), function( $groupId ) { if( $groupId == 2 ) { return FALSE; } return TRUE; }); foreach( $instruction['params'][1] as $column => $value ) { if( $value === "<%NO_GUESTS%>" ) { $instruction['params'][1][ $column ] = implode( ",", $groups ); } } } $method = $instruction['method']; $params = $instruction['params']; \IPS\Db::i()->$method( ...$params ); } catch( \Exception $e ) { if( $instruction['method'] == 'insert' ) { return; } throw $e; } } } } /** * Install database changes from an upgrade schema file * * @param int $version Version to execute database updates from * @param int $lastJsonIndex JSON index to begin from * @param int $limit Limit updates * @param bool $return Check table size first and return queries for larger tables instead of running automatically * @return array Returns an array: ( count: count of queries run, queriesToRun: array of queries to run) * @note We ignore some database errors that shouldn't prevent us from continuing. * @li 1007: Can't create database because it already exists * @li 1008: Can't drop database because it does not exist * @li 1050: Can't rename a table as it already exists * @li 1051: Can't drop a table because it doesn't exist * @li 1060: Can't add a column as it already exists * @li 1062: Can't add an index as index already exists * @li 1062: Can't add a row as PKEY already exists * @li 1091: Can't drop key or column because it does not exist */ public function installDatabaseUpdates( $version=0, $lastJsonIndex=0, $limit=50, $return=FALSE ) { $toReturn = array(); $count = 0; /* Try to prevent timeouts to the extent possible */ $cutOff = null; if( $maxExecution = @ini_get( 'max_execution_time' ) ) { /* If max_execution_time is set to "no limit" we should add a hard limit to prevent browser timeouts */ if ( $maxExecution == -1 ) { $maxExecution = 30; } $cutOff = time() + ( $maxExecution * .5 ); } if( file_exists( $this->getApplicationPath() . "/setup/upg_{$version}/queries.json" ) ) { $schema = json_decode( file_get_contents( $this->getApplicationPath() . "/setup/upg_{$version}/queries.json" ), TRUE ); ksort($schema, SORT_NUMERIC); foreach( $schema as $jsonIndex => $instruction['params'] ) { if ( $lastJsonIndex AND ( $jsonIndex <= $lastJsonIndex ) ) { continue; } if ( $count >= $limit ) { return array( 'count' => $count, 'queriesToRun' => $toReturn ); } else if( $cutOff !== null AND time() >= $cutOff ) { return array( 'count' => $count, 'queriesToRun' => $toReturn ); } $_SESSION['lastJsonIndex'] = $jsonIndex; $count++; /* Get the table name, we need it */ $_table = $instruction['params']['params'][0]; if ( !\is_string( $_table ) ) { $_table = $instruction['params']['params'][0]['name']; } /* Check table size first and store query if requested */ if( $return === TRUE ) { if( /* Only run manually if we need to */ \IPS\Db::i()->recommendManualQuery( $_table ) AND /* And if it's not a drop table, insert or rename table query */ !\in_array( $instruction['params']['method'], array( 'dropTable', 'insert', 'renameTable' ) ) AND /* ANNNNNDDD only if the method is not delete or there's a where clause, i.e. a truncate table statement does not run manually */ ( $instruction['params']['method'] != 'delete' OR isset( $instructions['params']['params'][1] ) ) ) { \IPS\Log::debug( "Big table " . $_table . ", storing query to run manually", 'upgrade' ); \IPS\Db::i()->returnQuery = TRUE; $method = $instruction['params']['method']; $params = $instruction['params']['params']; $query = \IPS\Db::i()->$method( ...$params ); if( $query ) { $toReturn[] = $query; if ( $instruction['params']['method'] == 'renameTable' ) { \IPS\Db::i()->cachedTableData[ $instruction['params']['params'][1] ] = \IPS\Db::i()->cachedTableData[ $_table ]; foreach( $toReturn as $k => $v ) { $toReturn[ $k ] = preg_replace( "/\`" . \IPS\Db::i()->prefix . $_table . "\`/", "`" . \IPS\Db::i()->prefix . $instruction['params']['params'][1] . "`", $v ); } } return array( 'count' => $count, 'queriesToRun' => $toReturn ); } } } try { $method = $instruction['params']['method']; $params = $instruction['params']['params']; \IPS\Db::i()->$method( ...$params ); } catch( \IPS\Db\Exception $e ) { \IPS\Log::log( "Error (" . $e->getCode() . ") " . $e->getMessage() . ": " . $instruction['params']['method'] . ' ' . json_encode( $instruction['params']['params'] ), 'upgrade_error' ); /* If the issue is with a create table other than exists, we should just throw it */ if ( $instruction['params']['method'] == 'createTable' and ! \in_array( $e->getCode(), array( 1007, 1050 ) ) ) { throw $e; } /* Can't change a column as it doesn't exist */ if ( $e->getCode() == 1054 ) { if ( $instruction['params']['method'] == 'changeColumn' ) { if ( \IPS\Db::i()->checkForTable( $instruction['params']['params'][0] ) ) { /* Does the column exist already? */ if ( \IPS\Db::i()->checkForColumn( $instruction['params']['params'][0], $instruction['params']['params'][2]['name'] ) ) { /* Just make sure it's up to date */ \IPS\Db::i()->changeColumn( $instruction['params']['params'][0], $instruction['params']['params'][2]['name'], $instruction['params']['params'][2] ); continue; } else { /* The table exists, so lets just add the column */ \IPS\Db::i()->addColumn( $instruction['params']['params'][0], $instruction['params']['params'][2] ); continue; } } } throw $e; } /* Can't rename a table as it doesn't exist */ else if ( $e->getCode() == 1017 ) { if ( $instruction['params']['method'] == 'renameTable' ) { if ( \IPS\Db::i()->checkForTable( $instruction['params']['params'][1] ) ) { /* The table we are renaming to *does* exist */ continue; } } throw $e; } /* Possibly trying to change a column to not null that has NULL values */ else if ( $e->getCode() == 1138 ) { if ( $instruction['params']['method'] == 'changeColumn' and ! $instruction['params']['params'][2]['allow_null'] ) { $currentDefintion = \IPS\Db::i()->getTableDefinition( $instruction['params']['params'][0] ); $column = $instruction['params']['params'][2]['name']; if ( isset( $currentDefintion['columns'][ $column ] ) AND $currentDefintion['columns'][ $column ]['allow_null'] ) { \IPS\Db::i()->update( $instruction['params']['params'][0], array( $column => '' ), array( $column . ' IS NULL' ) ); /* Just make sure it's up to date */ \IPS\Db::i()->changeColumn( $instruction['params']['params'][0], $instruction['params']['params'][1], $instruction['params']['params'][2] ); continue; } } throw $e; } /* If the error isn't important we should ignore it */ else if( !\in_array( $e->getCode(), array( 1007, 1008, 1050, 1060, 1061, 1062, 1091, 1051 ) ) ) { throw $e; } } } } return array( 'count' => $count, 'queriesToRun' => $toReturn ); } /** * Rebuild common data during an install or upgrade. This is a shortcut method which * * Installs module data from JSON file * * Installs task data from JSON file * * Installs setting data from JSON file * * Installs ACP live search keywords from JSON file * * Installs hooks from JSON file * * Updates latest version in the database * * @param bool $skipMember Skip clearing member cache clearing * @return void */ public function installJsonData( $skipMember=FALSE ) { /* Rebuild modules */ $this->installModules(); /* Rebuild tasks */ $this->installTasks(); /* Rebuild settings */ $this->installSettings(); /* Rebuild sidebar widgets */ $this->installWidgets(); /* Rebuild search keywords */ $this->installSearchKeywords(); /* Rebuild hooks */ $this->installHooks(); /* Update app version data */ $versions = $this->getAllVersions(); $longVersions = array_keys( $versions ); $humanVersions = array_values( $versions ); if( \count($versions) ) { $latestLVersion = array_pop( $longVersions ); $latestHVersion = array_pop( $humanVersions ); \IPS\Db::i()->update( 'core_applications', array( 'app_version' => $latestHVersion, 'app_long_version' => $latestLVersion ), array( 'app_directory=?', $this->directory ) ); } unset( \IPS\Data\Store::i()->applications ); if( !$skipMember ) { \IPS\Member::clearCreateMenu(); } } /** * Install the application's modules * * @note A module's "default" status will not be adjusted during upgrades - if there is already a module flagged as default, it will remain the default. * @return void */ public function installModules() { if( file_exists( $this->getApplicationPath() . "/data/modules.json" ) ) { $currentModules = array(); $moduleStore = array(); $hasDefault = FALSE; foreach ( \IPS\Db::i()->select( '*', 'core_modules', array( 'sys_module_application=?', $this->directory ) ) as $row ) { if( $row['sys_module_default'] ) { $hasDefault = TRUE; } $currentModules[ $row['sys_module_area'] ][ $row['sys_module_key'] ] = array( 'default_controller' => $row['sys_module_default_controller'], 'protected' => $row['sys_module_protected'], 'default' => $row['sys_module_default'] ); $moduleStore[ $row['sys_module_area'] ][ $row['sys_module_key'] ] = $row; } $insert = array(); $update = array(); $position = 0; foreach( json_decode( file_get_contents( $this->getApplicationPath() . "/data/modules.json" ), TRUE ) as $area => $modules ) { foreach ( $modules as $key => $data ) { $position++; if ( !isset( $currentModules[ $area ][ $key ] ) ) { $module = new \IPS\Application\Module; } elseif ( $currentModules[ $area ][ $key ] != $data ) { $module = \IPS\Application\Module::constructFromData( $moduleStore[ $area ][ $key ] ); } else { continue; } $module->application = $this->directory; $module->key = $key; $module->protected = \intval( $data['protected'] ); $module->visible = TRUE; $module->position = $position; $module->area = $area; $module->default_controller = $data['default_controller']; /* We don't set/change default status if a module is already flagged as the default. An administrator may legitimately wish to change which module is the default, and we wouldn't want to reset that. */ if( !$hasDefault ) { $module->default = ( isset( $data['default'] ) and $data['default'] ); } $module->save( TRUE ); } } } } /** * Install the application's tasks * * @return void */ public function installTasks() { if( file_exists( $this->getApplicationPath() . "/data/tasks.json" ) ) { $taskJson = json_decode( file_get_contents( $this->getApplicationPath() . "/data/tasks.json" ), TRUE ); foreach ( $taskJson as $key => $frequency ) { \IPS\Db::i()->replace( 'core_tasks', array( 'app' => $this->directory, 'key' => $key, 'frequency' => $frequency, 'next_run' => \IPS\DateTime::create()->add( new \DateInterval( $frequency ) )->getTimestamp() ) ); } /* Delete any tasks that are no longer present */ \IPS\Db::i()->delete( 'core_tasks', [ [ 'app=?', $this->directory ], [ \IPS\Db::i()->in( '`key`', array_keys( $taskJson ), true ) ] ] ); } } /** * Install the application's extension data where required * * @param bool $newInstall TRUE if the community is being installed for the first time (opposed to an app being added) * @return void */ public function installExtensions( $newInstall=FALSE ) { /* File storage */ $settings = json_decode( \IPS\Settings::i()->upload_settings, TRUE ); try { /* Only check for Amazon when installing an app via the Admin CP on Community in the Cloud. The CiC Installer will handle brand new installs. */ if ( \IPS\CIC AND !$newInstall ) { $fileSystem = \IPS\Db::i()->select( '*', 'core_file_storage', array( 'method=?', 'Cloud' ), 'id ASC' )->first(); } else { $fileSystem = \IPS\Db::i()->select( '*', 'core_file_storage', array( 'method=?', 'FileSystem' ), 'id ASC' )->first(); } } catch( \UnderflowException $ex ) { $fileSystem = \IPS\Db::i()->select( '*', 'core_file_storage', NULL, 'id ASC' )->first(); } foreach( $this->extensions( 'core', 'FileStorage' ) as $key => $path ) { $settings[ 'filestorage__' . $this->directory . '_' . $key ] = $fileSystem['id']; } \IPS\Settings::i()->changeValues( array( 'upload_settings' => json_encode( $settings ) ) ); $inserts = array(); foreach( $this->extensions( 'core', 'Notifications' ) as $key => $class ) { if ( method_exists( $class, 'getConfiguration' ) ) { $defaults = $class->getConfiguration( NULL ); foreach( $defaults AS $k => $config ) { $inserts[] = array( 'notification_key' => $k, 'default' => implode( ',', $config['default'] ), 'disabled' => implode( ',', $config['disabled'] ), ); } } } if( \count( $inserts ) ) { \IPS\Db::i()->insert( 'core_notification_defaults', $inserts ); } /* Install Menu items */ if ( !$newInstall ) { $defaultNavigation = $this->defaultFrontNavigation(); foreach ( $defaultNavigation as $type => $tabs ) { foreach ( $tabs as $config ) { $config['real_app'] = $this->directory; if ( !isset( $config['app'] ) ) { $config['app'] = $this->directory; } \IPS\core\FrontNavigation::insertMenuItem( NULL, $config, \IPS\Db::i()->select( 'MAX(position)', 'core_menu' )->first() ); } } unset( \IPS\Data\Store::i()->frontNavigation ); } } /** * Install the application's settings * * @return void */ public function installSettings() { if( file_exists( $this->getApplicationPath() . "/data/settings.json" ) ) { $currentData = iterator_to_array( \IPS\Db::i()->select( array( 'conf_key', 'conf_default', 'conf_report' ), 'core_sys_conf_settings' )->setKeyField('conf_key') ); $insert = array(); $update = array(); foreach ( json_decode( file_get_contents( $this->getApplicationPath() . "/data/settings.json" ), TRUE ) as $setting ) { $report = ( isset( $setting['report'] ) and $setting['report'] != 'none' ) ? $setting['report'] : NULL; if ( ! array_key_exists( $setting['key'], $currentData ) ) { $insert[] = array( 'conf_key' => $setting['key'], 'conf_value' => $setting['default'], 'conf_default' => $setting['default'], 'conf_app' => $this->directory, 'conf_report' => $report ); } elseif ( $currentData[ $setting['key'] ]['conf_default'] != $setting['default'] or $currentData[ $setting['key'] ]['conf_report'] != $report ) { $update[] = array( array( 'conf_default' => $setting['default'], 'conf_report' => $report ), array( 'conf_key=?', $setting['key'] ) ); } } if ( !empty( $insert ) ) { \IPS\Db::i()->insert( 'core_sys_conf_settings', $insert, TRUE ); } foreach ( $update as $data ) { \IPS\Db::i()->update( 'core_sys_conf_settings', $data[0], $data[1] ); } \IPS\Settings::i()->clearCache(); } } /** * Install the application's language strings * * @param int|null $offset Offset to begin import from * @param int|null $limit Number of rows to import * @return int Rows inserted */ public function installLanguages( $offset=null, $limit=null ) { $languages = array_keys( \IPS\Lang::languages() ); $inserted = 0; $current = array(); foreach( $languages as $languageId ) { foreach( iterator_to_array( \IPS\Db::i()->select( 'word_key, word_default, word_js', 'core_sys_lang_words', array( 'word_app=? AND lang_id=?', $this->directory, $languageId ) ) ) as $word ) { $current[ $languageId ][ $word['word_key'] . '-.-' . $word['word_js'] ] = $word['word_default']; } } if ( !$offset and file_exists( $this->getApplicationPath() . "/data/installLang.json" ) ) { $inserts = array(); foreach ( json_decode( file_get_contents( $this->getApplicationPath() . "/data/installLang.json" ), TRUE ) as $key => $default ) { foreach( $languages as $languageId ) { if ( !isset( $current[ $languageId ][ $key . '-.-0' ] ) ) { $inserts[] = array( 'word_app' => $this->directory, 'word_key' => $key, 'lang_id' => $languageId, 'word_default' => $default, 'word_custom' => $default, 'word_default_version' => $this->long_version, 'word_custom_version' => $this->long_version, 'word_js' => 0, 'word_export' => 0, ); } } } if ( \count( $inserts ) ) { \IPS\Db::i()->insert( 'core_sys_lang_words', $inserts, TRUE ); } } if( file_exists( $this->getApplicationPath() . "/data/lang.xml" ) ) { /* Open XML file */ $xml = \IPS\Xml\XMLReader::safeOpen( $this->getApplicationPath() . "/data/lang.xml" ); $xml->read(); /* Get the version */ $xml->read(); $xml->read(); $version = $xml->getAttribute('version'); /* Get all installed languages */ $inserts = array(); $batchSize = 25; $batchesDone = 0; $i = 0; /* Try to prevent timeouts to the extent possible */ $cutOff = null; if( $maxExecution = @ini_get( 'max_execution_time' ) ) { /* If max_execution_time is set to "no limit" we should add a hard limit to prevent browser timeouts */ if ( $maxExecution == -1 ) { $maxExecution = 30; } $cutOff = time() + ( $maxExecution * .5 ); } /* Start looping through each word */ while ( $xml->read() ) { if( $xml->name != 'word' OR $xml->nodeType != \XMLReader::ELEMENT ) { continue; } if( $cutOff !== null AND time() >= $cutOff ) { return $inserted; } $i++; if ( $offset !== null ) { if ( $i - 1 < $offset ) { $xml->next(); continue; } } $inserted++; $key = $xml->getAttribute('key'); $value = $xml->readString(); foreach( $languages as $languageId ) { if ( !isset( $current[ $languageId ][ $key . '-.-' . (int) $xml->getAttribute('js') ] ) or $current[ $languageId ][ $key . '-.-' . (int) $xml->getAttribute('js') ] != $value ) { $inserts[] = array( 'word_app' => $this->directory, 'word_key' => $key, 'lang_id' => $languageId, 'word_default' => $value, 'word_default_version' => $version, 'word_js' => (int) $xml->getAttribute('js'), 'word_export' => 1, ); } } $done = ( $limit !== null AND $i === ( $limit + $offset ) ); if ( $done OR $i % $batchSize === 0 ) { if ( \count( $inserts ) ) { \IPS\Db::i()->insert( 'core_sys_lang_words', $inserts, TRUE ); $inserts = array(); } $batchesDone++; } if ( $done ) { break; } $xml->next(); } if ( \count( $inserts ) ) { \IPS\Db::i()->insert( 'core_sys_lang_words', $inserts, TRUE ); } } return $inserted; } /** * Install the application's email templates * * @return void */ public function installEmailTemplates() { if( file_exists( $this->getApplicationPath() . "/data/emails.xml" ) ) { /* First, delete any existing non-customized email templates for this app */ \IPS\Db::i()->delete( 'core_email_templates', array( 'template_app=? AND template_parent=0', $this->directory ) ); /* Open XML file */ $xml = \IPS\Xml\XMLReader::safeOpen( $this->getApplicationPath() . "/data/emails.xml" ); $xml->read(); /* Start looping through each word */ while ( $xml->read() and $xml->name == 'template' ) { if( $xml->nodeType != \XMLReader::ELEMENT ) { continue; } $insert = array( 'template_parent' => 0, 'template_app' => $this->directory, 'template_edited' => 0, 'template_pinned' => 0 ); while ( $xml->read() and $xml->name != 'template' ) { if( $xml->nodeType != \XMLReader::ELEMENT ) { continue; } switch( $xml->name ) { case 'template_name': $insert['template_name'] = $xml->readString(); $insert['template_key'] = md5( $this->directory . ';' . $insert['template_name'] ); break; case 'template_data': $insert['template_data'] = $xml->readString(); break; case 'template_content_html': $insert['template_content_html'] = $xml->readString(); break; case 'template_content_plaintext': $insert['template_content_plaintext'] = $xml->readString(); break; case 'template_pinned': $insert['template_pinned'] = $xml->readString(); break; } } \IPS\Db::i()->replace( 'core_email_templates', $insert ); } /* Now re-associate customized email templates */ foreach( \IPS\Db::i()->select( '*', 'core_email_templates', array( 'template_app=? AND template_parent>0', $this->directory ) ) as $template ) { /* Find the real parent now */ try { $parent = \IPS\Db::i()->select( '*', 'core_email_templates', array( 'template_app=? and template_name=? and template_parent=0', $template['template_app'], $template['template_name'] ) )->first(); /* And now update this template */ \IPS\Db::i()->update( 'core_email_templates', array( 'template_parent' => $parent['template_id'], 'template_data' => $parent['template_data'] ), array( 'template_id=?', $template['template_id'] ) ); \IPS\Db::i()->update( 'core_email_templates', array( 'template_edited' => 1 ), array( 'template_id=?', $parent['template_id'] ) ); } catch( \UnderflowException $ex ) { } } \IPS\Data\Cache::i()->clearAll(); \IPS\Data\Store::i()->clearAll(); } } /** * Install the application's skin templates, CSS files and resources * * @param bool $update If set to true, do not overwrite current theme setting values * @return void */ public function installSkins( $update=FALSE ) { /* Clear old caches */ \IPS\Data\Cache::i()->clearAll(); \IPS\Data\Store::i()->clearAll(); /* Install the stuff */ $this->installThemeSettings( $update ); $this->clearTemplates(); $this->installTemplates( $update ); } /** * Install the application's theme settings * * @param bool $update If set to true, do not overwrite current theme setting values * @return void */ public function installThemeSettings( $update=FALSE ) { if ( file_exists( $this->getApplicationPath() . "/data/themesettings.json" ) ) { unset( \IPS\Data\Store::i()->themes ); try { $defaultThemeId = \IPS\Theme::load('default', 'set_key')->id; } catch( \Exception $ex ) { $defaultThemeId = \IPS\Theme::defaultTheme(); } $currentSettings = iterator_to_array( \IPS\Db::i()->select( '*', 'core_theme_settings_fields', array( 'sc_set_id=? AND sc_app=?', $defaultThemeId, $this->directory ) )->setKeyField('sc_key') ); $json = json_decode( file_get_contents( $this->getApplicationPath() . "/data/themesettings.json" ), TRUE ); /* Add */ foreach( $json as $key => $data) { $insertedSetting = FALSE; if ( ! isset( $currentSettings[ $data['sc_key'] ] ) ) { $insertedSetting = TRUE; $currentId = \IPS\Db::i()->insert( 'core_theme_settings_fields', array( 'sc_set_id' => $defaultThemeId, 'sc_key' => $data['sc_key'], 'sc_tab_key' => $data['sc_tab_key'], 'sc_type' => $data['sc_type'], 'sc_multiple' => $data['sc_multiple'], 'sc_default' => $data['sc_default'], 'sc_content' => $data['sc_content'], 'sc_show_in_vse' => ( isset( $data['sc_show_in_vse'] ) ) ? $data['sc_show_in_vse'] : 0, 'sc_updated' => time(), 'sc_app' => $this->directory, 'sc_title' => $data['sc_title'], 'sc_order' => $data['sc_order'], 'sc_condition' => $data['sc_condition'], ) ); $currentSettings[ $data['sc_key'] ] = $data; } else { /* Update */ \IPS\Db::i()->update( 'core_theme_settings_fields', array( 'sc_tab_key' => $data['sc_tab_key'], 'sc_type' => $data['sc_type'], 'sc_multiple' => $data['sc_multiple'], 'sc_default' => $data['sc_default'], 'sc_show_in_vse' => ( isset( $data['sc_show_in_vse'] ) ) ? $data['sc_show_in_vse'] : 0, 'sc_content' => $data['sc_content'], 'sc_title' => $data['sc_title'], 'sc_order' => $data['sc_order'], 'sc_condition' => $data['sc_condition'], ), array( 'sc_set_id=? AND sc_key=? AND sc_app=?', $defaultThemeId, $data['sc_key'], $this->directory ) ); $currentId = $currentSettings[ $data['sc_key'] ]['sc_id']; } /* Are we updating the value? */ if( $update === FALSE OR $insertedSetting === TRUE ) { \IPS\Db::i()->delete('core_theme_settings_values', array('sv_id=?', $currentId ) ); \IPS\Db::i()->insert('core_theme_settings_values', array( 'sv_id' => $currentId, 'sv_value' => (string)$data['sc_default'] ) ); } } if ( $update ) { $defaultCurrentSettings = $currentSettings; foreach( \IPS\Theme::themes() as $theme ) { /* If we are using the stock default theme, then use the setting values from the JSON as the base */ if ( $theme->id == $defaultThemeId ) { $currentSettings = $defaultCurrentSettings; } else { $currentSettings = iterator_to_array( \IPS\Db::i()->select( '*', 'core_theme_settings_fields', array( 'sc_set_id=?', $theme->id ) )->setKeyField('sc_key') ); } $added = FALSE; $save = json_decode( $theme->template_settings, TRUE ); /* Add */ foreach( $json as $key => $data ) { if ( ! isset( $currentSettings[ $data['sc_key'] ] ) ) { $added = TRUE; $save[ $data['sc_key'] ] = $data['sc_default']; \IPS\Db::i()->insert( 'core_theme_settings_fields', array( 'sc_set_id' => $theme->id, 'sc_key' => $data['sc_key'], 'sc_tab_key' => $data['sc_tab_key'], 'sc_type' => $data['sc_type'], 'sc_multiple' => $data['sc_multiple'], 'sc_default' => $data['sc_default'], 'sc_content' => $data['sc_content'], 'sc_show_in_vse' => ( isset( $data['sc_show_in_vse'] ) ) ? $data['sc_show_in_vse'] : 0, 'sc_updated' => time(), 'sc_app' => $this->directory, 'sc_title' => $data['sc_title'], 'sc_order' => $data['sc_order'], 'sc_condition' => $data['sc_condition'], ) ); } else { /* Update */ \IPS\Db::i()->update( 'core_theme_settings_fields', array( 'sc_type' => $data['sc_type'], 'sc_multiple' => $data['sc_multiple'], 'sc_default' => $data['sc_default'], 'sc_show_in_vse' => ( isset( $data['sc_show_in_vse'] ) ) ? $data['sc_show_in_vse'] : 0, 'sc_content' => $data['sc_content'], 'sc_title' => $data['sc_title'], 'sc_condition' => $data['sc_condition'], ), array( 'sc_set_id=? AND sc_key=?', $theme->id, $data['sc_key'] ) ); $currentId = $currentSettings[ $data['sc_key'] ]['sc_id']; try { $currentValue = \IPS\Db::i()->select( 'sv_value', 'core_theme_settings_values', array( array( 'sv_id=?', $currentId ) ) )->first(); } catch( \UnderFlowException $ex ) { $currentValue = $currentSettings[ $data['sc_key'] ]['sc_default']; } /* Are we using the existing default? If so, update it */ if ( ( $data['sc_default'] != $currentSettings[ $data['sc_key'] ]['sc_default'] ) and ( $currentValue == $defaultCurrentSettings[ $data['sc_key'] ]['sc_default'] ) ) { $added = TRUE; $save[ $data['sc_key'] ] = $data['sc_default']; \IPS\Db::i()->delete('core_theme_settings_values', array('sv_id=?', $currentId ) ); \IPS\Db::i()->insert('core_theme_settings_values', array( 'sv_id' => $currentId, 'sv_value' => (string)$data['sc_default'] ) ); } } } if ( $added ) { $theme->template_settings = json_encode( $save ); $theme->save(); } } } } } /** * Clear out existing templates before installing new ones * * @return void */ public function clearTemplates() { if( file_exists( $this->getApplicationPath() . "/data/theme.xml" ) ) { unset( \IPS\Data\Store::i()->themes ); \IPS\Theme::removeTemplates( $this->directory ); \IPS\Theme::removeCss( $this->directory ); \IPS\Theme::clearFiles( \IPS\Theme::CSS ); \IPS\Theme::removeResources( $this->directory ); \IPS\Theme::resetAllCacheKeys(); } } /** * Install the application's templates * Theme resources should be raw binary data everywhere (filesystem and DB) except in the theme XML download where they are base64 encoded. * * @param bool $update If set to true, do not overwrite current theme setting values * @param int|null $offset Offset to begin import from * @param int|null $limit Number of rows to import * @return int Rows inserted */ public function installTemplates( $update=FALSE, $offset=null, $limit=null ) { $i = 0; $inserted = 0; if ( \IPS\Dispatcher::hasInstance() AND class_exists( '\IPS\Dispatcher', FALSE ) and \IPS\Dispatcher::i()->controllerLocation === 'setup' ) { $class = '\IPS\Theme'; } else { $class = ( \IPS\Theme::designersModeEnabled() ) ? '\IPS\Theme\Advanced\Theme' : '\IPS\Theme'; } if( file_exists( $this->getApplicationPath() . "/data/theme.xml" ) ) { unset( \IPS\Data\Store::i()->themes ); /* Try to prevent timeouts to the extent possible */ $cutOff = null; if( $maxExecution = @ini_get( 'max_execution_time' ) ) { /* If max_execution_time is set to "no limit" we should add a hard limit to prevent browser timeouts */ if ( $maxExecution == -1 ) { $maxExecution = 30; } $cutOff = time() + ( $maxExecution * .5 ); } /* Open XML file */ $xml = \IPS\Xml\XMLReader::safeOpen( $this->getApplicationPath() . "/data/theme.xml" ); $xml->read(); while( $xml->read() ) { if( $xml->nodeType != \XMLReader::ELEMENT ) { continue; } if( $cutOff !== null AND time() >= $cutOff ) { break; } $i++; if ( $offset !== null ) { if ( $i - 1 < $offset ) { $xml->next(); continue; } } $inserted++; if( $xml->name == 'template' ) { $template = array( 'app' => $this->directory, 'group' => $xml->getAttribute('template_group'), 'name' => $xml->getAttribute('template_name'), 'variables' => $xml->getAttribute('template_data'), 'content' => $xml->readString(), 'location' => $xml->getAttribute('template_location'), '_default_template' => true ); try { $class::addTemplate( $template ); } catch( \OverflowException $e ) { if ( ! $update ) { throw $e; } } } else if( $xml->name == 'css' ) { $css = array( 'app' => $this->directory, 'location' => $xml->getAttribute('css_location'), 'path' => $xml->getAttribute('css_path'), 'name' => $xml->getAttribute('css_name'), 'content' => $xml->readString(), '_default_template' => true ); try { $class::addCss( $css ); } catch( \OverflowException $e ) { if( ! $update ) { throw $e; } } } else if( $xml->name == 'resource' ) { $resource = array( 'app' => $this->directory, 'location' => $xml->getAttribute('location'), 'path' => $xml->getAttribute('path'), 'name' => $xml->getAttribute('name'), 'content' => base64_decode( $xml->readString() ), ); $class::addResource( $resource, TRUE ); } if( $limit !== null AND $i === ( $limit + $offset ) ) { break; } } } return $inserted; } /** * Install the application's javascript * * @param int|null $offset Offset to begin import from * @param int|null $limit Number of rows to import * @return int Rows inserted */ public function installJavascript( $offset=null, $limit=null ) { if( file_exists( $this->getApplicationPath() . "/data/javascript.xml" ) ) { return \IPS\Output\Javascript::importXml( $this->getApplicationPath() . "/data/javascript.xml", $offset, $limit ); } } /** * Install the application's ACP search keywords * * @return void */ public function installSearchKeywords() { if( file_exists( $this->getApplicationPath() . "/data/acpsearch.json" ) ) { \IPS\Db::i()->delete( 'core_acp_search_index', array( 'app=?', $this->directory ) ); $inserts = array(); $maxInserts = 50; foreach( json_decode( file_get_contents( $this->getApplicationPath() . "/data/acpsearch.json" ), TRUE ) as $url => $data ) { foreach ( $data['keywords'] as $word ) { $inserts[] = array( 'url' => $url, 'keyword' => $word, 'app' => $this->directory, 'lang_key' => $data['lang_key'], 'restriction' => $data['restriction'] ?: NULL ); if( \count( $inserts ) >= $maxInserts ) { \IPS\Db::i()->insert( 'core_acp_search_index', $inserts ); $inserts = array(); } } } if( \count( $inserts ) ) { \IPS\Db::i()->insert( 'core_acp_search_index', $inserts ); } } } /** * Install hooks * * @return void */ public function installHooks() { if( file_exists( $this->getApplicationPath() . "/data/hooks.json" ) ) { \IPS\Db::i()->delete( 'core_hooks', array( 'app=?', $this->directory ) ); $inserts = array(); $templatesToRecompile = array(); foreach( json_decode( file_get_contents( $this->getApplicationPath() . "/data/hooks.json" ), TRUE ) as $filename => $data ) { \IPS\Db::i()->insert( 'core_hooks', array( 'app' => $this->directory, 'type' => $data['type'], 'class' => $data['class'], 'filename' => $filename ) ); if ( $data['type'] === 'S' ) { $templatesToRecompile[ $data['class'] ] = $data['class']; } } \IPS\Plugin\Hook::writeDataFile(); foreach ( $templatesToRecompile as $k ) { $exploded = explode( '_', $k ); \IPS\Theme::deleteCompiledTemplate( $exploded[1], $exploded[2], $exploded[3] ); } } } /** * Install the application's widgets * * @return void */ public function installWidgets() { if( file_exists( $this->getApplicationPath() . "/data/widgets.json" ) ) { \IPS\Db::i()->delete( 'core_widgets', array( 'app=?', $this->directory ) ); $inserts = array(); foreach ( json_decode( file_get_contents( $this->getApplicationPath() . "/data/widgets.json" ), TRUE ) as $key => $json ) { $inserts[] = array( 'app' => $this->directory, 'key' => $key, 'class' => $json['class'], 'restrict' => json_encode( $json['restrict'] ), 'default_area' => ( isset( $json['default_area'] ) ? $json['default_area'] : NULL ), 'allow_reuse' => ( isset( $json['allow_reuse'] ) ? $json['allow_reuse'] : 0 ), 'menu_style' => ( isset( $json['menu_style'] ) ? $json['menu_style'] : 'menu' ), 'embeddable' => ( isset( $json['embeddable'] ) ? $json['embeddable'] : 0 ), ); } if( \count( $inserts ) ) { \IPS\Db::i()->insert( 'core_widgets', $inserts, TRUE ); unset( \IPS\Data\Store::i()->widgets ); } } } /** * Install 'other' items. Left blank here so that application classes can override for app * specific installation needs. Always run as the last step. * * @return void */ public function installOther() { } /** * Default front navigation * * @code // Each item... array( 'key' => 'Example', // The extension key 'app' => 'core', // [Optional] The extension application. If ommitted, uses this application 'config' => array(...), // [Optional] The configuration for the menu item 'title' => 'SomeLangKey', // [Optional] If provided, the value of this language key will be copied to menu_item_X 'children' => array(...), // [Optional] Array of child menu items for this item. Each has the same format. ) return array( 'rootTabs' => array(), // These go in the top row 'browseTabs' => array(), // These go under the Browse tab on a new install or when restoring the default configuraiton; or in the top row if installing the app later (when the Browse tab may not exist) 'browseTabsEnd' => array(), // These go under the Browse tab after all other items on a new install or when restoring the default configuraiton; or in the top row if installing the app later (when the Browse tab may not exist) 'activityTabs' => array(), // These go under the Activity tab on a new install or when restoring the default configuraiton; or in the top row if installing the app later (when the Activity tab may not exist) ) * @endcode * @return array */ public function defaultFrontNavigation() { return array( 'rootTabs' => array(), 'browseTabs' => array(), 'browseTabsEnd' => array(), 'activityTabs' => array() ); } /** * Database check * * @return array Queries needed to correct database in the following format ( table => x, query = x ); */ public function databaseCheck() { $db = \IPS\Db::i(); $changesToMake = array(); /* If member IDs are getting near the legacy mediumint limit, we need to increase it. */ $maxMemberId = \IPS\Db::i()->select( 'max(member_id)', 'core_members' )->first(); $enableBigInt = ( $maxMemberId > 8288607 ); /* Loop the tables in the schema */ foreach( json_decode( file_get_contents( $this->getApplicationPath() . "/data/schema.json" ), TRUE ) as $tableName => $tableDefinition ) { $tableChanges = array(); $needIgnore = false; $innoDbFullTextIndexes = array(); /* Get our local definition of this table */ try { $localDefinition = \IPS\Db::i()->getTableDefinition( $tableName, FALSE, TRUE ); $originalDefinition = $localDefinition; #Store this before it is normalised and engine stripped $localDefinition = \IPS\Db::i()->normalizeDefinition( $localDefinition ); if( isset( $tableDefinition['reporting'] ) ) { unset( $tableDefinition['reporting'] ); } if( isset( $tableDefinition['inserts'] ) ) { unset( $tableDefinition['inserts'] ); } /* Now we have to add the correct colation for text columns to our compare definition to flag any columns that don't have the correct charset/collation */ $tableDefinition['columns'] = array_map( function( $column ){ if( \in_array( mb_strtoupper( $column['type'] ), array( 'CHAR', 'VARCHAR', 'TINYTEXT', 'TEXT', 'MEDIUMTEXT', 'LONGTEXT', 'ENUM', 'SET' ) ) ) { $column['collation'] = \IPS\Db::i()->collation; } return $column; }, $tableDefinition['columns'] ); /* And store our definition */ $compareDefinition = \IPS\Db::i()->normalizeDefinition( $tableDefinition ); $tableDefinition = \IPS\Db::i()->updateDefinitionIndexLengths( $tableDefinition ); if( isset( $compareDefinition['comment'] ) AND !$compareDefinition['comment'] ) { unset( $compareDefinition['comment'] ); } /* Ensure that we use the proper engine, not whatever is in the schema.json as this will confuse index sub_part lengths */ if ( isset( $originalDefinition['engine'] ) ) { $tableDefinition['engine'] = $originalDefinition['engine']; } if ( $compareDefinition != $localDefinition ) { $dropped = array(); /* Loop the columns */ foreach ( $tableDefinition['columns'] as $columnName => $columnData ) { /* If it doesn't exist in the local database, create it */ if ( !isset( $localDefinition['columns'][ $columnName ] ) ) { $tableChanges[] = "ADD COLUMN {$db->compileColumnDefinition( $columnData )}"; } /* Or if it's wrong, change it */ elseif ( $compareDefinition['columns'][ $columnName ] != $localDefinition['columns'][ $columnName ] ) { /* If the only difference is MEDIUMIT or INT should be BIGINT UNSIGNED - that's where we changed the member ID column. We don't need to flag it */ $differences = array(); foreach ( $columnData as $k => $v ) { if ( isset( $localDefinition['columns'][ $columnName ][ $k ] ) AND $v != $localDefinition['columns'][ $columnName ][ $k ] ) { $differences[ $k ] = array( 'is' => $localDefinition['columns'][ $columnName ][ $k ], 'shouldBe' => $v ); } } if ( isset( $differences['type'] ) and ( $differences['type']['is'] == 'MEDIUMINT' or $differences['type']['is'] == 'INT' ) and $differences['type']['shouldBe'] == 'BIGINT' AND !$enableBigInt ) { unset( $differences['type'] ); if ( isset( $differences['length'] ) ) { unset( $differences['length'] ); } if ( isset( $differences['unsigned'] ) and !$differences['unsigned']['is'] and $differences['unsigned']['shouldBe'] ) { unset( $differences['unsigned'] ); } } /* Remove attempted changes back to empty string when INT */ if( !empty( $differences['default'] ) AND $differences['default']['is'] == 0 AND $differences['default']['shouldBe'] == '' ) { unset( $differences['default'] ); } /* If this is a decimal column, ignore unsigned attribute */ if( $compareDefinition['columns'][ $columnName ]['type'] == 'DECIMAL' AND isset( $differences['unsigned'] ) ) { unset( $differences['unsigned'] ); } /* If there were other differences, carry on... */ if ( $differences ) { /* We re-add indexes after changing columns */ $indexesToAdd = array(); /* First check indexes to see if any need to be adjusted */ foreach( $localDefinition['indexes'] as $indexName => $indexData ) { /* We skip the primary key as it can cause errors related to auto-increment */ if( $indexName == 'PRIMARY' ) { if ( isset( $tableDefinition['columns'][ $indexData['columns'][0] ] ) and isset( $tableDefinition['columns'][ $indexData['columns'][0] ]['auto_increment'] ) and $tableDefinition['columns'][ $indexData['columns'][0] ]['auto_increment'] === TRUE ) { continue; } } foreach( $indexData['columns'] as $indexColumn ) { /* If the column we are about to adjust is included in this index, see if it needs adjusting */ if( $indexColumn == $columnName AND !\in_array( $indexName, $dropped ) ) { $thisIndex = $db->updateDefinitionIndexLengths( $tableDefinition ); if( !isset( $thisIndex['indexes'][ $indexName ] ) ) { $tableChanges[] = "DROP INDEX `{$db->escape_string( $indexName )}`"; $dropped[] = $indexName; } elseif( $thisIndex['indexes'][ $indexName ] !== $localDefinition['indexes'][ $indexName ] ) { $tableChanges[] = "DROP INDEX `{$db->escape_string( $indexName )}`"; $indexesToAdd[] = $db->buildIndex( $tableName, $thisIndex['indexes'][ $indexName ], $tableDefinition ); $dropped[] = $indexName; if( $tableDefinition['indexes'][ $indexName ]['type'] == 'unique' OR $tableDefinition['indexes'][ $indexName ]['type'] == 'primary' ) { $needIgnore = TRUE; } } } } } /* If we are about to adjust the column to not allow NULL values then adjust those values first... */ if( isset( $columnData['allow_null'] ) and $columnData['allow_null'] === FALSE ) { $defaultValue = "''"; /* Default value */ if( isset( $columnData['default'] ) and !\in_array( \strtoupper( $columnData['type'] ), array( 'TINYTEXT', 'TEXT', 'MEDIUMTEXT', 'LONGTEXT', 'BLOB', 'MEDIUMBLOB', 'BIGBLOB', 'LONGBLOB' ) ) ) { if( $columnData['type'] == 'BIT' ) { $defaultValue = "{$columnData['default']}"; } else { $defaultValue = \in_array( $columnData['type'], array( 'TINYINT', 'SMALLINT', 'MEDIUMINT', 'INT', 'INTEGER', 'BIGINT', 'REAL', 'DOUBLE', 'FLOAT', 'DECIMAL', 'NUMERIC' ) ) ? \floatval( $columnData['default'] ) : ( ! \in_array( $columnData['default'], array( 'CURRENT_TIMESTAMP', 'BIT' ) ) ? '\'' . $db->escape_string( $columnData['default'] ) . '\'' : $columnData['default'] ); } } $changesToMake[] = array( 'table' => $tableName, 'query' => "UPDATE `{$db->prefix}{$db->escape_string( $tableName )}` SET `{$db->escape_string( $columnName )}`={$defaultValue} WHERE `{$db->escape_string( $columnName )}` IS NULL;" ); } $tableChanges[] = "CHANGE COLUMN `{$db->escape_string( $columnName )}` {$db->compileColumnDefinition( $columnData )}"; if( \count( $indexesToAdd ) ) { $tableChanges = array_merge( $tableChanges, $indexesToAdd ); } } } } /* Loop the index */ foreach ( $compareDefinition['indexes'] as $indexName => $indexData ) { if( \in_array( $indexName, $dropped ) ) { continue; } if ( !isset( $localDefinition['indexes'][ $indexName ] ) ) { /* InnoDB FullText indexes must be added one at a time */ if( $tableDefinition['engine'] === 'InnoDB' AND $tableDefinition['indexes'][ $indexName ]['type'] === 'fulltext' ) { $innoDbFullTextIndexes[] = $db->buildIndex( $tableName, $tableDefinition['indexes'][ $indexName ], $tableDefinition ); } else { $tableChanges[] = $db->buildIndex( $tableName, $tableDefinition['indexes'][ $indexName ], $tableDefinition ); } if( $tableDefinition['indexes'][ $indexName ]['type'] == 'unique' OR $tableDefinition['indexes'][ $indexName ]['type'] == 'primary' ) { $needIgnore = TRUE; } } elseif ( $indexData != $localDefinition['indexes'][ $indexName ] ) { $tableChanges[] = ( ( $indexName == 'PRIMARY KEY' ) ? "DROP " . $indexName . ", " : "DROP INDEX `" . $db->escape_string( $indexName ) . "`, " ) . $db->buildIndex( $tableName, $tableDefinition['indexes'][ $indexName ], $tableDefinition ); if( $tableDefinition['indexes'][ $indexName ]['type'] == 'unique' OR $tableDefinition['indexes'][ $indexName ]['type'] == 'primary' ) { $needIgnore = TRUE; } } } /* Remove unnecessary indexes, which can be an issue if, for example, there is a UNIQUE index that the schema doesn't think should be there */ foreach ( $localDefinition['indexes'] as $indexName => $indexData ) { if ( $indexName != 'PRIMARY' and !isset( $compareDefinition['indexes'][ $indexName ] ) ) { /* If the index is on a column which we don't recognise (which may happen on tables which we add columns to like the ones that store custom fields, or very naughty third parties adding columns on tables they don't own), don't drop it */ foreach ( $indexData['columns'] as $indexedColumn ) { if ( !isset( $compareDefinition['columns'][ $indexedColumn ] ) ) { continue 2; } } /* Still here? Go ahead */ $dropIndexQuery = "DROP INDEX `{$db->escape_string( $indexName )}`"; if ( !\in_array( $dropIndexQuery, $tableChanges ) ) // We skip the primary key as it can cause errors related to auto-increment { $tableChanges[] = $dropIndexQuery; } } } } if( \count( $tableChanges ) ) { if( $needIgnore ) { $changesToMake[] = array( 'table' => $tableName, 'query' => "CREATE TABLE `{$db->prefix}{$db->escape_string( $tableName )}_new` LIKE `{$db->prefix}{$db->escape_string( $tableName )}`;" ); $changesToMake[] = array( 'table' => $tableName, 'query' => "ALTER TABLE `{$db->prefix}{$db->escape_string( $tableName )}_new` " . implode( ", ", $tableChanges ) . ";" ); $changesToMake[] = array( 'table' => $tableName, 'query' => "INSERT IGNORE INTO `{$db->prefix}{$db->escape_string( $tableName )}_new` SELECT * FROM `{$db->prefix}{$db->escape_string( $tableName )}`;" ); $changesToMake[] = array( 'table' => $tableName, 'query' => "DROP TABLE `{$db->prefix}{$db->escape_string( $tableName )}`;" ); $changesToMake[] = array( 'table' => $tableName, 'query' => "RENAME TABLE `{$db->prefix}{$db->escape_string( $tableName )}_new` TO `{$db->prefix}{$db->escape_string( $tableName )}`;" ); } else { $changesToMake[] = array( 'table' => $tableName, 'query' => "ALTER TABLE `{$db->prefix}{$db->escape_string( $tableName )}` " . implode( ", ", $tableChanges ) . ";" ); } } /* InnoDB FullText indexes must be added one at a time */ if( \count( $innoDbFullTextIndexes ) ) { foreach( $innoDbFullTextIndexes as $newIndex ) { $changesToMake[] = array( 'table' => $tableName, 'query' => "ALTER TABLE `{$db->prefix}{$db->escape_string( $tableName )}` " . $newIndex . ";" ); } } } /* If the table doesn't exist, create it */ catch ( \OutOfRangeException $e ) { $changesToMake[] = array( 'table' => $tableName, 'query' => $db->_createTableQuery( $tableDefinition ) ); } } /* And loop any install routine for columns added to other tables */ if ( file_exists( $this->getApplicationPath() . "/setup/install/queries.json" ) ) { foreach( json_decode( file_get_contents( $this->getApplicationPath() . "/setup/install/queries.json" ), TRUE ) as $query ) { switch ( $query['method'] ) { /* Add column */ case 'addColumn': $localDefinition = \IPS\Db::i()->getTableDefinition( $query['params'][0] ); if ( !isset( $localDefinition['columns'][ $query['params'][1]['name'] ] ) ) { $changesToMake[] = array( 'table' => $query['params'][0], 'query' => "ALTER TABLE `{$db->prefix}{$query['params'][0]}` ADD COLUMN {$db->compileColumnDefinition( $query['params'][1] )}" ); } else { $correctDefinition = $db->compileColumnDefinition( $query['params'][1] ); if ( $correctDefinition != $db->compileColumnDefinition( $localDefinition['columns'][ $query['params'][1]['name'] ] ) ) { $changesToMake[] = array( 'table' => $query['params'][0], 'query' => "ALTER TABLE `{$db->prefix}{$query['params'][0]}` CHANGE COLUMN `{$query['params'][1]['name']}` {$correctDefinition}" ); } } break; } } } /* Return */ return $changesToMake; } /** * Create a new version number and move current working version * code into it * * @param int $long The "long" version number (e.g. 100000) * @param string $human The "human" version number (e.g. "1.0.0") * @return void */ public function assignNewVersion( $long, $human ) { /* Add to versions.json */ $json = json_decode( \file_get_contents( \IPS\ROOT_PATH . "/applications/{$this->directory}/data/versions.json" ), TRUE ); $json[ $long ] = $human; static::writeJson( \IPS\ROOT_PATH . "/applications/{$this->directory}/data/versions.json", $json ); /* Do stuff */ $setupDir = \IPS\ROOT_PATH . "/applications/{$this->directory}/setup"; $workingDir = $setupDir . "/upg_working"; if ( file_exists( $workingDir ) ) { /* We need to make sure the array is 1-indexed otherwise the upgrader gets confused */ $queriesJsonFile = $workingDir . "/queries.json"; if ( file_exists( $queriesJsonFile ) ) { $write = array(); $i = 0; foreach ( json_decode( \file_get_contents( $queriesJsonFile ), TRUE ) as $query ) { $write[ ++$i ] = $query; } static::writeJson( $queriesJsonFile, $write ); } /* Add the actual version number in upgrade.php & options.php */ $versionReplacement = function( $file ) use ( $human, $long ) { if ( file_exists( $file ) ) { $contents = file_get_contents( $file ); $contents = str_replace( array( '{version_human}', 'upg_working', '{version_long}' ), array( $human, "upg_{$long}", $long ), $contents ); \file_put_contents( $file, $contents ); } }; /* Make the replacement */ $versionReplacement( $workingDir . "/upgrade.php" ); $versionReplacement( $workingDir . "/options.php" ); /* Rename the directory */ rename( $workingDir, $setupDir . "/upg_{$long}" ); } /* Update core_dev */ \IPS\Db::i()->update( 'core_dev', array( 'working_version' => $long, ), array( 'app_key=? AND working_version=?', $this->directory, 'working' ) ); } /** * Build application for release * * @return void * @throws \RuntimeException */ public function build() { /* Use full upgrader? */ $forceFullUpgrade = FALSE; /* Write the application data to the application.json file */ $applicationData = array( 'application_title' => \IPS\Member::loggedIn()->language()->get('__app_' . $this->directory ), 'app_author' => $this->author, 'app_directory' => $this->directory, 'app_protected' => $this->protected, 'app_website' => $this->website, 'app_update_check' => $this->update_check, 'app_hide_tab' => $this->hide_tab ); \IPS\Application::writeJson( \IPS\ROOT_PATH . '/applications/' . $this->directory . '/data/application.json', $applicationData ); /* Update app version data */ $versions = $this->getAllVersions(); $longVersions = array_keys( $versions ); $humanVersions = array_values( $versions ); if( \count($versions) ) { $latestLVersion = array_pop( $longVersions ); $latestHVersion = array_pop( $humanVersions ); \IPS\Db::i()->update( 'core_applications', array( 'app_version' => $latestHVersion, 'app_long_version' => $latestLVersion ), array( 'app_directory=?', $this->directory ) ); $this->long_version = $latestLVersion; $this->version = $latestHVersion; } $setupDir = \IPS\ROOT_PATH . '/applications/' . $this->directory . '/setup/upg_' . $this->long_version; if ( !is_dir( $setupDir ) ) { mkdir( $setupDir ); } /* Take care of languages for this app */ $languageChanges = $this->buildLanguages(); $langChangesFile = $setupDir . '/lang.json'; if ( \count( array_filter( $languageChanges['normal'] ) ) or \count( array_filter( $languageChanges['js'] ) ) ) { if ( file_exists( $langChangesFile ) ) { $previousLangChanges = json_decode( file_get_contents( $langChangesFile ), TRUE ); $languageChanges['normal'] = $this->_combineChanges( $languageChanges['normal'], $previousLangChanges['normal'] ); $languageChanges['js'] = $this->_combineChanges( $languageChanges['js'], $previousLangChanges['js'] ); } if ( \count( array_filter( $languageChanges['normal'] ) ) or \count( array_filter( $languageChanges['js'] ) ) ) { \file_put_contents( $langChangesFile, json_encode( $languageChanges, JSON_PRETTY_PRINT ) ); } elseif ( \file_exists( $langChangesFile ) ) { \unlink( $langChangesFile ); } } $this->installLanguages(); /* Take care of skins for this app */ $themeChanges = $this->buildThemeTemplates(); $themeChangesFile = $setupDir . '/theme.json'; if ( \count( array_filter( $themeChanges['html'] ) ) or \count( array_filter( $themeChanges['css'] ) ) or \count( array_filter( $themeChanges['resources'] ) ) ) { if ( file_exists( $themeChangesFile ) ) { $previousThemeChanges = json_decode( file_get_contents( $themeChangesFile ), TRUE ); $themeChanges['html'] = $this->_combineChanges( $themeChanges['html'], $previousThemeChanges['html'] ); $themeChanges['css'] = $this->_combineChanges( $themeChanges['css'], $previousThemeChanges['css'] ); $themeChanges['resources'] = $this->_combineChanges( $themeChanges['resources'], $previousThemeChanges['resources'] ); } if ( \count( array_filter( $themeChanges['html'] ) ) or \count( array_filter( $themeChanges['css'] ) ) or \count( array_filter( $themeChanges['resources'] ) ) ) { \file_put_contents( $themeChangesFile, json_encode( $themeChanges, JSON_PRETTY_PRINT ) ); } elseif ( \file_exists( $themeChangesFile ) ) { \unlink( $themeChangesFile ); } } /* Take care of emails for this app */ $emailTemplateChanges = $this->buildEmailTemplates(); $emailTemplateChangesFile = $setupDir . '/emailTemplates.json'; if ( \count( array_filter( $emailTemplateChanges ) ) ) { if ( file_exists( $emailTemplateChangesFile ) ) { $emailTemplateChanges = $this->_combineChanges( $emailTemplateChanges, json_decode( file_get_contents( $emailTemplateChangesFile ), TRUE ) ); } if ( \count( array_filter( $emailTemplateChanges ) ) ) { \file_put_contents( $emailTemplateChangesFile, json_encode( $emailTemplateChanges, JSON_PRETTY_PRINT ) ); } elseif ( \file_exists( $emailTemplateChangesFile ) ) { \unlink( $emailTemplateChangesFile ); } } $this->installEmailTemplates(); /* Take care of javascript for this app */ $jsChanges = $this->buildJavascript(); $jsChangesFile = $setupDir . '/javascript.json'; if ( \count( array_filter( $jsChanges['files'] ) ) or \count( array_filter( $jsChanges['orders'] ) ) ) { if ( file_exists( $jsChangesFile ) ) { $previousJsChanges = json_decode( file_get_contents( $jsChangesFile ), TRUE ); $jsChanges['files'] = $this->_combineChanges( $jsChanges['files'], $previousJsChanges['files'] ); $jsChanges['orders'] = $this->_combineChanges( $jsChanges['orders'], $previousJsChanges['orders'] ); } if ( \count( array_filter( $jsChanges['files'] ) ) or \count( array_filter( $jsChanges['orders'] ) ) ) { \file_put_contents( $jsChangesFile, json_encode( $jsChanges, JSON_PRETTY_PRINT ) ); } elseif ( \file_exists( $jsChangesFile ) ) { \unlink( $jsChangesFile ); } /* Force full upgrade if global JS has changed */ foreach( new \RecursiveIteratorIterator( new \RecursiveArrayIterator( $jsChanges ) ) as $k => $v ) { if( mb_substr( $v, 0, 6 ) === 'global' ) { $forceFullUpgrade = true; break; } } } $this->installJavascript(); /* Take care of hooks for this app */ $this->buildHooks(); /* And custom build routines */ foreach( $this->extensions( 'core', 'Build' ) as $builder ) { $builder->build(); } /* Write a build.xml file with the current json data so we know what has changed next time we build */ $jsonChanges = $this->buildJsonData(); foreach ( array( 'modules', 'tasks', 'settings', 'widgets', 'acpSearchKeywords', 'hooks', 'themeSettings' ) as $k ) { if ( isset( $jsonChanges[ $k ] ) and ( $jsonChanges[ $k ]['added'] or $jsonChanges[ $k ]['edited'] or $jsonChanges[ $k ]['removed'] ) ) { $changesFile = "{$setupDir}/{$k}.json"; if ( file_exists( $changesFile ) ) { $jsonChanges[ $k ] = $this->_combineChanges( $jsonChanges[ $k ], json_decode( file_get_contents( $changesFile ), TRUE ) ); } if ( $jsonChanges[ $k ]['added'] or $jsonChanges[ $k ]['edited'] or $jsonChanges[ $k ]['removed'] ) { \file_put_contents( $changesFile, json_encode( $jsonChanges[ $k ], JSON_PRETTY_PRINT ) ); } elseif ( \file_exists( $changesFile ) ) { \unlink( $changesFile ); } } } /* Included CMS Templates */ if( file_exists( \IPS\ROOT_PATH . '/applications/' . $this->directory . '/dev/cmsTemplates.json' ) ) { $pagesTemplates = json_decode( file_get_contents( \IPS\ROOT_PATH . '/applications/' . $this->directory . '/dev/cmsTemplates.json' ), TRUE ); $xml = \IPS\cms\Templates::exportAsXml( $pagesTemplates ); if( $xml ) { if ( is_writable( \IPS\ROOT_PATH . '/applications/' . $this->directory . '/data' ) ) { \file_put_contents( \IPS\ROOT_PATH . '/applications/' . $this->directory . '/data/cmsTemplates.xml', $xml->outputMemory() ); } else { throw new \RuntimeException( \IPS\Member::loggedIn()->language()->addToStack('dev_could_not_write_data') ); } } } /* Write the version data file */ \file_put_contents( $setupDir . '/data.json', json_encode( array( 'id' => $this->long_version, 'name' => $this->version, 'steps' => array( 'queries' => file_exists( $setupDir . "/queries.json" ), 'lang' => file_exists( $langChangesFile ), 'theme' => file_exists( $themeChangesFile ), 'themeSettings' => file_exists( $setupDir . "/themeSettings.json" ), 'javascript' => file_exists( $jsChangesFile ), 'emailTemplates' => file_exists( $emailTemplateChangesFile ), 'hooks' => file_exists( $setupDir . "/hooks.json" ), 'acpSearchKeywords' => file_exists( $setupDir . "/acpSearchKeywords.json" ), 'settings' => file_exists( $setupDir . "/settings.json" ), 'tasks' => file_exists( $setupDir . "/tasks.json" ), 'modules' => file_exists( $setupDir . "/modules.json" ), 'widgets' => file_exists( $setupDir . "/widgets.json" ), 'customOptions' => file_exists( $setupDir . "/options.php" ), 'customRoutines' => file_exists( $setupDir . "/upgrade.php" ), ), 'forceMainUpgrader' => $forceFullUpgrade, 'forceManualDownloadNoCiC' => FALSE, 'forceManualDownloadCiC' => FALSE, ), JSON_PRETTY_PRINT ) ); } /** * Combine information about changes when rebuilding a version after it was already built once before * * @param array $newChanges The changes detected in this build * @param array $previousChanges The changes detected in the previous build * @param bool $keysOnly Set to TRUE if the changes is just a list of keys, or FALSE if they're key/values * @return array */ protected function _combineChanges( $newChanges, $previousChanges, $keysOnly=TRUE ) { if ( $keysOnly ) { foreach ( $newChanges['added'] as $v ) { if ( \in_array( $v, $previousChanges['removed'] ) ) { unset( $previousChanges['removed'][ array_search( $v, $previousChanges['removed'] ) ] ); } else { $previousChanges['added'][] = $v; } } foreach ( $newChanges['edited'] as $v ) { if ( !\in_array( $v, $previousChanges['added'] ) ) { $previousChanges['edited'][] = $v; } } foreach ( $newChanges['removed'] as $v ) { if ( \in_array( $v, $previousChanges['added'] ) ) { unset( $previousChanges['added'][ array_search( $v, $previousChanges['added'] ) ] ); } elseif ( \in_array( $v, $previousChanges['edited'] ) ) { unset( $previousChanges['edited'][ array_search( $v, $previousChanges['edited'] ) ] ); $previousChanges['removed'][] = $v; } elseif ( !\in_array( $v, $previousChanges['removed'] ) ) { $previousChanges['removed'][] = $v; } } } else { foreach ( $newChanges['added'] as $k => $v ) { if ( isset( $previousChanges['removed'][ $k ] ) ) { unset( $previousChanges['removed'][ $k ] ); } else { $previousChanges['added'][ $k ] = $v; } } foreach ( $newChanges['edited'] as $k => $v ) { if ( !isset( $previousChanges['added'][ $k ] ) ) { $previousChanges['edited'][ $k ] = $v; } } foreach ( $newChanges['removed'] as $k => $v ) { if ( isset( $previousChanges['added'][ $k ] ) ) { unset( $previousChanges['added'][ $k ] ); } elseif ( isset( $previousChanges['edited'][ $k ] ) ) { unset( $previousChanges['edited'][ $k ] ); $previousChanges['removed'][ $k ] = $v; } elseif ( !isset( $previousChanges['removed'][ $k ] ) ) { $previousChanges['removed'][ $k ] = $v; } } } return $previousChanges; } /** * Build skin templates for an app * * @return void * @throws \RuntimeException */ public function buildThemeTemplates() { /* Delete compiled items */ \IPS\Theme::deleteCompiledTemplate( $this->directory ); \IPS\Theme::deleteCompiledCss( $this->directory ); \IPS\Theme::removeResources( $this->directory ); \IPS\Theme::i()->importDevHtml( $this->directory, 0 ); \IPS\Theme::i()->importDevCss( $this->directory, 0 ); /* Get current XML file for calculating differences */ $return = array( 'html' => array( 'added' => array(), 'edited' => array(), 'removed' => array() ), 'css' => array( 'added' => array(), 'edited' => array(), 'removed' => array() ), 'resources' => array( 'added' => array(), 'edited' => array(), 'removed' => array() ) ); $current = array( 'html' => array(), 'css' => array(), 'resources' => array() ); $currentFile = \IPS\ROOT_PATH . "/applications/{$this->directory}/data/theme.xml"; if ( file_exists( $currentFile ) ) { $xml = \IPS\Xml\SimpleXML::loadFile( $currentFile ); foreach ( $xml->template as $html ) { $attributes = iterator_to_array( $html->attributes() ); $current['html'][ "{$attributes['template_location']}/{$attributes['template_group']}/{$attributes['template_name']}" ] = array( 'params' => $attributes['template_data'], 'content' => (string) $html ); } foreach ( $xml->css as $css ) { $attributes = iterator_to_array( $css->attributes() ); $current['css'][ "{$attributes['css_location']}/" . ( $attributes['css_path'] == '.' ? '' : "{$attributes['css_path']}/" ) . $attributes['css_name'] ] = array( 'params' => $attributes['css_attributes'], 'content' => (string) $css ); } foreach ( $xml->resource as $resource ) { $attributes = iterator_to_array( $resource->attributes() ); $current['resources'][ "{$attributes['location']}{$attributes['path']}{$attributes['name']}" ] = (string) $resource; } } /* Build XML and write to app directory */ $xml = new \XMLWriter; $xml->openMemory(); $xml->setIndent( TRUE ); $xml->startDocument( '1.0', 'UTF-8' ); /* Root tag */ $xml->startElement('theme'); $xml->startAttribute('name'); $xml->text( "Default" ); $xml->endAttribute(); $xml->startAttribute('author_name'); $xml->text( "Invision Power Services, Inc" ); $xml->endAttribute(); $xml->startAttribute('author_url'); $xml->text( "https://www.invisioncommunity.com" ); $xml->endAttribute(); /* Skin settings */ foreach ( \IPS\Db::i()->select( 'core_theme_settings_fields.*', 'core_theme_settings_fields', array( 'sc_set_id=? AND sc_app=?', 1, $this->directory ), 'sc_key ASC' ) as $row ) { /* Initiate the tag */ $xml->startElement('field'); unset( $row['sc_id'], $row['sc_set_id'] ); foreach( $row as $k => $v ) { if ( $k != 'sc_content' ) { $xml->startAttribute( $k ); $xml->text( $v ); $xml->endAttribute(); } } /* Write value */ if ( preg_match( '/<|>|&/', $row['sc_content'] ) ) { $xml->writeCData( str_replace( ']]>', ']]]]>', $row['sc_content'] ) ); } else { $xml->text( $row['sc_content'] ); } /* Close the tag */ $xml->endElement(); } /* Templates */ foreach ( \IPS\Db::i()->select( '*', 'core_theme_templates', array( 'template_set_id=? AND template_user_added=? AND template_app=?', 0, 0 , $this->directory ), 'template_group, template_name, template_location' ) as $template ) { /* Initiate the