apostrophe-pages
Version:
Adds trees of pages to the Apostrophe content management system
1,371 lines (1,265 loc) • 86.4 kB
JavaScript
/*jshint undef:true */
/*jshint node:true */
var async = require('async');
var _ = require('lodash');
var extend = require('extend');
var path = require('path');
RegExp.quote = require('regexp-quote');
module.exports = function(options, callback) {
return new pages(options, callback);
};
function pages(options, callback) {
var apos = options.apos;
var app = options.app;
var self = this;
var aposPages = this;
self._action = '/apos-pages';
self._apos = apos;
// Usage: app.get('*', pages.serve({ typePath: __dirname + '/views/pages' }))
//
// If you use this global wildcard route, make it your LAST route,
// as otherwise it overrides everything else.
//
// If you want to mount your pages as a "subdirectory:"
//
// app.get('/pages/*', pages.serve({ ... }))
//
// You can use other route patterns, as long as req.params[0] contains the
// page slug.
//
// self.serve will automatically prepend a / to the slug if
// req.params[0] does not contain one.
//
// The page object is passed to the Nunjucks type as `page`.
//
// If you want to also load all areas on the "global" page, for instance
// to fetch shared headers and footers used across a site, supply a
// `load` callback:
//
// app.get('/pages/*', pages.serve({ load: [ 'global' ] }, ...))
//
// The page with the slug `global` then becomes visible to the Nunjucks
// type as `global`. Note that it may not exist yet, in which case
// `global` is not set. Your type code must allow for this.
//
// You can include functions in the load: array. If you do so, those
// functions are invoked as callbacks, and receive 'req' as their first
// parameter. They should add additional page objects as properties of the
// req.extras object, then invoke the callback they receive as their
// second parameter with null, or with an error if they have failed in a
// way that should result in a 500 error. All such extra pages are made
// visible to Nunjucks. For instance, if you load req.extras.department,
// then a variable named department containing that page is visible to Nunjucks.
//
// It is is also acceptable to pass a single function rather than an
// array as the `load` property.
//
// The type name used to render the page is taken from
// the type property of the req.page object. You will need to set the
// directory from which page type templates are loaded:
//
// app.get('*', pages.serve({ typePath: __dirname + '/views/pages' })
//
// You can also override individual type paths. Any paths you don't
// override continue to respect typePath. Note that you are still
// specifying a folder's path, which must contain a nunjucks type
// named home.html to render a page with that type property:
//
// app.get('*', pages.serve({ ..., typePaths: { home: __dirname + '/views/pages' } })
//
// In the event the page slug requested is not found, the notfound type
// is rendered. You can override the notfound type path like any other.
//
// Loaders can access the page loaded by `page.serve` as `req.page`. This will
// be null if no page slug matched the URL exactly. However, if there is a page
// that matches a leading portion of the URL when followed by `/`, that page
// is also made available as `req.bestPage`. In this case the remainder of the
// URL after the slug of the best page is returned as `req.remainder`. If more
// than one page partially matches the URL the longest match is provided.
//
// Loaders can thus implement multiple-page experiences of almost any complexity
// by paying attention to `req.remainder` and choosing to set `req.template` to
// something that suits their purposes. If `req.template` is set by a loader it is
// used instead of the original type of the page to select a template. Usually this
// process begins by examining `req.bestPage.type` to determine whether it is suitable
// for this treatment (a blog page, for example, might need to implement virtual
// subpages for articles in this way).
//
// Loaders can also set req.page to req.bestPage, and should do so when electing
// to accept a partial match, because this makes the page available to templates.
//
// Page type templates will want to render areas, passing along the slug and the
// edit permission flag:
//
// {{ aposArea({ slug: slug + ':main', area: page.main, edit: edit }) }}
//
// {{ aposArea({ slug: 'global:footer', area: global.footer, edit: edit }) }}
//
// You can access all properties of the page via the 'page' object. Any pages
// added to extras by `load` callbacks are also visible, like `global` above.
//
// If you want to create pages dynamically when nonexistent page slugs are visited,
// you can supply a notfound handler:
//
// // Just create an empty page object like a wiki would
// app.get('*', pages.serve({
// notfound: function(req, callback) {
// req.page = { };
// callback(null);
// }
// });
//
// If you do not set req.page the normal page-not-found behavior is applied.
// If you do not supply a type property, 'default' is assumed.
//
// A JSON interface is also built in for each page: if you add
// ?pageInformation=json to the URL, a JSON description of the page
// is returned, including any information added to the page object
// by loader functions. This is available only to users with
// editing permissions.
self.serve = function(options) {
if(!options) {
options = {};
}
_.defaults(options, {
root: ''
});
return function(req, res) {
// we can use the __ function here, since we're in a request
var __ = res.__;
// let's push translations for the page types for this specific request
// the reason we do it here, as opposed to a global push is because
// we can't know which language the user wants until the request is served
var pageTypesLocaleStrings = {};
_.each(self.types, function(type){
pageTypesLocaleStrings[type.label] = __(type.label);
if(type.pluralLabel)
pageTypesLocaleStrings[type.pluralLabel] = __(type.pluralLabel);
if(type.instanceLabel)
pageTypesLocaleStrings[type.instanceLabel] = __(type.instanceLabel);
});
apos.pushLocaleStrings(pageTypesLocaleStrings, req);
function time(fn, name) {
return function(callback) {
req.traceIn(name);
return fn(function(err) {
req.traceOut();
return callback(err);
});
};
}
function timeSync(fn, name) {
req.traceIn(name);
fn();
req.traceOut();
}
// Let's defer various types of widget joins
// until the last possible minute for all
// content loaded as part of this request, so
// we can do it with one efficient query
// per type instead of many queries
req.deferredLoads = {};
req.deferredLoaders = {};
// Express doesn't provide the absolute URL the user asked for by default.
// TODO: move this to middleware for even more general availability in Apostrophe.
// See: https://github.com/visionmedia/express/issues/1377
if (!req.absoluteUrl) {
req.absoluteUrl = req.protocol + '://' + req.get('Host') + apos.prefix + req.url;
}
// allow earlier middleware to start populating req.extras if
// it wants to
req.extras = req.extras || {};
req.traceIn('TOTAL');
return async.series([time(page, 'page'), time(secondChanceLogin, 'secondChanceLogin'), time(relatives, 'relatives'), time(load, 'load'), time(notfound, 'notfound'), time(executeDeferredLoads, 'deferred loads')], main);
function page(callback) {
// Get content for this page
req.slug = req.params[0];
// Fix common screwups in URLs: leading/trailing whitespace,
// presence of trailing slashes (but always restore the
// leading slash). Express leaves escape codes uninterpreted
// in the path, so look for %20, not ' '.
req.slug = req.slug.trim();
req.slug = req.slug.replace(/\/+$/, '');
if ((!req.slug.length) || (req.slug.charAt(0) !== '/')) {
req.slug = '/' + req.slug;
}
// Had to change the URL, so redirect to it. TODO: this
// contains an assumption that we are mounted at /
if (req.slug !== req.params[0]) {
return res.redirect(req.slug);
}
apos.getPage(req, req.slug, function(e, page, bestPage, remainder) {
if (e) {
return callback(e);
}
// Set on exact slug matches only
// "What if there is no page?" We'll note that later
// and send the 404 type. We still want to load all
// the global stuff first
req.page = page;
// Set on partial slug matches followed by a / and on
// exact matches as well
req.bestPage = bestPage;
// Set to the empty string on exact matches, otherwise
// to the portion of the URL after the slug of req.bestPage. Note
// that any trailing / has already been removed. A leading
// / is always present, even if the page is the home page.
req.remainder = remainder;
if (req.bestPage) {
req.bestPage.url = self._apos.slugToUrl(req.bestPage.slug);
}
return callback(null);
});
}
function secondChanceLogin(callback) {
if (!options.secondChanceLogin) {
return callback(null);
}
if (req.user) {
return callback(null);
}
if (req.page) {
return callback(null);
}
// Try again with admin privs. If we get a better page,
// note the URL in the session and redirect to login.
return apos.getPage(apos.getTaskReq(), req.slug, { fields: { slug: 1 } }, function(e, page, bestPage, remainder) {
if (e) {
return callback(e);
}
if (page || (bestPage && req.bestPage && req.bestPage.slug < bestPage.slug)) {
res.cookie('aposAfterLogin', req.url);
return res.redirect('/login');
}
return callback(null);
});
}
function relatives(callback) {
if(!req.bestPage) {
return callback(null);
}
async.series({
ancestors: time(function(callback) {
// ancestors are always fetched. You need 'em
// for tabs, you need 'em for breadcrumb, you
// need 'em for the admin UI. You just need 'em.
var ancestorOptions = options.ancestorOptions ? _.cloneDeep(options.ancestorOptions) : {};
if (!ancestorOptions.childrenOptions) {
ancestorOptions.childrenOptions = {};
}
ancestorOptions.childrenOptions.orphan = false;
return self.getAncestors(req, req.bestPage, options.ancestorCriteria || {}, ancestorOptions || {}, function(err, ancestors) {
req.bestPage.ancestors = ancestors;
if (ancestors.length) {
// Also set parent as a convenience
req.bestPage.parent = req.bestPage.ancestors.slice(-1)[0];
}
return callback(err);
});
}, 'ancestors'),
peers: time(function(callback) {
if (options.peers || true) {
var ancestors = req.bestPage.ancestors;
if (!ancestors.length) {
// The only peer of the homepage is itself.
//
// Avoid a circular reference that crashes
// extend() later when we try to pass the homepage
// as the .permalink option to a loader. This
// happens if the homepage is a blog.
var selfAsPeer = {};
extend(true, selfAsPeer, req.bestPage);
req.bestPage.peers = [ selfAsPeer ];
return callback(null);
}
var parent = ancestors[ancestors.length - 1];
var peerOptions = options.peerOptions ? _.cloneDeep(options.peerOptions) : {};
peerOptions.orphan = false;
self.getDescendants(req, parent, peerOptions, function(err, pages) {
req.bestPage.peers = pages;
return callback(err);
});
} else {
return callback(null);
}
}, 'peers'),
descendants: time(function(callback) {
if (options.descendants || true) {
var descendantOptions = options.descendantOptions ? _.cloneDeep(options.descendantOptions) : {};
descendantOptions.orphan = false;
return self.getDescendants(req, req.bestPage, options.descendantCriteria || {}, descendantOptions, function(err, children) {
req.bestPage.children = children;
return callback(err);
});
} else {
return callback(null);
}
}, 'descendants'),
tabs: time(function(callback) {
if (options.tabs || true) {
var tabOptions = options.tabOptions ? _.cloneDeep(options.tabOptions) : {};
tabOptions.orphan = false;
self.getDescendants(req, req.bestPage.ancestors[0] ? req.bestPage.ancestors[0] : req.bestPage, options.tabCriteria || {}, tabOptions, function(err, pages) {
req.bestPage.tabs = pages;
return callback(err);
});
} else {
return callback(null);
}
}, 'tabs')
}, callback);
}
function load(callback) {
// Get any shared pages like global footers, also
// invoke load callbacks if needed
var loadList = options.load ? options.load : [];
// Be tolerant if they pass just one function
if (typeof(loadList) === 'function') {
loadList = [ loadList ];
}
// Turn any slugs into callbacks to fetch those slugs.
// This is a little lazy: if we turn out to need multiple
// pages of shared stuff we could coalesce them into a
// single mongo query. However we typically don't, or
// we're loading some of them only in certain situations.
// So let's not prematurely optimize
loadList = loadList.map(function(item) {
if (typeof(item) !== 'function') {
return function(callback) {
// Hardcoded slugs of virtual pages to be loaded for every user every time
// imply we're not concerned with permissions. Avoiding them saves us the
// hassle of precreating pages like "global" just to set published: true etc.
apos.getPage(req, item, { permissions: false }, function(err, page) {
if (err) {
return callback(err);
}
// The new syntax for aposArea() requires a more convincing fake page!
// Populate slug and permissions correctly
req.extras[item] = page ? page : { slug: item };
if (!page && req.user && req.user.permissions.admin) {
req.extras[item]._edit = true;
}
return callback(null);
});
};
} else {
// Already a callback, now wrap it in a function that can
// see the req variable
return function(callback) {
return item(req, callback);
};
}
});
// series lets later modules' loaders see the results of earlier ones
return async.series(loadList, callback);
}
function notfound(callback) {
// Implement the automatic redirect mechanism for pages whose
// slugs have changed, unless an alternate mechanism has been specified
if ((!req.page) || (req.notfound)) {
if (options.notfound) {
return options.notfound(req, function(err) {
return callback(err);
});
} else {
// Check for a redirect from an old slug before giving up
apos.redirects.findOne({from: req.slug }, function(err, redirect) {
if (redirect) {
return res.redirect(options.root + redirect.to);
} else {
return callback(null);
}
});
}
} else {
return callback(null);
}
}
function executeDeferredLoads(callback) {
// Keep making passes until there are
// no more recursive loads to do; loads
// may do joins that require more loads, etc.
var deferredLoads;
var deferredLoaders;
return async.whilst(function() {
deferredLoads = req.deferredLoads;
deferredLoaders = req.deferredLoaders;
req.deferredLoads = {};
req.deferredLoaders = {};
return !_.isEmpty(deferredLoads);
}, function(callback) {
return async.eachSeries(
_.keys(deferredLoads),
function(type, callback) {
return deferredLoaders[type](req, deferredLoads[type], callback);
},
callback);
}, callback);
}
function main(err) {
var providePage = true;
// Rendering errors isn't much different from
// rendering other stuff. We still get access
// to shared stuff loaded via `load`.
// If the load functions already picked a type respect it,
// whether it is on the allowed list for manual type choices
// or not. Otherwise implement standard behaviors
// pages.serve treats the request object as a repository of everything
// we know about this request so far, including simple hints about the
// desired response. This is different from the default paradigm
// of Express.
if (req.contentType) {
res.setHeader('Content-Type', req.contentType);
}
if (req.redirect) {
return res.redirect(req.redirect);
}
if (req.notfound) {
// A loader asked us to 404
res.statusCode = 404;
req.template = 'notfound';
providePage = false;
} else if (!req.template) {
if (err) {
console.log(err);
req.template = 'serverError';
res.statusCode = 500;
providePage = false;
} else if (req.loginRequired) {
req.template = 'loginRequired';
providePage = false;
} else if (req.insufficient) {
req.template = 'insufficient';
providePage = false;
} else if (req.page) {
// Make sure the type is allowed
req.template = req.page.type;
// This check was coded incorrectly and never
// actually flunked a missing template. I have
// fixed the check, but I don't want to break 0.5 sites.
// TODO: revive this code in 0.6 and test more.
//
// -Tom
//
// if (!_.some(aposPages.types, function(item) {
// return item.name === req.template;
// })) {
// req.template = 'default';
// }
} else {
res.statusCode = 404;
req.template = 'notfound';
providePage = false;
}
}
if (req.template === undefined) {
// Supply a default template name
req.template = 'default';
}
if (providePage) {
req.traceIn('prune page');
req.pushData({
aposPages: {
// Prune the page back so we're not sending everything
// we know about every event in every widget etc., which
// is redundant and results in slow page loads and
// high bandwidth usage
page: apos.prunePage(req.bestPage)
}
});
req.traceOut();
}
if (typeof(req.contextMenu) === 'function') {
// Context menu can be generated on the fly
// by a function
req.contextMenu = req.contextMenu(req);
}
var args = {
edit: providePage ? req.bestPage._edit : null,
slug: providePage ? req.bestPage.slug : null,
page: providePage ? req.bestPage : null,
// Allow page loaders to set the context menu
contextMenu: req.contextMenu
};
if (args.page && args.edit && (!args.contextMenu)) {
// Standard context menu for a regular page
args.contextMenu = [
{
name: 'new-page',
label: 'New Page'
},
{
name: 'edit-page',
label: 'Page Settings'
},
{
name: 'versions-page',
label: 'Page Versions'
},
{
name: 'delete-page',
label: 'Move to Trash'
},
{
name: 'reorganize-page',
label: 'Reorganize'
}
];
}
else if (args.contextMenu && req.user) {
// This user does NOT have permission to see reorg,
// but it might exist already in the contextMenu (why??),
// so we have to remove it explicitly.
args.contextMenu = _.filter(args.contextMenu, function(item) {
return item.name !== 'reorganize-page';
});
}
if (args.page) {
var type = self.getType(args.page.type);
if (type && type.childTypes && (!type.childTypes.length)) {
// Snip out add page if no
// child page types are allowed
args.contextMenu = _.filter(args.contextMenu, function(item) {
return item.name !== 'new-page';
});
}
}
_.extend(args, req.extras);
// A simple way to access everything we know about
// the page in JSON format. Allow this only if we
// have editing privileges on the page.
if ((req.query.pageInformation === 'json') && args.page && (args.page._edit)) {
return res.send(args.page);
}
var path;
if (typeof(req.template) === 'string') {
path = __dirname + '/views/' + req.template;
if (options.templatePath) {
path = options.templatePath + '/' + req.template;
}
}
var result;
timeSync(function() {
result = self.renderPage(req, path ? path : req.template, args);
if (req.statusCode) {
res.statusCode = req.statusCode;
}
}, 'render');
req.traceOut();
self._apos.traceReport(req);
if (!req.user) {
// Most recent Apostrophe page they saw is a good
// candidate to redirect them to if they choose to
// log in.
//
// However several types of URLs are not really of
// interest for this purpose:
//
// * AJAX loads of partial pages
// * 404 and other error pages
// * Static asset URLs that may or may not
// actually exist (file extension is present)
if (options.updateAposAfterLogin && ((!res.statusCode) || (res.statusCode === 200)) && (!req.xhr) && (!req.query.xhr) && (!(req.url.match(/\.\w+$/)))) {
res.cookie('aposAfterLogin', req.url);
}
}
return res.send(result);
}
};
};
// Fetch ancestors of the specified page. We need req to
// determine permissions. Normally areas associated with
// ancestors are not returned. If you specify options.areas as
// `true`, all areas will be returned. If you specify options.areas
// as an array of area names, areas in that list will be returned.
//
// You may use options.getOptions to pass additional options
// directly to apos.get, notably trash: 'any' for use when
// implementing reorganize, trashcan, etc.
//
// You may use the criteria parameter to directly specify additional
// MongoDB criteria ancestors must match to be returned.
//
// You may skip the criteria and options arguments.
self.getAncestors = function(req, page, criteriaArg, options, callback) {
if (arguments.length === 4) {
callback = arguments[3];
options = arguments[2];
criteriaArg = {};
}
if (arguments.length === 3) {
callback = arguments[2];
criteriaArg = {};
options = {};
}
_.defaults(options, {
root: ''
});
var paths = [];
// Pages that are not part of the tree and the home page of the tree
// have no ancestors
if ((!page.path) || (page.path.indexOf('/') === -1)) {
return callback(null, paths);
}
var components = page.path.split('/');
var path = '';
_.each(components, function(component) {
path += component;
// Don't redundantly load ourselves
if (path === page.path) {
return;
}
paths.push(path);
path += '/';
});
var getOptions = {
fields: {
lowSearchText: 0, highSearchText: 0, searchSummary: 0
},
sort: {
path: 1
}
};
if (options.areas) {
getOptions.areas = options.areas;
} else {
// We can't populate the fields option because we can't know the names
// of all the area properties in the world in order to exclude them.
// Use the `areas` option which filters them after fetching so we at least
// don't pay to run their loaders
getOptions.areas = false;
}
if (options.getOptions) {
extend(true, getOptions, options.getOptions);
}
var criteria = {
$and: [
{ path: { $in: paths } },
criteriaArg
]
};
var pages;
return async.series({
getAncestors: function(callback) {
// Get metadata about the related pages, skipping expensive stuff.
// Sorting by path works because longer strings sort
// later than shorter prefixes
return apos.get(req, criteria, getOptions, function(err, results) {
if (err) {
return callback(err);
}
pages = results.pages;
_.each(pages, function(page) {
page.url = self._apos.slugToUrl(page.slug);
});
return callback(null);
});
},
getChildrenOfAncestors: function(callback) {
if (!options.children) {
return callback(null);
}
// TODO: there is a clever mongo query to avoid
// separate invocations of getDescendants
return async.eachSeries(pages, function(page, callback) {
var childrenOptions = options.childrenOptions || {};
return self.getDescendants(req, page, {}, childrenOptions, function(err, pages) {
if (err) {
return callback(err);
}
page.children = pages;
return callback(null);
});
}, callback);
}
}, function (err) {
if (err) {
return callback(err);
}
return callback(null, pages);
});
};
// We need req to determine permissions
self.getParent = function(req, page, options, callback) {
if (arguments.length === 3) {
callback = arguments[2];
options = {};
}
return self.getAncestors(req, page, options, function(err, ancestors) {
if (err) {
return callback(err);
}
if (!ancestors.length) {
return callback(null);
}
return callback(null, ancestors[ancestors.length - 1]);
});
};
// The `trash` option controls whether pages with the trash flag are
// included. If true, only trash is returned. If false, only non-trash
// is returned. If null, both are returned. false is the default.
//
// The `orphan` option works the same way. `orphan` pages are
// normally accessible, but are snot shown in subnav, tabs, etc., so
// this is the only method for which `orphan` defaults to false.
//
// Normally areas associated with ancestors are not returned.
// If you specify `options.areas` as `true`, all areas will be returned.
// If you specify `options.areas` as an array of area names, areas on that
// list will be returned.
//
// Specifying options.depth = 1 fetches immediate children only.
// You may specify any depth. The default depth is 1.
//
// You may also pass arbitrary mongodb criteria as the criteria parameter.
//
// You may skip the criteria argument, or both criteria and options.
self.getDescendants = function(req, ofPage, criteriaArg, optionsArg, callback) {
if (arguments.length === 4) {
callback = arguments[3];
optionsArg = arguments[2];
criteriaArg = {};
}
if (arguments.length === 3) {
callback = arguments[2];
optionsArg = {};
criteriaArg = {};
}
var options = {};
extend(true, options, optionsArg);
_.defaults(options, {
root: ''
});
var depth = options.depth;
// Careful, let them specify a depth of 0 but still have a good default
if (depth === undefined) {
depth = 1;
}
var criteria = {
$and: [
{
path: new RegExp('^' + RegExp.quote(ofPage.path + '/')),
level: { $gt: ofPage.level, $lte: ofPage.level + depth }
}, criteriaArg
]
};
// Skip expensive things
options.fields = { lowSearchText: 0, highSearchText: 0, searchSummary: 0 };
if (!options.areas) {
// Don't fetch areas at all unless we're interested in a specific
// subset of them
options.areas = false;
}
options.sort = { level: 1, rank: 1 };
apos.get(req, criteria, options, function(err, results) {
if (err) {
return callback(err);
}
var pages = results.pages;
var children = [];
var pagesByPath = {};
_.each(pages, function(page) {
page.children = [];
page.url = self._apos.slugToUrl(page.slug);
pagesByPath[page.path] = page;
var last = page.path.lastIndexOf('/');
var parentPath = page.path.substr(0, last);
if (pagesByPath[parentPath]) {
pagesByPath[parentPath].children.push(page);
} else if (page.level === (ofPage.level + 1)) {
children.push(page);
} else {
// The parent of this page is hidden from us, so we shouldn't
// include this page in the results as viewed from here
}
});
return callback(null, children);
});
};
// Get all pages that have the mentioned tag. 'options' parameter, if present,
// may contain an 'areas' flag indicating that the content areas should be returned,
// otherwise only metadata is returned. Pages are sorted by rank, which is helpful
// if you are using tags to display a subset of child pages and wish to preserve their
// natural order. Pages are not returned in a tree structure, pages of any level
// may appear anywhere in the result
self.getByTag = function(req, tag, options, callback) {
return self.getByTags(req, [tag], options, callback);
};
// Get all pages that have at least one of the mentioned tags. 'options' parameter,
// if present, may contain an 'areas' flag indicating that the content areas should
// be returned, otherwise only metadata is returned.
//
// Pages are sorted by rank, which is helpful if you are using tags to display a subset
// of child pages and wish to preserve their natural order. Pages are not returned in a tree
// structure, pages of any level may appear anywhere in the result
self.getByTags = function(req, tags, options, callback) {
if (!callback) {
callback = options;
options = {};
}
if (!options.areas) {
options.areas = false;
}
var criteria = { path: { $exists: 1 }, tags: { $in: tags }};
return apos.get(req, criteria, options, function(err, results) {
if (err) {
return callback(err);
}
var pages;
if (results) {
pages = results.pages;
}
return callback(null, results);
});
};
// Filter the pages array to those pages that have the specified tag. Returns directly,
// has no callback
self.filterByTag = function(pages, tag) {
return self.filterByTags(pages, [tag]);
};
// Filter the pages array to those pages that have at least one of the specified tags.
// Returns directly, has no callback
self.filterByTags = function(pages, tags) {
return _.filter(pages, function(page) {
return page.tags && _.some(page.tags, function(tag) {
return _.contains(tags, tag);
});
});
};
// position can be 'before', 'after' or 'inside' and determines the
// moved page's new relationship to the target page. You may pass
// page objects instead of slugs if you have them. The callback
// receives an error and, if there is no error, also an array of
// objects with _id and slug properties, indicating the new slugs
// of all modified pages
self.move = function(req, movedSlug, targetSlug, position, callback) {
var moved, target, parent, oldParent, changed = [];
if (typeof(movedSlug) === 'object') {
moved = movedSlug;
}
if (typeof(targetSlug) === 'object') {
target = targetSlug;
}
var rank;
var originalPath;
var originalSlug;
async.series([getMoved, getTarget, getOldParent, getParent, permissions, nudgeNewPeers, moveSelf, updateRedirects, moveDescendants, trashDescendants ], finish);
function getMoved(callback) {
if (moved) {
return callback(null);
}
if (movedSlug.charAt(0) !== '/') {
return callback('not a tree page');
}
apos.pages.findOne({ slug: movedSlug }, function(err, page) {
if (!page) {
return callback('no such page');
}
moved = page;
if (!moved.level) {
return callback('cannot move root');
}
// You can't move the trashcan itself, but you can move its children
if (moved.trash && (moved.level === 1)) {
return callback('cannot move trashcan');
}
return callback(null);
});
}
function getTarget(callback) {
if (target) {
return callback(null);
}
if (targetSlug.charAt(0) !== '/') {
return callback('not a tree page');
}
apos.pages.findOne({ slug: targetSlug }, function(err, page) {
if (!page) {
return callback('no such page');
}
target = page;
if ((target.trash) && (target.level === 1) && (position === 'after')) {
return callback('trash must be last');
}
return callback(null);
});
}
function getOldParent(callback) {
self.getParent(req, moved, { getOptions: { permissions: false, trash: 'any' } }, function(err, parentArg) {
oldParent = parentArg;
return callback(err);
});
}
function getParent(callback) {
if (position === 'inside') {
parent = target;
rank = 0;
return callback(null);
}
if (position === 'before') {
rank = target.rank;
if (rank >= 1000000) {
// It's legit to move a page before search or trash, but we
// don't want its rank to wind up in the reserved range. Find
// the rank of the next page down and increment that.
return self.pages.find({ slug: /^\//, path: /^home\/[^\/]$/ }, { rank: 1 }).sort({ rank: -1 }).limit(1).toArray(function(err, pages) {
if (err) {
return callback(err);
}
if (!pages.length) {
rank = 1;
return callback(null);
}
rank = pages[0].rank;
return callback(null);
});
}
} else if (position === 'after') {
if (target.rank >= 1000000) {
// Reserved range
return callback('cannot move a page after a system page');
}
rank = target.rank + 1;
} else {
return callback('no such position option');
}
self.getParent(req, target, { getOptions: { permissions: false, trash: 'any' } }, function(err, parentArg) {
if (!parentArg) {
return callback('cannot create peer of home page');
}
parent = parentArg;
return callback(null);
});
}
function permissions(callback) {
if (!apos.permissions.can(req, 'publish-page', moved)) {
return callback('forbidden');
}
// You can always move a page into the trash. You can
// also change the order of subpages if you can
// edit the subpage you're moving. Otherwise you
// must have edit permissions for the new parent page.
if ((oldParent._id !== parent._id) && (parent.path !== 'home/trash') && (!apos.permissions.can(req, 'edit-page', parent))) {
return callback('forbidden');
}
return callback(null);
}
// This results in problems if the target is below us, and
// it really doesn't matter if there are gaps between ranks. -Tom
//
// function nudgeOldPeers(callback) {
// // Nudge up the pages that used to follow us
// // Leave reserved range alone
// var oldParentPath = path.dirname(moved.path);
// apos.pages.update({ path: new RegExp('^' + RegExp.quote(oldParentPath + '/')), level: moved.level, rank: { $gte: moved.rank, $lte: 1000000 }}, { $inc: { rank: -1 } }, { multi: true }, function(err, count) {
// return callback(err);
// });
// }
function nudgeNewPeers(callback) {
// Nudge down the pages that should now follow us
// Leave reserved range alone
// Always remember multi: true
apos.pages.update({ path: new RegExp('^' + RegExp.quote(parent.path + '/')), level: parent.level + 1, rank: { $gte: rank, $lte: 1000000 } }, { $inc: { rank: 1 } }, { multi: true }, function(err, count) {
return callback(err);
});
}
function moveSelf(callback) {
originalPath = moved.path;
originalSlug = moved.slug;
var level = parent.level + 1;
var newPath = parent.path + '/' + path.basename(moved.path);
// We're going to use update with $set, but we also want to update
// the object so that moveDescendants can see what we did
moved.path = newPath;
// If the old slug wasn't customized, update the slug as well as the path
if (parent._id !== oldParent._id) {
var matchOldParentSlugPrefix = new RegExp('^' + RegExp.quote(apos.addSlashIfNeeded(oldParent.slug)));
if (moved.slug.match(matchOldParentSlugPrefix)) {
var slugStem = parent.slug;
if (slugStem !== '/') {
slugStem += '/';
}
moved.slug = moved.slug.replace(matchOldParentSlugPrefix, apos.addSlashIfNeeded(parent.slug));
changed.push({
_id: moved._id,
slug: moved.slug
});
}
}
moved.level = level;
moved.rank = rank;
// Are we in the trashcan? Our new parent reveals that
if (parent.trash) {
moved.trash = true;
} else {
delete moved.trash;
}
apos.putPage(req, originalSlug, moved, function(err, page) {
moved = page;
return callback(null);
});
}
function updateRedirects(callback) {
return apos.updateRedirect(originalSlug, moved.slug, callback);
}
function moveDescendants(callback) {
return self.updateDescendantPathsAndSlugs(moved, originalPath, originalSlug, function(err, changedArg) {
if (err) {
return callback(err);
}
changed = changed.concat(changedArg);
return callback(null);
});
}
function trashDescendants(callback) {
// Make sure our descendants have the same trash status
var matchParentPathPrefix = new RegExp('^' + RegExp.quote(moved.path + '/'));
var $set = {};
var $unset = {};
if (moved.trash) {
$set.trash = true;
} else {
$unset.trash = true;
}
var action = {};
if (!_.isEmpty($set)) {
action.$set = $set;
}
if (!_.isEmpty($unset)) {
action.$unset = $unset;
}
if (_.isEmpty(action)) {
return setImmediate(callback);
}
return apos.pages.update({ path: matchParentPathPrefix }, action, { multi: true }, callback);
}
function finish(err) {
if (err) {
return callback(err);
}
return callback(null, changed);
}
};
/**
* Update the paths and slugs of descendant pages, changing slugs only if they were
* compatible with the original slug. On success, invokes callback with
* null and an array of objects with _id and slug properties, indicating
* the new slugs for any objects that were modified.
* @param {page} page
* @param {string} originalPath
* @param {string} originalSlug
* @param {Function} callback
*/
self.updateDescendantPathsAndSlugs = function(page, originalPath, originalSlug, callback) {
// If our slug changed, then our descendants' slugs should
// also change, if they are still similar. You can't do a
// global substring replace in MongoDB the way you can
// in MySQL, so we need to fetch them and update them
// individually. async.mapSeries is a good choice because
// there may be zillions of descendants and we don't want
// to choke the server. We could use async.mapLimit, but
// let's not get fancy just yet
var changed = [];
if ((originalSlug === page.slug) && (originalPath === page.path)) {
return callback(null, changed);
}
var oldLevel = originalPath.split('/').length - 1;
var matchParentPathPrefix = new RegExp('^' + RegExp.quote(originalPath + '/'));
var matchParentSlugPrefix = new RegExp('^' + RegExp.quote(originalSlug + '/'));
var done = false;
var cursor = apos.pages.find({ path: matchParentPathPrefix }, { slug: 1, path: 1, level: 1 });
return async.whilst(function() { return !done; }, function(callback) {
return cursor.nextObject(function(err, desc) {
if (err) {
return callback(err);
}
if (!desc) {
// This means there are no more objects
done = true;
return callback(null);
}
var newSlug = desc.slug.replace(matchParentSlugPrefix, page.slug + '/');
changed.push({
_id: desc._id,
slug: newSlug
});
return async.series({
update: function(callback) {
return apos.pages.update({ _id: desc._id }, { $set: {
// Always matches
path: desc.path.replace(matchParentPathPrefix, page.path + '/'),
// Might not match, and we don't care (if they edited the slug that far up,
// they did so intentionally)
slug: newSlug,
level: desc.level + (page.level - oldLevel)
}}, callback);
},
redirect: function(callback) {
if (desc.slug === newSlug) {
return setImmediate(callback);
}
return apos.updateRedirect(desc.slug, newSlug, callback);
}
}, callback);
});
}, function(err) {
if (err) {
return callback(err);
}
return callback(null, changed);
});
};
// Return a page type object if one was configured for the given type name.
// JavaScript doesn't iterate over object properties in a defined order,
// so we maintain the list of types as a flat array. This convenience method
// prevents this from being inconvenient and allows us to choose to do more
// optimization later.
self.getType = function(name) {
return _.find(aposPages.types, function(item) {
return item.name === name;
});
};
// Add a page type.
//
// The simplest type you can pass in is an object with name and label properties.
// That enables a custom page template in your views folder. You can do a lot more
// than that, though; see apostrophe-snippets for the basis from which blogs and
// events are both built.
//
// To simplify the creation of the page types menu, you may push a type more than
// once under the same name: usually the first time with just the name and label,
// and the second time with a complete page type manager object, as when initializing
// the blog module. The last version added wins.
self.addType = function(type) {
var found = false;
var i;
for (i = 0; (i < self.types.length); i++) {
if (self.types[i].name === type.name) {
self.types[i] = type;
return;
}
}
self.types.push(type);
apos.pushGlobalCallWhen('user', 'aposPages.addType(?)', {
name: type.name,
label: type.label,
childTypes: type.childTypes,
descendantTypes: type.descendantTypes
});
};
// Get the index type objects corresponding to an instance or the name of an
// instance type. Instance types are a relevant concept for snippet pages,
// blog pages, event calendar pages, etc. and everything derived from them.
//
// In this pattern "instance" pages, like individual blogPosts, are outside
// of the page tree but "index" pages, like blogs, are in the page tree and
// display some or all of the blogPosts according to their own criteria.
self.getIndexTypes = function(instanceTypeOrInstance) {
var instanceTypeName = instanceTypeOrInstance.type || instanceTypeOrInstance;
var instanceTypes = [];
var i;
return _.filter(self.types, function(type) {
return (type._instance === instanceTypeName);
});
};
// Get the names of the index types corresponding to an instance type or the name of an
// instance type
self.getIndexTypeNames = function(instanceTypeOrInstance) {
return _.pluck(self.getIndexTypes(instanceTypeOrInstance), 'name');
};
// Returns the manager object corresponding to a type name or an object of the type.
//
// Some manager objects are responsible for two types: an instance type
// ("blogPost") that does not appear in the page tree, and an index type
// ("blog") that does. In such cases the manager object has an _instance
// property which indicates the instance type name and a `name` property
// which indicates the index type name.
//
// If the manager object has an _instance property, then
// it will have both .get and .getIndexes methods for retrieving the
// instances and the indexes, respectively. If not, then the
// .get method should always be used.
self.getManager = function(nameOrObject) {
var name = nameOrObject.type || nameOrObject;
if (name === 'page') {
// A special case: we are interested in all "regular pages", those that
// have a slug starting with "/" and are therefore directly part
// of the page tree. Provide a manager object with a suitable
// "get" method.
return {
get: function(req, _criteria, filters, callback) {
var criteria = {
$and: [
{
slug: /^\//
},
_criteria
]
};
return apos.get(req, criteria, filters, function(err, results) {
if (err) {
return callback(err);
}
if (!results.pages) {
return callback(null, results);
}
// Allow getOptions: { children: true } or
// getOptions: { children: { depth: 2 } }
if (!(filters && filters.children)) {
return callback(null, results);
}
var childrenOptions = {};
if (typeof(filters.children) === 'object') {
childrenOptions = filters.children;
}
return async.eachSeries(results.pages, function(item, callback) {
return self.getDescendants(req, item, {}, childrenOptions, function(err, children) {
if (err) {
return callback(err);
}
item.children = children;
return callback(null);
});
}, function(err) {
if (err) {
return callback(err);
}
return callback(null, results);
});
});
}
};
}
var instanceManager = self.getIndexTypes(name)[0];
if (instanceManager) {
return instanceManager;
}
var indexManager = _.find(self.types, function(type) {
return type.name === name;
});
return indexManager;
};
// Get all the instance type names: the type names which have a corresponding
// index type - snippet, event, blogPost, etc.
self.getAllInstanceTypeNames = function() {
var result = [];
_.each(self.types, function(type) {
if (type._instance) {
result.push(type._instance);
}
});
return result;
};
// May be called to re-order page types. Called automatically once at the end
// of initialization, which is usually sufficient now that the `types` option
// is permitted to contain types that will later be reinitialized by other
// modules, e.g. blog
self.setMenu = function(choices) {
apos.pushGlobalData({
aposPages: {
menu: choices
}
});
};
if (!options.types) {
options.types = [ { name: 'defau