diff --git a/composer.json b/composer.json index 7c63cad..6394fcf 100644 --- a/composer.json +++ b/composer.json @@ -1,14 +1,14 @@ { "require-dev": { "jakub-onderka/php-parallel-lint": "0.9.2", - "mediawiki/mediawiki-codesniffer": "0.12.0", + "mediawiki/mediawiki-codesniffer": "13.0.0", "jakub-onderka/php-console-highlighter": "0.3.2" }, "scripts": { "fix": "phpcbf", "test": [ "parallel-lint . --exclude vendor", "phpcs -p -s" ] } } diff --git a/maintenance/purge.php b/maintenance/purge.php index 3af0e3a..6c4cce4 100644 --- a/maintenance/purge.php +++ b/maintenance/purge.php @@ -1,73 +1,73 @@ addDescription( 'Purge unneeded database rows (deleted lists/entries or orphaned sortkeys).' ); $this->addOption( 'before', 'Purge deleted lists/entries before this timestamp', true, true ); } /** - * @inheritdoc + * @inheritDoc */ public function execute() { $now = wfTimestampNow(); if ( $this->hasOption( 'before' ) ) { $before = wfTimestamp( TS_MW, $this->getOption( 'before' ) ); if ( !$before || $now <= $before ) { // Let's not delete all rows if the user entered an invalid timestamp. $this->error( 'Invalid timestamp', 1 ); } } else { $before = Utils::getDeletedExpiry(); } $this->output( "...purging deleted rows\n" ); $this->getReadingListRepository()->purgeOldDeleted( $before ); $this->output( "...purging orphaned sortkeys\n" ); $this->getReadingListRepository()->purgeSortkeys(); $this->output( "done.\n" ); } /** * Initializes the repository. * @return ReadingListRepository */ private function getReadingListRepository() { $services = MediaWikiServices::getInstance(); $loadBalancerFactory = $services->getDBLoadBalancerFactory(); $dbw = Utils::getDB( DB_MASTER, $services ); $dbr = Utils::getDB( DB_REPLICA, $services ); $user = User::newSystemUser( 'Maintenance script', [ 'steal' => true ] ); // There isn't really any way for this user to be non-local, but let's be future-proof. $centralId = CentralIdLookup::factory()->centralIdFromLocalUser( $user ); $repository = new ReadingListRepository( $centralId, $dbw, $dbr, $loadBalancerFactory ); $repository->setLogger( LoggerFactory::getInstance( 'readinglists' ) ); return $repository; } } $maintClass = Purge::class; require_once RUN_MAINTENANCE_IF_MAIN; diff --git a/src/Api/ApiQueryReadingListEntries.php b/src/Api/ApiQueryReadingListEntries.php index e90da94..ef6e9e5 100644 --- a/src/Api/ApiQueryReadingListEntries.php +++ b/src/Api/ApiQueryReadingListEntries.php @@ -1,231 +1,231 @@ run(); } catch ( ReadingListRepositoryException $e ) { $this->dieWithException( $e ); } } /** - * @inheritdoc + * @inheritDoc * @param ApiPageSet $resultPageSet All output should be appended to this object * @return void */ public function executeGenerator( $resultPageSet ) { try { $this->run( $resultPageSet ); } catch ( ReadingListRepositoryException $e ) { $this->dieWithException( $e ); } } /** * Main API logic. * @param ApiPageSet|null $resultPageSet */ private function run( ApiPageSet $resultPageSet = null ) { if ( $this->getUser()->isAnon() ) { $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-viewmyprivateinfo' ) ], 'notloggedin' ); } $this->checkUserRightsAny( 'viewmyprivateinfo' ); $lists = $this->getParameter( 'lists' ); $changedSince = $this->getParameter( 'changedsince' ); $limit = $this->getParameter( 'limit' ); $offset = $this->getParameter( 'continue' ); $mode = $changedSince !== null ? self::MODE_CHANGES : self::MODE_ALL; $this->requireOnlyOneParameter( $this->extractRequestParams(), 'lists', 'changedsince' ); if ( $mode === self::MODE_CHANGES ) { $expiry = Utils::getDeletedExpiry(); if ( $changedSince < $expiry ) { $errorMessage = $this->msg( 'readinglists-apierror-too-old', static::$prefix, wfTimestamp( TS_ISO_8601, $expiry ) ); $this->dieWithError( $errorMessage ); } } $path = [ 'query', $this->getModuleName() ]; $result = $this->getResult(); $result->addIndexedTagName( $path, 'entry' ); $repository = $this->getReadingListRepository( $this->getUser() ); if ( $mode === self::MODE_CHANGES ) { $res = $repository->getListEntriesByDateUpdated( $changedSince, $limit + 1, $offset ); } else { $res = $repository->getListEntries( $lists, $limit + 1, $offset ); } $titles = []; $resultOffset = 0; $fits = true; foreach ( $res as $row ) { $isLastRow = ( $resultOffset === $res->numRows() - 1 ); if ( $resultPageSet ) { $titles[] = $this->getResultTitle( $row ); } else { $fits = $result->addValue( $path, null, $this->getResultItem( $row, $mode ) ); } if ( !$fits || ++$resultOffset >= $limit && !$isLastRow ) { $this->setContinueEnumParameter( 'continue', $offset + $resultOffset ); break; } } if ( $resultPageSet ) { $resultPageSet->populateFromTitles( $titles ); } } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return [ 'lists' => [ self::PARAM_TYPE => 'integer', self::PARAM_ISMULTI => true, ], 'changedsince' => [ self::PARAM_TYPE => 'timestamp', self::PARAM_HELP_MSG => $this->msg( 'apihelp-query+readinglistentries-param-changedsince', wfTimestamp( TS_ISO_8601, Utils::getDeletedExpiry() ) ), ], 'limit' => [ self::PARAM_DFLT => 10, self::PARAM_TYPE => 'limit', self::PARAM_MIN => 1, self::PARAM_MAX => self::LIMIT_BIG1, self::PARAM_MAX2 => self::LIMIT_BIG2, ], 'continue' => [ self::PARAM_TYPE => 'integer', self::PARAM_DFLT => 0, self::PARAM_HELP_MSG => 'api-help-param-continue', ], ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { $prefix = static::$prefix; return [ "action=query&list=readinglistentries&${prefix}lists=10|11|12" => 'apihelp-query+readinglistentries-example-1', "action=query&list=readinglistentries&${prefix}changedsince=2013-01-01T00:00:00Z" => 'apihelp-query+readinglistentries-example-2', ]; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } /** * Initialize a reverse interwiki lookup helper. * @return ReverseInterwikiLookup */ private function getReverseInterwikiLookup() { return MediaWikiServices::getInstance()->getService( 'ReverseInterwikiLookup' ); } /** * Transform a row into an API result item * @param ReadingListEntryRow $row * @param string $mode One of the MODE_* constants. * @return array */ private function getResultItem( $row, $mode ) { return [ 'id' => (int)$row->rle_id, 'listId' => (int)$row->rle_rl_id, 'project' => $row->rle_project, 'title' => $row->rle_title, 'created' => wfTimestamp( TS_ISO_8601, $row->rle_date_created ), 'updated' => wfTimestamp( TS_ISO_8601, $row->rle_date_updated ), ] + ( $mode === self::MODE_CHANGES ? [ 'deleted' => (bool)$row->rle_deleted ] : [] ); } /** * Transform a row into an API result item * @param ReadingListEntryRow $row * @return Title|string */ private function getResultTitle( $row ) { $interwikiPrefix = $this->getReverseInterwikiLookup()->lookup( $row->rle_project ); if ( is_string( $interwikiPrefix ) ) { // This will handle correctly the case of $interwikiPrefix === '' as well. return Title::makeTitle( NS_MAIN, $row->rle_title, '', $interwikiPrefix ); } elseif ( is_array( $interwikiPrefix ) ) { $title = implode( ':', array_slice( $interwikiPrefix, 1 ) ). ':' . $row->rle_title; $prefix = $interwikiPrefix[0]; return Title::makeTitle( NS_MAIN, $title, '', $prefix ); } // For lack of a better option let's create an invalid title. // ApiPageSet::populateFromTitles() is not documented to accept strings // but it will actually work. return 'Invalid project|' . $row->rle_project . '|' . $row->rle_title; } } diff --git a/src/Api/ApiQueryReadingListOrder.php b/src/Api/ApiQueryReadingListOrder.php index 9bb78f0..6d231f7 100644 --- a/src/Api/ApiQueryReadingListOrder.php +++ b/src/Api/ApiQueryReadingListOrder.php @@ -1,105 +1,104 @@ getUser()->isAnon() ) { $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-viewmyprivateinfo' ) ], 'notloggedin' ); } $this->checkUserRightsAny( 'viewmyprivateinfo' ); $listorder = $this->getParameter( 'listorder' ); $lists = $this->getParameter( 'lists' ) ?: []; $this->requireAtLeastOneParameter( $this->extractRequestParams(), 'listorder', 'lists' ); $path = [ 'query', $this->getModuleName() ]; $result = $this->getResult(); $result->addIndexedTagName( $path, 'order' ); $repository = $this->getReadingListRepository( $this->getUser() ); if ( $listorder ) { $order = $repository->getListOrder(); ApiResult::setIndexedTagName( $order, 'list' ); $result->addValue( $path, null, [ 'type' => 'lists', 'order' => $order ] ); } foreach ( $lists as $list ) { $order = $repository->getListEntryOrder( $list ); ApiResult::setIndexedTagName( $order, 'entry' ); $result->addValue( $path, null, [ 'type' => 'entries', 'list' => $list, 'order' => $order ] ); } } catch ( ReadingListRepositoryException $e ) { $this->dieWithException( $e ); } } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return [ 'listorder' => [ self::PARAM_TYPE => 'boolean', ], 'lists' => [ self::PARAM_TYPE => 'integer', self::PARAM_ISMULTI => true, ], ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { $prefix = static::$prefix; return [ "action=query&meta=readinglistorder&${prefix}listorder=1" => 'apihelp-query+readinglistorder-example-1', "action=query&meta=readinglistorder&${prefix}lists=1|2" => 'apihelp-query+readinglistorder-example-2', ]; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } } diff --git a/src/Api/ApiQueryReadingLists.php b/src/Api/ApiQueryReadingLists.php index 48bdb93..2a0ff1b 100644 --- a/src/Api/ApiQueryReadingLists.php +++ b/src/Api/ApiQueryReadingLists.php @@ -1,221 +1,221 @@ getUser()->isAnon() ) { $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-viewmyprivateinfo' ) ], 'notloggedin' ); } $this->checkUserRightsAny( 'viewmyprivateinfo' ); $changedSince = $this->getParameter( 'changedsince' ); $project = $this->getParameter( 'project' ); $title = $this->getParameter( 'title' ); $limit = $this->getParameter( 'limit' ); $offset = $this->getParameter( 'continue' ); $path = [ 'query', $this->getModuleName() ]; $result = $this->getResult(); $result->addIndexedTagName( $path, 'list' ); $repository = $this->getReadingListRepository( $this->getUser() ); $mode = null; $this->requireMaxOneParameter( $this->extractRequestParams(), 'title', 'changedsince' ); if ( $project !== null && $title !== null ) { $mode = self::MODE_PAGE; } elseif ( $project !== null || $title !== null ) { $errorMessage = $this->msg( 'readinglists-apierror-project-title-param', static::$prefix ); $this->dieWithError( $errorMessage, 'missingparam' ); } elseif ( $changedSince !== null ) { $expiry = Utils::getDeletedExpiry(); if ( $changedSince < $expiry ) { $errorMessage = $this->msg( 'readinglists-apierror-too-old', static::$prefix, wfTimestamp( TS_ISO_8601, $expiry ) ); $this->dieWithError( $errorMessage ); } $mode = self::MODE_CHANGES; } else { $mode = self::MODE_ALL; } if ( $mode === self::MODE_PAGE ) { $res = $repository->getListsByPage( $project, $title, $limit + 1, $offset ); } elseif ( $mode === self::MODE_CHANGES ) { $res = $repository->getListsByDateUpdated( $changedSince, $limit + 1, $offset ); } else { $res = $repository->getAllLists( $limit + 1, $offset ); } $resultOffset = 0; foreach ( $res as $row ) { $isLastRow = ( $resultOffset === $res->numRows() - 1 ); $this->addExtraData( $row, $repository, $mode ); $item = $this->getResultItem( $row, $mode ); $fits = $result->addValue( $path, null, $item ); if ( !$fits || ++$resultOffset >= $limit && !$isLastRow ) { $this->setContinueEnumParameter( 'continue', $offset + $resultOffset ); break; } } } catch ( ReadingListRepositoryException $e ) { $this->dieWithException( $e ); } } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return [ 'project' => [ self::PARAM_TYPE => 'string', ], 'title' => [ self::PARAM_TYPE => 'string', ], 'changedsince' => [ self::PARAM_TYPE => 'timestamp', self::PARAM_HELP_MSG => $this->msg( 'apihelp-query+readinglists-param-changedsince', wfTimestamp( TS_ISO_8601, Utils::getDeletedExpiry() ) ), ], 'limit' => [ self::PARAM_DFLT => 10, self::PARAM_TYPE => 'limit', self::PARAM_MIN => 1, self::PARAM_MAX => self::LIMIT_BIG1, self::PARAM_MAX2 => self::LIMIT_BIG2, ], 'continue' => [ self::PARAM_TYPE => 'integer', self::PARAM_DFLT => 0, self::PARAM_HELP_MSG => 'api-help-param-continue', ], ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { $prefix = static::$prefix; return [ 'action=query&meta=readinglists' => 'apihelp-query+readinglists-example-1', "action=query&meta=readinglists&${prefix}changedsince=2013-01-01T00:00:00Z" => 'apihelp-query+readinglists-example-2', "action=query&meta=readinglists&${prefix}project=en.wikipedia.org&${prefix}title=Dog" => 'apihelp-query+readinglists-example-3', ]; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } /** * @param ReadingListRow $row * @param ReadingListRepository $repository * @param string $mode One of the MODE_* constants. * @return void * @fixme If apps really need this, find a more performant way to get the data */ private function addExtraData( &$row, $repository, $mode ) { if ( $mode !== self::MODE_PAGE ) { $row->order = $repository->getListEntryOrder( $row->rl_id ); if ( $row->rl_is_default ) { $row->list_order = $repository->getListOrder(); } } } /** * Transform a row into an API result item * @param ReadingListRow $row * @param string $mode One of the MODE_* constants. * @return array */ private function getResultItem( $row, $mode ) { $item = [ 'id' => (int)$row->rl_id, 'name' => (int)$row->rl_name, 'default' => (bool)$row->rl_is_default, 'description' => $row->rl_description, 'color' => $row->rl_color, 'image' => $row->rl_image, 'icon' => $row->rl_icon, 'created' => wfTimestamp( TS_ISO_8601, $row->rl_date_created ), 'updated' => wfTimestamp( TS_ISO_8601, $row->rl_date_updated ), ]; if ( $mode === self::MODE_CHANGES ) { $item['deleted'] = (bool)$row->rl_deleted; } if ( isset( $row->order ) ) { $item['order'] = $row->order; } if ( isset( $row->list_order ) ) { $item['listOrder'] = $row->list_order; } return $item; } } diff --git a/src/Api/ApiReadingLists.php b/src/Api/ApiReadingLists.php index 985e3f4..27e7c42 100644 --- a/src/Api/ApiReadingLists.php +++ b/src/Api/ApiReadingLists.php @@ -1,122 +1,122 @@ module class */ private static $submodules = [ 'setup' => ApiReadingListsSetup::class, 'teardown' => ApiReadingListsTeardown::class, 'create' => ApiReadingListsCreate::class, 'update' => ApiReadingListsUpdate::class, 'delete' => ApiReadingListsDelete::class, 'createentry' => ApiReadingListsCreateEntry::class, 'deleteentry' => ApiReadingListsDeleteEntry::class, 'order' => ApiReadingListsOrder::class, 'orderentry' => ApiReadingListsOrderEntry::class, ]; /** @var ApiModuleManager */ private $moduleManager; /** * Entry point for executing the module - * @inheritdoc + * @inheritDoc * @return void */ public function execute() { if ( $this->getUser()->isAnon() ) { $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-editmyprivateinfo' ) ], 'notloggedin' ); } $this->checkUserRightsAny( 'editmyprivateinfo' ); $command = $this->getParameter( 'command' ); $module = $this->moduleManager->getModule( $command, 'command' ); $module->extractRequestParams(); try { $module->execute(); $module->getResult()->addValue( null, $module->getModuleName(), [ 'result' => 'Success' ] ); } catch ( ReadingListRepositoryException $e ) { $module->getResult()->addValue( null, $module->getModuleName(), [ 'result' => 'Failure' ] ); $this->dieWithException( $e ); } } /** - * @inheritdoc + * @inheritDoc * @return ApiModuleManager */ public function getModuleManager() { if ( !$this->moduleManager ) { $modules = array_map( function ( $class ) { return [ 'class' => $class, 'factory' => "$class::factory", ]; }, self::$submodules ); $this->moduleManager = new ApiModuleManager( $this ); $this->moduleManager->addModules( $modules, 'command' ); } return $this->moduleManager; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return [ 'command' => [ self::PARAM_TYPE => 'submodule', self::PARAM_REQUIRED => true, ], ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isWriteMode() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function needsToken() { return 'csrf'; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } } diff --git a/src/Api/ApiReadingListsCreate.php b/src/Api/ApiReadingListsCreate.php index 2e79e91..3252335 100644 --- a/src/Api/ApiReadingListsCreate.php +++ b/src/Api/ApiReadingListsCreate.php @@ -1,112 +1,111 @@ extractRequestParams(); $listId = $this->getReadingListRepository( $this->getUser() )->addList( $params['name'], $params['description'], $params['color'], $params['image'], $params['icon'] ); $this->getResult()->addValue( null, $this->getModuleName(), [ 'id' => $listId ] ); } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return [ 'name' => [ self::PARAM_TYPE => 'string', self::PARAM_REQUIRED => true, ], 'description' => [ self::PARAM_TYPE => 'string', self::PARAM_DFLT => '', ], 'color' => [ self::PARAM_TYPE => 'string', self::PARAM_DFLT => '', ], 'image' => [ self::PARAM_TYPE => 'string', self::PARAM_DFLT => '', ], 'icon' => [ self::PARAM_TYPE => 'string', self::PARAM_DFLT => '', ], ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { return [ 'action=readinglists&command=create&name=dogs&description=Woof!&token=123ABC' => 'apihelp-readinglists+create-example-1', 'action=readinglists&command=create&name=dogs&description=Woof!' . '&color=brown&image=File:Australien_Kelpie.jpg&icon=File:Icon dog.gif&token=123ABC' => 'apihelp-readinglists+create-example-2', ]; } // The parent module already enforces these but they make documentation nicer. /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isWriteMode() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function mustBePosted() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } } diff --git a/src/Api/ApiReadingListsCreateEntry.php b/src/Api/ApiReadingListsCreateEntry.php index c75e104..ac06a63 100644 --- a/src/Api/ApiReadingListsCreateEntry.php +++ b/src/Api/ApiReadingListsCreateEntry.php @@ -1,104 +1,103 @@ getParameter( 'list' ); $project = $this->getParameter( 'project' ); $title = $this->getParameter( 'title' ); $entryId = $this->getReadingListRepository( $this->getUser() ) ->addListEntry( $listId, $project, $title ); $this->getResult()->addValue( null, $this->getModuleName(), [ 'id' => $entryId ] ); } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return [ 'list' => [ self::PARAM_TYPE => 'integer', self::PARAM_REQUIRED => true, ], 'project' => [ self::PARAM_TYPE => 'string', self::PARAM_REQUIRED => true, ], 'title' => [ self::PARAM_TYPE => 'string', self::PARAM_REQUIRED => true, ], ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { return [ 'action=readinglists&command=createentry&list=33&' . 'project=en.wikipedia.org&title=Dog&token=123ABC' => 'apihelp-readinglists+createentry-example-1', ]; } // The parent module already enforces these but they make documentation nicer. /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isWriteMode() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function mustBePosted() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } } diff --git a/src/Api/ApiReadingListsDelete.php b/src/Api/ApiReadingListsDelete.php index 0387c83..0afb4bf 100644 --- a/src/Api/ApiReadingListsDelete.php +++ b/src/Api/ApiReadingListsDelete.php @@ -1,91 +1,90 @@ getParameter( 'list' ); $this->getReadingListRepository( $this->getUser() )->deleteList( $listId ); } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return [ 'list' => [ self::PARAM_TYPE => 'integer', self::PARAM_REQUIRED => true, ], ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { return [ 'action=readinglists&command=delete&list=11&token=123ABC' => 'apihelp-readinglists+delete-example-1', ]; } // The parent module already enforces these but they make documentation nicer. /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isWriteMode() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function mustBePosted() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } } diff --git a/src/Api/ApiReadingListsDeleteEntry.php b/src/Api/ApiReadingListsDeleteEntry.php index b0484a1..ecefbea 100644 --- a/src/Api/ApiReadingListsDeleteEntry.php +++ b/src/Api/ApiReadingListsDeleteEntry.php @@ -1,91 +1,90 @@ getParameter( 'entry' ); $this->getReadingListRepository( $this->getUser() )->deleteListEntry( $entryId ); } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return [ 'entry' => [ self::PARAM_TYPE => 'integer', self::PARAM_REQUIRED => true, ], ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { return [ 'action=readinglists&command=deleteentry&entry=8&token=123ABC' => 'apihelp-readinglists+deleteentry-example-1', ]; } // The parent module already enforces these but they make documentation nicer. /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isWriteMode() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function mustBePosted() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } } diff --git a/src/Api/ApiReadingListsOrder.php b/src/Api/ApiReadingListsOrder.php index 63b0183..053b751 100644 --- a/src/Api/ApiReadingListsOrder.php +++ b/src/Api/ApiReadingListsOrder.php @@ -1,94 +1,93 @@ getParameter( 'order' ); $this->getReadingListRepository( $this->getUser() )->setListOrder( $order ); } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return [ 'order' => [ self::PARAM_TYPE => 'integer', self::PARAM_ISMULTI => true, self::PARAM_ISMULTI_LIMIT1 => 1000, self::PARAM_ISMULTI_LIMIT2 => 1000, self::PARAM_REQUIRED => true, ], ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { return [ 'action=readinglists&command=order&order=8|3|7|5|1&token=123ABC' => 'apihelp-readinglists+orderentry-example-1', ]; } // The parent module already enforces these but they make documentation nicer. /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isWriteMode() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function mustBePosted() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } } diff --git a/src/Api/ApiReadingListsOrderEntry.php b/src/Api/ApiReadingListsOrderEntry.php index b360a11..a1241b3 100644 --- a/src/Api/ApiReadingListsOrderEntry.php +++ b/src/Api/ApiReadingListsOrderEntry.php @@ -1,99 +1,98 @@ getParameter( 'list' ); $order = $this->getParameter( 'order' ); $this->getReadingListRepository( $this->getUser() )->setListEntryOrder( $listId, $order ); } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return [ 'list' => [ self::PARAM_TYPE => 'integer', self::PARAM_REQUIRED => true, ], 'order' => [ self::PARAM_TYPE => 'integer', self::PARAM_ISMULTI => true, self::PARAM_ISMULTI_LIMIT1 => 1000, self::PARAM_ISMULTI_LIMIT2 => 1000, self::PARAM_REQUIRED => true, ], ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { return [ 'action=readinglists&command=orderentry&list=12&order=6|3|9|18|12&token=123ABC' => 'apihelp-readinglists+orderentry-example-1', ]; } // The parent module already enforces these but they make documentation nicer. /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isWriteMode() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function mustBePosted() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } } diff --git a/src/Api/ApiReadingListsSetup.php b/src/Api/ApiReadingListsSetup.php index f9ef21c..9f0069b 100644 --- a/src/Api/ApiReadingListsSetup.php +++ b/src/Api/ApiReadingListsSetup.php @@ -1,84 +1,83 @@ getReadingListRepository( $this->getUser() )->setupForUser(); } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return []; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { return [ 'action=readinglists&command=setup&token=123ABC' => 'apihelp-readinglists+setup-example-1', ]; } // The parent module already enforces these but they make documentation nicer. /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isWriteMode() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function mustBePosted() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } } diff --git a/src/Api/ApiReadingListsTeardown.php b/src/Api/ApiReadingListsTeardown.php index aa7b95f..a14ff30 100644 --- a/src/Api/ApiReadingListsTeardown.php +++ b/src/Api/ApiReadingListsTeardown.php @@ -1,84 +1,83 @@ getReadingListRepository( $this->getUser() )->teardownForUser(); } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return []; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { return [ 'action=readinglists&command=teardown&token=123ABC' => 'apihelp-readinglists+teardown-example-1', ]; } // The parent module already enforces these but they make documentation nicer. /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isWriteMode() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function mustBePosted() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } } diff --git a/src/Api/ApiReadingListsUpdate.php b/src/Api/ApiReadingListsUpdate.php index ec7e572..8c65e9d 100644 --- a/src/Api/ApiReadingListsUpdate.php +++ b/src/Api/ApiReadingListsUpdate.php @@ -1,108 +1,107 @@ extractRequestParams(); $this->requireAtLeastOneParameter( $params, 'name', 'description', 'color', 'image', 'icon' ); $this->getReadingListRepository( $this->getUser() )->updateList( $params['list'], $params['name'], $params['description'], $params['color'], $params['image'], $params['icon'] ); } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getAllowedParams() { return [ 'list' => [ self::PARAM_TYPE => 'integer', self::PARAM_REQUIRED => true, ], 'name' => [ self::PARAM_TYPE => 'string', ], 'description' => [ self::PARAM_TYPE => 'string', ], 'color' => [ self::PARAM_TYPE => 'string', ], 'image' => [ self::PARAM_TYPE => 'string', ], 'icon' => [ self::PARAM_TYPE => 'string', ], ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ public function getHelpUrls() { return [ 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API', ]; } /** - * @inheritdoc + * @inheritDoc * @return array */ protected function getExamplesMessages() { return [ 'action=readinglists&command=update&list=42&name=New+name&token=123ABC' => 'apihelp-readinglists+update-example-1', ]; } // The parent module already enforces these but they make documentation nicer. /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isWriteMode() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function mustBePosted() { return true; } /** - * @inheritdoc + * @inheritDoc * @return bool */ public function isInternal() { // ReadingLists API is still experimental return true; } } diff --git a/src/Api/ApiTrait.php b/src/Api/ApiTrait.php index bb7dd13..4af09f5 100644 --- a/src/Api/ApiTrait.php +++ b/src/Api/ApiTrait.php @@ -1,103 +1,96 @@ getDBLoadBalancerFactory(); $dbw = Utils::getDB( DB_MASTER, $services ); $dbr = Utils::getDB( DB_REPLICA, $services ); if ( static::$prefix ) { // We are in one of the read modules, $parent is ApiQuery. // This is an ApiQueryBase subclass so we need to pass ApiQuery. $module = new static( $parent, $name, static::$prefix ); } else { // We are in one of the write submodules, $parent is ApiReadingLists. // This is an ApiBase subclass so we need to pass ApiMain. $module = new static( $parent->getMain(), $name, static::$prefix ); } $module->parent = $parent; $module->injectDatabaseDependencies( $loadBalancerFactory, $dbw, $dbr ); return $module; } /** * Get the parent module. * @return ApiBase */ public function getParent() { return $this->parent; } /** * Set database-related dependencies. Required when initializing a module that uses this trait. * @param LBFactory $loadBalancerFactory * @param DBConnRef $dbw Master connection * @param DBConnRef $dbr Replica connection */ protected function injectDatabaseDependencies( LBFactory $loadBalancerFactory, DBConnRef $dbw, DBConnRef $dbr ) { $this->loadBalancerFactory = $loadBalancerFactory; $this->dbw = $dbw; $this->dbr = $dbr; } /** * Get the repository for the given user. * @param User $user * @return ReadingListRepository */ protected function getReadingListRepository( User $user = null ) { $centralId = CentralIdLookup::factory()->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW ); $repository = new ReadingListRepository( $centralId, $this->dbw, $this->dbr, $this->loadBalancerFactory ); $repository->setLogger( LoggerFactory::getInstance( 'readinglists' ) ); return $repository; } } diff --git a/src/ReadingListRepository.php b/src/ReadingListRepository.php index cf2fe28..c657907 100644 --- a/src/ReadingListRepository.php +++ b/src/ReadingListRepository.php @@ -1,994 +1,993 @@ userId = $userId; $this->dbw = $dbw; $this->dbr = $dbr; $this->lbFactory = $lbFactory; $this->logger = new NullLogger(); } /** * @param LoggerInterface $logger * @return void */ public function setLogger( LoggerInterface $logger ) { $this->logger = $logger; } // setup / teardown /** * Set up the service for the given user. * This is a pre-requisite for doing anything else. It will create a default list. * @return void * @throws ReadingListRepositoryException */ public function setupForUser() { $this->assertUser(); if ( $this->isSetupForUser( self::READ_LOCKING ) ) { throw new ReadingListRepositoryException( 'readinglists-db-error-already-set-up' ); } $this->dbw->insert( 'reading_list', [ 'rl_user_id' => $this->userId, 'rl_is_default' => 1, 'rl_name' => 'default', 'rl_description' => '', 'rl_color' => '', 'rl_image' => '', 'rl_icon' => '', 'rl_date_created' => $this->dbw->timestamp(), 'rl_date_updated' => $this->dbw->timestamp(), 'rl_deleted' => 0, ], __METHOD__ ); $this->dbw->insert( 'reading_list_sortkey', [ 'rls_rl_id' => $this->dbw->insertId(), 'rls_index' => 0, ] ); $this->logger->info( 'Set up for user {user}', [ 'user' => $this->userId ] ); } /** * Remove all data for the given user. * No other operation can be performed for the user except setup. * @return void * @throws ReadingListRepositoryException */ public function teardownForUser() { $this->assertUser(); if ( !$this->isSetupForUser() ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); } $this->dbw->delete( 'reading_list', [ 'rl_user_id' => $this->userId ], __METHOD__ ); $this->dbw->delete( 'reading_list_entry', [ 'rle_user_id' => $this->userId ], __METHOD__ ); $this->logger->info( 'Tore down for user {user}', [ 'user' => $this->userId ] ); } /** * Check whether reading lists have been set up for the given user (ie. setupForUser() was * called with $userId and teardownForUser() was not called with the same id afterwards). * @param int $flags IDBAccessObject flags * @throws ReadingListRepositoryException * @return bool */ public function isSetupForUser( $flags = 0 ) { $this->assertUser(); list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags ); $db = ( $index === DB_MASTER ) ? $this->dbw : $this->dbr; $options = array_merge( $options, [ 'LIMIT' => 1 ] ); $res = $db->select( 'reading_list', '1', [ 'rl_user_id' => $this->userId, // It would probably be fine to just check if the user has lists at all, // but this way is extra safe against races as setup is the only operation that // creates a default list. 'rl_is_default' => 1, ], __METHOD__, $options ); return (bool)$res->numRows(); } // list CRUD /** * Create a new list. * @param string $name * @param string $description * @param string $color * @param string $image * @param string $icon * @return int The ID of the new list * @throws ReadingListRepositoryException */ public function addList( $name, $description = '', $color = '', $image = '', $icon = '' ) { $this->assertUser(); if ( !$this->isSetupForUser( self::READ_LOCKING ) ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); } $this->dbw->insert( 'reading_list', [ 'rl_user_id' => $this->userId, 'rl_is_default' => 0, 'rl_name' => $name, 'rl_description' => $description, 'rl_color' => $color, 'rl_image' => $image, 'rl_icon' => $icon, 'rl_date_created' => $this->dbw->timestamp(), 'rl_date_updated' => $this->dbw->timestamp(), 'rl_deleted' => 0, ], __METHOD__ ); $this->logger->info( 'Added list {list} for user {user}', [ 'list' => $this->dbw->insertId(), 'user' => $this->userId, ] ); return $this->dbw->insertId(); } /** * Get all lists of the user. * @param int $limit * @param int $offset * @return IResultWrapper * @throws ReadingListRepositoryException */ public function getAllLists( $limit = 1000, $offset = 0 ) { // TODO sortkeys? $this->assertUser(); $res = $this->dbr->select( [ 'reading_list', 'reading_list_sortkey' ], $this->getListFields(), [ 'rl_user_id' => $this->userId, 'rl_deleted' => 0, ], __METHOD__, [ 'LIMIT' => $limit, 'OFFSET' => $offset, 'ORDER BY' => 'rls_index', ], [ 'reading_list_sortkey' => [ 'LEFT JOIN', 'rl_id = rls_rl_id' ], ] ); if ( $res->numRows() === 0 && !$this->isSetupForUser() ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); } return $res; } /** * Update a list. * Fields for which the parameter was set to null will preserve their original value. * @param int $id * @param string|null $name * @param string|null $description * @param string|null $color * @param string|null $image * @param string|null $icon * @return void * @throws ReadingListRepositoryException * @throws LogicException */ public function updateList( $id, $name = null, $description = null, $color = null, $image = null, $icon = null ) { $this->assertUser(); $data = array_filter( [ 'rl_name' => $name, 'rl_description' => $description, 'rl_color' => $color, 'rl_image' => $image, 'rl_icon' => $icon, 'rl_date_updated' => $this->dbw->timestamp(), ], function ( $field ) { return $field !== null; } ); $this->dbw->update( 'reading_list', $data, [ 'rl_id' => $id, 'rl_user_id' => $this->userId, ], __METHOD__ ); if ( $this->dbw->affectedRows() ) { return; } // failed; see what went wrong so we can return a useful error message /** @var ReadingListRow $row */ $row = $this->dbw->selectRow( 'reading_list', [ 'rl_user_id' ], [ 'rl_id' => $id ], __METHOD__ ); if ( !$row ) { throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list', [ $id ] ); } elseif ( $row->rl_user_id != $this->userId ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list', [ $id ] ); } else { throw new LogicException( 'updateList failed for unknown reason' ); } } /** * Delete a list. * @param int $id * @return void * @throws ReadingListRepositoryException */ public function deleteList( $id ) { $this->assertUser(); $this->dbw->update( 'reading_list', [ 'rl_deleted' => 1, 'rl_date_updated' => $this->dbw->timestamp(), ], [ 'rl_id' => $id, 'rl_user_id' => $this->userId, // cannot delete the default list 'rl_is_default' => 0, ], __METHOD__ ); if ( $this->dbw->affectedRows() ) { $this->logger->info( 'Deleted list {list} for user {user}', [ 'list' => $id, 'user' => $this->userId, ] ); return; } // failed; see what went wrong so we can return a useful error message /** @var ReadingListRow $row */ $row = $this->dbw->selectRow( 'reading_list', [ 'rl_user_id', 'rl_is_default' ], [ 'rl_id' => $id ], __METHOD__ ); if ( !$row ) { throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list', [ $id ] ); } elseif ( $row->rl_user_id != $this->userId ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list', [ $id ] ); } elseif ( $row->rl_is_default ) { throw new ReadingListRepositoryException( 'readinglists-db-error-cannot-delete-default-list' ); } else { throw new LogicException( 'deleteList failed for unknown reason' ); } } // list entry CRUD /** * Add a new page to a list. * @param int $id List ID * @param string $project Project identifier (typically a domain name) * @param string $title Page title (in localized prefixed DBkey format) * @return int The ID of the new list entry * @throws ReadingListRepositoryException */ public function addListEntry( $id, $project, $title ) { $this->assertUser(); // verify that the list exists and we have access to it /** @var ReadingListRow $row */ $row = $this->dbw->selectRow( 'reading_list', 'rl_user_id', [ 'rl_id' => $id, ], __METHOD__, [ 'FOR UPDATE' ] ); if ( !$row ) { throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list', [ $id ] ); } elseif ( $row->rl_user_id != $this->userId ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list', [ $id ] ); } $this->dbw->insert( 'reading_list_entry', [ 'rle_rl_id' => $id, 'rle_user_id' => $this->userId, 'rle_project' => $project, 'rle_title' => $title, 'rle_date_created' => $this->dbw->timestamp(), 'rle_date_updated' => $this->dbw->timestamp(), 'rle_deleted' => 0, ], __METHOD__, // throw custom exception for unique constraint on rle_rl_id + rle_project + rle_title [ 'IGNORE' ] ); if ( !$this->dbw->affectedRows() ) { throw new ReadingListRepositoryException( 'readinglists-db-error-duplicate-page' ); } $this->logger->info( 'Added entry {entry} for user {user}', [ 'entry' => $this->dbw->insertId(), 'user' => $this->userId, ] ); return $this->dbw->insertId(); } /** * Get the entries of one or more lists. * @param array $ids List ids * @param int $limit * @param int $offset * @return IResultWrapper * @throws ReadingListRepositoryException */ public function getListEntries( array $ids, $limit = 1000, $offset = 0 ) { // TODO sortkeys? $this->assertUser(); if ( !$ids ) { throw new ReadingListRepositoryException( 'readinglists-db-error-empty-list-ids' ); } // sanity check for nice error messages $res = $this->dbr->select( 'reading_list', [ 'rl_id', 'rl_user_id', 'rl_deleted' ], [ 'rl_id' => $ids ] ); $filtered = []; foreach ( $res as $row ) { /** @var ReadingListRow $row */ if ( $row->rl_user_id != $this->userId ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list', [ $row->rl_id ] ); } elseif ( $row->rl_deleted ) { throw new ReadingListRepositoryException( 'readinglists-db-error-list-deleted', [ $row->rl_id ] ); } $filtered[] = $row->rl_id; } $missing = array_diff( $ids, $filtered ); if ( $missing ) { throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list', [ reset( $missing ) ] ); } $res = $this->dbr->select( [ 'reading_list_entry', 'reading_list_entry_sortkey' ], $this->getListEntryFields(), [ 'rle_rl_id' => $ids, 'rle_user_id' => $this->userId, 'rle_deleted' => 0, ], __METHOD__, [ 'LIMIT' => $limit, 'OFFSET' => $offset, 'ORDER BY' => [ 'rle_rl_id', 'rles_index' ], ], [ 'reading_list_entry_sortkey' => [ 'LEFT JOIN', 'rle_id = rles_rle_id' ], ] ); return $res; } /** * Delete a page from a list. * @param int $id * @return void * @throws ReadingListRepositoryException */ public function deleteListEntry( $id ) { $this->assertUser(); $this->dbw->update( 'reading_list_entry', [ 'rle_deleted' => 1, 'rle_date_updated' => $this->dbw->timestamp(), ], [ 'rle_id' => $id, 'rle_user_id' => $this->userId, ], __METHOD__ ); if ( $this->dbw->affectedRows() ) { $this->logger->info( 'Deleted entry {entry} for user {user}', [ 'entry' => $id, 'user' => $this->userId, ] ); return; } // failed; see what went wrong so we can return a useful error message /** @var ReadingListEntryRow $row */ $row = $this->dbw->selectRow( 'reading_list_entry', [ 'rle_user_id' ], [ 'rle_id' => $id ], __METHOD__ ); if ( !$row ) { throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list-entry', [ $id ] ); } elseif ( $row->rle_user_id != $this->userId ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list-entry', [ $id ] ); } else { throw new LogicException( 'deleteListEntry failed for unknown reason' ); } } // sorting /** * Return the ids of all lists in order. * @return int[] * @throws ReadingListRepositoryException */ public function getListOrder() { $this->assertUser(); $ids = $this->dbr->selectFieldValues( [ 'reading_list', 'reading_list_sortkey' ], 'rl_id', [ 'rl_user_id' => $this->userId, 'rl_deleted' => 0, ], __METHOD__, [ 'ORDER BY' => 'rls_index', ], [ 'reading_list_sortkey' => [ 'LEFT JOIN', 'rl_id = rls_rl_id' ], ] ); if ( !$ids ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); } return $ids; } /** * Update the order of lists. * @param array $order A list of all reading list ids, in the desired order. * @return void * @throws ReadingListRepositoryException */ public function setListOrder( array $order ) { $this->assertUser(); if ( !$order ) { throw new ReadingListRepositoryException( 'readinglists-db-error-empty-order' ); } if ( !$this->isSetupForUser( self::READ_LOCKING ) ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-set-up' ); } // Make sure the lists exist and the user owns them. $res = $this->dbw->select( 'reading_list', [ 'rl_id', 'rl_user_id', 'rl_deleted' ], [ 'rl_id' => $order ] ); $filtered = []; foreach ( $res as $row ) { /** @var ReadingListRow $row */ if ( $row->rl_user_id != $this->userId ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list', [ $row->rl_id ] ); } elseif ( $row->rl_deleted ) { throw new ReadingListRepositoryException( 'readinglists-db-error-list-deleted', [ $row->rl_id ] ); } $filtered[] = $row->rl_id; } $missing = array_diff( $order, $filtered ); if ( $missing ) { throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list', [ reset( $missing ) ] ); } $this->dbw->deleteJoin( 'reading_list_sortkey', 'reading_list', 'rls_rl_id', 'rl_id', [ 'rl_user_id' => $this->userId ] ); $this->dbw->insert( 'reading_list_sortkey', array_map( function ( $id, $index ) { return [ 'rls_rl_id' => $id, 'rls_index' => $index, ]; }, array_values( $order ), array_keys( $order ) ) ); // Touch timestamp of default list so that syncing devices know to update the list order. $this->dbw->update( 'reading_list', [ 'rl_date_updated' => $this->dbw->timestamp() ], [ 'rl_user_id' => $this->userId, 'rl_is_default' => 1, ] ); } /** * Return the ids of all entries of the list in order. * @param int $id List ID * @return int[] * @throws ReadingListRepositoryException */ public function getListEntryOrder( $id ) { $this->assertUser(); $ids = $this->dbr->selectFieldValues( [ 'reading_list_entry', 'reading_list_entry_sortkey' ], 'rle_id', [ 'rle_rl_id' => $id, 'rle_user_id' => $this->userId, 'rle_deleted' => 0, ], __METHOD__, [ 'ORDER BY' => 'rles_index', ], [ 'reading_list_entry_sortkey' => [ 'LEFT JOIN', 'rle_id = rles_rle_id' ], ] ); if ( !$ids ) { /** @var ReadingListRow $row */ $row = $this->dbr->selectRow( 'reading_list', [ 'rl_user_id', 'rl_deleted' ], [ 'rl_id' => $id ] ); if ( !$row ) { throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list', [ $row->rl_id ] ); } elseif ( $row->rl_user_id != $this->userId ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list', [ $row->rl_id ] ); } elseif ( $row->rl_deleted ) { throw new ReadingListRepositoryException( 'readinglists-db-error-list-deleted', [ $row->rl_id ] ); } } return $ids; } /** * Update the order of the entries of a list. * @param int $id List ID * @param array $order A list of IDs for all entries of the list, in the desired order. * @return void * @throws ReadingListRepositoryException */ public function setListEntryOrder( $id, array $order ) { $this->assertUser(); if ( !$order ) { throw new ReadingListRepositoryException( 'readinglists-db-error-empty-order' ); } $this->dbw->select( 'reading_list', '1', [ 'rl_id' => $id ], __METHOD__, [ 'FOR UPDATE' ] ); // Make sure the list entries exist and the user owns them. $res = $this->dbw->select( 'reading_list_entry', [ 'rle_id', 'rle_rl_id', 'rle_user_id', 'rle_deleted' ], [ 'rle_id' => $order ] ); $filtered = []; foreach ( $res as $row ) { /** @var ReadingListEntryRow $row */ if ( $row->rle_user_id != $this->userId ) { throw new ReadingListRepositoryException( 'readinglists-db-error-not-own-list-entry', [ $row->rle_id ] ); } elseif ( $row->rle_rl_id != $id ) { throw new ReadingListRepositoryException( 'readinglists-db-error-entry-not-in-list', [ $row->rle_id ] ); } elseif ( $row->rle_deleted ) { throw new ReadingListRepositoryException( 'readinglists-db-error-list-entry-deleted', [ $row->rle_id ] ); } $filtered[] = $row->rle_id; } $missing = array_diff( $order, $filtered ); if ( $missing ) { throw new ReadingListRepositoryException( 'readinglists-db-error-no-such-list-entry', [ reset( $missing ) ] ); } $this->dbw->deleteJoin( 'reading_list_entry_sortkey', 'reading_list_entry', 'rles_rle_id', 'rle_id', [ 'rle_rl_id' => $id ] ); $this->dbw->insert( 'reading_list_entry_sortkey', array_map( function ( $id, $index ) { return [ 'rles_rle_id' => $id, 'rles_index' => $index, ]; }, array_values( $order ), array_keys( $order ) ) ); // Touch timestamp of the list so that syncing devices know to update the list order. $this->dbw->update( 'reading_list', [ 'rl_date_updated' => $this->dbw->timestamp() ], [ 'rl_id' => $id ] ); } /** * Purge sortkeys whose lists have been deleted. * Unlike most other methods in the class, this one ignores user IDs. * @return void */ public function purgeSortkeys() { // purge list sortkeys while ( true ) { $ids = $this->dbw->selectFieldValues( [ 'reading_list_sortkey', 'reading_list' ], 'rls_rl_id', [ 'rl_id' => null, ], __METHOD__, [ 'GROUP BY' => 'rls_rl_id', 'LIMIT' => 1000, ], [ 'reading_list' => [ 'LEFT JOIN', 'rl_id = rls_rl_id' ], ] ); if ( !$ids ) { break; } $this->dbw->delete( 'reading_list_sortkey', [ 'rls_rl_id' => $ids, ] ); $this->logger->debug( 'Purged {num} list sortkeys', [ 'num' => $this->dbw->affectedRows() ] ); $this->lbFactory->waitForReplication(); } // purge entry sortkeys while ( true ) { $ids = $this->dbw->selectFieldValues( [ 'reading_list_entry_sortkey', 'reading_list_entry' ], 'rles_rle_id', [ 'rle_id' => null, ], __METHOD__, [ 'GROUP BY' => 'rles_rle_id', 'LIMIT' => 1000, ], [ 'reading_list_entry' => [ 'LEFT JOIN', 'rle_id = rles_rle_id' ], ] ); if ( !$ids ) { break; } $this->dbw->delete( 'reading_list_entry_sortkey', [ 'rles_rle_id' => $ids, ] ); $this->logger->debug( 'Purged {num} entry sortkeys', [ 'num' => $this->dbw->affectedRows() ] ); $this->lbFactory->waitForReplication(); } } // sync /** * Get lists that have changed since a given date. * Unlike other methods this returns deleted lists as well. Only changes to list metadata * (including deletion) are considered, not changes to list entries. * @param string $date The cutoff date in TS_MW format * @param int $limit * @param int $offset * @throws ReadingListRepositoryException * @return IResultWrapper */ public function getListsByDateUpdated( $date, $limit = 1000, $offset = 0 ) { $this->assertUser(); $res = $this->dbr->select( 'reading_list', $this->getListFields(), [ 'rl_user_id' => $this->userId, 'rl_date_updated > ' . $this->dbr->addQuotes( $this->dbr->timestamp( $date ) ), ], __METHOD__, [ 'LIMIT' => $limit, 'OFFSET' => $offset, 'ORDER BY' => 'rl_id', ] ); return $res; } /** * Get list entries that have changed since a given date. * Unlike other methods this returns deleted entries as well (but not entries inside deleted * lists). * @param string $date The cutoff date in TS_MW format * @param int $limit * @param int $offset * @throws ReadingListRepositoryException * @return IResultWrapper */ public function getListEntriesByDateUpdated( $date, $limit = 1000, $offset = 0 ) { $this->assertUser(); $res = $this->dbr->select( [ 'reading_list', 'reading_list_entry' ], $this->getListEntryFields(), [ 'rl_id = rle_rl_id', 'rl_user_id' => $this->userId, 'rl_deleted' => 0, 'rle_date_updated > ' . $this->dbr->addQuotes( $this->dbr->timestamp( $date ) ), ], __METHOD__, [ 'LIMIT' => $limit, 'OFFSET' => $offset, 'ORDER BY' => [ 'rle_rl_id', 'rle_id' ], ] ); return $res; } /** * Purge all deleted lists/entries older than $before. * Unlike most other methods in the class, this one ignores user IDs. * @param string $before A timestamp in TS_MW format. * @return void */ public function purgeOldDeleted( $before ) { // purge deleted lists and their entries while ( true ) { $ids = $this->dbw->selectFieldValues( 'reading_list', 'rl_id', [ 'rl_deleted' => 1, 'rl_date_updated < ' . $this->dbw->addQuotes( $this->dbw->timestamp( $before ) ), ], __METHOD__, [ 'LIMIT' => 1000 ] ); if ( !$ids ) { break; } $this->dbw->delete( 'reading_list_entry', [ 'rle_rl_id' => $ids ] ); $this->dbw->delete( 'reading_list', [ 'rl_id' => $ids ] ); $this->logger->debug( 'Purged {num} deleted lists', [ 'num' => $this->dbw->affectedRows() ] ); $this->lbFactory->waitForReplication(); } // purge deleted list entries while ( true ) { $ids = $this->dbw->selectFieldValues( 'reading_list_entry', 'rle_id', [ 'rle_deleted' => 1, 'rle_date_updated < ' . $this->dbw->addQuotes( $this->dbw->timestamp( $before ) ), ], __METHOD__, [ 'LIMIT' => 1000 ] ); if ( !$ids ) { break; } $this->dbw->delete( 'reading_list_entry', [ 'rle_id' => $ids ] ); $this->logger->debug( 'Purged {num} deleted entries', [ 'num' => $this->dbw->affectedRows() ] ); $this->lbFactory->waitForReplication(); } } // membership /** * Return all lists which contain a given page. * @param string $project Project identifier (typically a domain name) * @param string $title Page title (in localized prefixed DBkey format) * @param int $limit * @param int $offset * @throws ReadingListRepositoryException * @return IResultWrapper */ public function getListsByPage( $project, $title, $limit = 1000, $offset = 0 ) { $this->assertUser(); $res = $this->dbr->select( [ 'reading_list', 'reading_list_entry' ], $this->getListFields(), [ 'rl_id = rle_rl_id', 'rl_user_id' => $this->userId, 'rle_project' => $project, 'rle_title' => $title, 'rl_deleted' => 0, 'rle_deleted' => 0, ], __METHOD__, [ 'LIMIT' => $limit, 'OFFSET' => $offset, 'GROUP BY' => $this->getListFields(), 'ORDER BY' => 'rl_id', ] ); return $res; } // helper methods /** * Get this list of reading_list fields that normally need to be selected. * @return array */ private function getListFields() { return [ 'rl_id', // returning rl_user_id is pointless as lists are only available to the owner 'rl_is_default', 'rl_name', 'rl_description', 'rl_color', 'rl_image', 'rl_icon', 'rl_date_created', 'rl_date_updated', 'rl_deleted', ]; } /** * Get this list of reading_list_entry fields that normally need to be selected. * @return array */ private function getListEntryFields() { return [ 'rle_id', 'rle_rl_id', // returning rle_user_id is pointless as lists are only available to the owner 'rle_project', 'rle_title', 'rle_date_created', 'rle_date_updated', 'rle_deleted', ]; } /** * Require the user to be specified. * @throws ReadingListRepositoryException */ private function assertUser() { if ( !is_int( $this->userId ) ) { throw new ReadingListRepositoryException( 'readinglists-db-error-user-required' ); } } }