UNPKG

apostrophe-pages

Version:

Adds trees of pages to the Apostrophe content management system

1,371 lines (1,265 loc) 86.4 kB
/*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