/*
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 SectionServiceModule(pb) {
/**
* Service for managing the site's navigation
* @class SectionService
* @constructor
* @param {object} options
* @param {String} options.site uid
* @param {Boolean} options.onlyThisSite should section service only return value set specifically by site rather than defaulting to global
*/
function SectionService(options) {
this.site = pb.SiteService.getCurrentSite(options.site) || pb.SiteService.GLOBAL_SITE;
this.onlyThisSite = options.onlyThisSite || false;
this.settings = pb.SettingServiceFactory.getServiceBySite(this.site, this.onlyThisSite);
this.siteQueryService = new pb.SiteQueryService({site: this.site, onlyThisSite: this.onlyThisSite});
}
/**
*
* @private
* @static
* @readonly
* @property VALID_TYPES
* @type {Object}
*/
var VALID_TYPES = {
container: true,
section: true,
article: true,
page: true,
link: true,
};
/**
*
* @static
* @method getPillNavOptions
* @param {String} activePill
* @return {Array}
*/
SectionService.getPillNavOptions = function(/*activePill*/) {
return [
{
name: 'new_nav_item',
title: '',
icon: 'plus',
href: '/admin/content/navigation/new'
}
];
};
/**
*
* @method removeFromSectionMap
* @param {Object} section
* @param {Array} [sectionMap]
* @param {Function} cb
*/
SectionService.prototype.removeFromSectionMap = function(section, sectionMap, cb) {
var self = this;
if (!cb) {
cb = sectionMap;
sectionMap = null;
}
//ensure we have an ID
if (util.isObject(section)) {
section = section[pb.DAO.getIdField()].toString();
}
//provide a function to abstract retrieval of map
var sectionMapWasNull = sectionMap ? false : true;
var getSectionMap = function (sectionMap, callback) {
if (util.isArray(sectionMap)) {
callback(null, sectionMap);
}
else {
self.settings.get('section_map', callback);
}
};
//retrieve map
getSectionMap(sectionMap, function(err, sectionMap) {
if (util.isError(err)) {
cb(err, false);
return;
}
else if (sectionMap === null) {
cb(new Error("The section map is null and therefore cannot have any sections removed", false));
return;
}
//update map
var orphans = self._removeFromSectionMap(section, sectionMap);
//when the section map was not provided persist it back
if (sectionMapWasNull) {
self.settings.set('section_map', sectionMap, function(err/*, result*/) {
cb(err, orphans);
});
}
else {
cb(null, orphans);
}
});
};
/**
*
* @private
* @method _removeFromSectionMap
* @param {String} sid
* @param {Array} sectionMap
*/
SectionService.prototype._removeFromSectionMap = function(sid, sectionMap) {
//inspect the top level
var orphans = [];
for (var i = sectionMap.length - 1; i >= 0; i--) {
var item = sectionMap[i];
if (item.uid === sid) {
sectionMap.splice(i, 1);
util.arrayPushAll(item.children, orphans);
}
else if (util.isArray(item.children)) {
for (var j = item.children.length - 1; j >= 0; j--) {
var child = item.children[j];
if (child.uid === sid) {
item.children.splice(j, 1);
}
}
}
}
return orphans;
};
/**
*
* @private
* @method getSectionMapIndex
* @param {String} sid
* @param {Array} sectionMap
* @return {Object}
*/
SectionService.prototype.getSectionMapIndex = function(sid, sectionMap) {
//inspect the top level
var result = {
index: -1,
childIndex: -1
};
for (var i = sectionMap.length - 1; i >= 0; i--) {
var item = sectionMap[i];
if (item.uid === sid) {
result.index = i;
}
else if (util.isArray(item.children)) {
for (var j = item.children.length - 1; j >= 0; j--) {
var child = item.children[j];
if (child.uid === sid) {
result.childIndex = j;
}
}
}
}
return result;
};
/**
*
* @method updateNavMap
* @param {Object} section
* @param {Function} cb
*/
SectionService.prototype.updateNavMap = function(section, cb) {
var self = this;
//do validation
if (!util.isObject(section) || !section[pb.DAO.getIdField()]) {
return cb(new Error("A valid section object must be provided", false));
}
//retrieve the section map
var sid = section[pb.DAO.getIdField()].toString();
self.settings.get('section_map', function(err, sectionMap) {
if (util.isError(err)) {
return cb(err, false);
}
//create it if not already done
var mapWasNull = sectionMap === null;
if(mapWasNull) {
sectionMap = [];
}
//check if the section already exist in sectionMap
var sectionIndex = self.getSectionMapIndex(sid, sectionMap);
//remove the section from the map
var orphans = self._removeFromSectionMap(sid, sectionMap);
//make a top level item if there is no parent or the map was originally
//empty (means its impossible for there to be a parent)
var navItem = {
uid: sid,
children: orphans
};
if (mapWasNull || !section.parent) {
//we are attaching the items back to a parent. There are no
//orphans to return in the callback.
orphans = [];
if (sectionIndex.index > -1) {
sectionMap.splice(sectionIndex.index, 0, navItem);
}
else {
sectionMap.push(navItem);
}
}
else {//set as child of parent in map
//we only support two levels so ensure we drop any children
navItem.children = undefined;
for (var i = 0; i < sectionMap.length; i++) {
if (sectionMap[i].uid === section.parent) {
if (sectionIndex.childIndex > -1) {
sectionMap[i].children.splice(sectionIndex.childIndex, 0, navItem);
}
else {
sectionMap[i].children.push(navItem);
}
break;
}
}
}
self.settings.set('section_map', sectionMap, function(err, settingSaveResult){
if (util.isError(err)){
return cb(err);
}
else if (!settingSaveResult) {
return cb(new Error('Failed to persist cached navigation map'));
}
cb(null, orphans);
});
});
};
/**
*
* @method deleteChildren
* @param {String} parentId
* @param {Function} cb
*/
SectionService.prototype.deleteChildren = function(parentId, cb) {
var where = {
parent: ''+parentId
};
var dao = new pb.DAO();
dao.delete(where, 'section', cb);
};
/**
*
* @method getFormattedSections
* @param {Localization} localizationService
* @param {String} [currUrl]
* @param {Function} cb
*/
SectionService.prototype.getFormattedSections = function(localizationService, currUrl, cb) {
var self = this;
if (util.isFunction(currUrl)) {
cb = currUrl;
currUrl = null;
}
self.settings.get('section_map', function(err, sectionMap) {
if (util.isError(err) || sectionMap === null) {
cb(err, []);
return;
}
//retrieve sections
self.siteQueryService.q('section', function(err, sections) {
if (util.isError(err)) {
return cb(err, []);
}
var formattedSections = [];
for(var i = 0; i < sectionMap.length; i++) {
var section = SectionService.getSectionData(sectionMap[i].uid, sections, currUrl);
if (util.isNullOrUndefined(section)) {
pb.log.error('SectionService: The navigation map is out of sync. Root [%s] could not be found for site [%s].', sectionMap[i].uid, self.site);
continue;
}
if(sectionMap[i].children.length === 0) {
formattedSections.push(section);
}
else {
if(section) {
section.dropdown = 'dropdown';
section.children = [];
for(var j = 0; j < sectionMap[i].children.length; j++) {
var child = SectionService.getSectionData(sectionMap[i].children[j].uid, sections, currUrl);
if (util.isNullOrUndefined(child)) {
pb.log.error('SectionService: The navigation map is out of sync. Child [%s] could not be found for site [%s].', sectionMap[i].children[j].uid, self.site);
continue;
}
//when the child is active so is the parent.
if (child.active) {
section.active = true;
}
section.children.push(child);
}
formattedSections.push(section);
}
}
}
cb(null, formattedSections);
});
});
};
/**
*
* @method getParentSelectList
* @param {String|ObjectID} currItem
* @param {Function} cb
*/
SectionService.prototype.getParentSelectList = function(currItem, cb) {
cb = cb || currItem;
var where = {
type: 'container'
};
if (currItem && !util.isFunction(currItem)) {
where[pb.DAO.getIdField()] = pb.DAO.getNotIdField(currItem);
}
var opts = {
select: {
_id: 1,
name: 1
},
where: where,
order: ['name', pb.DAO.ASC]
};
this.siteQueryService.q('section', opts, cb);
};
/**
*
* @static
* @method trimForType
* @param {Object} navItem
*/
SectionService.trimForType = function(navItem) {
if (navItem.type === 'container') {
navItem.parent = null;
navItem.url = null;
navItem.editor = null;
navItem.item = null;
navItem.link = null;
navItem.new_tab = null;
}
else if (navItem.type === 'section') {
navItem.item = null;
navItem.link = null;
navItem.new_tab = null;
}
else if (navItem.type === 'article' || navItem.type === 'page') {
navItem.link = null;
navItem.url = null;
navItem.editor = null;
navItem.new_tab = null;
}
else if (navItem.type === 'link') {
navItem.editor = null;
navItem.url = null;
navItem.item = null;
}
};
/**
*
* @method validate
* @param {Object} navItem
* @param {Function} cb
*/
SectionService.prototype.validate = function(navItem, cb) {
var self = this;
var errors = [];
if (!util.isObject(navItem)) {
errors.push({field: '', message: 'A valid navigation item must be provided'});
cb(null, errors);
return;
}
//verify type
if (!SectionService.isValidType(navItem.type)) {
errors.push({field: 'type', message: 'An invalid type ['+navItem.type+'] was provided'});
cb(null, errors);
return;
}
//name
this.validateNavItemName(navItem, function(err, validationError) {
if (util.isError(err)) {
cb(err, errors);
return;
}
if (validationError) {
errors.push(validationError);
}
//description
if (!pb.validation.isNonEmptyStr(navItem.name, true)) {
errors.push({field: 'name', message: 'An invalid name ['+navItem.name+'] was provided'});
}
//compile all errors and call back
var onDone = function(err, validationErrors) {
util.arrayPushAll(validationErrors, errors);
cb(err, errors);
};
//validate for each type of nav item
switch(navItem.type) {
case 'container':
onDone(null, []);
break;
case 'section':
self.validateSectionNavItem(navItem, onDone);
break;
case 'article':
case 'page':
self.validateContentNavItem(navItem, onDone);
break;
case 'link':
self.validateLinkNavItem(navItem, onDone);
break;
default:
throw new Error("An invalid nav item type made it through!");
}
});
};
/**
*
* @method validateLinkNavItem
* @param {Object} navItem
* @param {Function} cb
*/
SectionService.prototype.validateLinkNavItem = function(navItem, cb) {
var errors = [];
if (!pb.validation.isUrl(navItem.link, true) && navItem.link.charAt(0) !== '/') {
errors.push({field: 'link', message: 'A valid link is required'});
}
process.nextTick(function() {
cb(null, errors);
});
};
/**
*
* @method validateNavItemName
* @param {Object} navItem
* @param {Function} cb
*/
SectionService.prototype.validateNavItemName = function(navItem, cb) {
if (!pb.validation.isNonEmptyStr(navItem.name, true) || navItem.name === 'admin') {
cb(null, {field: 'name', message: 'An invalid name ['+navItem.name+'] was provided'});
return;
}
var where = {
name: navItem.name
};
this.siteQueryService.unique('section', where, navItem[pb.DAO.getIdField()], function(err, unique) {
var error = null;
if (!unique) {
error = {field: 'name', message: 'The provided name is not unique'};
}
cb(err, error);
});
};
/**
*
* @method validateContentNavItem
* @param {Object} navItem
* @param {Function} cb
*/
SectionService.prototype.validateContentNavItem = function(navItem, cb) {
var self = this;
var errors = [];
var tasks = [
//parent
function(callback) {
self.validateNavItemParent(navItem.parent, function(err, validationError) {
if (validationError) {
errors.push(validationError);
}
callback(err, null);
});
},
//content
function(callback) {
self.validateNavItemContent(navItem.type, navItem.item, function(err, validationError) {
if (validationError) {
errors.push(validationError);
}
callback(err, null);
});
}
];
async.series(tasks, function(err/*, results*/) {
cb(err, errors);
});
};
/**
*
* @method validateSectionNavItem
* @param {Object} navItem
* @param {Function} cb
*/
SectionService.prototype.validateSectionNavItem = function(navItem, cb) {
var self = this;
var errors = [];
var tasks = [
//url
function(callback) {
var params = {
type: 'section',
id: navItem[pb.DAO.getIdField()],
url: navItem.url,
site: self.site
};
var urlService = new pb.UrlService();
urlService.existsForType(params, function(err, exists) {
if (exists) {
errors.push({field: 'url', message: 'The url key ['+navItem.url+'] already exists'});
}
callback(err, null);
});
},
//parent
function(callback) {
self.validateNavItemParent(navItem.parent, function(err, validationError) {
if (validationError) {
errors.push(validationError);
}
callback(err, null);
});
},
//editor
function(callback) {
self.validateNavItemEditor(navItem.editor, function(err, validationError) {
if (validationError) {
errors.push(validationError);
}
callback(err, null);
});
}
];
async.series(tasks, function(err/*, results*/) {
cb(err, errors);
});
};
/**
*
* @method validateNavItemParent
* @param {String} parent
* @param {Function} cb
*/
SectionService.prototype.validateNavItemParent = function(parent, cb) {
var error = null;
if (!pb.validation.isNonEmptyStr(parent, false)) {
error = {field: 'parent', message: 'The parent must be a valid nav item container ID'};
cb(null, error);
}
else if (parent) {
//ensure parent exists
var where = pb.DAO.getIdWhere(parent);
where.type = 'container';
var dao = new pb.DAO();
dao.count('section', where, function(err, count) {
if (count !== 1) {
error = {field: 'parent', message: 'The parent is not valid'};
}
cb(err, error);
});
}
else {
cb(null, null);
}
};
/**
*
* @method validateNavItemContent
* @param {String} type
* @param {String} content
* @param {Function} cb
*/
SectionService.prototype.validateNavItemContent = function(type, content, cb) {
var error = null;
if (!pb.validation.isNonEmptyStr(content, true)) {
error = {field: 'item', message: 'The content must be a valid ID'};
cb(null, error);
return;
}
//ensure content exists
var where = pb.DAO.getIdWhere(content);
var dao = new pb.DAO();
dao.count(type, where, function(err, count) {
if (count !== 1) {
error = {field: 'item', message: 'The content is not valid'};
}
cb(err, error);
});
};
/**
*
* @method validateNavItemEditor
* @param {String} editor
* @param {Function} cb
*/
SectionService.prototype.validateNavItemEditor = function(editor, cb) {
var error = null;
if (!pb.validation.isNonEmptyStr(editor, true)) {
error = {field: 'editor', message: 'The editor must be a valid user ID'};
cb(null, error);
return;
}
var service = new pb.UserService();
service.hasAccessLevel(editor, pb.SecurityService.ACCESS_EDITOR, function(err, hasAccess) {
if (!hasAccess) {
error = {field: 'editor', message: 'The editor is not valid'};
}
cb(err, error);
});
};
/**
*
* @method save
* @param {Object} navItem
* @param {Object} [options]
* @param {Function} cb
*/
SectionService.prototype.save = function(navItem, options, cb) {
if (util.isFunction(options)) {
cb = options;
options = {};
}
//validate
var self = this;
self.validate(navItem, function(err, validationErrors) {
if (util.isError(err)) {
return cb(err);
}
else if (validationErrors.length > 0) {
return cb(null, validationErrors);
}
//persist the changes
self.siteQueryService.save(navItem, function(err, data) {
if(util.isError(err)) {
return cb(err);
}
//update the navigation map
self.updateNavMap(navItem, function(err, orphans) {
if (util.isError(err)) {
return cb(err);
}
else if (orphans.length === 0) {
//we kept the children so there is nothing to do
return cb(null, true);
}
//ok, now we can delete the orhphans if they exist
self.deleteChildren(navItem[pb.DAO.getIdField()], cb);
});
});
});
};
/**
*
* @static
* @method getSectionData
* @param {String} uid
* @param {Object} navItems
* @param {String} currUrl
*/
SectionService.getSectionData = function(uid, navItems, currUrl) {
for(var i = 0; i < navItems.length; i++) {
var navItem = navItems[i];
if(navItem[pb.DAO.getIdField()].toString() === uid) {
SectionService.formatUrl(navItem);
//check for URL comparison
if (currUrl === navItem.url) {
navItem.active = true;
}
return navItem;
}
}
return null;
};
/**
*
* @static
* @method formatUrl
* @param {Object} navItem
*/
SectionService.formatUrl = function(navItem) {
if (util.isString(navItem.link)) {
navItem.url = navItem.link;
}
else if(navItem.url)
{
navItem.url = pb.UrlService.urlJoin('/section', navItem.url);
}
else if (navItem.type === 'article') {
navItem.url = pb.UrlService.urlJoin('/article', navItem.item);
}
else if (navItem.type === 'page') {
navItem.url = pb.UrlService.urlJoin('/page', navItem.item);
}
else {
navItem.url = '#' + (navItem.name || '');
}
};
/**
* @static
* @method
* @param {Localization} ls
* @return {Array}
*/
SectionService.getTypes = function(ls) {
if (!ls) {
ls = new pb.Localization();
}
return [
{
value: "container",
label: ls.g('generic.CONTAINER')
},
{
value: "section",
label: ls.g('generic.SECTION')
},
{
value: "article",
label: ls.g('generic.ARTICLE')
},
{
value: "page",
label: ls.g('generic.PAGE')
},
{
value: "link",
label: ls.g('generic.LINK')
}
];
};
/**
* @static
* @method isValidType
* @param {String|Object} type
* @return {Boolean}
*/
SectionService.isValidType = function(type) {
if (util.isObject(type)) {
type = type.type;
}
return VALID_TYPES[type] === true;
};
//exports
return SectionService;
};