From 1c2801c8613033f24c0bd6ffc8e62c1933b72f17 Mon Sep 17 00:00:00 2001 From: Robert Vogel Date: Fri, 15 Nov 2024 15:07:42 +0100 Subject: [PATCH] Sync with Extension:CollabPads@REL1_39-1.0.x --- .eslintrc.json | 24 -- .gitignore | 7 + .phan/config.php | 3 - .stylelintrc.json | 9 - Dockerfile | 15 +- bin/init.sh | 46 +++ bin/server.php | 53 ++-- config.docker.php | 4 +- config.example.php | 3 +- docker-compose.yml | 3 +- sample.env | 3 +- src/DAO/DAOFactory.php | 11 +- src/DAO/MongoDBAuthorDAO.php | 14 +- src/DAO/MongoDBCollabSessionDAO.php | 156 ++++++++-- src/DAO/MongoDBDAOBase.php | 12 +- src/Handler/MessageHandler.php | 147 +++++++--- src/Handler/OpenHandler.php | 24 +- src/IAuthorDAO.php | 8 +- src/ICollabSessionDAO.php | 59 +++- src/Model/Author.php | 51 ++++ src/Model/Change.php | 299 +++++++++++++++++++ src/Model/LinearSelection.php | 24 ++ src/Model/NullSelection.php | 26 ++ src/Model/Range.php | 98 +++++++ src/Model/Selection.php | 41 +++ src/Model/Store.php | 116 ++++++++ src/Model/TableSelection.php | 72 +++++ src/Model/Transaction.php | 410 +++++++++++++++++++++++++++ src/Rebaser.php | 314 ++++++++++++++++++++ src/Socket.php | 6 +- tests/phpunit/MessageHandlerTest.php | 51 ++-- tests/phpunit/OpenHandlerTest.php | 15 +- tests/phpunit/RebaserTest.php | 76 +++++ 33 files changed, 2012 insertions(+), 188 deletions(-) delete mode 100644 .eslintrc.json create mode 100644 .gitignore delete mode 100644 .phan/config.php delete mode 100644 .stylelintrc.json create mode 100644 bin/init.sh create mode 100644 src/Model/Author.php create mode 100644 src/Model/Change.php create mode 100644 src/Model/LinearSelection.php create mode 100644 src/Model/NullSelection.php create mode 100644 src/Model/Range.php create mode 100644 src/Model/Selection.php create mode 100644 src/Model/Store.php create mode 100644 src/Model/TableSelection.php create mode 100644 src/Model/Transaction.php create mode 100644 src/Rebaser.php create mode 100644 tests/phpunit/RebaserTest.php diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 371912a..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "root": true, - "extends": [ - "wikimedia/client-es6", - "wikimedia/jquery", - "wikimedia/mediawiki" - ], - "globals": { - "collabpad": "readonly", - "collabpads": "readonly", - "jQuery": "readonly", - "mediaWiki": "readonly", - "mws": "readonly", - "OOJSPlus": "readonly", - "ve": "readonly", - "bs": "readonly" - }, - "rules": { - "camelcase": "off" - }, - "parserOptions": { - "ecmaVersion": 8 - } -} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86b22f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/composer.lock +/package-lock.json +vendor/ +node_modules/ +mongo/ +config.php +.env \ No newline at end of file diff --git a/.phan/config.php b/.phan/config.php deleted file mode 100644 index 76efd06..0000000 --- a/.phan/config.php +++ /dev/null @@ -1,3 +0,0 @@ - $PID_FILE +} + +is_server_running() { + if [ -f $PID_FILE ]; then + PID=`cat $PID_FILE` + if ps -p $PID > /dev/null; then + return 0 + else + return 1 + fi + else + return 1 + fi +} + +restart_server() { + printf "Server is not running, restarting...\n" + start_server +} + +monitor_server() { + while true; do + is_server_running + if [ $? -eq 0 ]; then + sleep 1 + else + restart_server + fi + done +} + +# Start the server +printf "Starting the server...\n" +start_server +# Monitor the server +monitor_server diff --git a/bin/server.php b/bin/server.php index e8f4c86..c61da7c 100644 --- a/bin/server.php +++ b/bin/server.php @@ -5,7 +5,9 @@ use GuzzleHttp\Client; use MediaWiki\Extension\CollabPads\Backend\DAO\DAOFactory; +use MediaWiki\Extension\CollabPads\Backend\Rebaser; use MediaWiki\Extension\CollabPads\Backend\Socket; +use MongoDB\Driver\Exception\ConnectionTimeoutException; use Monolog\Handler\StreamHandler; use Monolog\Logger; use Ratchet\Http\HttpServer; @@ -42,27 +44,40 @@ $clientOptions = $config['http-client-options'] ?: []; $logger->debug( 'HTTP Client Options: ' . json_encode( $clientOptions ) ); $httpClient = new Client( $clientOptions ); - $retryTime = 5; -for ( $x = 0; ; $x++ ) { +for ( $x = 0; $x < 5; $x++ ) { + $logger->info( "Connecting to MongoDB..." ); try { - $authorDAO = DAOFactory::createAuthorDAO( $config ); - $sessionDAO = DAOFactory::createSessionDAO( $config ); - - $server = IoServer::factory( - new HttpServer( - new WsServer( - new Socket( $config, $httpClient, $authorDAO, $sessionDAO, $logger ) - ) - ), - $port, - $config['request-ip'] - ); - - $logger->info( "System: starting server... count = $x" ); - $server->run(); - } catch ( Throwable $e ) { - $logger->error( 'Error: ' . $e->getMessage() . " Retrying in $retryTime seconds..." ); + $authorDAO = DAOFactory::createAuthorDAO( $config, $logger ); + $sessionDAO = DAOFactory::createSessionDAO( $config, $logger ); + $authorDAO->cleanConnections(); + $sessionDAO->cleanConnections(); + break; + } catch ( ConnectionTimeoutException $e ) { + $logger->error( "MongoDB not available: " . $e->getMessage() ); + if ( $x === 4 ) { + $logger->error( "Max retries reached, exiting..." ); + exit( 1 ); + } + $logger->error( "Retrying in $retryTime seconds..." ); sleep( $retryTime ); + continue; } } + +$rebaser = new Rebaser( $sessionDAO ); +$rebaser->setLogger( $logger ); +$server = IoServer::factory( + new HttpServer( + new WsServer( + new Socket( + $config, $httpClient, $authorDAO, $sessionDAO, $logger, $rebaser + ) + ) + ), + $port, + $config['request-ip'] +); + +$logger->info( "System: starting server..." ); +$server->run(); diff --git a/config.docker.php b/config.docker.php index 29be4a9..3aba195 100644 --- a/config.docker.php +++ b/config.docker.php @@ -22,6 +22,8 @@ 'db-name' => getenv( 'COLLABPADS_BACKEND_MONGO_DB_NAME' ) ?: 'collabpads', 'db-user' => getenv( 'COLLABPADS_BACKEND_MONGO_DB_USER' ) ?: '', 'db-password' => getenv( 'COLLABPADS_BACKEND_MONGO_DB_PASSWORD' ) ?: '', + 'db-defaultauthdb' => getenv( 'COLLABPADS_BACKEND_MONGO_DB_DEFAULT_AUTH_DB' ) ?: 'admin', 'log-level' => getenv( 'COLLABPADS_BACKEND_LOG_LEVEL' ) ?: 'warn', - 'http-client-options' => $httpClientOptions + 'http-client-options' => $httpClientOptions, + 'behaviourOnError' => getenv( 'COLLBAPADS_BACKEND_BEHAVIOUR_ON_ERROR' ) ?: 'reinit', ]; diff --git a/config.example.php b/config.example.php index b7f1551..7b371fe 100644 --- a/config.example.php +++ b/config.example.php @@ -14,5 +14,6 @@ 'db-user' => '', 'db-password' => '', 'log-level' => 'INFO', - 'http-client-options' => [] + 'http-client-options' => [], + 'behaviourOnError' => 'reinit' ]; diff --git a/docker-compose.yml b/docker-compose.yml index aaccc95..e43ffae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: COLLABPADS_BACKEND_MONGO_DB_PASSWORD: ${COLLABPADS_BACKEND_MONGO_DB_PASSWORD} COLLABPADS_BACKEND_LOG_LEVEL: ${COLLABPADS_BACKEND_LOG_LEVEL} COLLABPADS_BACKEND_HTTP_CLIENT_OPTIONS: ${COLLABPADS_BACKEND_HTTP_CLIENT_OPTIONS} + COLLBAPADS_BACKEND_BEHAVIOUR_ON_ERROR: ${COLLBAPADS_BACKEND_BEHAVIOUR_ON_ERROR} extra_hosts: - "host.docker.internal:host-gateway" collabpads-database: @@ -24,4 +25,4 @@ services: # environment: # ME_CONFIG_MONGODB_SERVER: collabpads-database # ports: -# - 8091:8081 \ No newline at end of file +# - 8091:8081 diff --git a/sample.env b/sample.env index 3695f81..152c163 100644 --- a/sample.env +++ b/sample.env @@ -6,4 +6,5 @@ COLLABPADS_BACKEND_MONGO_DB_NAME=collabpads COLLABPADS_BACKEND_MONGO_DB_USER= COLLABPADS_BACKEND_MONGO_DB_PASSWORD= COLLABPADS_BACKEND_LOG_LEVEL=warn -COLLABPADS_BACKEND_HTTP_CLIENT_OPTIONS={} \ No newline at end of file +COLLABPADS_BACKEND_HTTP_CLIENT_OPTIONS={} +COLLBAPADS_BACKEND_BEHAVIOUR_ON_ERROR=reinit \ No newline at end of file diff --git a/src/DAO/DAOFactory.php b/src/DAO/DAOFactory.php index 5a50236..87da6a5 100644 --- a/src/DAO/DAOFactory.php +++ b/src/DAO/DAOFactory.php @@ -5,18 +5,22 @@ use Exception; use MediaWiki\Extension\CollabPads\Backend\IAuthorDAO; use MediaWiki\Extension\CollabPads\Backend\ICollabSessionDAO; +use Psr\Log\LoggerInterface; use UnexpectedValueException; class DAOFactory { /** * @param array $config + * @param LoggerInterface $logger * @return ICollabSessionDAO * @throws Exception */ - public static function createSessionDAO( array $config ): ICollabSessionDAO { + public static function createSessionDAO( array $config, LoggerInterface $logger ): ICollabSessionDAO { if ( $config['db-type'] === 'mongo' ) { - return new MongoDBCollabSessionDAO( $config ); + $instance = new MongoDBCollabSessionDAO( $config ); + $instance->setLogger( $logger ); + return $instance; } throw new UnexpectedValueException( "Invalid database type '{$config['db-type']}'" ); @@ -24,10 +28,11 @@ public static function createSessionDAO( array $config ): ICollabSessionDAO { /** * @param array $config + * @param LoggerInterface $logger * @return IAuthorDAO * @throws Exception */ - public static function createAuthorDAO( array $config ): IAuthorDAO { + public static function createAuthorDAO( array $config, LoggerInterface $logger ): IAuthorDAO { if ( $config['db-type'] === 'mongo' ) { return new MongoDBAuthorDAO( $config ); } diff --git a/src/DAO/MongoDBAuthorDAO.php b/src/DAO/MongoDBAuthorDAO.php index 43ce29f..f451674 100644 --- a/src/DAO/MongoDBAuthorDAO.php +++ b/src/DAO/MongoDBAuthorDAO.php @@ -3,6 +3,7 @@ namespace MediaWiki\Extension\CollabPads\Backend\DAO; use MediaWiki\Extension\CollabPads\Backend\IAuthorDAO; +use MediaWiki\Extension\CollabPads\Backend\Model\Author; class MongoDBAuthorDAO extends MongoDBDAOBase implements IAuthorDAO { @@ -55,12 +56,13 @@ public function deleteConnection( int $connectionId, int $authorId ) { public function getSessionByConnection( int $connectionId ): int { $result = $this->collection->find( [ 'a_sessions.c_id' => $connectionId ], - [ 'projection' => [ 'a_sessions.s_id.$' => 1 ] ] + [ 'projection' => [ 'a_sessions.$' => 1 ] ] ); foreach ( $result as $row ) { return $row["a_sessions"][0]["s_id"]; } + return 0; } /** @@ -72,8 +74,9 @@ public function getAuthorByConnection( int $connectionId ) { ); foreach ( $result as $row ) { - return $row; + return new Author( $row['a_id'], $row['a_name'] ); } + return null; } /** @@ -92,6 +95,7 @@ public function getConnectionByName( int $sessionId, string $authorName ) { } return $output; } + return []; } /** @@ -103,8 +107,9 @@ public function getAuthorByName( string $authorName ) { ); foreach ( $result as $row ) { - return $row; + return new Author( $row['a_id'], $authorName ); } + return null; } /** @@ -116,8 +121,9 @@ public function getAuthorById( int $authorId ) { ); foreach ( $result as $row ) { - return $row; + return new Author( $authorId, $row['a_name'] ); } + return null; } /** diff --git a/src/DAO/MongoDBCollabSessionDAO.php b/src/DAO/MongoDBCollabSessionDAO.php index da523f7..ed64231 100644 --- a/src/DAO/MongoDBCollabSessionDAO.php +++ b/src/DAO/MongoDBCollabSessionDAO.php @@ -3,8 +3,34 @@ namespace MediaWiki\Extension\CollabPads\Backend\DAO; use MediaWiki\Extension\CollabPads\Backend\ICollabSessionDAO; +use MediaWiki\Extension\CollabPads\Backend\Model\Author; +use MediaWiki\Extension\CollabPads\Backend\Model\Change; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; -class MongoDBCollabSessionDAO extends MongoDBDAOBase implements ICollabSessionDAO { +class MongoDBCollabSessionDAO extends MongoDBDAOBase implements ICollabSessionDAO, LoggerAwareInterface { + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @param array $config + * @throws \Exception + */ + public function __construct( array $config ) { + parent::__construct( $config ); + $this->logger = new NullLogger(); + } + + /** + * @inheritDoc + */ + public function setLogger( LoggerInterface $logger ): void { + $this->logger = $logger; + } /** * @inheritDoc @@ -36,7 +62,8 @@ public function setNewSession( string $wikiScriptPath, string $pageTitle, int $p ], 's_active_connections' => [], 's_history' => [], - 's_stores' => [] + 's_stores' => [], + 's_selections' => [], ] ); return $sessionId; @@ -97,7 +124,7 @@ public function isAuthorInSession( int $sessionId, int $authorId ): bool { * @inheritDoc */ public function changeAuthorDataInSession( - int $sessionId, int $authorId, string $authorData, string $authorValue + int $sessionId, int $authorId, string $authorData, mixed $authorValue ) { $this->collection->updateOne( [ 's_id' => $sessionId, 's_authors.authorId' => $authorId ], @@ -130,28 +157,27 @@ public function deactivateAuthor( int $sessionId, bool $authorActive, int $autho ], '$pop' => [ 's_authors.$.connection' => 1 - ] + ], ] ); + $this->clearAuthorRebaseData( $sessionId, $authorId ); } /** - * @inheritDoc + * @param int $sessionId + * @param Change $change + * @return void */ - public function setChangeInStores( int $sessionId, $store ) { - $this->collection->updateOne( - [ 's_id' => $sessionId ], - [ '$push' => [ 's_stores' => $store ] ] - ); - } + public function replaceHistory( int $sessionId, Change $change ) { + $data = json_decode( json_encode( $change ), true ); - /** - * @inheritDoc - */ - public function setChangeInHistory( int $sessionId, $change ) { $this->collection->updateOne( [ 's_id' => $sessionId ], - [ '$push' => [ 's_history' => $change ] ] + [ '$set' => [ + 's_history' => $data['transactions'], + 's_stores' => $data['stores'], + 's_selections' => $data['selections'] + ] ] ); } @@ -198,6 +224,64 @@ public function getAuthorInSession( int $sessionId, int $authorId ) { } } } + return []; + } + + /** + * @param int $sessionId + * @param Author $author + * @return Change|null + */ + public function getAuthorContinueBase( int $sessionId, Author $author ): ?Change { + $author = $this->getAuthorInSession( $sessionId, $author->getId() ); + if ( !$author ) { + return null; + } + $cb = $author['value']['continueBase'] ?? null; + if ( !$cb ) { + return null; + } + $cb = json_decode( json_encode( $cb ), true ); + $stores = $cb['stores'] ?? []; + if ( is_array( $stores ) && isset( $stores['hashes'] ) ) { + // To be removed + $stores = [ $stores ]; + } + return new Change( $cb['start'], $cb['transactions'], $cb['selections'] ?? [], $stores ); + } + + /** + * @param int $sessionId + * @param int $authorId + * @return void + */ + public function clearAuthorRebaseData( int $sessionId, int $authorId ) { + // Clear continue base and rejections + $this->collection->updateOne( + [ 's_id' => $sessionId, 's_authors.authorId' => $authorId ], + [ + '$unset' => [ + 's_authors.$.continueBase' => '', + 's_authors.$.rejections' => '' + ] + ] + ); + } + + /** + * @param int $sessionId + * @param Author $author + * @return int + */ + public function getAuthorRejections( int $sessionId, Author $author ): int { + $author = $this->getAuthorInSession( $sessionId, $author->getId() ); + if ( !$author ) { + return 0; + } + if ( isset( $author['value']['rejections'] ) ) { + return (int)$author['value']['rejections']; + } + return 0; } /** @@ -210,8 +294,42 @@ public function getFullHistoryFromSession( int $sessionId ) { ); foreach ( $result as $row ) { - return $row['s_history']; + return json_decode( json_encode( $row['s_history'] ), true ); + } + + return []; + } + + /** + * @param int $sessionId + * @return array + */ + public function getFullSelectionsFromSession( int $sessionId ): array { + $result = $this->collection->find( + [ 's_id' => $sessionId ], + [ 'projection' => [ 's_selections' => 1 ] ] + ); + + foreach ( $result as $row ) { + if ( !isset( $row['s_selections'] ) ) { + return []; + } + return json_decode( json_encode( $row['s_selections'] ), true ); } + return []; + } + + /** + * @param int $sessionId + * @return Change + */ + public function getChange( int $sessionId ): Change { + // TODO: Combine + $transactions = $this->getFullHistoryFromSession( $sessionId ); + $stores = $this->getFullStoresFromSession( $sessionId ); + // Maybe we should store selections + $selections = []; + return new Change( 0, $transactions, $selections, $stores ); } /** @@ -224,8 +342,10 @@ public function getFullStoresFromSession( int $sessionId ) { ); foreach ( $result as $row ) { - return $row['s_stores']; + // Convert to array + return json_decode( json_encode( $row['s_stores'] ), true ); } + return []; } /** diff --git a/src/DAO/MongoDBDAOBase.php b/src/DAO/MongoDBDAOBase.php index a428d79..d1b5139 100644 --- a/src/DAO/MongoDBDAOBase.php +++ b/src/DAO/MongoDBDAOBase.php @@ -34,6 +34,11 @@ abstract class MongoDBDAOBase { */ private $dbAuthString = ''; + /** + * @var string + */ + private $defaultAuthDB = ''; + /** * @var Database */ @@ -59,6 +64,9 @@ public function __construct( array $config ) { } else { throw new Exception( 'Config "db-port" is not set!' ); } + if ( isset( $config['db-defaultauthdb'] ) ) { + $this->defaultAuthDB = $config['db-defaultauthdb']; + } if ( $config['db-user'] !== '' ) { $this->dbAuthString = @@ -87,9 +95,7 @@ private function initDB() { . ':' . $this->dbPort . '/' - . $this->dbName - . '?authSource=' - . $this->dbName; + . $this->defaultAuthDB; $client = new Client( $connectionString ); $this->db = $client->selectDatabase( $this->dbName ); diff --git a/src/Handler/MessageHandler.php b/src/Handler/MessageHandler.php index 5ff7ac8..7f70161 100644 --- a/src/Handler/MessageHandler.php +++ b/src/Handler/MessageHandler.php @@ -2,12 +2,16 @@ namespace MediaWiki\Extension\CollabPads\Backend\Handler; +use Exception; use MediaWiki\Extension\CollabPads\Backend\ConnectionList; use MediaWiki\Extension\CollabPads\Backend\EventType; use MediaWiki\Extension\CollabPads\Backend\IAuthorDAO; use MediaWiki\Extension\CollabPads\Backend\ICollabSessionDAO; +use MediaWiki\Extension\CollabPads\Backend\Model\Change; +use MediaWiki\Extension\CollabPads\Backend\Rebaser; use Psr\Log\LoggerInterface; use Ratchet\ConnectionInterface; +use Throwable; class MessageHandler { use BackendHandlerTrait; @@ -27,35 +31,55 @@ class MessageHandler { */ private $logger; + /** @var Rebaser */ + private Rebaser $rebaser; + + /** @var array */ + private array $config; + /** * @param IAuthorDAO $authorDAO * @param ICollabSessionDAO $sessionDAO * @param LoggerInterface $logger + * @param Rebaser $rebaser + * @param array $config */ - public function __construct( IAuthorDAO $authorDAO, ICollabSessionDAO $sessionDAO, LoggerInterface $logger ) { + public function __construct( + IAuthorDAO $authorDAO, ICollabSessionDAO $sessionDAO, LoggerInterface $logger, Rebaser $rebaser, array $config + ) { $this->authorDAO = $authorDAO; $this->sessionDAO = $sessionDAO; - $this->logger = $logger; + $this->rebaser = $rebaser; + $this->config = $config; } /** - * @param ConnectionInterface $from - * @param string $msg - * @param ConnectionList $connectionList + * Handles incoming messages, processes events, and routes them to appropriate actions. + * + * @param ConnectionInterface $from Source connection of the incoming message + * @param string $msg Raw message received + * @param ConnectionList $connectionList List of connections for message distribution * @return void + * @throws Exception */ public function handle( ConnectionInterface $from, $msg, ConnectionList $connectionList ): void { $relevantConnections = $notRelevantConnections = []; - // split the request into components + $this->logger->debug( "Received raw message: $msg" ); + // Parse incoming message to extract eventID, eventName, and optional eventData preg_match( '/(?\w+)(\[\"(?\w+)\"(?:\,(?[\s\S]+))?\])?/', $msg, $msgArgs ); - // setting main configs for request response + // Add additional connection and author details $msgArgs['connectionId'] = $from->resourceId; - $msgArgs['authorId'] = $this->authorDAO->getAuthorByConnection( $from->resourceId )['a_id']; + $author = $this->authorDAO->getAuthorByConnection( $from->resourceId ); + if ( !$author ) { + $this->logger->error( "Author not found for connection ID: {$msgArgs['connectionId']}" ); + return; + } + $msgArgs['authorId'] = $author->getId(); $msgArgs['sessionId'] = $this->authorDAO->getSessionByConnection( $from->resourceId ); - $this->logger->debug( "Received message: " . json_encode( $msgArgs ) ); + $message = null; switch ( $msgArgs['eventId'] ) { case EventType::IS_ALIVE: $this->logger->debug( "Received keep-alive message from {$msgArgs['connectionId']}" ); @@ -82,7 +106,34 @@ public function handle( ConnectionInterface $from, $msg, ConnectionList $connect ); break; case 'submitChange': - $message = $this->newChange( $msgArgs ); + try { + $eventData = $this->parseEventData( $msgArgs ); + if ( !$eventData ) { + throw new Exception( 'Error parsing eventData: ' . json_encode( $msgArgs ) ); + } + $change = $this->createChange( $eventData ); + if ( !$change ) { + throw new Exception( 'Error creating change: ' . json_encode( $eventData ) ); + } + $change = $this->rebaser->applyChange( + $msgArgs['sessionId'], $author, $eventData['backtrack'] ?? 0, $change + ); + } catch ( Throwable $e ) { + // Original implementation did not catch exceptions, it would only not emit a message + // if rebasing cannot be done in expected way. Any unexpected errors would be thrown + $this->logger->error( "Error processing change: " . $e->getMessage(), [ + 'backtrace' => $e->getTraceAsString(), + 'line' => $e->getLine(), + ] ); + + return; + } + + if ( !$change->isEmpty() ) { + $message = $this->newChange( $msgArgs['sessionId'], $change ); + } else { + $this->logger->error( "Change is empty, skipping" ); + } break; case 'deleteSession': $message = $this->deleteSession( $msgArgs['authorId'] ); @@ -108,12 +159,12 @@ public function handle( ConnectionInterface $from, $msg, ConnectionList $connect // logevents from users will not be processed return; default: - $this->logger->error( "Unknown ContentName:{$msgArgs['eventName']}" ); + $this->logger->error( "Unknown event name: {$msgArgs['eventName']}" ); return; } break; default: - $this->logger->error( "Unknown EventType:{$msgArgs['eventId']}" ); + $this->logger->error( "Unknown event type: {$msgArgs['eventId']}" ); return; } @@ -147,7 +198,7 @@ private function authorDisconnect( array $msgArgs ): string { if ( $author ) { $this->sessionDAO->deactivateAuthor( $msgArgs['sessionId'], $authorActive, $msgArgs['authorId'] ); $this->authorDAO->deleteConnection( $msgArgs['connectionId'], $msgArgs['authorId'] ); - + $this->sessionDAO->clearAuthorRebaseData( $msgArgs['sessionId'], $msgArgs['authorId'] ); return $authorActive ? "" : $this->response( EventType::CONTENT, 'authorDisconnect', $author[ 'id' ] ); } @@ -185,6 +236,7 @@ private function authorChange( array $msgArgs ): string { $this->sessionDAO->changeAuthorDataInSession( $msgArgs['sessionId'], $msgArgs['authorId'], $key, $value ); } + $this->sessionDAO->clearAuthorRebaseData( $msgArgs['sessionId'], $msgArgs['authorId'] ); $author = $this->sessionDAO->getAuthorInSession( $msgArgs['sessionId'], $msgArgs['authorId'] ); $realName = ( isset( $author['value']['realName'] ) ) ? $author['value']['realName'] : ''; @@ -217,46 +269,55 @@ private function fixSurrogatePairs( string $json ): ?string { } /** - * @param array $msgArgs + * @param int $sessionId + * @param Change $change * @return string */ - private function newChange( array $msgArgs ): string { - $rawJson = $msgArgs['eventData']; - $event = json_decode( $rawJson, true ); + private function newChange( int $sessionId, Change $change ): string { + $changeData = json_encode( $change, JSON_UNESCAPED_SLASHES ); + $this->logger->debug( 'Emit change', [ 'sessionId' => $sessionId, 'change' => $changeData ] ); + return $this->response( EventType::CONTENT, 'newChange', $changeData ); + } + /** + * @param array $eventData + * @return Change|null + */ + private function createChange( array $eventData ): ?Change { + if ( isset( $eventData['change'] ) ) { + return new Change( + $eventData['change']['start'], + $eventData['change']['transactions'] ?? [], + $eventData['change']['selections'] ?? [], + $eventData['change']['stores'] ?? [] + ); + } + + return null; + } + + /** + * @param array $args + * @return array|null + */ + private function parseEventData( array $args ): ?array { + $rawJson = $args['eventData'] ?? null; + if ( !$rawJson ) { + $this->logger->error( "Missing eventData in message", $args ); + return null; + } + $eventData = json_decode( $rawJson, true ); if ( json_last_error() === JSON_ERROR_UTF16 ) { - $this->logger->debug( 'JSON_ERROR_UTF16... fixing Surrogate Pairs' ); + $this->logger->info( 'JSON_ERROR_UTF16... fixing Surrogate Pairs' ); $cleanedJson = $this->fixSurrogatePairs( $rawJson ); - $event = json_decode( $cleanedJson, true ); + $eventData = json_decode( $cleanedJson, true ); } if ( json_last_error() !== JSON_ERROR_NONE ) { $this->logger->error( 'JSON decode error: ' . json_last_error_msg() ); - return ''; + return null; } - - if ( isset( $event['change'] ) ) { - $change = $event['change']; - if ( !empty( $change['transactions'] ) ) { - foreach ( $change['transactions'] as $transaction ) { - $this->sessionDAO->setChangeInHistory( $msgArgs['sessionId'], $transaction ); - } - - if ( isset( $change['stores'] ) ) { - foreach ( $change['stores'] as $store ) { - if ( $store ) { - $this->sessionDAO->setChangeInStores( $msgArgs['sessionId'], $store ); - } - } - } - } - - $changeData = json_encode( $change, JSON_UNESCAPED_SLASHES ); - } else { - return ''; - } - - return $this->response( EventType::CONTENT, 'newChange', $changeData ); + return $eventData; } /** diff --git a/src/Handler/OpenHandler.php b/src/Handler/OpenHandler.php index 99dc5e2..a6c6ceb 100644 --- a/src/Handler/OpenHandler.php +++ b/src/Handler/OpenHandler.php @@ -8,6 +8,7 @@ use MediaWiki\Extension\CollabPads\Backend\EventType; use MediaWiki\Extension\CollabPads\Backend\IAuthorDAO; use MediaWiki\Extension\CollabPads\Backend\ICollabSessionDAO; +use MediaWiki\Extension\CollabPads\Backend\Model\Author; use Psr\Log\LoggerInterface; use Ratchet\ConnectionInterface; @@ -82,13 +83,13 @@ public function handle( ConnectionInterface $conn, ConnectionList $connectionLis $author = $this->authorDAO->getAuthorByName( $configs['user']['userName'] ); if ( !$author ) { $author = $this->newAuthor( $configs['user']['userName'] ); - $this->logger->info( "Created new author: Name '{$author['a_name']}', ID '{$author['a_id']}'" ); + $this->logger->info( "Created new author: Name '{$author->getName()}', ID '{$author->getId()}'" ); } else { - $this->logger->info( "Found existing author: Name '{$author['a_name']}', ID '{$author['a_id']}'" ); + $this->logger->info( "Found existing author: Name '{$author->getName()}', ID '{$author->getId()}'" ); } - $configs['authorId'] = $author['a_id']; - $configs['authorName'] = $author['a_name']; + $configs['authorId'] = $author->getId(); + $configs['authorName'] = $author->getName(); // Check if session exists & create if not $session = $this->sessionDAO->getSessionByName( $configs['wikiScriptPath'], $configs['pageTitle'], $configs['pageNamespace'] ); // phpcs:ignore Generic.Files.LineLength.TooLong @@ -198,11 +199,17 @@ private function createCurlRequest( ConnectionInterface $conn, string $pageTitle /** * @param string $authorName - * @return array + * @return Author + * @throws Exception */ - private function newAuthor( string $authorName ) { + private function newAuthor( string $authorName ): Author { $this->authorDAO->setNewAuthor( $authorName ); - return $this->authorDAO->getAuthorByName( $authorName ); + $author = $this->authorDAO->getAuthorByName( $authorName ); + if ( !$author ) { + $this->logger->error( "Could not create new author for name $authorName" ); + throw new Exception( "Could not create new author for name $authorName" ); + } + return $author; } /** @@ -223,7 +230,6 @@ private function newSession( array $config ) { */ private function getSettingsString(): string { $settingsArray = [ - 'sid' => "9mbmwV5MzVClKxBSpAAI", 'upgrades' => [], 'pingInterval' => $this->serverConfigs['ping-interval'], 'pingTimeout' => $this->serverConfigs['ping-timeout'] @@ -297,7 +303,7 @@ private function initDoc( array $config ): string { "start" => 0, "transactions" => $this->sessionDAO->getFullHistoryFromSession( $config['sessionId'] ) ?: [], "stores" => $this->sessionDAO->getFullStoresFromSession( $config['sessionId'] ) ?: [], - "selections" => [] + "selections" => $this->sessionDAO->getFullSelectionsFromSession( $config['sessionId'] ) ?: [] ], "authors" => $sessionAuthors ]; diff --git a/src/IAuthorDAO.php b/src/IAuthorDAO.php index 1756886..64b79a2 100644 --- a/src/IAuthorDAO.php +++ b/src/IAuthorDAO.php @@ -2,6 +2,8 @@ namespace MediaWiki\Extension\CollabPads\Backend; +use MediaWiki\Extension\CollabPads\Backend\Model\Author; + interface IAuthorDAO { /** @@ -30,7 +32,7 @@ public function getSessionByConnection( int $connectionId ): int; /** * @param int $connectionId - * @return array + * @return Author|null */ public function getAuthorByConnection( int $connectionId ); @@ -43,13 +45,13 @@ public function getConnectionByName( int $sessionId, string $authorName ); /** * @param string $authorName - * @return array + * @return Author|null */ public function getAuthorByName( string $authorName ); /** * @param int $authorId - * @return array + * @return ?Author */ public function getAuthorById( int $authorId ); diff --git a/src/ICollabSessionDAO.php b/src/ICollabSessionDAO.php index 7714c1d..abd8b7b 100644 --- a/src/ICollabSessionDAO.php +++ b/src/ICollabSessionDAO.php @@ -2,6 +2,9 @@ namespace MediaWiki\Extension\CollabPads\Backend; +use MediaWiki\Extension\CollabPads\Backend\Model\Author; +use MediaWiki\Extension\CollabPads\Backend\Model\Change; + interface ICollabSessionDAO { /** @@ -42,9 +45,9 @@ public function isAuthorInSession( int $sessionId, int $authorId ): bool; * @param int $sessionId * @param int $authorId * @param string $authorData - * @param string $authorValue + * @param mixed $authorValue */ - public function changeAuthorDataInSession( int $sessionId, int $authorId, string $authorData, string $authorValue ); + public function changeAuthorDataInSession( int $sessionId, int $authorId, string $authorData, mixed $authorValue ); /** * @param int $sessionId @@ -60,18 +63,6 @@ public function activateAuthor( int $sessionId, int $authorId, int $connectionId */ public function deactivateAuthor( int $sessionId, bool $authorActive, int $authorId ); - /** - * @param int $sessionId - * @param mixed $store - */ - public function setChangeInStores( int $sessionId, $store ); - - /** - * @param int $sessionId - * @param mixed $change - */ - public function setChangeInHistory( int $sessionId, $change ); - /** * @param int $sessionId * @return array @@ -121,4 +112,44 @@ public function getSessionByName( string $wikiScriptPath, string $pageTitle, int * @return void */ public function cleanConnections(); + + /** + * @param int $sessionId + * @param Author $author + * @return Change|null + */ + public function getAuthorContinueBase( int $sessionId, Author $author ): ?Change; + + /** + * @param int $sessionId + * @param Author $author + * @return int + */ + public function getAuthorRejections( int $sessionId, Author $author ): int; + + /** + * @param int $sessionId + * @return array + */ + public function getFullSelectionsFromSession( int $sessionId ): array; + + /** + * @param int $sessionId + * @return Change + */ + public function getChange( int $sessionId ): Change; + + /** + * @param int $sessionId + * @param Change $change + * @return mixed + */ + public function replaceHistory( int $sessionId, Change $change ); + + /** + * @param int $sessionId + * @param int $authorId + * @return mixed + */ + public function clearAuthorRebaseData( int $sessionId, int $authorId ); } diff --git a/src/Model/Author.php b/src/Model/Author.php new file mode 100644 index 0000000..9154a65 --- /dev/null +++ b/src/Model/Author.php @@ -0,0 +1,51 @@ +id = $id; + $this->name = $name; + } + + /** + * @return int + */ + public function getId(): int { + return $this->id; + } + + /** + * @return string + */ + public function getName(): string { + return $this->name; + } + + /** + * @return mixed + */ + public function jsonSerialize(): mixed { + return [ + 'id' => $this->id, + 'name' => $this->name + ]; + } + + // TODO: Add stuff from session +} diff --git a/src/Model/Change.php b/src/Model/Change.php new file mode 100644 index 0000000..582e678 --- /dev/null +++ b/src/Model/Change.php @@ -0,0 +1,299 @@ +start = $start; + $this->transactions = $this->normalizeTransactions( $transactions ); + $this->selections = $selections; + $this->store = new Store( [], [] ); + $this->storeLengthAtTransaction = []; + if ( $stores ) { + foreach ( $stores as $store ) { + if ( !$store ) { + continue; + } + if ( is_array( $store ) ) { + $store = Store::fromData( $store ); + } + if ( $store->getLength() === 0 ) { + continue; + } + $this->store->merge( $store ); + $this->storeLengthAtTransaction[] = $this->store->getLength(); + } + } + } + + /** + * @return int + */ + public function getStart(): int { + return $this->start; + } + + /** + * @param int $start + * @return void + */ + public function setStart( int $start ) { + $this->start = $start; + } + + /** + * @return Transaction[] + */ + public function getTransactions(): array { + // Return clones to prevent modifications + return array_map( static function ( Transaction $transaction ) { + return new Transaction( $transaction->getOperations(), $transaction->getAuthor() ); + }, $this->transactions ); + } + + /** + * @return array + */ + public function getSelections(): array { + return $this->selections; + } + + /** + * @return array + */ + public function getStores(): array { + $start = 0; + $stores = []; + for ( $i = 0; $i < $this->getLength(); $i++ ) { + if ( !isset( $this->storeLengthAtTransaction[$i] ) ) { + continue; + } + $end = $this->storeLengthAtTransaction[$i]; + $sliced = $this->store->slice( $start, $end ); + if ( $sliced->getLength() > 0 ) { + $stores[] = $sliced; + } + $start = $end; + } + return $stores; + } + + /** + * @param int $length + * @return Change + */ + public function truncate( int $length ): Change { + return new Change( + $this->start, + array_slice( $this->transactions, 0, $length ), + [], + array_slice( $this->getStores(), 0, $length ) + ); + } + + /** + * @param Change $otherChange + * @return Change + * @throws Exception + */ + public function concat( Change $otherChange ): Change { + if ( $otherChange->getStart() !== $this->start + $this->getLength() ) { + throw new Exception( 'Concat: this ends at ' . ( $this->start + $this->getLength() ) . + ' but other starts at ' . $otherChange->getStart() ); + } + + return new Change( + $this->start, + array_merge( $this->getTransactions(), $otherChange->getTransactions() ), + $otherChange->getSelections(), + array_merge( $this->getStores(), $otherChange->getStores() ) + ); + } + + /** + * @param Transaction $transaction + * @param int $storeLength + * @return void + */ + public function pushTransaction( Transaction $transaction, int $storeLength ) { + $this->transactions[] = $transaction; + $this->storeLengthAtTransaction[] = $storeLength; + } + + /** + * @param Change $other + * @return void + * @throws Exception + */ + public function push( Change $other ) { + if ( $other->getStart() !== $this->start + $this->getLength() ) { + throw new Exception( 'Push: this ends at ' . ( $this->start + $this->getLength() ) . + ' but other starts at ' . $other->getStart() ); + } + + $stores = $other->getStores(); + foreach ( $other->getTransactions() as $i => $transaction ) { + $store = $stores[ $i ] ?? null; + if ( $store ) { + $this->store->merge( $store ); + } + $this->pushTransaction( $transaction, $this->store->getLength() ); + } + $this->selections = $other->selections; + } + + /** + * @param int $authorId + * @param Selection $selection + * @return void + */ + public function setSelection( int $authorId, Selection $selection ) { + $this->selections[ $authorId ] = $selection->jsonSerialize(); + } + + /** + * @param int $start + * @return Change + */ + public function mostRecent( int $start ): Change { + return new Change( + $start, + array_slice( $this->transactions, $start - $this->start ), + $this->selections, + array_slice( $this->getStores(), $start - $this->start ) + ); + } + + /** + * @return int + */ + public function getLength(): int { + return count( $this->transactions ); + } + + /** + * @return bool + */ + public function isEmpty(): bool { + return count( $this->transactions ) === 0 && count( $this->selections ) === 0; + } + + /** + * @return array + */ + public function jsonSerialize(): mixed { + return [ + 'start' => $this->getStart(), + 'transactions' => $this->getTransactions(), + 'selections' => $this->getSelections(), + 'stores' => $this->getStores() + ]; + } + + /** + * @param array $transactions + * @return Transaction[] + */ + private function normalizeTransactions( array $transactions ): array { + $normalized = []; + $lastInfo = null; + foreach ( $transactions as $transaction ) { + if ( $transaction instanceof Transaction ) { + $normalized[] = $transaction; + continue; + } + + if ( is_string( $transaction ) ) { + if ( !$lastInfo ) { + continue; + } + $insertion = Transaction::split( $transaction ); + + $transaction = new Transaction( [ + [ 'type' => 'retain', 'length' => $lastInfo['end'] ], + [ 'type' => 'replace', 'remove' => [], 'insert' => $insertion ], + [ 'type' => 'retain', 'length' => $lastInfo['docLength'] - $lastInfo['end'] ] + ], $lastInfo['author'] ); + } else { + $hasAuthor = isset( $transaction['a'] ); + if ( !$hasAuthor && $lastInfo ) { + $transaction['a'] = $lastInfo['author']; + } + $transaction = Transaction::fromMinified( $transaction ); + } + $normalized[] = $transaction; + $lastInfo = $this->getTransactionInfo( $transaction ); + } + + return $normalized; + } + + /** + * @param Transaction $transaction + * @return array|null + */ + private function getTransactionInfo( Transaction $transaction ): ?array { + $op0 = $transaction->getOperations()[0] ?? null; + $op1 = $transaction->getOperations()[1] ?? null; + $op2 = $transaction->getOperations()[2] ?? null; + + if ( $op0 && $op0['type'] === 'replace' && ( !$op1 || $op1['type'] === 'retain' ) && !$op2 ) { + $replaceOp = $op0; + $start = 0; + $end = $start + count( $replaceOp['insert'] ?? [] ); + $docLength = $end; + } elseif ( + $op0 && $op0['type'] === 'retain' && $op1 && $op1['type'] === 'replace' && + ( !$op2 || $op2['type'] === 'retain' ) + ) { + $replaceOp = $op1; + $start = $op0['length']; + $end = $start + count( $replaceOp['insert'] ?? [] ); + $docLength = $end + ( $op2 ? $op2['length'] : 0 ); + } else { + return null; + } + + return [ + 'start' => $start, + 'end' => $end, + 'docLength' => $docLength, + 'author' => $transaction->getAuthor() + ]; + } + +} diff --git a/src/Model/LinearSelection.php b/src/Model/LinearSelection.php new file mode 100644 index 0000000..2fbeb88 --- /dev/null +++ b/src/Model/LinearSelection.php @@ -0,0 +1,24 @@ + 'linear' + ], parent::jsonSerialize() ); + } + + /** + * @param Transaction $transaction + * @param int $author + * @return Selection + */ + public function translateByTransactionWithAuthor( Transaction $transaction, int $author ): Selection { + return new static( $transaction->translateRangeWithAuthor( $this->getRange(), $author ) ); + } +} diff --git a/src/Model/NullSelection.php b/src/Model/NullSelection.php new file mode 100644 index 0000000..86acb6e --- /dev/null +++ b/src/Model/NullSelection.php @@ -0,0 +1,26 @@ + null ]; + } + + /** + * @param Transaction $transaction + * @param int $author + * @return $this + */ + public function translateByTransactionWithAuthor( Transaction $transaction, int $author ): Selection { + return new self; + } +} diff --git a/src/Model/Range.php b/src/Model/Range.php new file mode 100644 index 0000000..5b45cae --- /dev/null +++ b/src/Model/Range.php @@ -0,0 +1,98 @@ +from = $from; + $this->to = $to; + $this->start = min( $from, $to ); + $this->end = max( $from, $to ); + } + + /** + * @param array $range + * @return static + */ + public static function newFromData( array $range ) { + return new static( $range['from'], $range['to'] ); + } + + /** + * @return int|mixed + */ + public function getStart(): mixed { + return $this->start; + } + + /** + * @return int + */ + public function getFrom(): int { + return $this->from; + } + + /** + * @return int|mixed + */ + public function getEnd(): mixed { + return $this->end; + } + + /** + * @return int + */ + public function getTo(): int { + return $this->to; + } + + /** + * @return bool + */ + public function isBackwards(): bool { + return $this->from > $this->to; + } + + /** + * @return mixed + */ + public function jsonSerialize(): mixed { + return [ + 'from' => $this->from, + 'to' => $this->to + ]; + } + + /** + * @return bool + */ + public function isCollapsed(): bool { + return $this->from === $this->to; + } + +} diff --git a/src/Model/Selection.php b/src/Model/Selection.php new file mode 100644 index 0000000..a4f4861 --- /dev/null +++ b/src/Model/Selection.php @@ -0,0 +1,41 @@ +range = $range; + } + + /** + * @return Range + */ + public function getRange(): Range { + return $this->range; + } + + /** + * @return array[] + */ + public function jsonSerialize(): mixed { + return [ + 'range' => $this->range + ]; + } + + /** + * @param Transaction $transaction + * @param int $author + * @return Selection + */ + abstract public function translateByTransactionWithAuthor( Transaction $transaction, int $author ): Selection; +} diff --git a/src/Model/Store.php b/src/Model/Store.php new file mode 100644 index 0000000..ce54ef0 --- /dev/null +++ b/src/Model/Store.php @@ -0,0 +1,116 @@ +hashes = $hashes; + $this->hashStore = $hashStore; + } + + /** + * @return array + */ + public function getHashes(): array { + return $this->hashes; + } + + /** + * @return array + */ + public function getHashStore(): array { + return $this->hashStore; + } + + /** + * @param mixed $data + * @return self + */ + public static function fromData( $data ) { + if ( !$data ) { + return new self( [], [] ); + } + return new self( $data['hashes'], $data['hashStore'] ); + } + + public function jsonSerialize(): mixed { + return [ + 'hashes' => $this->hashes, + 'hashStore' => $this->hashStore + ]; + } + + /** + * @return int + */ + public function getLength(): int { + return count( $this->hashes ); + } + + /** + * @param Store $other + * @return void + */ + public function merge( Store $other ) { + if ( $other === $this ) { + return; + } + + foreach ( $other->getHashes() as $otherHash ) { + if ( !array_key_exists( $otherHash, $this->hashStore ) ) { + $this->hashStore[ $otherHash ] = $other->hashStore[ $otherHash ]; + $this->hashes[] = $otherHash; + } + } + } + + /** + * @param int $start + * @param int $end + * @return Store + */ + public function slice( int $start, int $end ): Store { + $newHashes = array_slice( $this->hashes, $start, $end - $start ); + $newHashStore = []; + foreach ( $newHashes as $hash ) { + $newHashStore[ $hash ] = $this->hashStore[ $hash ]; + } + + return new self( $newHashes, $newHashStore ); + } + + /** + * @param Store $omit + * @return Store + */ + public function difference( Store $omit ) { + if ( $omit instanceof Store ) { + $omit = $omit->getHashStore(); + } + $newHashes = []; + $newHashStore = []; + + foreach ( $this->hashes as $hash ) { + if ( !array_key_exists( $hash, $omit ) ) { + $newHashes[] = $hash; + $newHashStore[ $hash ] = $this->hashStore[ $hash ]; + } + } + return new Store( $newHashes, $newHashStore ); + } +} diff --git a/src/Model/TableSelection.php b/src/Model/TableSelection.php new file mode 100644 index 0000000..8229cc4 --- /dev/null +++ b/src/Model/TableSelection.php @@ -0,0 +1,72 @@ +toCol = $toCol; + $this->toRow = $toRow; + $this->fromCol = $fromCol; + $this->fromRow = $fromRow; + } + + /** + * @return array[] + */ + public function jsonSerialize(): mixed { + return [ + 'type' => 'table', + 'tableRange' => [ + 'type' => 'range', + 'from' => $this->getRange()->getFrom(), + 'to' => $this->getRange()->getTo() + ], + 'fromCol' => $this->fromCol, + 'fromRow' => $this->fromRow, + 'toCol' => $this->toCol, + 'toRow' => $this->toRow + ]; + } + + /** + * @param Transaction $transaction + * @param int $author + * @return $this + */ + public function translateByTransactionWithAuthor( Transaction $transaction, int $author ): Selection { + $newRange = $transaction->translateRangeWithAuthor( $this->getRange(), $author ); + if ( $newRange->isCollapsed() ) { + return new NullSelection(); + } + return new static( $newRange, $this->fromCol, $this->fromRow, $this->toCol, $this->toRow ); + } +} diff --git a/src/Model/Transaction.php b/src/Model/Transaction.php new file mode 100644 index 0000000..6cd95cb --- /dev/null +++ b/src/Model/Transaction.php @@ -0,0 +1,410 @@ +operations = $operations; + $this->author = $author; + } + + /** + * @param array|string $data + * @return Transaction + */ + public static function fromMinified( $data ): Transaction { + $operations = static::deminifyOperations( $data ); + return new Transaction( $operations, $data['a'] ); + } + + /** + * @param string $data + * @return array + */ + public static function split( string $data ): array { + $bits = mb_str_split( $data ); + $final = []; + foreach ( $bits as $bit ) { + if ( static::isEmoji( $bit ) ) { + $final[] = $bit; + // Pad for surrogate + $final[] = ' '; + continue; + } + $final[] = $bit; + } + + return $final; + } + + /** + * @param string $char + * @return bool + */ + private static function isEmoji( string $char ): bool { + // Convert the string to UTF-16 + $utf16 = mb_convert_encoding( $char, 'UTF-16', 'UTF-8' ); + + // Unpack the UTF-16 string into an array of code units + $codeUnits = unpack( 'n*', $utf16 ); + + // Check if the string contains surrogate pairs + foreach ( $codeUnits as $codeUnit ) { + if ( $codeUnit >= 0xD800 && $codeUnit <= 0xDFFF ) { + return true; + } + } + + return false; + } + + /** + * @param Transaction $foreign + * @return bool + */ + public function equals( Transaction $foreign ): bool { + return $this->author === $foreign->author && $this->operations === $foreign->operations; + } + + /** + * @return int + */ + public function getAuthor(): int { + return $this->author; + } + + /** + * @param array $data + * @return array + */ + private static function deminifyOperations( array $data ) { + $ops = $data['o']; + $expanded = []; + foreach ( $ops as $op ) { + if ( is_numeric( $op ) ) { + $expanded[] = [ 'type' => 'retain' , 'length' => $op ]; + continue; + } + if ( static::isLinearArray( $op ) ) { + $expanded[] = [ + 'type' => 'replace', + 'remove' => static::deminifyLinearData( $op[0] ), + 'insert' => static::deminifyLinearData( $op[1] ) + ]; + } else { + $expanded[] = $op; + } + } + + return $expanded; + } + + /** + * @param mixed $op + * @return bool + */ + private static function isLinearArray( $op ): bool { + return is_array( $op ) && array_keys( $op ) === range( 0, count( $op ) - 1 ); + } + + /** + * @param mixed $element + * @return array|mixed + */ + private static function deminifyLinearData( $element ) { + if ( is_string( $element ) ) { + if ( $element === '' ) { + return []; + } + return static::split( $element ); + } + return $element; + } + + /** + * @return array + */ + public function getActiveRangeAndLengthDiff() { + $offset = 0; + $diff = 0; + + $start = $startOpIndex = $end = $endOpIndex = null; + for ( $i = 0, $len = count( $this->operations ); $i < $len; $i++ ) { + $op = $this->operations[ $i ]; + $active = $op['type'] !== 'retain'; + // Place start marker + if ( $active && $start === null ) { + $start = $offset; + $startOpIndex = $i; + } + // Adjust offset and diff + if ( $op['type'] === 'retain' ) { + $offset += $op['length']; + } elseif ( $op['type'] === 'replace' ) { + $offset += $this->operationLen( $op['insert'] ); + $diff += $this->operationLen( $op['insert'] ) - $this->operationLen( $op['remove'] ); + } + if ( $op['type'] === 'attribute' || $op['type'] === 'replaceMetadata' ) { + // Op with length 0 but that effectively modifies 1 position + $end = $offset + 1; + $endOpIndex = $i + 1; + } elseif ( $active ) { + $end = $offset; + $endOpIndex = $i + 1; + } + } + + return [ + 'start' => $start, + 'end' => $end, + 'startOpIndex' => $startOpIndex, + 'endOpIndex' => $endOpIndex, + 'diff' => $diff + ]; + } + + /** + * @param string $place + * @param int $diff + * @return void + */ + public function adjustRetain( string $place, int $diff ) { + if ( $diff === 0 ) { + return; + } + $start = $place === 'start'; + $ops = $this->operations; + $i = $start ? 0 : count( $ops ) - 1; + + if ( $ops[$i] && $ops[$i]['type'] === 'retain' ) { + $ops[$i]['length'] += $diff; + if ( $ops[$i]['length'] < 0 ) { + throw new \Error( 'Negative retain length' ); + } elseif ( $ops[$i]['length'] === 0 ) { + array_splice( $ops, $i, 1 ); + } + $this->operations = $ops; + return; + } + if ( $diff < 0 ) { + throw new \Error( 'Negative retain length' ); + } + $this->operations = array_splice( + $ops, $start ? 0 : count( $ops ), 0, [ 'type' => 'retain', 'length' => $diff ] + ); + } + + /** + * @return mixed + */ + public function jsonSerialize(): mixed { + $operations = array_map( function ( $op ) { + if ( $op['type'] === 'retain' ) { + return $op['length']; + } + $insertLength = isset( $op['insert'] ) ? $this->operationLen( $op['insert'] ) : 0; + if ( + $op['type'] === 'replace' && + ( !isset( $op['insertedDataOffset' ] ) || !$op['insertedDataOffset'] ) && + ( !isset( $op['insertedDataLength' ] ) || $op['insertedDataLength'] === $insertLength ) + ) { + return [ $this->minifyLinearData( $op['remove'] ), $this->minifyLinearData( $op['insert'] ) ]; + } + return $op; + }, $this->operations ); + + if ( $this->author !== null ) { + return [ + 'o' => $operations, + 'a' => $this->author + ]; + } else { + return $operations; + } + } + + /** + * @param Range $range + * @param int $author + * @return Range + */ + public function translateRangeWithAuthor( Range $range, int $author ): Range { + $backward = !$this->author || !$author || $author < $this->author; + $start = $this->translateOffset( $range->getStart(), $backward ); + $end = $this->translateOffset( $range->getEnd(), $backward ); + + return $range->isBackwards() ? new Range( $end, $start ) : new Range( $start, $end ); + } + + /** + * @param mixed $data + * @return mixed|string + */ + private function minifyLinearData( mixed $data ) { + if ( is_array( $data ) ) { + if ( empty( $data ) ) { + return ''; + } + $allSingle = true; + foreach ( $data as $element ) { + if ( !is_string( $element ) || strlen( $element ) !== 1 ) { + $allSingle = false; + break; + } + } + if ( $allSingle ) { + return implode( '', $data ); + } + // Handle special case => template with no params + // Due to the way PHP handles json encoding/decoding of empty arrays, it will + // produce an array, instead of {} + // in JSON output, this breaks Parsoid conversion + foreach ( $data as &$element ) { + if ( + isset( $element['type'] ) && $element['type'] === 'mwTransclusionBlock' && + isset( $element['attributes']['mw']['parts'] ) && is_array( $element['attributes']['mw']['parts'] ) + ) { + foreach ( $element['attributes']['mw']['parts'] as &$part ) { + if ( + is_array( $part ) && isset( $part['template']['params'] ) && + is_array( $part['template']['params'] ) && empty( $part['template']['params'] ) + ) { + $part['template']['params'] = new \stdClass(); + } + } + } + } + + } + return $data; + } + + /** + * @param int $offset + * @param bool $excludeInsertion + * @return int|mixed + */ + private function translateOffset( int $offset, bool $excludeInsertion ) { + $cursor = 0; + $adjustment = 0; + foreach ( $this->operations as $operation ) { + if ( + $operation['type'] === 'retain' || + ( + $operation['type'] === 'replace' && + $this->operationLen( $operation['insert'] ) === $this->operationLen( $operation['remove'] ) && + $this->compareElementsForTranslate( $operation['insert'], $operation['remove'] ) + ) + ) { + $retainLength = $operation['type'] === 'retain' ? + $operation['length'] : + $this->operationLen( $operation['remove'] ); + if ( $offset >= $cursor && $offset < $cursor + $retainLength ) { + return $offset + $adjustment; + } + $cursor += $retainLength; + continue; + } else { + $insertLength = $this->operationLen( $operation['insert'] ); + $removeLength = $this->operationLen( $operation['remove'] ); + $prevAdjustment = $adjustment; + $adjustment += $insertLength - $removeLength; + if ( $offset === $cursor + $removeLength ) { + if ( $excludeInsertion && $insertLength > $removeLength ) { + return $offset + $adjustment - $insertLength + $removeLength; + } + return $offset + $adjustment; + } elseif ( $offset === $cursor ) { + if ( $insertLength === 0 ) { + return $cursor + $removeLength + $adjustment; + } + return $cursor + $prevAdjustment; + } elseif ( $offset > $cursor && $offset < $cursor + $removeLength ) { + return $cursor + $removeLength + $adjustment; + } + $cursor += $removeLength; + } + } + return $offset + $adjustment; + } + + /** + * @param mixed $op + * @return int + */ + private function operationLen( mixed $op ): int { + if ( is_array( $op ) ) { + if ( isset( $op['length'] ) ) { + return (int)$op['length']; + } + return count( $op ); + } + return strlen( $op ) ?? 0; + } + + /** + * @param mixed $insert + * @param mixed $remove + * @return bool + */ + private function compareElementsForTranslate( mixed $insert, mixed $remove ) { + foreach ( $insert as $i => $element ) { + if ( !$this->doCompareElements( $element, $remove[$i] ) ) { + return false; + } + } + return true; + } + + /** + * @param mixed $a + * @param mixed $b + * @return bool + */ + private function doCompareElements( mixed $a, mixed $b ): bool { + if ( $a === $b ) { + return true; + } + $aPlain = $a; + $bPlain = $b; + + if ( is_array( $a ) && array_keys( $a ) === range( 0, count( $a ) - 1 ) ) { + $aPlain = $a[0]; + } + if ( is_array( $b ) && array_keys( $b ) === range( 0, count( $b ) - 1 ) ) { + $bPlain = $b[0]; + } + + if ( is_string( $aPlain ) && is_string( $bPlain ) ) { + return $aPlain === $bPlain; + } + if ( ( isset( $aPlain['type'] ) && isset( $bPlain['type'] ) ) && $aPlain['type'] !== $bPlain['type'] ) { + return false; + } + + return true; + } + + /** + * @return array + */ + public function getOperations(): array { + return $this->operations; + } + +} diff --git a/src/Rebaser.php b/src/Rebaser.php new file mode 100644 index 0000000..17d8143 --- /dev/null +++ b/src/Rebaser.php @@ -0,0 +1,314 @@ +session = $session; + $this->logger = new NullLogger(); + } + + /** + * @param LoggerInterface $logger + * @return void + */ + public function setLogger( LoggerInterface $logger ): void { + $this->logger = $logger; + } + + /** + * @param int $sessionId + * @param Author $author + * @param int $backtrack + * @param Change $change + * @return Change + * @throws Exception + */ + public function applyChange( int $sessionId, Author $author, int $backtrack, Change $change ): Change { + $this->logger->info( "Rebasing change", [ + 'author' => json_encode( $author ), + 'change' => json_encode( $change ), + 'backtrack' => $backtrack, + 'sessionId' => $sessionId, + ] ); + + $base = $this->session->getAuthorContinueBase( $sessionId, $author ) ?? $change->truncate( 0 ); + $rejections = $this->session->getAuthorRejections( $sessionId, $author ); + if ( $rejections > $backtrack ) { + // Comment from original implementation: + // Follow-on does not fully acknowledge outstanding conflicts: reject entirely + $rejections = $rejections - $backtrack + $change->getLength(); + $this->session->changeAuthorDataInSession( $sessionId, $author->getId(), 'rejections', $rejections ); + // Comment from original implementation: + // FIXME argh this publishes an empty change, which is not what we want + // PHP-port comment: + // As original comment above says, this is definitely not what we want, + // not clear how to fix or even when this would happen + $this->logger->warning( "Rejections higher than backtrack, rejecting change entirely", [ + 'rejections' => $rejections, + 'backtrack' => $backtrack + ] ); + $appliedChange = new Change( 0, [], [], [] ); + } elseif ( $rejections < $backtrack ) { + $this->logger->error( "Cannot backtrack long enough: Backtrack=$backtrack, rejections=$rejections" ); + throw new Exception( "Cannot backtrack long enough: Backtrack=$backtrack, rejections=$rejections" ); + } else { + if ( $change->getStart() > $base->getStart() ) { + // Comment from original implementation: + // Remote has rebased some committed changes into its history since base was built. + // They are guaranteed to be equivalent to the start of base. See mathematical + // docs for proof (Cuius rei demonstrationem mirabilem sane deteximus hanc marginis + // exiguitas non caperet). + $base = $base->mostRecent( $change->getStart() ); + } + $sessionChange = $this->session->getChange( $sessionId ); + $base = $base->concat( $sessionChange->mostRecent( $base->getStart() + $base->getLength() ) ); + $result = $this->rebaseUncommittedChange( $base, $change ); + $rejections = $result['rejected'] ? $result['rejected']->getLength() : 0; + if ( !$result['rebased']->isEmpty() ) { + // Update session with newly applied change + $sessionChange->push( $result['rebased'] ); + $this->session->replaceHistory( $sessionId, $sessionChange ); + } + $this->session->changeAuthorDataInSession( + $sessionId, + $author->getId(), + 'rejections', + $rejections + ); + $this->session->changeAuthorDataInSession( + $sessionId, + $author->getId(), + 'continueBase', + json_decode( json_encode( $result['transposedHistory'] ), true ) + ); + $appliedChange = $result['rebased']; + } + + $this->logger->debug( 'Change rebased', [ + 'sessionId' => $sessionId, + 'authorId' => $author->getId(), + 'incoming' => json_decode( json_encode( $change ), true ), + 'applied' => json_decode( json_encode( $appliedChange ), true ), + 'backtrack' => $backtrack, + 'rejections' => $rejections + ] ); + return $appliedChange; + } + + /** + * @param Change $base + * @param Change $uncommited + * @return Change[] + * @throws Exception + */ + private function rebaseUncommittedChange( Change $base, Change $uncommited ): array { + if ( $base->getStart() !== $uncommited->getStart() ) { + if ( $base->getStart() > $uncommited->getStart() && $base->getLength() === 0 ) { + $base->setStart( $uncommited->getStart() ); + return $this->rebaseUncommittedChange( $base, $uncommited ); + } + if ( $uncommited->getStart() > $base->getStart() && $uncommited->getLength() === 0 ) { + $uncommited->setStart( $base->getStart() ); + return $this->rebaseUncommittedChange( $base, $uncommited ); + } + + throw new Exception( 'Different starts: ' . $base->getStart() . ' and ' . $uncommited->getStart() ); + } + + $transactionsA = $base->getTransactions(); + $transactionsB = $uncommited->getTransactions(); + $storesA = $base->getStores(); + $storesB = $uncommited->getStores(); + $selectionsA = $base->getSelections(); + $selectionsB = $uncommited->getSelections(); + $rejected = null; + + // Comment from original implementation: + // For each element b_i of transactionsB, rebase the whole list transactionsA over b_i. + // To rebase a1, a2, a3, …, aN over b_i, first we rebase a1 onto b_i. Then we rebase + // a2 onto some b', defined as + // + // b_i' := b_i|a1 , that is b_i.rebasedOnto(a1) + // + // (which as proven above is equivalent to inv(a1) * b_i * a1) + // + // Similarly we rebase a3 onto b_i'' := b_i'|a2, and so on. + // + // The rebased a_j are used for the transposed history: they will all get rebased over the + // rest of transactionsB in the same way. + // The fully rebased b_i forms the i'th element of the rebased transactionsB. + // + // If any rebase b_i|a_j fails, we stop rebasing at b_i (i.e. finishing with b_{i-1}). + // We return + // - rebased: (uncommitted sliced up to i) rebased onto history + // - transposedHistory: history rebased onto (uncommitted sliced up to i) + // - rejected: uncommitted sliced from i onwards + for ( $i = 0, $iLen = count( $transactionsB ); $i < $iLen; $i++ ) { + $b = $transactionsB[ $i ]; + $storeB = $storesB[ $i ] ?? null; + $rebasedTransactionsA = []; + $rebasedStoresA = []; + for ( $j = 0, $jLen = count( $transactionsA ); $j < $jLen; $j++ ) { + $a = $transactionsA[ $j ]; + $storeA = $storesA[ $j ] ?? null; + $rebases = $b->getAuthor() < $a->getAuthor() ? + array_reverse( $this->rebaseTransactions( $b, $a ) ) : + $this->rebaseTransactions( $a, $b ); + if ( $rebases[ 0 ] === null ) { + $rejected = $uncommited->mostRecent( $uncommited->getStart() + $i ); + $transactionsB = array_slice( $transactionsB, 0, $i ); + $storesB = array_slice( $storesB, 0, $i ); + $selectionsB = []; + break 2; + } + $rebasedTransactionsA[ $j ] = $rebases[ 0 ]; + if ( $storeA && $storeB ) { + $rebasedStoresA[ $j ] = $storeA->difference( $storeB ); + } + $b = $rebases[ 1 ]; + if ( $storeB && $storeA ) { + $storeB = $storeB->difference( $storeA ); + } + } + $transactionsA = $rebasedTransactionsA; + $storesA = $rebasedStoresA; + $transactionsB[ $i ] = $b; + if ( $storeB ) { + $storesB[ $i ] = $storeB; + } + } + $rebased = new Change( + $uncommited->getStart() + count( $transactionsA ), + $transactionsB, + $selectionsB, + $storesB + ); + + $transposedHistory = new Change( + $base->getStart() + count( $transactionsB ), + $transactionsA, + $selectionsA, + $storesA + ); + foreach ( $selectionsB as $authorId => $selection ) { + $translated = $this->translateSelectionByChange( $selection, $transposedHistory, $authorId ); + if ( $translated ) { + $rebased->setSelection( $authorId, $translated ); + } + } + foreach ( $selectionsA as $authorId => $selection ) { + $translated = $this->translateSelectionByChange( $selection, $rebased, $authorId ); + if ( $translated ) { + $transposedHistory->setSelection( $authorId, $translated ); + } + } + + return [ + 'rejected' => $rejected, + 'rebased' => $rebased, + 'transposedHistory' => $transposedHistory + ]; + } + + /** + * @param Transaction $a + * @param Transaction $b + * @return array|null[] + */ + private function rebaseTransactions( Transaction $a, Transaction $b ): array { + $infoA = $a->getActiveRangeAndLengthDiff(); + $infoB = $b->getActiveRangeAndLengthDiff(); + + if ( $infoA['start'] === null || $infoB['start'] === null ) { + // One of the transactions is a no-op: only need to adjust its retain length. + // We can safely adjust both, because the no-op must have diff 0 + $a->adjustRetain( 'start', $infoB['diff'] ); + $b->adjustRetain( 'start', $infoA['diff'] ); + } elseif ( $infoA['end'] <= $infoB['start'] ) { + // This includes the case where both transactions are insertions at the same + // point + $b->adjustRetain( 'start', $infoA['diff'] ); + $a->adjustRetain( 'end', $infoB['diff'] ); + } elseif ( $infoB['end'] <= $infoA['start'] ) { + $a->adjustRetain( 'start', $infoB['diff'] ); + $b->adjustRetain( 'end', $infoA['diff'] ); + } else { + $this->logger->error( 'Failed to rebase transactions', [ + 'a' => $a, + 'b' => $b, + 'infoA' => $infoA, + 'infoB' => $infoB + ] ); + // The active ranges overlap: conflict + return [ null, null ]; + } + return [ $a, $b ]; + } + + /** + * @param array $selection + * @param Change $change + * @param int $authorId + * @return Selection|null + * @throws Exception + */ + private function translateSelectionByChange( array $selection, Change $change, int $authorId ): ?Selection { + $type = $selection['type']; + if ( !$type ) { + $this->logger->error( 'Trying to create selection without type', $selection ); + throw new Exception( 'Selection type not set' ); + } + switch ( $type ) { + case 'linear': + $selection = new LinearSelection( Range::newFromData( $selection['range'] ) ); + break; + case 'table': + $selection = new TableSelection( + Range::newFromData( $selection['tableRange'] ), + $selection['fromCol'], $selection['fromRow'], $selection['toCol'], $selection['toRow'] + ); + break; + case null: + $selection = new NullSelection(); + break; + default: + // Not a selection event + return null; + } + + foreach ( $change->getTransactions() as $transaction ) { + $selection = $selection->translateByTransactionWithAuthor( $transaction, $authorId ); + } + return $selection; + } + +} diff --git a/src/Socket.php b/src/Socket.php index 4d6f9f2..a11b915 100644 --- a/src/Socket.php +++ b/src/Socket.php @@ -50,9 +50,11 @@ class Socket implements MessageComponentInterface { * @param IAuthorDAO $authorDAO * @param ICollabSessionDAO $sessionDAO * @param LoggerInterface $logger + * @param Rebaser $rebaser */ public function __construct( - $config, $httpClient, IAuthorDAO $authorDAO, ICollabSessionDAO $sessionDAO, LoggerInterface $logger + $config, $httpClient, IAuthorDAO $authorDAO, + ICollabSessionDAO $sessionDAO, LoggerInterface $logger, Rebaser $rebaser ) { $this->logger = $logger; @@ -62,7 +64,7 @@ public function __construct( $this->sessionDAO->cleanConnections(); $this->openHandler = new OpenHandler( $authorDAO, $sessionDAO, $config, $httpClient, $logger ); - $this->messageHandler = new MessageHandler( $authorDAO, $sessionDAO, $logger ); + $this->messageHandler = new MessageHandler( $authorDAO, $sessionDAO, $logger, $rebaser, $config ); $this->connectionList = new ConnectionList(); } diff --git a/tests/phpunit/MessageHandlerTest.php b/tests/phpunit/MessageHandlerTest.php index 16cff0b..5d03b79 100644 --- a/tests/phpunit/MessageHandlerTest.php +++ b/tests/phpunit/MessageHandlerTest.php @@ -8,6 +8,9 @@ use MediaWiki\Extension\CollabPads\Backend\Handler\MessageHandler; use MediaWiki\Extension\CollabPads\Backend\IAuthorDAO; use MediaWiki\Extension\CollabPads\Backend\ICollabSessionDAO; +use MediaWiki\Extension\CollabPads\Backend\Model\Author; +use MediaWiki\Extension\CollabPads\Backend\Model\Change; +use MediaWiki\Extension\CollabPads\Backend\Rebaser; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Ratchet\ConnectionInterface; @@ -74,7 +77,10 @@ public function testAuthorDisconnect( $this->authorDAOMock->expects( $this->once() )->method( 'deleteConnection' ) ->with( $initiatorConnection['connectionId'], $initiatorConnection['authorId'] ); - $messageHandler = new MessageHandler( $this->authorDAOMock, $this->sessionDAOMock, $this->loggerMock ); + $rebaserMock = $this->createMock( Rebaser::class ); + $messageHandler = new MessageHandler( + $this->authorDAOMock, $this->sessionDAOMock, $this->loggerMock, $rebaserMock + ); $messageHandler->handle( $this->initiatorConnectionMock, $messageFromClient, $connectionList ); } @@ -142,7 +148,10 @@ public function testSaveRevision( $this->initTest( $authorConnections, $initiatorConnection, $sessionId ); - $messageHandler = new MessageHandler( $this->authorDAOMock, $this->sessionDAOMock, $this->loggerMock ); + $rebaserMock = $this->createMock( Rebaser::class ); + $messageHandler = new MessageHandler( + $this->authorDAOMock, $this->sessionDAOMock, $this->loggerMock, $rebaserMock + ); $messageHandler->handle( $this->initiatorConnectionMock, $messageFromClient, $connectionList ); } @@ -217,6 +226,7 @@ public function testAuthorChange( 'id' => $initiatorConnection['authorId'], 'value' => [ 'name' => $authorData['name'], + 'realName' => '', 'color' => $authorData['color'] ] ] ); @@ -225,7 +235,10 @@ public function testAuthorChange( $this->sessionDAOMock->expects( $this->once() )->method( 'changeAuthorDataInSession' ) ->with( $sessionId, $initiatorConnection['authorId'], 'color', $authorData['color'] ); - $messageHandler = new MessageHandler( $this->authorDAOMock, $this->sessionDAOMock, $this->loggerMock ); + $rebaserMock = $this->createMock( Rebaser::class ); + $messageHandler = new MessageHandler( + $this->authorDAOMock, $this->sessionDAOMock, $this->loggerMock, $rebaserMock + ); $messageHandler->handle( $this->initiatorConnectionMock, $messageFromClient, $connectionList ); } @@ -267,7 +280,7 @@ public function provideAuthorChangeData(): array { 'color' => 'B96091' ], // Message client should get in response - '42["authorChange",{"authorId":2,"authorData":{"name":"TestUser1","color":"B96091"}}]', + '42["authorChange",{"authorId":2,"authorData":{"name":"TestUser1","realName":"","color":"B96091"}}]', // Client connections which should eventually receive response from the server [ 100, @@ -300,16 +313,15 @@ public function testNewChange( $this->initTest( $authorConnections, $initiatorConnection, $sessionId ); - $changeData = json_decode( $changeDataRaw, true ); - - // Make sure that transaction is added to the DB - $this->sessionDAOMock->expects( $this->once() )->method( 'setChangeInHistory' ) - ->with( $sessionId, $changeData['transactions'][0] ); - - $this->sessionDAOMock->expects( $this->once() )->method( 'setChangeInStores' ) - ->with( $sessionId, null ); - - $messageHandler = new MessageHandler( $this->authorDAOMock, $this->sessionDAOMock, $this->loggerMock ); + $rebaserMock = $this->createMock( Rebaser::class ); + $rebaserMock->method( 'applyChange' )->willReturnCallback( + static function ( int $sessionId, Author $author, int $backtrack, Change $change ) { + return $change; + } + ); + $messageHandler = new MessageHandler( + $this->authorDAOMock, $this->sessionDAOMock, $this->loggerMock, $rebaserMock + ); $messageHandler->handle( $this->initiatorConnectionMock, $messageFromClient, $connectionList ); } @@ -345,13 +357,13 @@ public function provideNewChangeData(): array { 128888, // Message received from the client // phpcs:ignore Generic.Files.LineLength.TooLong - '42["submitChange",{"backtrack":0,"change":{"start":5,"transactions":[{"o":[47,["","s"],58],"a":1}],"selections":{"1":{"type":"linear","range":{"type":"range","from":48,"to":48}}}}}]', + '42["submitChange",{"backtrack":0,"change":{"start":5,"transactions":[{"o":[47,["","s"],58],"a":1}],"selections":{"1":{"type":"linear","range":{"type":"range","from":48,"to":48}}}},"stores":[]}]', // Change actual data // phpcs:ignore Generic.Files.LineLength.TooLong '{"start":5,"transactions":[{"o":[47,["","s"],58],"a":1}],"selections":{"1":{"type":"linear","range":{"type":"range","from":48,"to":48}}}}', // Message client should get in response // phpcs:ignore Generic.Files.LineLength.TooLong - '42["newChange",{"start":5,"transactions":[{"o":[47,["","s"],58],"a":1}],"selections":{"1":{"type":"linear","range":{"type":"range","from":48,"to":48}}}}]', + '42["newChange",{"start":5,"transactions":[{"o":[47,["","s"],58],"a":1}],"selections":{"1":{"type":"linear","range":{"type":"range","from":48,"to":48}}},"stores":[]}]', // Client connections which should eventually receive response from the server [ 100, @@ -383,7 +395,10 @@ public function testDeleteSession( // Make sure that session is deleted in result $this->sessionDAOMock->expects( $this->once() )->method( 'deleteSession' )->with( $sessionId ); - $messageHandler = new MessageHandler( $this->authorDAOMock, $this->sessionDAOMock, $this->loggerMock ); + $rebaserMock = $this->createMock( Rebaser::class ); + $messageHandler = new MessageHandler( + $this->authorDAOMock, $this->sessionDAOMock, $this->loggerMock, $rebaserMock + ); $messageHandler->handle( $this->initiatorConnectionMock, $messageFromClient, $connectionList ); } @@ -436,7 +451,7 @@ private function initTest( array $authorConnections, array $initiatorConnection, $this->authorDAOMock = $this->createMock( MongoDBAuthorDAO::class ); $this->authorDAOMock->method( 'getAuthorByConnection' )->willReturn( - [ 'a_id' => $initiatorConnection['authorId'] ] + new Author( $initiatorConnection['authorId'], '' ) ); $this->authorDAOMock->method( 'getSessionByConnection' )->willReturn( $sessionId ); diff --git a/tests/phpunit/OpenHandlerTest.php b/tests/phpunit/OpenHandlerTest.php index 3b3466e..4c4bf8d 100644 --- a/tests/phpunit/OpenHandlerTest.php +++ b/tests/phpunit/OpenHandlerTest.php @@ -10,6 +10,7 @@ use MediaWiki\Extension\CollabPads\Backend\DAO\MongoDBAuthorDAO; use MediaWiki\Extension\CollabPads\Backend\DAO\MongoDBCollabSessionDAO; use MediaWiki\Extension\CollabPads\Backend\Handler\OpenHandler; +use MediaWiki\Extension\CollabPads\Backend\Model\Author; use PHPUnit\Framework\TestCase; use Psr\Http\Message\StreamInterface; use Psr\Log\LoggerInterface; @@ -85,16 +86,16 @@ class OpenHandlerTest extends TestCase { */ public function testSuccess() { $authorDAOMock = $this->createMock( MongoDBAuthorDAO::class ); - $authorDAOMock->method( 'getAuthorByName' )->willReturn( [ - 'a_name' => $this->userName, - 'a_id' => $this->authorId - ] ); + $authorDAOMock->method( 'getAuthorByName' )->willReturn( + new Author( $this->authorId, $this->userName ) + ); $sessionDAOMock = $this->createMock( MongoDBCollabSessionDAO::class ); $sessionDAOMock->method( 'getSessionByName' )->willReturn( [ 's_token' => $this->sessionToken, 's_id' => $this->sessionId, - 's_page_title' => $this->pageTitle + 's_page_title' => $this->pageTitle, + 's_page_namespace' => NS_MAIN, ] ); $sessionDAOMock->method( 'getAuthorInSession' )->willReturn( [ 'id' => $this->authorId, @@ -124,6 +125,7 @@ public function testSuccess() { 'userName' => $this->userName ], 'pageTitle' => $this->pageTitle, + 'pageNamespace' => NS_MAIN, 'message' => 'Access granted!' ] ); @@ -155,7 +157,7 @@ public function testSuccess() { $connectionListMock = $this->createMock( ConnectionList::class ); $settingsResponse = [ - 'sid' => "9mbmwV5MzVClKxBSpAAI", + // 'sid' => "9mbmwV5MzVClKxBSpAAI", 'upgrades' => [], 'pingInterval' => $this->serverConfigs['ping-interval'], 'pingTimeout' => $this->serverConfigs['ping-timeout'] @@ -177,6 +179,7 @@ public function testSuccess() { 'authors' => [ $this->authorId => [ 'name' => $this->userName, + 'realName' => '', 'color' => 'some-color' ] ] diff --git a/tests/phpunit/RebaserTest.php b/tests/phpunit/RebaserTest.php new file mode 100644 index 0000000..03288b2 --- /dev/null +++ b/tests/phpunit/RebaserTest.php @@ -0,0 +1,76 @@ +createMock( MongoDBCollabSessionDAO::class ); + $sessionDaoMock->method( 'getChange' )->willReturn( $sessionChange ); + + $rebaser = new Rebaser( $sessionDaoMock ); + $result = $rebaser->applyChange( 1, $author, 0, $change ); + $this->assertSame( $expect['start'], $result->getStart() ); + $this->assertSame( [ $expect['tx'] ], $result->serialize( $result->getTransactions() ) ); + } + + /** + * @return array[] + */ + public function provideData(): array { + // TODO: Add more + return [ + 'same-start' => [ + new Change( 2, [ + [ + 'o' => [ + 1, [ '', 'b' ], 5 + ], + 'a' => 1 + ], + [ + 'o' => [ + 3, [ '', 'a' ],6 + ], + 'a' => 1 + ] + ], [], [] ), + new Change( 2, [ + [ + 'o' => [ + 3, [ '', 'b' ],6 + ], + 'a' => 2 + ] + ], [], [] ), + new Author( 2, 'dummy' ), + [ + 'start' => 4, + 'tx' => [ + 'o' => [ + 3, [ '', 'b' ],6 + ], + 'a' => 2 + ] + ] + ] + ]; + } +}