API Docs for: 0.8.0
Show:

File: include/service/entities/content/article_renderer.js

/*
 Copyright (C) 2016  PencilBlue, LLC

 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 3 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, see <http://www.gnu.org/licenses/>.
 */

//dependencies
var util  = require('../../../util.js');
var async = require('async');

module.exports = function(pb) {

    //pb dependencies
    var DAO = pb.DAO;

    /**
     * Retrieves the necessary data as well as prepares the layout so a view
     * loader can complete the render of content
     * @class ArticleRenderer
     * @constructor
     * @param {Object} context
     * @param {String} context.hostname
     * @param {String} context.site
     * @param {Boolean} context.onlyThisSite
     * @param {UserService} [context.userService]
     */
    function ArticleRenderer(context) {
        if (context) {

            /**
             * @property commentService
             * @type {CommentService}
             */
            this.commentService = new pb.CommentService(context);

            /**
             * @property hostname
             * @type {string}
             */
            this.hostname = context.hostname;
        }

        /**
         * @property site
         * @type {string}
         */
        this.site = pb.SiteService.getCurrentSite(context.site);

        /**
         * @property onlyThisSite
         * @type {boolean}
         */
        this.onlyThisSite = context.onlyThisSite;

        /**
         * Instance of user service.  No context is provided and therefore can only be used
         * @property userService
         * @type {UserService}
         */
        this.userService = context.userService || new pb.UserService(context);
    }

    /**
     * @private
     * @static
     * @readonly
     * @property READ_MORE_FLAG
     * @type {String}
     */
    var READ_MORE_FLAG = '^read_more^';

    /**
     * @private
     * @static
     * @readonly
     * @property ANONYMOUS_COMMENTER
     * @type {String}
     */
    var ANONYMOUS_COMMENTER = 'Anonymous';

    /**
     * @method render
     * @param {Object} content
     * @param {Object} context
     * @param {Object} [context.authors] A hash of user objects representing the
     * authors of the content to be rendered
     * @param {Object} context.contentSettings The content settings
     * @param {Integer} context.contentCount An integer representing the total
     * number of content objects that will be processed for this request
     * @param {Boolean} [context.renderBylines=true]
     * @param {Boolean} [context.renderTimestamp=true]
     * @param {Boolean} [context.renderComments=true]
     * @param {Boolean} [context.readMore=false]
     * @param {Function} cb
     */
    ArticleRenderer.prototype.render = function(content, context, cb) {
        if (!util.isObject(content)) {
            return cb(new Error('content parameter must be a valid object'));
        }
        else if (!util.isObject(context)) {
            return cb(new Error('context parameter must be a valid object'));
        }
        else if (!util.isObject(context.authors) && context.renderBylines !== false) {
            return cb(new Error('context.authors parameter must be a valid hash of users when the bylines will be rendered'));
        }
        else if (!util.isObject(context.contentSettings)) {
            return cb(new Error('context.contentSettings parameter must be a valid hash of content settings'));
        }
        else if (isNaN(context.contentCount)) {
            return cb(new Error('context.contentCount parameter must be a valid integer greater than 0'));
        }

        if (context.renderBylines !== false) {
            this.formatBylines(content, context);
        }
        if (context.renderTimestamp !== false) {
            this.formatTimestamp(content, context);
        }
        this.formatLayout(content, context);

        //build out task list
        var tasks = [
            util.wrapTask(this, this.formatMediaReferences, [content, context])
        ];

        //render comments unless explicitly asked not too
        if (context.renderComments !== false) {
            tasks.push(util.wrapTask(this, this.formatComments, [content, context]));
        }

        //run the tasks
        async.parallel(tasks, function(err/*, results*/) {
            cb(err, content);
        });
    };

    /**
     * @method formatBylines
     * @param {Object} content
     * @param {Object} context
     */
    ArticleRenderer.prototype.formatBylines = function(content, context) {

        var author = context.authors[content.author];
        if (util.isNullOrUndefined(author)) {
            pb.log.warn('ArticleRenderer: Failed to find author [%s] for content [%s]', content.author, content[DAO.getIdField()]);
            return;
        }

        var contentSettings = context.contentSettings;
        if(author.photo && contentSettings.display_author_photo) {
            content.author_photo     = author.photo;
            content.media_body_style = '';
        }

        content.author_name = this.userService.getFormattedName(author);
        content.author_position = '';
        if (author.position && contentSettings.display_author_position) {
            content.author_position = author.position;
        }
    };

    /**
     * @method formatTimestamp
     * @param {Object} content
     * @param {Object} context
     */
    ArticleRenderer.prototype.formatTimestamp = function(content, context) {
        if(context.contentSettings.display_timestamp ) {
            content.timestamp = pb.ContentService.getTimestampTextFromSettings(
              content.publish_date,
              context.contentSettings
            );
        }
    };

    /**
     * @method formatLayout
     * @param {Object} content
     * @param {Object} context
     */
    ArticleRenderer.prototype.formatLayout = function(content, context) {
        var contentSettings = context.contentSettings;

        if(this.containsReadMoreFlag(content)) {
            this.formatLayoutForReadMore(content, context);
        }
        else if(context.readMore && contentSettings.auto_break_articles) {
            this.formatAutoBreaks(content, context);
        }
    };

    /**
     * @method formatMediaReferences
     * @param {Object} content
     * @param {Object} context
     * @param {Function} cb
     */
    ArticleRenderer.prototype.formatMediaReferences = function(content, context, cb) {
        var self = this;

        content.layout  = this.getLayout(content);
        var mediaLoader = new pb.MediaLoader({site: self.site, onlyThisSite: self.onlyThisSite});
        mediaLoader.start(content.layout, function(err, newLayout) {
            content.layout = newLayout;
            self.setLayout(content, undefined);
            cb(err);
        });
    };

    /**
     * @method formatComments
     * @param {Object} content
     * @param {Object} context
     * @param {Function} cb
     */
    ArticleRenderer.prototype.formatComments = function(content, context, cb) {
        var self = this;
        if (!pb.ArticleService.allowComments(context.contentSettings, content)) {
            return cb(null);
        }

        var opts = {
            where: {
                article: content[pb.DAO.getIdField()] + ''
            },
            order: [['created', pb.DAO.ASC]]
        };
        this.commentService.getAll(opts, function(err, comments) {
            if(util.isError(err) || comments.length === 0) {
                return cb(err);
            }

            self.getCommenters(comments, context.contentSettings, function(err, commentsWithCommenters) {
                content.comments = commentsWithCommenters;
                cb(null, null);
            });
        });
    };

    /**
     * Retrieves the commenters for an array of comments
     *
     * @method getCommenters
     * @param {Array} comments Array of comment objects
     * @param {Object} contentSettings Content settings to use for processing
     * @param {Function} cb Callback function
     */
    ArticleRenderer.prototype.getCommenters = function(comments, contentSettings, cb) {
        var self = this;

        //callback for iteration to handle setting the commenter attributes
        var processComment = function(comment, commenter) {
            comment.commenter_name = ANONYMOUS_COMMENTER;
            comment.timestamp      = pb.ContentService.getTimestampTextFromSettings(comment.created, contentSettings);

            if (commenter) {
                comment.commenter_name = self.userService.getFormattedName(commenter);
                if(commenter.photo) {
                    comment.commenter_photo = commenter.photo;
                }
                if(commenter.position) {
                    comment.commenter_position = commenter.position;
                }
            }
        };

        var processedComments = [];
        var users             = {};
        var tasks             = util.getTasks(comments, function(comments, i) {
            return function(callback) {

                var comment   = comments[i];
                if (!comment.commenter || users[comment.commenter]) {

                    //user already commented so pull locally
                    processComment(comment, users[comment.commenter]);
                    processedComments.push(comment);
                    callback(null, true);
                    return;
                }

                //user has not already commented so load
                var dao = new pb.DAO();
                dao.loadById(comment.commenter, 'user', function(err, commenter) {
                    if(util.isError(err) || commenter === null) {
                        callback(null, false);
                        return;
                    }

                    //process the comment
                    users[commenter[pb.DAO.getIdField()].toString()] = commenter;
                    processComment(comment, commenter);
                    processedComments.push(comment);

                    callback(null, true);
                });
            };
        });
        async.series(tasks, function(err, result) {
            cb(err, processedComments);
        });
    };

    /**
     * @method formatAutoBreak
     * @param {Object} content
     * @param {Object} context
     */
    ArticleRenderer.prototype.formatAutoBreaks = function(content, context) {
        var contentSettings = context.contentSettings;
        var breakString = '<br>';
        var tempLayout;
        var layout = this.getLayout(content);

        // Firefox uses br and Chrome uses div in content editables.
        // We need to see which one is being used
        var brIndex = layout.indexOf('<br>');
        if(brIndex === -1) {
            brIndex = layout.indexOf('<br />');
            breakString = '<br />';
        }
        var divIndex = layout.indexOf('</div>');

        // Temporarily replace double breaks with a directive so we don't mess up the count
        if(divIndex === -1 || (brIndex > -1 && divIndex > -1 && brIndex < divIndex)) {
            tempLayout = layout.split(breakString + breakString).join(breakString + '^dbl_pgf_break^');
        }
        else {
            breakString = '</div>';
            tempLayout = layout.split('<div><br></div>').join(breakString + '^dbl_pgf_break^')
              .split('<div><br /></div>').join(breakString + '^dbl_pgf_break^');
        }

        // Split the layout by paragraphs and remove any empty indices
        var tempLayoutArray = tempLayout.split(breakString);
        for(var i = 0; i < tempLayoutArray.length; i++) {
            if(!tempLayoutArray[i].length) {
                tempLayoutArray.splice(i, 1);
                i--;
            }
        }

        // Only continue if we have more than 1 paragraph
        if(tempLayoutArray.length > 1) {
            var newLayout = '';

            // Cutoff the content at the right number of paragraphs
            for(i = 0; i < tempLayoutArray.length && i < contentSettings.auto_break_articles; i++) {
                if(i === contentSettings.auto_break_articles - 1 && i !== tempLayoutArray.length - 1) {

                    newLayout += tempLayoutArray[i] + this.getReadMoreSpan(content, contentSettings.read_more_text) + breakString;
                    continue;
                }
                newLayout += tempLayoutArray[i] + breakString;
            }

            if(breakString === '</div>') {
                breakString = '<div><br /></div>';
            }

            // Replace the double breaks
            newLayout = newLayout.split('^dbl_pgf_break^').join(breakString);

            this.setLayout(content, newLayout);
        }
    };

    /**
     * @method formatLayoutForReadMore
     * @param {Object} content
     * @param {Object} context
     * @param {boolean} context.readMore
     */
    ArticleRenderer.prototype.formatLayoutForReadMore = function(content, context) {
        var layout = this.getLayout(content);

        if(context.readMore) {
            var beforeReadMore = layout.substr(0, layout.indexOf(READ_MORE_FLAG));
            layout = beforeReadMore + this.getReadMoreSpan(content, context.contentSettings.read_more_text);
        }
        else {
            layout = layout.split(READ_MORE_FLAG).join('');
        }
        this.setLayout(content, layout);
    };

    /**
     *
     * @method getReadMoreSpan
     * @param {Object} content
     * @param {String} anchorContent
     * @return {String}
     */
    ArticleRenderer.prototype.getReadMoreSpan = function(content, anchorContent) {
        return '&nbsp;<span class="read_more">' + this.getReadMoreLink(content, anchorContent) + '</span>';
    };

    /**
     * @method getReadMoreLink
     * @param {Object} content
     * @param {String} anchorContent
     * @return {String}
     */
    ArticleRenderer.prototype.getReadMoreLink = function(content, anchorContent) {

        var path = pb.UrlService.urlJoin(this.getContentLinkPrefix() + content.url);
        return '<a href="' + pb.UrlService.createSystemUrl(path, { hostname: this.hostname }) + '">' + anchorContent + '</a>';
    };

    /**
     * @method getContentLinkPrefix
     * @return {String}
     */
    ArticleRenderer.prototype.getContentLinkPrefix = function() {
        return '/article/';
    };

    /**
     * Retrieves the layout from the content object. Provides a mechanism to
     * allow for layout parameter to have any name.
     * @method getLayout
     * @param {Object} content
     * @return {String}
     */
    ArticleRenderer.prototype.getLayout = function(content) {
        return content.article_layout;
    };

    /**
     * A workaround to allow this prototype to operate on articles and pages.
     * The layout parameter is not the same.  Until we introduce breaking
     * changes this will have to do.
     * @method setLayout
     * @param {Object} content
     * @param {String} layout
     */
    ArticleRenderer.prototype.setLayout = function(content, layout) {
        content.article_layout = layout;
    };

    /**
     * @method containsReadMoreFlag
     * @param {Object} content
     * @return {Boolean}
     */
    ArticleRenderer.prototype.containsReadMoreFlag = function(content) {
        if (!util.isObject(content)) {
            throw new Error('The content parameter must be an object');
        }
        return this.getLayout(content).indexOf(READ_MORE_FLAG) > -1;
    };

    return ArticleRenderer;
};