/*
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 ' <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;
};