Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @package Invision Community * @subpackage convert * @since 21 Jan 2015 */ namespace IPS\convert; /* 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; } /** * Converter Application Class */ class _App extends \IPS\Patterns\ActiveRecord { /** * @brief [ActiveRecord] Caches * @note Defined cache keys will be cleared automatically as needed */ protected $caches = array( 'convert_apps' ); /** * @brief [ActiveRecord] ID Database Column */ public static $databaseColumnId = 'app_id'; /** * @brief [ActiveRecord] Database table */ public static $databaseTable = 'convert_apps'; /** * @brief Array Storage of loaded ID links. */ protected $linkCache = array(); /** * @brief Flag to indicate the log is simply a notice, and that it is informational only. Useful for when data is missing, but can be covered via default values. */ const LOG_NOTICE = 1; /** * @brief Flag to indicate the log is a warning, and should be checked to see if conversion happened correctly. Useful for indicating something cannot be converted due to being orphaned or otherwise having missing data (ex. no parent topic). */ const LOG_WARNING = 2; /** * @brief Flag to indicate something went wrong, and the data did not convert correctly. Useful for indicating when something legitimately does not convert, but should. */ const LOG_ERROR = 3; /** * Get converted apps * * @return \IPS\Patterns\ActiveRecordIterator */ public static function apps() { return new \IPS\Patterns\ActiveRecordIterator( new \ArrayIterator( static::getStore() ), 'IPS\convert\App' ); } /** * Get all converter apps * * @return array */ public static function getStore() { if ( !isset( \IPS\Data\Store::i()->convert_apps ) ) { try { $rows = iterator_to_array( \IPS\Db::i()->select( '*', 'convert_apps', array(), 'app_id ASC' ) ); } catch ( \IPS\Db\Exception $e ) { if ( $e->getCode() === 1146 ) { $rows = iterator_to_array( \IPS\Db::i()->select( '*', 'conv_apps', array(), 'app_id ASC' ) ); } else { throw $e; } } \IPS\Data\Store::i()->convert_apps = $rows; } return \IPS\Data\Store::i()->convert_apps; } /** * [ActiveRecord] Save Record * * @return void */ public function save() { if ( !$this->app_id ) { $this->start_date = time(); parent::save(); \IPS\convert\Application::checkConvParent( $this->getSource()->getLibrary()->app ); \IPS\Db::i()->insert( 'convert_app_sessions', array( 'session_app_id' => $this->app_id, 'session_app_data' => json_encode( array( 'completed' => array(), 'working' => array(), 'more_info' => array() ) ), ) ); } $classname = \get_class( $this->getSource( TRUE, FALSE ) ); $this->login = ( $classname::loginEnabled() === TRUE ) ? 1 : 0; $this->db_driver = 'mysql'; /* I was going to drop this, but it has the potential for expansion in the future, as all we do is select from the source */ $this->app_merge = 1; parent::save(); } /** * [ActiveRecord] Delete Record * * @return void */ public function delete() { foreach( array( 'convert_link', 'convert_link_pms', 'convert_link_topics', 'convert_link_posts' ) AS $table ) { \IPS\Db::i()->delete( $table, array( 'app=?', $this->app_id ) ); } \IPS\Db::i()->delete( 'convert_app_sessions', array( 'session_app_id=?', $this->app_id ) ); \IPS\Db::i()->delete( 'convert_logs', array( 'log_app=?', $this->app_id ) ); parent::delete(); } /** * @brief Session data store */ protected $_sessionData = null; /** * Save Session Data for this application * * @param array $value Session Data * @return void */ public function set__session( $value ) { \IPS\Db::i()->update( 'convert_app_sessions', array( 'session_app_data' => json_encode( $value ) ), array( 'session_app_id=?', $this->app_id ) ); $this->_sessionData = $value; } /** * Get Session Data for this application * * @return array */ public function get__session() { /* Use ready-cached session data */ if( $this->_sessionData !== null ) { return $this->_sessionData; } try { $this->_sessionData = json_decode( \IPS\Db::i()->select( 'session_app_data', 'convert_app_sessions', array( 'session_app_id=?', $this->app_id ) )->first(), TRUE ); return $this->_sessionData; } catch( \Exception $e ) { /* If it doesn't exist, create it in the database and return an empty array. */ \IPS\Db::i()->insert( 'convert_app_sessions', array( 'session_app_id' => $this->app_id, 'session_app_data' => json_encode( array( 'completed' => array(), 'working' => array(), 'more_info' => array() ) ) ) ); return array( 'completed' => array(), 'working' => array(), 'more_info' => array() ); } } /** * [Legacy] Get Software. Automatically adjusts if legacy software which has since had its application key changed. * * @return string */ public function get_sw() { switch( $this->_data['sw'] ) { case 'board': return 'forums'; break; case 'ccs': return 'cms'; break; default: return $this->_data['sw']; break; } } /** * [Legacy] Automatically fix any legacy application keys that have changed. * * @param string $value App value * @return void */ public function set_sw( $value ) { switch( $value ) { case 'board': $this->_data['sw'] = 'forums'; break; case 'ccs': $this->_data['sw'] = 'cms'; break; default: $this->_data['sw'] = $value; break; } } /** * @brief Parent Store */ protected $parentStore = NULL; /** * Get parent application * * @return \IPS\convert\App * @throws \BadMethodCallException */ public function get__parent() { if ( \is_null( $this->parentStore ) ) { if ( ! $this->parent ) { throw new \BadMethodCallException; } try { $this->parentStore = static::constructFromData( \IPS\Db::i()->select( '*', 'convert_apps', array( "app_id=?", $this->parent ) )->first() ); } catch( \UnderflowException $e ) { throw new \BadMethodCallException; } catch( \OutOfRangeException $e ) { throw new \BadMethodCallException; } } return $this->parentStore; } /** * Return this application's children applications * * @return array|\IPS\Patterns\ActiveRecordIterator */ public function children() { /* If we have a parent, we are already a child and will not have children */ if( $this->parent ) { return array(); } return new \IPS\Patterns\ActiveRecordIterator( \IPS\Db::i()->select( '*', 'convert_apps', array( 'parent=?', $this->app_id ) ), 'IPS\convert\App' ); } /** * Retrieves an Invision Community ID from a Foreign ID. * * @param mixed $foreign_id The Foreign ID * @param string|array $type The type of item, or an array of types to check. * @param boolean $parent If set to TRUE, then retrieves from the parent application if one is available. * @param boolean $mainTable If set to TRUE, and the type is of either 'core_message_topics', 'core_message_posts', 'core_message_topic_user_map', 'forums_topics', or 'forums_posts', then the ID is retrieved from convert_link, rather than the other link tables. * * @return integer The Invision Community ID. * @throws \OutOfRangeException */ public function getLink( $foreign_id, $type, $parent=FALSE, $mainTable=FALSE ) { if ( !\is_array( $type ) ) { $type = array( $type ); } $key = \count( $type ) > 1 ? md5( implode('', $type ) ) : $type[0]; if ( isset( $this->linkCache[ $key ][ $foreign_id ] ) ) { if( $this->linkCache[ $key ][ $foreign_id ] === FALSE ) { throw new \OutOfRangeException( 'link_invalid' ); } return $this->linkCache[ $key ][ $foreign_id ]; } foreach ( $type as $t ) { $table = $this->_getLinkTableName( $t, $mainTable ); } try { $link = \IPS\Db::i()->select( 'ipb_id', $table, array( \IPS\Db::i()->in( 'type', $type ) . ' AND foreign_id=? AND app=?', (string) $foreign_id, ( $parent === TRUE and $this->parent ) ? $this->parent : $this->app_id ), 'link_id DESC' )->first(); $this->linkCache[ $key ][ $foreign_id ] = $link; return $this->linkCache[ $key ][ $foreign_id ]; } catch ( \UnderflowException $e ) { /* If lookup failed, and we have a parent, try it anyway */ try { $link = \IPS\Db::i()->select( 'ipb_id', $table, array( \IPS\Db::i()->in( 'type', $type ) . ' AND foreign_id=? AND app=?', (string) $foreign_id, $this->parent ), 'link_id DESC' )->first(); $this->linkCache[ $key ][ $foreign_id ] = $link; return $this->linkCache[ $key ][ $foreign_id ]; } catch( \UnderflowException $e ) {} /* Still here? Throw the exception */ throw new \OutOfRangeException( 'link_invalid' ); } } /** * Get conversion ID of the master core conversion * * @return int */ public function getMasterConversionId(): int { try { return $this->_parent->app_id; } catch( \BadMethodCallException $e ) { return $this->app_id; } } /** * @brief Sibling Link Cache */ protected $siblingLinkCache = array(); /** * Retrieves an Invision Community iD from a Foriegn ID in a Sibling Application * * @param mixed $foreign_id The Foreign ID. * @param string $type The type of item. * @param string $sibling The sibling software library. * @param boolean $mainTable If set to TRUE, and the type is of either 'core_message_topics', 'core_message_posts', 'core_message_topic_user_map', 'forums_topics', or 'forums_posts', then the ID is retrieved from convert_link, rather than the other link tables. * * @return inteer The Invision Community ID. * @throws \OutOfRangeException */ public function getSiblingLink( $foreign_id, $type, $sibling, $mainTable=FALSE ) { if ( isset( $this->siblingLinkCache[$type][$sibling][$foreign_id] ) ) { return $this->siblingLinkCache[$type][$sibling][$foreign_id]; } try { $sibling = static::constructFromData( \IPS\Db::i()->select( '*', 'convert_apps', array( "sw=? AND parent=?", $sibling, $this->parent ) )->first() ); } catch( \UnderflowException $e ) { throw new \OutOfRangeException( 'sibling_invalid' ); } return $sibling->getLink( $foreign_id, $type, FALSE, $mainTable ); } /** * Saves a foreign ID to Invision Community ID reference to the convert_link tables. * * @param integer $ips_id The Invision Community ID * @param mixed $foreign_id The Foreign ID * @param string $type The type of item * @param boolean $duplicate If TRUE, then this item is a duplicate and was merged into existing $ips_id * @param boolean $mainTable If TRUE, then link will be stored in the main convert_link table even if $type is 'core_message_topics', 'core_message_posts', 'core_message_topic_user_map', 'forums_topics', or 'forums_posts' * @return void */ public function addLink( $ips_id, $foreign_id, $type, $duplicate=FALSE, $mainTable=FALSE ) { $table = 'convert_link'; if ( \in_array( $type, array( 'core_message_topics', 'core_message_posts', 'core_message_topic_user_map', 'forums_topics', 'forums_posts' ) ) AND $mainTable === FALSE ) { if ( \in_array( $type, array( 'core_message_topics', 'core_message_posts', 'core_message_topic_user_map' ) ) ) { $table = 'convert_link_pms'; } else { $tableType = str_replace( 'forums_', '', $type ); $table = "convert_link_{$tableType}"; } } \IPS\Db::i()->insert( $table, array( 'ipb_id' => $ips_id, 'foreign_id' => $foreign_id, 'type' => $type, 'duplicate' => ( $duplicate === TRUE ) ? 1 : 0, 'app' => $this->app_id ) ); $this->linkCache[$type][$foreign_id] = $ips_id; } /** * Pre-cache link data based on source data * * @param array $data * @param array $map */ public function preCacheLinks( array $data, array $map ) { $cachesToLookup = []; $tables = []; /* Check for data */ if( !\count( $data ) ) { return; } foreach( $map as $type => $columns ) { $cachesToLookup[ $type ] = array(); if ( \is_array( $columns ) ) { foreach ( $columns as $column ) { $cachesToLookup[ $type ] = \array_merge( $cachesToLookup[ $type ], \array_column( $data, $column ) ); } $cachesToLookup[ $type ] = \array_map( 'strval', \array_unique( $cachesToLookup[ $type ] ) ); } else { $cachesToLookup[ $type ] = \array_map( 'strval', \array_unique( \array_column( $data, $columns ) ) ); } /* Check whether there are values we don't have cached */ if( isset( $this->linkCache[ $type ] ) ) { $cachesToLookup[ $type ] = \array_diff( $cachesToLookup[ $type ], \array_keys( $this->linkCache[ $type ] ) ); if ( !\count( $cachesToLookup[ $type ] ) ) { /* nope, so skip the query */ continue; } } $tables[ $type ] = $this->_getLinkTableName( $type ); } foreach( $tables as $link => $table ) { if( !isset( $this->linkCache[ $link ] ) ) { $this->linkCache[ $link ] = array(); } $where = [ '`type`=? AND ' . \IPS\Db::i()->in('foreign_id', $cachesToLookup[ $link ] ) . ' AND (app=? OR app=?)', $link, $this->parent, $this->app_id ]; foreach( \IPS\Db::i()->select( 'ipb_id, foreign_id', $table, $where ) as $result ) { $this->linkCache[ $link ][ (string) $result['foreign_id'] ] = $result['ipb_id']; } foreach( $cachesToLookup[ $link ] as $foreignId ) { if( !isset( $this->linkCache[ $link ][ (string) $foreignId ] ) ) { $this->linkCache[ $link ][ (string) $foreignId ] = FALSE; } } } } /** * Checks to see if a link exists for an Invision Community ID * * @param integer $ips_id The Invision Community ID. * @param string $type The type of item * @param bool $mainTable If TRUE, then link will be check against the main convert_link table even if $type is 'core_message_topics', 'core_message_posts', 'core_message_topic_user_map', 'forums_topics', or 'forums_posts' * @return void * @throws \OutOfRangeException */ public function checkLink( $ips_id, $type, $mainTable=FALSE ) { $table = $this->_getLinkTableName( $type, $mainTable ); try { $link = \IPS\Db::i()->select( '*', $table, array( "app=? AND ipb_id=? AND type=?", $this->app_id, $ips_id, $type ) )->first(); } catch( \UnderflowException $e ) { throw new \OutOfRangeException; } } /** * Checks to see if this application also has a sibling of a specific type * * @param string $software The application key to look for * @return void * @throws \OutOfRangeException */ public function checkForSibling( $software ) { try { \IPS\Db::i()->select( '*', 'convert_apps', array( "sw=? AND parent=?", $software, $this->parent ) )->first(); } catch( \UnderflowException $e ) { throw new \OutOfRangeException; } } /** * Delete a stored foreignId <-> IPS4 ID relation * * @param mixed $foreignId Foreign data ID * @param string $type The type of item * @return void */ public function deleteLink( $foreignId, $type ) { /* Build cache key */ if ( !\is_array( $type ) ) { $type = array( $type ); } $key = \count( $type ) > 1 ? md5( implode('', $type ) ) : $type[0]; $table = $this->_getLinkTableName( $type ); \IPS\Db::i()->delete( $table, array( 'foreign_id=? AND type=? AND app=?', $foreignId, $type, $this->app_id ) ); /* Remove this from the link cache */ if( isset( $this->linkCache[ $key ][ $foreignId ] ) ) { unset( $this->linkCache[ $key ][ $foreignId ] ); } } /** * @brief Sibling Cache */ protected $siblingCache = array(); /** * Construct an \IPS\convert\App object for a sibling application. * * @param string $software The application key to look for * @return \IPS\convert\App * @throws \OutOfRangeException */ public function getSibling( $software ) { if ( !isset( $this->siblingCache[$software] ) ) { try { $this->siblingCache[$software] = static::constructFromData( \IPS\Db::i()->select( '*', 'convert_apps', array( "sw=? AND parent=?", $software, $this->parent ) )->first() ); } catch( \Exception $e ) { throw new \OutOfRangeException; } } return $this->siblingCache[$software]; } /** * Fetch the source application class file. * * @param bool $construct Construct the object * @param bool $needDB Establish a database connection ($construct must be TRUE) * @return \IPS\convert\Software|string * @throws \InvalidArgumentException */ public function getSource( $construct=TRUE, $needDB=TRUE ) { /* Update software keys for communities converted to 3.x */ switch( $this->_data['sw'] ) { case 'board': case 'ccs': $this->sw = $this->sw; $this->save(); break; } /* Change any app keys to newer versions */ switch( $this->_data['app_key'] ) { case 'photopost8': case 'photopost7': $this->app_key = 'photopost'; $this->save(); break; case 'bbpress23': case 'bbpress_standalone': $this->app_key = 'bbpress'; $this->save(); break; case 'mybb_legacy': $this->app_key = 'mybb'; $this->save(); break; case 'phpbb_legacy': $this->app_key = 'phpbb'; $this->save(); break; case 'smf_legacy': $this->app_key = 'smf'; $this->save(); break; case 'vbulletin_legacy': case 'vbulletin_legacy36': case 'vbulletin_subs': case 'vbblog': case 'vbdynamics': $this->app_key = 'vbulletin'; $this->save(); break; case 'vb5connect': $this->app_key = 'vbulletin5'; $this->save(); break; /* Make sure UBBThreads is now all lowercase */ case 'UBBthreads': $this->app_key = 'ubbthreads'; $this->save(); break; } $classname = \IPS\convert\Software::software()[ $this->_data['sw'] ][ $this->_data['app_key'] ]; if ( ! class_exists( $classname ) ) { throw new \InvalidArgumentException( 'invalid_source' ); } if ( $construct ) { return new $classname( $this, $needDB ); } else { return $classname; } } /** * Log Something * * @param string $message The message to log. * @param string $method The current conversion method (convert_posts, convert_topics, etc.) * @param integer $severity The severity level of the log. Default to LOG_NOTICE * @param int|NULL $id The item ID * @return void * @throws \InvalidArgumentException */ public function log( $message, $method, $severity=1, $id=NULL ) { if ( ! \in_array( $severity, array( static::LOG_NOTICE, static::LOG_WARNING, static::LOG_ERROR ) ) ) { throw new \InvalidArgumentException( 'invalid_severity' ); } \IPS\Db::i()->insert( 'convert_logs', array( 'log_message' => $message, 'log_app' => $this->app_id, 'log_severity' => $severity, 'log_method' => $method, 'log_item_id' => $id, 'log_time' => time() ) ); } /** * Callback function to return all dependencies not yet converted. * * @param string $value Value from depency array * @return boolean */ public function dependencies( $value ) { if ( !\in_array( $value, $this->_session['completed'] ) ) { return TRUE; } return FALSE; } /** * Saves information about the current step to the session. * * @param string $method The converison method * @param array $values Values from the form. * @return void */ public function saveMoreInfo( $method, $values=array() ) { $sessionData = $this->_session; unset( $values['reconfigure'], $values['empty_local_data'] ); $this->_session = array( 'working' => $sessionData['working'], 'completed' => $sessionData['completed'], 'more_info' => array_merge( $sessionData['more_info'], array( $method => $values ) ) ); } /** * Magic __isset() method * * @param mixed $key key * @return bool */ public function __isset( $key ) { if ( method_exists( $this, 'get_' . $key ) ) { return TRUE; } if ( isset( $this->data[$key] ) ) { return TRUE; } return FALSE; } /** * Set running flag * * @param string $method Method name * @param bool $status Flag status * @return void */ public function setRunningFlag( $method, $status ) { $running = isset( $this->_session['running'] ) ? $this->_session['running'] : array(); /* If setting running flag, set to a timestamp */ if( $status === TRUE ) { $status = time(); } $running[ $method ] = $status; $this->_session = array_merge( $this->_session, array( 'running' => $running ) ); } /** * Get running flag * * @param string $method Method name * @return bool|int False or a timestamp */ public function getRunningFlag( $method ) { if( isset( $this->_session['running'][ $method ] ) ) { return $this->_session['running'][ $method ]; } return FALSE; } /** * Get table name for link caches * * @param string $type Link type * @param bool $mainTable If set to TRUE, and the type is of either 'core_message_topics', 'core_message_posts', 'core_message_topic_user_map', 'forums_topics', or 'forums_posts', then the ID is retrieved from convert_link, rather than the other link tables. * @return string Table name */ protected function _getLinkTableName( string $type, bool $mainTable=FALSE ): string { $table = 'convert_link'; if ( $mainTable === FALSE ) { if ( \in_array( $type, array( 'core_message_topics', 'core_message_posts', 'core_message_topic_user_map', 'forums_topics', 'forums_posts' ) ) ) { if ( \in_array( $type, array( 'core_message_topics', 'core_message_posts', 'core_message_topic_user_map' ) ) ) { $table = 'convert_link_pms'; } else { $tableType = str_replace( 'forums_', '', $type ); $table = "convert_link_{$tableType}"; } } } return $table; } }