API Docs for: 0.8.0
Show:

File: include/service/entities/article_service.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/>.
 */
'use strict';

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

module.exports = function(pb) {

    /**
     * Retrieves articles and pages
     * @deprecated
     * @class ArticleService
     * @constructor
     * @param {string} siteUid
     * @param {boolean} onlyThisSite
     */
    function ArticleService(siteUid, onlyThisSite){
        this.object_type = ARTICLE_TYPE;
        this.site = pb.SiteService.getCurrentSite(siteUid);
        this.onlyThisSite = onlyThisSite;
        this.siteQueryService = new pb.SiteQueryService({site: this.site, onlyThisSite: onlyThisSite});
    }

    /**
     *
     * @private
     * @static
     * @readonly
     * @property ARTICLE_TYPE
     * @type {String}
     */
    var ARTICLE_TYPE = 'article';

    /**
     *
     * @private
     * @static
     * @readonly
     * @property PAGE_TYPE
     * @type {String}
     */
    var PAGE_TYPE = 'page';

    /**
     * Retrieves the content type
     *
     * @method getContentType
     * @return {String} Content type
     */
    ArticleService.prototype.getContentType = function() {
        return this.object_type;
    };

    /**
     * Sets the content type (article, page)
     *
     * @method setContentType
     * @param {String} type The content type
     */
    ArticleService.prototype.setContentType = function(type) {
        this.object_type = type;
    };

    /**
     * Finds an article or page by Id
     *
     * @method findById
     * @param {String}   articleId The article's object Id
     * @param {Function} cb        Callback function
     */
    ArticleService.prototype.findById = function(articleId, cb) {
        this.find(pb.DAO.getIdWhere(articleId), cb);
    };

    /**
     * Finds articles by section
     *
     * @method findBySection
     * @param {String}   sectionId The section's object Id
     * @param {Function} cb        Callback function
     */
    ArticleService.prototype.findBySection = function(sectionId, cb) {
        this.find({article_sections: sectionId}, cb);
    };

    /**
     * Finds articles and pages by topic
     *
     * @method findByTopic
     * @param {[type]}   topicId The topic's object Id
     * @param {Function} cb      Callback function
     */
    ArticleService.prototype.findByTopic = function(topicId, cb) {
        this.find({article_topics: topicId}, cb);
    };

    /**
     * Finds articles and pages matching criteria
     *
     * @method find
     * @param {Object} where Defines the where clause for the article search
     * @param {Object} [options] The options object that can provide query control
     * parameters
     * @param {Array} [options.order] The order that the results will be returned
     * in.  The default is publish date descending and created descending
     * @param {Object} [options.select] The fields that will be returned for each
     * article that matches the criteria
     * @param {Integer} [options.limit] The number of results to return
     * @param {Integer} [options.offset] The number of articles to skip before
     * returning results
     * @param {Function} cb Callback function that takes two parameters: the first
     * is an error, if occurred.  The second is an array of articles or possibly
     * null if an error occurs.
     */
    ArticleService.prototype.find = function(where, options, cb) {
        if (util.isFunction(options)) {
            cb      = options;
            options = {};
        }
        else if (!options) {
            options = {};
        }

        //verify the where is valid
        if (!util.isObject(where)) {
            return cb(new Error('The where clause must be an object'));
        }

        //build out query
        if(!where.publish_date) {
            where.publish_date = {$lt: new Date()};
        }
        if(!where.draft) {
            where.draft = {$ne: 1};
        }

        //build out the ordering
        var order;
        if (options.order) {
            order = options.order;
        }
        else {
            order = [['publish_date', pb.DAO.DESC], ['created', pb.DAO.DESC]];
        }

        //build out select
        var select;
        if (util.isObject(options.select)) {
            select = options.select;
        }
        else {
            select = pb.DAO.PROJECT_ALL;
        }

        //build out the limit (must be a valid integer)
        var limit;
        if (pb.validation.isInt(options.limit, true, true)) {
            limit = options.limit;
        }

        //build out the limit (must be a valid integer)
        var offset = 0;
        if (pb.validation.isInt(options.offset, true, true)) {
            offset = options.offset;
        }

        var self = this;
        self.siteQueryService.q(this.getContentType(), {where: where, select: select, order: order, limit: limit, offset: offset}, function(err, articles) {
            if (util.isError(err)) {
                return cb(err, []);
            }
            else if (articles.length === 0) {
                return cb(null, []);
            }

            //get authors
            self.getArticleAuthors(articles, function(err, authors) {

                var contentService = new pb.ContentService({site: self.site});
                contentService.getSettings(function(err, contentSettings) {

                    var tasks = util.getTasks(articles, function(articles, i) {
                        return function(callback) {
                            self.processArticleForDisplay(articles[i], articles.length, authors, contentSettings, options, function(){
                                callback(null, null);
                            });
                        };
                    });
                    async.series(tasks, function(err/*, results*/) {
                        cb(err, articles);
                    });
                });
            });
        });

    };

    /**
     * Updates articles
     * @method update
     * @param {String} articleId	id of article
     * @param {Object} fields	fields to update
     * @param {Object} options
     * @param {Function} cb      Callback function
     */
    ArticleService.prototype.update = function(articleId, fields, options, cb) {
        if(!util.isObject(fields)){
            return cb(new Error('The fields parameter is required'));
        }

        var where = pb.DAO.getIdWhere(articleId);
        var content_type = this.getContentType();

        var dao  = new pb.DAO();
        dao.updateFields(content_type, where, fields, options, cb);
    };

    /**
     * Retrieves data necessary for displaying an articles and appends it to the
     * article object
     *
     * @method processArticleForDisplay
     * @param {Object}   article         The artice to process
     * @param {Number}   articleCount    The total number of articles
     * @param {Array}    authors         Available authors retrieved from the database
     * @param {Object}   contentSettings Content settings to use for processing
     * @param {object} [options]
     * @param {Function} cb              Callback function
     */
    ArticleService.prototype.processArticleForDisplay = function(article, articleCount, authors, contentSettings, options, cb) {
        if (util.isFunction(options)) {
            cb = options;
            options = cb;
        }

        var self = this;

        if (this.getContentType() === ARTICLE_TYPE) {
            if(contentSettings.display_bylines) {

                for(var j = 0; j < authors.length; j++) {

                    if(pb.DAO.areIdsEqual(authors[j][pb.DAO.getIdField()], article.author)) {
                        if(authors[j].photo && contentSettings.display_author_photo) {
                            article.author_photo     = authors[j].photo;
                            article.media_body_style = '';
                        }

                        article.author_name     = pb.users.getFormattedName(authors[j]);
                        article.author_position = (authors[j].position && contentSettings.display_author_position) ? authors[j].position : '';
                        break;
                    }
                }
            }

            if(contentSettings.display_timestamp ) {
                article.timestamp = pb.ContentService.getTimestampTextFromSettings(
                  article.publish_date,
                  contentSettings
                );
            }

            if(article.article_layout.indexOf('^read_more^') > -1) {
                if(articleCount > 1) {
                    article.article_layout = article.article_layout.substr(0, article.article_layout.indexOf('^read_more^')) + ' <a href="' + pb.config.siteRoot + '/article/' + article.url + '">' + contentSettings.read_more_text + '...</a>';
                }
                else {
                    article.article_layout = article.article_layout.split('^read_more^').join('');
                }
            }
            else if(articleCount > 1 && contentSettings.auto_break_articles) {
                var breakString = '<br>';
                var tempLayout;

                // Firefox uses br and Chrome uses div in content editables.
                // We need to see which one is being used
                var brIndex = article.article_layout.indexOf('<br>');
                if(brIndex === -1) {
                    brIndex = article.article_layout.indexOf('<br />');
                    breakString = '<br />';
                }
                var divIndex = article.article_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 = article.article_layout.split(breakString + breakString).join(breakString + '^dbl_pgf_break^');
                }
                else {
                    breakString = '</div>';
                    tempLayout = article.article_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 article 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] + '&nbsp;<a href="' + pb.config.siteRoot + '/article/' + article.url + '">' + contentSettings.read_more_text + '...</a>' + 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);

                    article.article_layout = newLayout;
                }
            }
        }

        article.layout  = article.article_layout;
        var mediaLoader = new MediaLoader({site: self.site, onlyThisSite: self.onlyThisSite});
        mediaLoader.start(article[this.getContentType()+'_layout'], options, function(err, newLayout) {
            article.layout = newLayout;
            delete article.article_layout;

            if (self.getContentType() === ARTICLE_TYPE && ArticleService.allowComments(contentSettings, article)) {

                var opts = {
                    where: {
                        article: article[pb.DAO.getIdField()].toString()
                    },
                    order: [['created', pb.DAO.ASC]]
                };
                var dao   = new pb.DAO();
                dao.q('comment', opts, function(err, comments) {
                    if(util.isError(err) || comments.length === 0) {
                        return cb(null, null);
                    }

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

    /**
     * Retrieves the authors of an array of articles
     *
     * @method getArticleAuthors
     * @param {Array}    articles Array of article objects
     * @param {Function} cb       Callback function
     */
    ArticleService.prototype.getArticleAuthors = function(articles, cb) {

        //gather all author IDs
        var dao = new pb.DAO();
        dao.q('user', {where: pb.DAO.getIdInWhere(articles, 'author')}, cb);
    };

    /**
     * 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
     */
    ArticleService.prototype.getCommenters = function(comments, contentSettings, cb) {

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

            if (commenter) {
                comment.commenter_name = pb.users.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);
        });
    };

    /**
     * Retrieves the article and byline templates
     *
     * @method getTemplates
     * @param {Object} [opts]
     * @param {Function} cb Callback function
     */
    ArticleService.prototype.getTemplates = function(opts, cb) {
        if (util.isFunction(opts)) {
            cb = opts;
            opts = {};
        }

        var ts = new pb.TemplateService(opts);
        ts.load('elements/article', function(err, articleTemplate) {
            ts.load('elements/article/byline', function(err, bylineTemplate) {
                cb(articleTemplate, bylineTemplate);
            });
        });
    };

    /**
     * Retrieves the SEO metadata for the specified content.
     * @method getMetaInfo
     * @param {Object} article The article to retrieve information for
     * @param {Function} cb A callback that takes two parameters.  The first is
     * an Error, if occurred.  The second is an object that contains 4
     * properties:
     * title - the SEO title,
     * description - the SEO description,
     * keywords - an array of SEO keywords that describe the content,
     * thumbnail - a URI path to the thumbnail image
     */
    ArticleService.prototype.getMetaInfo = function(article, cb) {
        var serviceV2 = new pb.ArticleServiceV2({site: this.site, onlyThisSite: this.onlyThisSite});
        serviceV2.getMetaInfo(article, cb);
    };

    /**
     * Retrieves the meta info for an article or page
     * @deprecated Since 0.4.1
     * @method getMetaInfo
     * @param {Object}   article An article or page object
     * @param {Function} cb      Callback function
     */
    ArticleService.getMetaInfo = function(article, cb) {
        if(util.isNullOrUndefined(article)) {
            return cb('', '', '');
        }

        //log deprecation
        pb.log.warn('ArticleService: ArticleService.getMetaInfo is deprecated and will be removed 0.5.0.  Use the prototype function getMetaInfo instead');

        //all wrapped up to ensure we can be multi-threaded here for backwards
        //compatibility
        (function(cb) {

            var articleService = new ArticleService();
            articleService.getMetaInfo(article, function(err, meta) {
                if (util.isError(err)) {
                    return cb('', '', '');
                }

                cb(meta.keywords, meta.description, meta.title, meta.thumbnail);
            });
        })(cb);
    };

    /**
     * Provided the content descriptor and the content settings object the
     * function indicates if comments should be allowed within the given
     * context of the content.
     * @method allowComments
     * @param {Object} contentSettings The settings object retrieved from the
     * content service
     * @param {Object} content The page or article that should or should not
     * have associated comments.
     * @return {Boolean}
     */
    ArticleService.allowComments = function(contentSettings, content) {
        return contentSettings.allow_comments && content.allow_comments;
    };

    /**
     * Handles retrieval and injection of media in articles and pages
     *
     * @module Services
     * @class MediaLoader
     * @constructor
     * @submodule Entities
     */
    function MediaLoader(opts) {
        /**
         * @property mediaService
         * @type {MediaService}
         */
        this.service = new pb.MediaServiceV2(opts);
    }

    /**
     * Processes an article or page to insert media
     * @method start
     * @param  {String} articleLayout The HTML layout of the article or page
     * @param {object} [options]
     * @param  {Function} cb
     */
    MediaLoader.prototype.start = function(articleLayout, options, cb) {
        if (util.isFunction(options)) {
            cb = options;
            options = {};
        }
        if (!util.isObject(options.media)) {
            options.media = {};
        }

        //scan for media that should be retrieved
        var flags = this.scanForFlags(articleLayout);
        if (flags.length === 0) {
            return cb(null, articleLayout);
        }

        //reconcile what media is already cached and that which should be loaded
        var idsToRetrieve = [];
        flags.forEach(function(flag) {
            if (!options.media[flag.id]) {
                idsToRetrieve.push(flag.id);
            }
        });

        //when all media is already cached just do the processing
        if (idsToRetrieve.length === 0) {
            return this.onMediaAvailable(articleLayout, options, cb);
        }

        //retrieve the media that we need
        var self = this;
        var opts = {
            where: idsToRetrieve.length === 1 ? pb.DAO.getIdWhere(idsToRetrieve[0]) : pb.DAO.getIdInWhere(idsToRetrieve)
        };
        this.service.getAll(opts, function(err, media) {
            if (util.isError(err)) {
                return cb(err);
            }

            //cache the retrieved media
            var idField = pb.DAO.getIdField();
            media.forEach(function(mediaItem) {
                options.media[mediaItem[idField].toString()] = mediaItem;
            });

            self.onMediaAvailable(articleLayout, options, cb);
        });
    };

    /**
     * @method onMediaAvailable
     * @param {String} articleLayout
     * @param {Object} options
     * @param {Function} cb
     */
    MediaLoader.prototype.onMediaAvailable = function(articleLayout, options, cb) {
        var self = this;

        this.getMediaTemplate(options, function(err, mediaTemplate) {
            options.mediaTemplate = mediaTemplate;

            async.whilst(
              function() {return articleLayout.indexOf('^media_display_') >= 0;},
              function(callback) {
                  self.replaceMediaTag(articleLayout, mediaTemplate, options.media, function(err, newArticleLayout) {
                      articleLayout = newArticleLayout;
                      callback();
                  });
              },
              function(err) {
                  cb(err, articleLayout);
              }
            );
        });
    };

    /**
     * Retrieves the media template for rendering media
     * @method getMediaTemplate
     * @param {Object} options
     * @param {string} [options.mediaTemplatePath='elements/media']
     * @param {Function} cb
     */
    MediaLoader.prototype.getMediaTemplate = function(options, cb) {
        if (options.mediaTemplate) {
            return cb(null, options.mediaTemplate);
        }

        var ts = new pb.TemplateService(options);
        ts.load(options.mediaTemplatePath || 'elements/media', cb);
    };

    /**
     * Scans a string for media flags then parses them to return an array of
     * each one that was found
     * @method scanForFlags
     * @param {String} layout
     * @return {Array}
     */
    MediaLoader.prototype.scanForFlags = function(layout) {
        if (!util.isString(layout)) {
            return [];
        }

        var index;
        var flags = [];
        while( (index = layout.indexOf('^media_display_')) >= 0) {
            flags.push(pb.MediaServiceV2.extractNextMediaFlag(layout));

            var nexPosition = layout.indexOf('^', index + 1);
            layout = layout.substr(nexPosition);
        }
        return flags;
    };

    /**
     * Replaces an article or page layout's ^media_display^ tag with a media embed
     * @method replaceMediaTag
     * @param {String}   layout        The HTML layout of the article or page
     * @param {String}   mediaTemplate The template of the media embed
     * @param {object} mediaCache
     * @param {Function} cb            Callback function
     */
    MediaLoader.prototype.replaceMediaTag = function(layout, mediaTemplate, mediaCache, cb) {
        var flag = pb.MediaServiceV2.extractNextMediaFlag(layout);
        if (!flag) {
            return cb(null, layout);
        }

        var data = mediaCache[flag.id];
        if (!data) {
            pb.log.warn("MediaLoader: Content contains reference to missing media [%s].", flag.id);
            return cb(null, layout.replace(flag.flag, ''));
        }

        //ensure the max height is set if explicity set for media replacement
        var options = {
            view: 'post',
            style: {},
            attrs: {
                frameborder: "0",
                allowfullscreen: ""
            }
        };
        if (flag.style.maxHeight) {
            options.style['max-height'] = flag.style.maxHeight;
        }
        this.service.render(data, options, function(err, html) {
            if (util.isError(err)) {
                return cb(err);
            }

            //get the style for the container
            var containerStyleStr = pb.MediaServiceV2.getStyleForPosition(flag.style.position) || '';

            //finish up replacements
            var mediaEmbed = mediaTemplate.split('^media^').join(html);
            mediaEmbed     = mediaEmbed.split('^caption^').join(data.caption);
            mediaEmbed     = mediaEmbed.split('^display_caption^').join(data.caption ? '' : 'display: none');
            mediaEmbed     = mediaEmbed.split('^container_style^').join(containerStyleStr);
            cb(null, layout.replace(flag.flag, mediaEmbed));
        });
    };

    //exports
    return {
        ArticleService: ArticleService,
        MediaLoader: MediaLoader
    };
};