ChangesListSpecialPage: Separate all functionality for generating feeds
authorBartosz Dziewoński <matma.rex@gmail.com>
Sun, 2 Feb 2014 16:30:44 +0000 (17:30 +0100)
committerSiebrand Mazeland <s.mazeland@xs4all.nl>
Wed, 12 Mar 2014 11:31:41 +0000 (12:31 +0100)
I should have done it at the beginning instead of trying to
extract it from recent changes.

Same for SpecialRecentChanges and SpecialRecentChangesLinked
(subclasses).

Created a new API module for it: ApiFeedRecentChanges.
It's somewhat un-API-like and hackish, but all feed modules are.

Old URLs redirect to new ones, so this should be fully
backwards-compatible assuming sane feed reader clients.

Change-Id: I06ee0f01d896bc66545a1800b24693ce7524e433

RELEASE-NOTES-1.23
includes/AutoLoader.php
includes/ChangesFeed.php
includes/api/ApiFeedRecentChanges.php [new file with mode: 0644]
includes/api/ApiMain.php
includes/specialpage/ChangesListSpecialPage.php
includes/specials/SpecialRecentchanges.php
includes/specials/SpecialRecentchangeslinked.php

index 4f4d414..ad2f747 100644 (file)
@@ -281,6 +281,9 @@ changes to languages because of Bugzilla reports.
   is transcluded.
 * (bug 62198) window.$j has been deprecated.
 * Preference "Disable link title conversion" was removed.
+* SpecialRecentChanges no longer includes any functionality for generating feeds
+  - it has been factored out to ApiFeedRecentChanges. Old URLs redirect to new
+  ones.
 
 ==== Removed classes ====
 * FakeMemCachedClient (deprecated in 1.18)
index 87dc95d..d4cc950 100644 (file)
@@ -273,6 +273,7 @@ $wgAutoloadLocalClasses = array(
        'ApiEmailUser' => 'includes/api/ApiEmailUser.php',
        'ApiExpandTemplates' => 'includes/api/ApiExpandTemplates.php',
        'ApiFeedContributions' => 'includes/api/ApiFeedContributions.php',
+       'ApiFeedRecentChanges' => 'includes/api/ApiFeedRecentChanges.php',
        'ApiFeedWatchlist' => 'includes/api/ApiFeedWatchlist.php',
        'ApiFileRevert' => 'includes/api/ApiFileRevert.php',
        'ApiFormatBase' => 'includes/api/ApiFormatBase.php',
index 0736c50..cf05782 100644 (file)
@@ -160,14 +160,28 @@ class ChangesFeed {
        }
 
        /**
-        * Generate the feed items given a row from the database.
+        * Generate the feed items given a row from the database, printing the feed.
         * @param $rows DatabaseBase resource with recentchanges rows
         * @param $feed Feed object
         */
        public static function generateFeed( $rows, &$feed ) {
                wfProfileIn( __METHOD__ );
-
+               $items = self::buildItems( $rows );
                $feed->outHeader();
+               foreach ( $items as $item ) {
+                       $feed->outItem( $item );
+               }
+               $feed->outFooter();
+               wfProfileOut( __METHOD__ );
+       }
+
+       /**
+        * Generate the feed items given a row from the database.
+        * @param $rows DatabaseBase resource with recentchanges rows
+        */
+       public static function buildItems( $rows ) {
+               wfProfileIn( __METHOD__ );
+               $items = array();
 
                # Merge adjacent edits by one user
                $sorted = array();
@@ -203,7 +217,7 @@ class ChangesFeed {
                                $url = $title->getFullURL();
                        }
 
-                       $item = new FeedItem(
+                       $items[] = new FeedItem(
                                $title->getPrefixedText(),
                                FeedUtils::formatDiff( $obj ),
                                $url,
@@ -212,10 +226,10 @@ class ChangesFeed {
                                        ? wfMessage( 'rev-deleted-user' )->escaped() : $obj->rc_user_text,
                                $talkpage
                        );
-                       $feed->outItem( $item );
                }
-               $feed->outFooter();
+
                wfProfileOut( __METHOD__ );
+               return $items;
        }
 
 }
diff --git a/includes/api/ApiFeedRecentChanges.php b/includes/api/ApiFeedRecentChanges.php
new file mode 100644 (file)
index 0000000..f1c1bf3
--- /dev/null
@@ -0,0 +1,203 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.23
+ */
+
+/**
+ * Recent changes feed.
+ *
+ * @ingroup API
+ */
+class ApiFeedRecentChanges extends ApiBase {
+
+       /**
+        * This module uses a custom feed wrapper printer.
+        *
+        * @return ApiFormatFeedWrapper
+        */
+       public function getCustomPrinter() {
+               return new ApiFormatFeedWrapper( $this->getMain() );
+       }
+
+       /**
+        * Format the rows (generated by SpecialRecentchanges or SpecialRecentchangeslinked)
+        * as an RSS/Atom feed.
+        */
+       public function execute() {
+               global $wgFeed, $wgFeedClasses;
+
+               $this->params = $this->extractRequestParams();
+
+               if ( !$wgFeed ) {
+                       $this->dieUsage( 'Syndication feeds are not available', 'feed-unavailable' );
+               }
+
+               if ( !isset( $wgFeedClasses[$this->params['feedformat']] ) ) {
+                       $this->dieUsage( 'Invalid subscription feed type', 'feed-invalid' );
+               }
+
+               $feedFormat = $this->params['feedformat'];
+               $specialClass = $this->params['target'] !== null
+                       ? 'SpecialRecentchangeslinked'
+                       : 'SpecialRecentchanges';
+
+               $formatter = $this->getFeedObject( $feedFormat, $specialClass );
+
+               // Everything is passed implicitly via $wgRequest… :(
+               // The row-getting functionality should maybe be factored out of ChangesListSpecialPage too…
+               $rc = new $specialClass();
+               $rows = $rc->getRows();
+
+               $feedItems = $rows ? ChangesFeed::buildItems( $rows ) : array();
+
+               ApiFormatFeedWrapper::setResult( $this->getResult(), $formatter, $feedItems );
+       }
+
+       /**
+        * Return a ChannelFeed object.
+        *
+        * @param string $feedFormat Feed's format (either 'rss' or 'atom')
+        * @param string $specialClass Relevant special page name (either 'SpecialRecentchanges' or
+        *     'SpecialRecentchangeslinked')
+        * @return ChannelFeed
+        */
+       public function getFeedObject( $feedFormat, $specialClass ) {
+               if ( $specialClass === 'SpecialRecentchangeslinked' ) {
+                       $title = Title::newFromText( $this->params['target'] );
+                       $feed = new ChangesFeed( $feedFormat, false );
+                       $feedObj = $feed->getFeedObject(
+                               $this->msg( 'recentchangeslinked-title', $title->getPrefixedText() )
+                                       ->inContentLanguage()->text(),
+                               $this->msg( 'recentchangeslinked-feed' )->inContentLanguage()->text(),
+                               SpecialPage::getTitleFor( 'Recentchangeslinked' )->getFullURL()
+                       );
+               } else {
+                       $feed = new ChangesFeed( $feedFormat, 'rcfeed' );
+                       $feedObj = $feed->getFeedObject(
+                               $this->msg( 'recentchanges' )->inContentLanguage()->text(),
+                               $this->msg( 'recentchanges-feed-description' )->inContentLanguage()->text(),
+                               SpecialPage::getTitleFor( 'Recentchanges' )->getFullURL()
+                       );
+               }
+
+               return $feedObj;
+       }
+
+       public function getAllowedParams() {
+               global $wgFeedClasses, $wgAllowCategorizedRecentChanges, $wgFeedLimit;
+               $feedFormatNames = array_keys( $wgFeedClasses );
+
+               $ret = array(
+                       'feedformat' => array(
+                               ApiBase::PARAM_DFLT => 'rss',
+                               ApiBase::PARAM_TYPE => $feedFormatNames,
+                       ),
+
+                       'namespace' => array(
+                               ApiBase::PARAM_TYPE => 'namespace',
+                       ),
+                       'invert' => false,
+                       'associated' => false,
+
+                       'days' => array(
+                               ApiBase::PARAM_DFLT => 7,
+                               ApiBase::PARAM_MIN => 1,
+                               ApiBase::PARAM_TYPE => 'integer',
+                       ),
+                       'limit' => array(
+                               ApiBase::PARAM_DFLT => 50,
+                               ApiBase::PARAM_MIN => 1,
+                               ApiBase::PARAM_MAX => $wgFeedLimit,
+                               ApiBase::PARAM_TYPE => 'integer',
+                       ),
+                       'from' => array(
+                               ApiBase::PARAM_TYPE => 'timestamp',
+                       ),
+
+                       'hideminor' => false,
+                       'hidebots' => false,
+                       'hideanons' => false,
+                       'hideliu' => false,
+                       'hidepatrolled' => false,
+                       'hidemyself' => false,
+
+                       'tagfilter' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                       ),
+
+                       'target' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                       ),
+                       'showlinkedto' => false,
+               );
+
+               if ( $wgAllowCategorizedRecentChanges ) {
+                       $ret += array(
+                               'categories' => array(
+                                       ApiBase::PARAM_TYPE => 'string',
+                                       ApiBase::PARAM_ISMULTI => true,
+                               ),
+                               'categories_any' => false,
+                       );
+               }
+
+               return $ret;
+       }
+
+       public function getParamDescription() {
+               return array(
+                       'feedformat' => 'The format of the feed',
+                       'namespace' => 'Namespace to limit the results to',
+                       'invert' => 'All namespaces but the selected one',
+                       'associated' => 'Include associated (talk or main) namespace',
+                       'days' => 'Days to limit the results to',
+                       'limit' => 'Maximum number of results to return',
+                       'from' => 'Show changes since then',
+                       'hideminor' => 'Hide minor changes',
+                       'hidebots' => 'Hide changes made by bots',
+                       'hideanons' => 'Hide changes made by anonymous users',
+                       'hideliu' => 'Hide changes made by registered users',
+                       'hidepatrolled' => 'Hide patrolled changes',
+                       'hidemyself' => 'Hide changes made by yourself',
+                       'tagfilter' => 'Filter by tag',
+                       'target' => 'Show only changes on pages linked from this page',
+                       'showlinkedto' => 'Show changes on pages linked to the selected page instead',
+                       'categories' => 'Show only changes on pages in all of these categories',
+                       'categories_any' => 'Show only changes on pages in any of the categories instead',
+               );
+       }
+
+       public function getDescription() {
+               return 'Returns a recent changes feed';
+       }
+
+       public function getPossibleErrors() {
+               return array_merge( parent::getPossibleErrors(), array(
+                       array( 'code' => 'feed-unavailable', 'info' => 'Syndication feeds are not available' ),
+                       array( 'code' => 'feed-invalid', 'info' => 'Invalid subscription feed type' ),
+               ) );
+       }
+
+       public function getExamples() {
+               return array(
+                       'api.php?action=feedrecentchanges',
+                       'api.php?action=feedrecentchanges&days=30'
+               );
+       }
+}
index 1a11b52..eb24a35 100644 (file)
@@ -56,6 +56,7 @@ class ApiMain extends ApiBase {
                'parse' => 'ApiParse',
                'opensearch' => 'ApiOpenSearch',
                'feedcontributions' => 'ApiFeedContributions',
+               'feedrecentchanges' => 'ApiFeedRecentChanges',
                'feedwatchlist' => 'ApiFeedWatchlist',
                'help' => 'ApiHelp',
                'paraminfo' => 'ApiParamInfo',
index 4e2556c..c08d033 100644 (file)
@@ -31,14 +31,6 @@ abstract class ChangesListSpecialPage extends SpecialPage {
        var $rcSubpage, $rcOptions; // @todo Rename these, make protected
        protected $customFilters;
 
-       /**
-        * The feed format to output as (either 'rss' or 'atom'), or null if no
-        * feed output was requested
-        *
-        * @var string $feedFormat
-        */
-       protected $feedFormat;
-
        /**
         * Main execution point
         *
@@ -46,19 +38,13 @@ abstract class ChangesListSpecialPage extends SpecialPage {
         */
        public function execute( $subpage ) {
                $this->rcSubpage = $subpage;
-               $this->feedFormat = $this->including() ? null : $this->getRequest()->getVal( 'feed' );
-               if ( $this->feedFormat !== 'atom' && $this->feedFormat !== 'rss' ) {
-                       $this->feedFormat = null;
-               }
 
                $this->setHeaders();
                $this->outputHeader();
                $this->addModules();
 
+               $rows = $this->getRows();
                $opts = $this->getOptions();
-               // Fetch results, prepare a batch link existence check query
-               $conds = $this->buildMainQueryConds( $opts );
-               $rows = $this->doMainQuery( $conds, $opts );
                if ( $rows === false ) {
                        if ( !$this->including() ) {
                                $this->doHeader( $opts );
@@ -67,26 +53,30 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                        return;
                }
 
-               if ( !$this->feedFormat ) {
-                       $batch = new LinkBatch;
-                       foreach ( $rows as $row ) {
-                               $batch->add( NS_USER, $row->rc_user_text );
-                               $batch->add( NS_USER_TALK, $row->rc_user_text );
-                               $batch->add( $row->rc_namespace, $row->rc_title );
-                       }
-                       $batch->execute();
-               }
-               if ( $this->feedFormat ) {
-                       list( $changesFeed, $formatter ) = $this->getFeedObject( $this->feedFormat );
-                       /** @var ChangesFeed $changesFeed */
-                       $changesFeed->execute( $formatter, $rows, $this->checkLastModified( $this->feedFormat ), $opts );
-               } else {
-                       $this->webOutput( $rows, $opts );
+               $batch = new LinkBatch;
+               foreach ( $rows as $row ) {
+                       $batch->add( NS_USER, $row->rc_user_text );
+                       $batch->add( NS_USER_TALK, $row->rc_user_text );
+                       $batch->add( $row->rc_namespace, $row->rc_title );
                }
+               $batch->execute();
+
+               $this->webOutput( $rows, $opts );
 
                $rows->free();
        }
 
+       /**
+        * Get the database result for this special page instance. Used by ApiFeedRecentChanges.
+        *
+        * @return bool|ResultWrapper Result or false
+        */
+       public function getRows() {
+               $opts = $this->getOptions();
+               $conds = $this->buildMainQueryConds( $opts );
+               return $this->doMainQuery( $conds, $opts );
+       }
+
        /**
         * Get the current FormOptions for this request
         *
@@ -461,30 +451,6 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                $out->addModules( 'mediawiki.special.changeslist.legend.js' );
        }
 
-       /**
-        * Return an array with a ChangesFeed object and ChannelFeed object.
-        *
-        * This is intentionally not abstract not to require subclasses which don't
-        * use feeds functionality to implement it.
-        *
-        * @param string $feedFormat Feed's format (either 'rss' or 'atom')
-        * @return array
-        */
-       public function getFeedObject( $feedFormat ) {
-               throw new MWException( "Not implemented" );
-       }
-
-       /**
-        * Get last-modified date, for client caching. Not implemented by default
-        * (returns current time).
-        *
-        * @param string $feedFormat
-        * @return string|bool
-        */
-       public function checkLastModified( $feedFormat ) {
-               return wfTimestampNow();
-       }
-
        protected function getGroupName() {
                return 'changes';
        }
index a5710a9..e05c8b8 100644 (file)
@@ -38,10 +38,19 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
         * @param string $subpage
         */
        public function execute( $subpage ) {
+               // Backwards-compatibility: redirect to new feed URLs
+               $feedFormat = $this->getRequest()->getVal( 'feed' );
+               if ( !$this->including() && $feedFormat ) {
+                       $query = $this->getFeedQuery();
+                       $query['feedformat'] = $feedFormat === 'atom' ? 'atom' : 'rss';
+                       $this->getOutput()->redirect( wfAppendQuery( wfScript( 'api' ), $query ) );
+                       return;
+               }
+
                // 10 seconds server-side caching max
                $this->getOutput()->setSquidMaxage( 10 );
                // Check if the client has a cached version
-               $lastmod = $this->checkLastModified( $this->feedFormat );
+               $lastmod = $this->checkLastModified();
                if ( $lastmod === false ) {
                        return;
                }
@@ -142,8 +151,7 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
        }
 
        public function validateOptions( FormOptions $opts ) {
-               global $wgFeedLimit;
-               $opts->validateIntBounds( 'limit', 0, $this->feedFormat ? $wgFeedLimit : 5000 );
+               $opts->validateIntBounds( 'limit', 0, 5000 );
                parent::validateOptions( $opts );
        }
 
@@ -244,16 +252,26 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                return $rows;
        }
 
+       public function outputFeedLinks() {
+               $this->addFeedLinks( $this->getFeedQuery() );
+       }
+
        /**
-        * Output feed links.
+        * Get URL query parameters for action=feedrecentchanges API feed of current recent changes view.
+        *
+        * @return array
         */
-       public function outputFeedLinks() {
-               $feedQuery = $this->getFeedQuery();
-               if ( $feedQuery !== '' ) {
-                       $this->getOutput()->setFeedAppendQuery( $feedQuery );
-               } else {
-                       $this->getOutput()->setFeedAppendQuery( false );
-               }
+       private function getFeedQuery() {
+               global $wgFeedLimit;
+               $query = array_filter( $this->getOptions()->getAllValues(), function ( $value ) {
+                       // API handles empty parameters in a different way
+                       return $value !== '';
+               } );
+               $query['action'] = 'feedrecentchanges';
+               if ( $query['limit'] > $wgFeedLimit ) {
+                       $query['limit'] = $wgFeedLimit;
+               }
+               return $query;
        }
 
        /**
@@ -465,64 +483,14 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
         * Don't use this if we are using the patrol feature, patrol changes don't
         * update the timestamp
         *
-        * @param string $feedFormat
         * @return string|bool
         */
-       public function checkLastModified( $feedFormat ) {
+       public function checkLastModified() {
                $dbr = $this->getDB();
                $lastmod = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', false, __METHOD__ );
-               if ( $feedFormat || !$this->getUser()->useRCPatrol() ) {
-                       if ( $lastmod && $this->getOutput()->checkLastModified( $lastmod ) ) {
-                               # Client cache fresh and headers sent, nothing more to do.
-                               return false;
-                       }
-               }
-
                return $lastmod;
        }
 
-       /**
-        * Return an array with a ChangesFeed object and ChannelFeed object.
-        *
-        * @param string $feedFormat Feed's format (either 'rss' or 'atom')
-        * @return array
-        */
-       public function getFeedObject( $feedFormat ) {
-               $changesFeed = new ChangesFeed( $feedFormat, 'rcfeed' );
-               $formatter = $changesFeed->getFeedObject(
-                       $this->msg( 'recentchanges' )->inContentLanguage()->text(),
-                       $this->msg( 'recentchanges-feed-description' )->inContentLanguage()->text(),
-                       $this->getPageTitle()->getFullURL()
-               );
-
-               return array( $changesFeed, $formatter );
-       }
-
-       /**
-        * Get the query string to append to feed link URLs.
-        *
-        * @return string
-        */
-       public function getFeedQuery() {
-               global $wgFeedLimit;
-
-               $options = $this->getOptions()->getChangedValues();
-
-               // wfArrayToCgi() omits options set to null or false
-               foreach ( $options as &$value ) {
-                       if ( $value === false ) {
-                               $value = '0';
-                       }
-               }
-               unset( $value );
-
-               if ( isset( $options['limit'] ) && $options['limit'] > $wgFeedLimit ) {
-                       $options['limit'] = $wgFeedLimit;
-               }
-
-               return wfArrayToCgi( $options );
-       }
-
        /**
         * Creates the choose namespace selection
         *
index 7cc8d30..34dd51f 100644 (file)
@@ -234,17 +234,6 @@ class SpecialRecentChangesLinked extends SpecialRecentChanges {
                return $extraOpts;
        }
 
-       public function getFeedObject( $feedFormat ) {
-               $feed = new ChangesFeed( $feedFormat, false );
-               $feedObj = $feed->getFeedObject(
-                       $this->msg( 'recentchangeslinked-title', $this->getTargetTitle()->getPrefixedText() )
-                               ->inContentLanguage()->text(),
-                       $this->msg( 'recentchangeslinked-feed' )->inContentLanguage()->text(),
-                       $this->getPageTitle()->getFullURL()
-               );
-               return array( $feed, $feedObj );
-       }
-
        /**
         * @return Title
         */