Cacomania: Build a feed reader with the Google feed API and AngularJS

Cacomania

Build a feed reader with the Google feed API and AngularJS

Guido Krömer - 4. July 2013 - Tags: ,

Originally I wrote a feed reader using AngularJS with a back end based on the Slim Framework and the SimplePie feed parser, but during writing the app it became more and more complex since I exceeded the point where an app fits into one or two tutorials. Therefore, the app discussed here is a radical stripped down version of the originally developed feed reader using the Google Feed API instead. The subscribed feed get saved using the JavaScript localStorage API.

Using the Google feed API with ngResource

The API provides JSONP besides JSON and XML as return format, ngResource supports JSONP as a method likes GET, POST... . The API is very clear and has just two functions, one returns the URL to a sites feed by the given site URL and the second function returns the feed items by the given feed URL. Both REST functions are wrapped into a module which has two factories returning a configured resource for the task.

angular.module('caco.MiniRSS.googleapis.feed', ['ngResource'])
    .factory('FeedLoad', function ($resource) {
        return $resource('http://ajax.googleapis.com/ajax/services/feed/load', {}, {
            fetch: { method: 'JSONP', params: {v: '1.0', callback: 'JSON_CALLBACK'} }
        });
    })
    .factory('UrlLookup', function ($resource) {
        return $resource('http://ajax.googleapis.com/ajax/services/feed/lookup', {}, {
            fetch: { method: 'JSONP', params: {v: '1.0', callback: 'JSON_CALLBACK'} }
        });
    });

Storing the feeds into the Browsers localStorage

Since the app has not a RESTful API for storing the subscribed feeds, the localStorage API is used for saving the feeds, the disadvantage to this solution is that the feeds are only saved on the local machine's Browser.

The localStorage is a simple key/value store and does not support saving objects or arrays, so all objects has to be encoded to JSON before saving and decoded when a value gets fetched. I wrapped the en- and decoding into a service called LocalObjectStorage which performs this tasks.

angular.module('caco.LocalObjectStorage', [])
    .service('LocalObjectStorage', function () {
        this.getObject = function (key) {
            return JSON.parse(localStorage.getItem(key))
        };

        this.setObject = function (key, value) {
            localStorage.setItem(key, JSON.stringify(value));
        };

        this.removeObject = function (key) {
            localStorage.removeItem(key);
        };

        this.contains = function (key) {
            return localStorage.getItem(key) ? true : false;
        };
    });

The layout

Before I continue writing about managing the subscribed feeds, I think some words about the page layout are needed for a better understanding. The layout is a classic two columns layout with a list of the subscribed feeds on the left and all other content, which is assigned to the ng-view directive, on the right. Two views displayed in the right column showing a feed management interface for adding and deleting a feed besides the views for displaying a feed or one particular feed item. Each column has it own controller the left column has a simple one which loads the list of subscribed feeds and the right column uses the controller specified in the routes configuration. Changes on the feeds list should be in sync between the two controllers. A broadcast is used when the list is updated, the controllers are listening on the event of the broadcast to update the feed list which keeps the feeds list in both columns in sync.

The screen shot below show the app displaying a management view, every change performed in the right column is updated in the left feed list, too.
AngularJS RSS feed reader, manage view, layout.

The persistent feed list service

This service manages the feed list and persists it, with the use of the LocalObjectStorage service. A modification of the feed list broadcasts a FeedList event, which gives the controllers the opportunity to refresh the list if it has been changed outside the controllers scope. I was not sure if using a broadcast is the best way to archive this. A question about this topic on Stack Overflow gave me no better idea so it seems to be okay, using a broadcast here.

The service has the three main functionalities, adding a feed, deleting a feed and getting the whole feed list. Besides those three functions there a two helpers for getting the minimal feed id and one for retrieving a single feed by the given id.

The get() method checks if a list exists in the localStorage otherwise it returns a new array containing a default subscribed feed. The add() and delete(id) method works quite similar, they are loading the current feed list using the get() method and performing their action on the array with push() or slice(), after that the modified array gets stored using the setObject() method. After a modification an event named "FeedList" gets fired using a $broadcast on the $rootScope.

angular.module('caco.MiniRSS.FeedList', ['caco.LocalObjectStorage'])
    .service('FeedList', function ($rootScope, LocalObjectStorage) {
        this.add = function (url, title) {
            var list = this.get();
            var id = localStorage.getItem('FeedListId') ? localStorage.getItem('FeedListId') : 1;

            list.push({
                url:    url,
                title:  title,
                id: id
            });

            LocalObjectStorage.setObject('FeedList',  list);
            localStorage.setItem('FeedListId', ++id);
            $rootScope.$broadcast('FeedList', list);
        };

        this.delete = function (id) {
            var list = this.get();

            for (var i = list.length - 1; i >= 0; i--) {
                if (list[i].id == id) {
                    list.splice(i, 1);
                }
            }

            LocalObjectStorage.setObject('FeedList', list);
            $rootScope.$broadcast('FeedList', list);
        };

        this.get = function () {
            if (LocalObjectStorage.contains('FeedList')) {
                return LocalObjectStorage.getObject('FeedList');
            }

            return new Array({
                url: 'http://cacodaemon.de/index.php?rss=1',
                title: 'Cacomania',
                id: 0
            });
        };

        this.getById = function(id) {
            var list = this.get();

            for (var i = list.length - 1; i >= 0; i--) {
                if (list[i].id == id) {
                    return list[i];
                }
            }

            return null;
        };

        this.getMinId = function () {
            var list = this.get();
            var minId = Number.MAX_VALUE;

            for (var i = list.length - 1; i >= 0; i--) {
                minId = Math.min(minId, list[i].id);
            }

            return minId;
        };
    });

The route configuration

The feed reader has just five routes, one default showing the feed with the lowest id, a route showing a specific feed by its id, a route for displaying a particular feed item and two routes for the management views. The Google API does not provide an unique hash or id determining a feed item, instead of this an item is specified by its feed id and a hash generated from the item title.

angular.module('caco.MiniRSS', ['caco.MiniRSS.FeedList', 'caco.MiniRSS.googleapis.feed'])
    .config(function ($routeProvider) {
        $routeProvider
            .when('/',                      {templateUrl: 'views/list.html',        controller: 'ItemsCtrl'})
            .when('/feed/:id',              {templateUrl: 'views/list.html',        controller: 'ItemsCtrl'})
            .when('/feed/:id/item/:hashKey',{templateUrl: 'views/item.html',        controller: 'ItemCrtl'})
            .when('/manage',                {templateUrl: 'views/manage/list.html', controller: 'FeedManage'})
            .when('/manage/add',            {templateUrl: 'views/manage/add.html',  controller: 'FeedManage'})
    });

The controllers

Let's start with the FeedListCtrl, which is the simplest one. This controller loads the list of feeds displayed in left column. On startup it loads the list using the FeedList's service get() method and listens on the "FeedList" which could get broad casted by the service on a modification of the list. The event listeners second argument contains the new feed list, a call of the get() method after the event has fired is needless here.

angular.module('caco.MiniRSS')
    .controller('FeedListCtrl', function ($scope, FeedList) {
        $scope.feeds = FeedList.get();

        $scope.$on('FeedList', function (event, data) {
            $scope.feeds = data;
        });
    });

The other controllers are bound to a route and gets discussed below.

The FeedManage controller

The following controller holds the functionality for manipulating the subscribed feeds, the core functionality gets delegated to the FeedList service. Among this main functionality looking up for a feed URL by the given site URL is performed by the lookup() method. The Google feed API supports a feed lookup by a site URL which has been encapsulated in the caco.MiniRSS.googleapis.feed module's UrlLookup factory. Since the feed look up does not return the feed title, on a successfully URL lookup, the feed has to be loaded for fetching the title, too. The API response error handling is a little bit inconsistent, occasionally a data.responseStatus which should be equals 200 can be used for determining if a URL has been found, but from time to time the response status is 200 and no URL is given, this is why I was forced to bloat the checks a little bit.

angular.module('caco.MiniRSS')
    .controller('FeedManage', function ($scope, $location, UrlLookup, FeedLoad, FeedList) {
        $scope.feeds = FeedList.get();

        $scope.add = function () {
            FeedList.add($scope.feed.url, $scope.feed.title);
            $location.path('/manage');
        };

        $scope.lookup = function () {
            UrlLookup.fetch({q: $scope.lookup.url}, {}, function (data) {
                if (data.responseStatus != 200 || (data.responseData && data.responseData.url == '')) {
                    alert(data.responseDetails || 'Feed not found!');
                    return;
                }

                $scope.feed = data.responseData;
                FeedLoad.fetch({q: data.responseData.url}, {}, function (data) { //lookup title
                    if (data.responseStatus != 200) {
                        return;
                    }

                    $scope.feed.title = data.responseData.feed.title;
                });
            });
        };

        $scope.delete = function (id) {
            FeedList.delete(id);
        };

        $scope.$on('FeedList', function (event, data) {
            $scope.feeds = data;
        });
    });

Looking up a feed URL could look like this one below:
AngularJS feed reader, management view - add a new feed including lookup.

The FeedList controller

Two routes are leading to the ItemsCtrl, the first is the default on without an id and the second one specifies the feed by it's id. For loading a feed the FeedList's service getById() method can be used for fetching the feed from the localStorage by the given id, if no id has been set the feed with the lowest id get loaded. The Google feed API only needs the URL for loading a feed, passed as parameter q, the num param limit's the number of items to load. The API result gets assigned to the controller's scope.

angular.module('caco.MiniRSS')
    .controller('ItemsCtrl', function ($scope, $routeParams, FeedList, FeedLoad) {
        var feed = FeedList.getById($routeParams.id || FeedList.getMinId())

        FeedLoad.fetch({q: feed.url, num: 50}, {}, function (data) {
            $scope.feed = data.responseData.feed;
            $scope.feed.id = feed.id;
        });
    });

The ItemCrtl controller

Since a feed item has no id, a hash gets generated from the item title, this hash is used to determine which item has to be assigned to the current scope. There is a filter using a hash function which does the job in the view. The API does not support loading a single item, the whole feed has to be loaded and the matching item has to be searched by building the hash of each title.

angular.module('caco.MiniRSS')
    .controller('ItemCrtl', function ($scope, $routeParams, FeedList, FeedLoad, HashString) {
        var feed = FeedList.getById($routeParams.id)

        FeedLoad.fetch({q: feed.url, num: 50}, {}, function (data) {
            $scope.feed = data.responseData.feed;
            $scope.id = $routeParams.id;
            var entries = data.responseData.feed.entries;

            for (var i = entries.length - 1; i >= 0; i--) {
                if (HashString.perform(entries[i].title) == $routeParams.hashKey) {
                    $scope.item = entries[i];
                }
            }
        });
    });

Hashing a string

The hash function I found here generates a hash likes the Java String.hashCode() method. By converting the function into an service it can be injected into a controller or filter.

angular.module('caco.MiniRSS')
.service('HashString', function () {
    this.perform = function(str){
        var hash = 0;
        if (str.length == 0) {
            return hash;
        }

        for (var i = str.length - 1; i >= 0; i--) {
            char = str.charCodeAt(i);
            hash = ((hash<<5)-hash)+char;
            hash = hash & hash;
        }

        return hash;
    };
});

Some view filter

There are two filters needed one generates the hash for the given item title and the other converts the publication date, the hash filter uses the HashString service described above.

angular.module('caco.MiniRSS')
    .filter('hash', function (HashString) {
        return function (value) {
            return HashString.perform(value);
        };
    });

The second filter localizes the publication date. It seems that the JavaScript Date class works well with pubDate format provided by the API, this keeps the filter below really simple.

angular.module('caco.MiniRSS')
    .filter('rssDate', function () {
        return function (value) {
            return new Date(value).toLocaleString();
        };
    });

The rssDate filter used in a view:

<td>
    {{item.publishedDate | rssDate}}
</td>

Displaying HTML content in a view

The last more or less interesting part is how to display html content in a view. The answer is really simple instead of putting the model content into double braces the ng-bind-html-unsafe attribute directive can be used like in the view below to display the item content:

<div class="well">
    <h1><a href="#/feed/{{id}}">{{feed.title}}</a></h1>
    <h2><a href="{{item.link}}">{{item.title}}</a></h2>
    <p ng-bind-html-unsafe="item.content"></p>
</div>

Some Last Words

I hope you enjoyed my petty tutorial, the whole code is available at GitHub and a working demo is located here. Feel free to leave a comment if you liked or disliked my posting.