apostrophe
Version:
The Apostrophe Content Management System.
1,361 lines (1,297 loc) • 133 kB
JavaScript
const _ = require('lodash');
const path = require('path');
const { klona } = require('klona');
const { SemanticAttributes } = require('@opentelemetry/semantic-conventions');
module.exports = {
cascades: [ 'filters', 'batchOperations', 'utilityOperations' ],
options: {
alias: 'page',
types: [
{
// So that the minimum parked pages don't result in an error when home
// has no manager. -Tom
name: '@apostrophecms/home-page',
label: 'apostrophe:home'
}
],
quickCreate: true,
minimumPark: [
{
slug: '/',
parkedId: 'home',
_defaults: {
title: 'Home',
type: '@apostrophecms/home-page'
}
},
{
slug: '/archive',
parkedId: 'archive',
type: '@apostrophecms/archive-page',
archived: true,
orphan: true,
title: 'Archive'
}
],
redirectFailedUpperCaseUrls: true,
relationshipSuggestionIcon: 'web-icon'
},
filters: {
add: {
archived: {
label: 'apostrophe:archived',
inputType: 'radio',
choices: [
{
value: false,
label: 'apostrophe:live'
},
{
value: true,
label: 'apostrophe:archived'
}
],
// TODO: Delete `allowedInChooser` if not used.
allowedInChooser: false,
def: false,
required: true
}
}
},
utilityOperations(self) {
return {
add: {
new: {
canCreate: true,
relationship: true,
button: true,
label: {
key: 'apostrophe:newDocType',
type: '$t(apostrophe:page)'
},
eventOptions: {
event: 'edit',
type: self.__meta.name
}
}
}
};
},
batchOperations: {
add: {
publish: {
label: 'apostrophe:publish',
messages: {
progress: 'apostrophe:batchPublishProgress',
completed: 'apostrophe:batchPublishCompleted',
completedWithFailures: 'apostrophe:batchPublishCompletedWithFailures',
failed: 'apostrophe:batchPublishFailed'
},
icon: 'earth-icon',
modalOptions: {
title: 'apostrophe:publishType',
description: 'apostrophe:publishingBatchConfirmation',
confirmationButton: 'apostrophe:publishingBatchConfirmationButton'
},
permission: 'publish'
},
archive: {
label: 'apostrophe:archive',
messages: {
progress: 'apostrophe:batchArchiveProgress',
completed: 'apostrophe:batchArchiveCompleted',
completedWithFailures: 'apostrophe:batchArchiveCompletedWithFailures',
failed: 'apostrophe:batchArchiveFailed'
},
icon: 'archive-arrow-down-icon',
if: {
archived: false
},
modalOptions: {
title: 'apostrophe:archiveType',
description: 'apostrophe:archivingBatchConfirmation',
confirmationButton: 'apostrophe:archivingBatchConfirmationButton'
},
permission: 'delete'
},
restore: {
label: 'apostrophe:restore',
messages: {
progress: 'apostrophe:batchRestoreProgress',
completed: 'apostrophe:batchRestoreCompleted',
completedWithFailures: 'apostrophe:batchRestoreCompletedWithFailures',
failed: 'apostrophe:batchRestoreFailed'
},
icon: 'archive-arrow-up-icon',
if: {
archived: true
},
modalOptions: {
title: 'apostrophe:restoreType',
description: 'apostrophe:restoreBatchConfirmation',
confirmationButton: 'apostrophe:restoreBatchConfirmationButton'
},
permission: 'edit'
},
localize: {
label: 'apostrophe:localize',
messages: {
icon: 'translate-icon',
progress: 'apostrophe:localizingBatch',
completed: 'apostrophe:localizedBatch',
completedWithFailures: 'apostrophe:localizedBatchWithFailures',
failed: 'apostrophe:localizedBatchFailed',
resultsEventName: 'apos-localize-batch-results'
},
if: {
archived: false
},
modal: 'AposI18nLocalize',
permission: 'edit'
}
},
group: {
more: {
icon: 'dots-vertical-icon',
operations: [ 'localize' ]
}
}
},
commands(self) {
return {
add: {
[`${self.__meta.name}:manager`]: {
type: 'item',
label: 'apostrophe:page',
action: {
type: 'admin-menu-click',
payload: {
itemName: `${self.__meta.name}:manager`
}
},
permission: {
action: 'edit',
type: self.__meta.name
},
shortcut: self.options.shortcut ?? `G,${self.apos.task.getReq().t('apostrophe:page').slice(0, 1)}`
},
[`${self.__meta.name}:create-new`]: {
type: 'item',
label: 'apostrophe:commandMenuCreateNew',
action: {
type: 'command-menu-manager-create-new'
},
permission: {
action: 'create',
type: self.__meta.name
},
shortcut: 'C'
},
// NOTE: there is no search in the page manager
// [`${self.__meta.name}:search`]: {
// type: 'item',
// label: 'apostrophe:commandMenuSearch',
// action: {
// type: 'command-menu-manager-focus-search'
// },
// shortcut: 'Ctrl+F Meta+F'
// },
[`${self.__meta.name}:select-all`]: {
type: 'item',
label: 'apostrophe:commandMenuSelectAll',
action: {
type: 'command-menu-manager-select-all'
},
shortcut: 'Ctrl+Shift+A Meta+Shift+A'
},
[`${self.__meta.name}:archive-selected`]: {
type: 'item',
label: 'apostrophe:commandMenuArchiveSelected',
action: {
type: 'command-menu-manager-archive-selected'
},
permission: {
action: 'delete',
type: self.__meta.name
},
shortcut: 'E'
},
[`${self.__meta.name}:exit-manager`]: {
type: 'item',
label: 'apostrophe:commandMenuExitManager',
action: {
type: 'command-menu-manager-close'
},
shortcut: 'Q'
}
},
modal: {
default: {
'@apostrophecms/command-menu:manager': {
label: 'apostrophe:commandMenuManager',
commands: [
`${self.__meta.name}:manager`
]
}
},
[`${self.__meta.name}:manager`]: {
'@apostrophecms/command-menu:manager': {
label: 'apostrophe:commandMenuManager',
commands: [
`${self.__meta.name}:create-new`,
`${self.__meta.name}:search`,
`${self.__meta.name}:select-all`,
`${self.__meta.name}:archive-selected`,
`${self.__meta.name}:exit-manager`
]
},
'@apostrophecms/command-menu:general': {
label: 'apostrophe:commandMenuGeneral',
commands: [
'@apostrophecms/command-menu:show-shortcut-list'
]
}
},
[`${self.__meta.name}:editor`]: {
'@apostrophecms/command-menu:content': {
label: 'apostrophe:commandMenuContent',
commands: [
'@apostrophecms/area:cut-widget',
'@apostrophecms/area:copy-widget',
'@apostrophecms/area:paste-widget',
'@apostrophecms/area:duplicate-widget',
'@apostrophecms/area:remove-widget'
]
},
'@apostrophecms/command-menu:general': {
label: 'apostrophe:commandMenuGeneral',
commands: [
'@apostrophecms/command-menu:show-shortcut-list'
]
}
}
}
};
},
async init(self) {
self.typeChoices = self.options.types || [];
// If "park" redeclares something with a parkedId present in "minimumPark",
// the later one should win
self.composeParked();
self.addManagerModal();
self.addEditorModal();
self.enableBrowserData();
self.addLegacyMigrations();
self.addMisreplicatedParkedPagesMigration();
self.addDuplicateParkedPagesMigration();
self.apos.migration.add('deduplicateRanks2', self.deduplicateRanks2Migration);
self.apos.migration.add('missingLastPublishedAt', self.missingLastPublishedAtMigration);
await self.createIndexes();
self.composeFilters();
},
restApiRoutes(self) {
return {
// Trees are arranged in a tree, not a list. So this API returns the home
// page, with _children populated if ?_children=1 is in the query string.
// An editor can also get a light version of the entire tree with ?all=1,
// for use in a drag-and-drop UI.
//
// If ?flat=1 is present, the pages are returned as a flat list rather
// than a tree, and the `_children` property of each is just an array of
// `_id`s.
//
// If ?autocomplete=x is present, then an autocomplete prefix search for
// pages matching that string is carried out, and a flat list of pages is
// returned, with no `_children`. This is mainly useful to our
// relationship editor. The user must have some page editing privileges to
// use it. The 10 best matches are returned as an object with a `results`
// property containing the array of pages. If ?type=x is present, only
// pages of that type are returned. This query parameter is only used in
// conjunction with ?autocomplete=x. It will be ignored otherwise.
//
// If querying for draft pages, you may add ?published=1 to attach a
// `_publishedDoc` property to each draft that also exists in a published
// form.
getAll: [
...self.apos.expressCacheOnDemand ? [ self.apos.expressCacheOnDemand ] : [],
async (req) => {
await self.publicApiCheckAsync(req);
const all = self.apos.launder.boolean(req.query.all);
const archived = self.apos.launder.booleanOrNull(req.query.archived);
const flat = self.apos.launder.boolean(req.query.flat);
const autocomplete = self.apos.launder.string(req.query.autocomplete);
const type = self.apos.launder.string(req.query.type);
if (autocomplete.length) {
if (!self.apos.permission.can(req, 'view', '@apostrophecms/any-page-type')) {
throw self.apos.error('forbidden');
}
if (type.length && !self.apos.permission.can(req, 'view', type)) {
throw self.apos.error('forbidden');
}
const query = self.getRestQuery(req)
.permission(false)
.limit(10)
.relationships(false)
.areas(false);
if (type.length) {
query.type(type);
}
return {
// For consistency with the pieces REST API we
// use a results property when returning a flat list
results: await query.toArray()
};
}
if (type.length) {
const manager = self.apos.doc.getManager(type);
if (!manager) {
throw self.apos.error('invalid');
}
const query = self.getRestQuery(req);
query
.type(type)
.ancestors(false)
.children(false)
.attachments(false)
.perPage(manager.options.perPage);
// populates totalPages when perPage is present
await query.toCount();
const docs = await query.toArray();
const choices = query.get('choicesResults');
return {
results: docs.map(doc => manager.removeForbiddenFields(req, doc)),
pages: query.get('totalPages'),
currentPage: query.get('page') || 1,
...choices && { choices }
};
}
if (all) {
if (!self.apos.permission.can(req, 'view', '@apostrophecms/any-page-type')) {
throw self.apos.error('forbidden');
}
const page = await self.getRestQuery(req)
.permission(false)
.and({ level: 0 })
.children({
depth: 1000,
archived,
orphan: null,
relationships: false,
areas: false,
permission: false,
withPublished: self.apos.launder.boolean(req.query.withPublished),
project: self.getAllProjection()
}).toObject();
if (
self.options.cache &&
self.options.cache.api &&
self.options.cache.api.maxAge
) {
self.setMaxAge(req, self.options.cache.api.maxAge);
}
if (!page) {
throw self.apos.error('notfound');
}
if (flat) {
const result = [];
flatten(result, page);
return {
// For consistency with the pieces REST API we
// use a results property when returning a flat list
results: result
};
} else {
return page;
}
} else {
const result = await self.getRestQuery(req).and({ level: 0 }).toObject();
if (
self.options.cache &&
self.options.cache.api &&
self.options.cache.api.maxAge
) {
self.setMaxAge(req, self.options.cache.api.maxAge);
}
if (!result) {
throw self.apos.error('notfound');
}
// Attach `_url` and `_urls` properties to the home page
self.apos.attachment.all(result, { annotate: true });
return result;
}
function flatten(result, node) {
const children = node._children;
node._children = _.map(node._children, '_id');
result.push(node);
_.each(children || [], function(child) {
flatten(result, child);
});
}
}
],
// _id may be a page _id, or the convenient shorthands
// `_home` or `_archive`
getOne: [
...self.apos.expressCacheOnDemand ? [ self.apos.expressCacheOnDemand ] : [],
async (req, _id) => {
_id = self.inferIdLocaleAndMode(req, _id);
// Edit access to draft is sufficient to fetch either
await self.publicApiCheckAsync(req);
const criteria = self.getIdCriteria(_id);
const result = await self
.getRestQuery(req)
.permission(false)
.and(criteria)
.toObject();
if (self.options.cache?.api?.maxAge) {
const { maxAge } = self.options.cache.api;
if (!self.options.cache.api.etags) {
self.setMaxAge(req, maxAge);
} else if (self.checkETag(req, result, maxAge)) {
return {};
}
}
if (!result) {
throw self.apos.error('notfound');
}
const renderAreas = req.query['render-areas'];
const inline = renderAreas === 'inline';
if (inline || self.apos.launder.boolean(renderAreas)) {
await self.apos.area.renderDocsAreas(req, [ result ], {
inline
});
}
// Attach `_url` and `_urls` properties
self.apos.attachment.all(result, { annotate: true });
return result;
}
],
// POST a new page to the site. The schema fields should be part of the
// JSON request body.
//
// You may pass `_targetId` and `_position` to specify the location in
// the page tree. `_targetId` is the _id of another page, and `_position`
// may be `before`, `after`, `firstChild` or `lastChild`.
//
// If you do not specify these properties they default to the homepage
// and `lastChild`, creating a subpage of the home page.
//
// You may pass _copyingId. If you do all properties not in `req.body`
// are copied from it.
//
// This call is atomic with respect to other REST write operations on
// pages.
async post(req) {
await self.publicApiCheckAsync(req);
let targetId = self.apos.launder.string(req.body._targetId);
let position = self.apos.launder.string(req.body._position || 'lastChild');
// Here we have to normalize before calling insert because we
// need the parent page to call newChild(). insert calls again but
// sees there's no work to be done, so no performance hit
const normalized = await self.getTargetIdAndPosition(
req,
null,
targetId,
position
);
targetId = normalized.targetId || '_home';
position = normalized.position;
const copyingId = self.apos.launder.id(req.body._copyingId);
const createId = self.apos.launder.id(req.body._createId);
const input = _.omit(req.body, '_targetId', '_position', '_copyingId');
if (typeof (input) !== 'object') {
// cheeky
throw self.apos.error('invalid');
}
if (req.body._newInstance) {
// If we're looking for a fresh page instance and aren't saving yet,
// simply get a new page doc and return it
const parentPage = await self.findForEditing(req, self.getIdCriteria(targetId))
.permission('create', '@apostrophecms/any-page-type').toObject();
const { _newInstance, ...body } = req.body;
const newChild = {
...self.newChild(parentPage),
...body
};
newChild._previewable = true;
return newChild;
}
return self.withLock(req, async () => {
const targetPage = await self
.findForEditing(req, self.getIdCriteria(targetId))
.ancestors(true)
.permission('create')
.toObject();
if (!targetPage) {
throw self.apos.error('notfound');
}
const manager = self.apos.doc.getManager(self.apos.launder.string(input.type));
if (!manager) {
// sneaky
throw self.apos.error('invalid');
}
let page;
if ((position === 'firstChild') || (position === 'lastChild')) {
page = self.newChild(targetPage);
} else {
const parentPage = targetPage._ancestors[targetPage._ancestors.length - 1];
if (!parentPage) {
throw self.apos.error('notfound');
}
page = self.newChild(parentPage);
}
await manager.convert(req, input, page, {
copyingId,
createId
});
await self.insert(req, targetPage._id, position, page, { lock: false });
return self.findOneForEditing(req, { _id: page._id }, {
attachments: true,
permission: false
});
});
},
// Consider using `PATCH` instead unless you're sure you have 100% up to
// date data for every property of the page. If you are trying to change
// one thing, `PATCH` is a smarter choice.
//
// Update the page via `PUT`. The entire page, including all areas,
// must be in req.body.
//
// To move a page in the tree at the same time, you may pass `_targetId`
// and `_position`. Unlike normal properties passed to PUT these are not
// mandatory to pass every time.
//
// This call is atomic with respect to other REST write operations on
// pages.
//
// If `_advisoryLock: { tabId: 'xyz', lock: true }` is passed, the
// operation will begin by obtaining an advisory lock on the document for
// the given context id, and no other items in the patch will be addressed
// unless that succeeds. The client must then refresh the lock frequently
// (by default, at least every 30 seconds) with repeated PATCH requests of
// the `_advisoryLock` property with the same context id. If
// `_advisoryLock: { tabId: 'xyz', lock: false }` is passed, the advisory
// lock will be released *after* addressing other items in the same patch.
// If `force: true` is added to the `_advisoryLock` object it will always
// remove any competing advisory lock.
//
// `_advisoryLock` is only relevant if you want to ask others not to edit
// the document while you are editing it in a modal or similar.
async put(req, _id) {
_id = self.inferIdLocaleAndMode(req, _id);
await self.publicApiCheckAsync(req);
return self.withLock(req, async () => {
const page = await self.findForEditing(req, { _id }).toObject();
if (!page) {
throw self.apos.error('notfound');
}
if (!page._edit) {
throw self.apos.error('forbidden');
}
const input = req.body;
const manager = self.apos.doc.getManager(
self.apos.launder.string(input.type) || page.type
);
if (!manager) {
throw self.apos.error('invalid');
}
let tabId = null;
let lock = false;
let force = false;
if (input._advisoryLock && ((typeof input._advisoryLock) === 'object')) {
tabId = self.apos.launder.string(input._advisoryLock.tabId);
lock = self.apos.launder.boolean(input._advisoryLock.lock);
force = self.apos.launder.boolean(input._advisoryLock.force);
}
if (tabId && lock) {
await self.apos.doc.lock(req, page, tabId, {
force
});
}
self.enforceParkedProperties(req, page, input);
await manager.convert(req, input, page);
await self.update(req, page);
if (input._targetId) {
const targetId = self.apos.launder.string(input._targetId);
const position = self.apos.launder.string(input._position);
await self.move(req, page._id, targetId, position);
}
if (tabId && !lock) {
await self.apos.doc.unlock(req, page, tabId);
}
return self.findOneForEditing(req, { _id: page._id }, { attachments: true });
});
},
async delete(req, _id) {
_id = self.inferIdLocaleAndMode(req, _id);
await self.publicApiCheckAsync(req);
const page = await self.findOneForEditing(req, {
_id
});
if (!page) {
throw self.apos.error('notfound');
}
return self.delete(req, page);
},
// Patch some properties of the page.
//
// You may pass `_targetId` and `_position` to move the page within the
// tree. `_position` may be `before`, `after` or `inside`. To move a page
// into or out of the archive, set `archived` to `true` or `false`.
async patch(req, _id) {
_id = self.inferIdLocaleAndMode(req, _id);
await self.publicApiCheckAsync(req);
return self.patch(req, _id);
}
};
},
apiRoutes(self) {
return {
post: {
':_id/publish': async (req) => {
const _id = self.inferIdLocaleAndMode(req, req.params._id);
const draft = await self.findOneForEditing(req.clone({
mode: 'draft'
}), {
aposDocId: _id.split(':')[0]
});
if (!draft) {
throw self.apos.error('notfound');
}
if (!draft.aposLocale) {
// Not subject to draft/publish workflow
throw self.apos.error('invalid');
}
return self.publish(req, draft);
},
':_id/localize': async (req) => {
const _id = self.inferIdLocaleAndMode(req, req.params._id);
const draft = await self.findOneForLocalizing(req.clone({
mode: 'draft'
}), {
aposDocId: _id.split(':')[0]
});
if (!draft) {
throw self.apos.error('notfound');
}
if (!draft.aposLocale) {
// Not subject to draft/publish workflow
throw self.apos.error('invalid');
}
const toLocale = self.apos.i18n.sanitizeLocaleName(req.body.toLocale);
const update = self.apos.launder.boolean(req.body.update);
if ((!toLocale) || (toLocale === req.locale)) {
throw self.apos.error('invalid');
}
return self.localize(req, draft, toLocale, {
update
});
},
':_id/unpublish': async (req) => {
const _id = self.apos.i18n.inferIdLocaleAndMode(req, req.params._id);
const aposDocId = _id.replace(/:.*$/, '');
const published = await self.findOneForEditing(req.clone({
mode: 'published'
}), {
aposDocId
});
if (!published) {
throw self.apos.error('notfound');
}
return self.withLock(
req,
async () => self.unpublish(req, published)
);
},
':_id/submit': async (req) => {
const _id = self.inferIdLocaleAndMode(req, req.params._id);
const draft = await self.findOneForEditing(req.clone({
mode: 'draft'
}), {
aposDocId: _id.split(':')[0]
});
if (!draft) {
throw self.apos.error('notfound');
}
const manager = self.apos.doc.getManager(draft.type);
return manager.submit(req, draft);
},
':_id/dismiss-submission': async (req) => {
const _id = self.inferIdLocaleAndMode(req, req.params._id);
const draft = await self.findOneForEditing(req.clone({
mode: 'draft'
}), {
aposDocId: _id.split(':')[0]
});
if (!draft) {
throw self.apos.error('notfound');
}
const manager = self.apos.doc.getManager(draft.type);
return manager.dismissSubmission(req, draft);
},
':_id/revert-draft-to-published': async (req) => {
const _id = self.inferIdLocaleAndMode(req, req.params._id);
const draft = await self.findOneForEditing(req.clone({
mode: 'draft'
}), {
aposDocId: _id.split(':')[0]
});
if (!draft) {
throw self.apos.error('notfound');
}
if (!draft.aposLocale) {
// Not subject to draft/publish workflow
throw self.apos.error('invalid');
}
return self.revertDraftToPublished(req, draft);
},
':_id/revert-published-to-previous': async (req) => {
const _id = self.inferIdLocaleAndMode(req, req.params._id);
const published = await self.findOneForEditing(req.clone({
mode: 'published'
}), {
aposDocId: _id.split(':')[0]
});
if (!published) {
throw self.apos.error('notfound');
}
if (!published.aposLocale) {
// Not subject to draft/publish workflow
throw self.apos.error('invalid');
}
return self.revertPublishedToPrevious(req, published);
},
':_id/share': async (req) => {
const { _id } = req.params;
const share = self.apos.launder.boolean(req.body.share);
if (!_id) {
throw self.apos.error('invalid');
}
const draft = await self.findOneForEditing(req, {
_id
});
if (!draft || draft.aposMode !== 'draft') {
throw self.apos.error('notfound');
}
const sharedDoc = share
? await self.share(req, draft)
: await self.unshare(req, draft);
return sharedDoc;
},
publish (req) {
if (!Array.isArray(req.body._ids)) {
throw self.apos.error('invalid');
}
req.body._ids = req.body._ids.map(_id => {
return self.inferIdLocaleAndMode(req, _id);
});
return self.apos.modules['@apostrophecms/job'].runBatch(
req,
req.body._ids,
async function(req, id) {
const piece = await self.findOneForEditing(req, { _id: id });
if (!piece) {
throw self.apos.error('notfound');
}
await self.publish(req, piece);
},
{
action: 'publish',
docTypes: [ self.__meta.name, '@apostrophecms/page' ]
}
);
},
async archive(req) {
if (!Array.isArray(req.body._ids)) {
throw self.apos.error('invalid');
}
const ids = req.body._ids.map(_id => {
return self.inferIdLocaleAndMode(req, _id);
});
const patches = await self.getBatchArchivePatches(req, ids);
return self.apos.modules['@apostrophecms/job'].runBatch(
req,
patches.map(patch => patch._id),
async function(req, id) {
const patch = patches.find(patch => patch._id === id);
await self.patch(
req.clone({
mode: 'draft',
body: patch.body
}),
patch._id
);
},
{
action: 'archive',
docTypes: [ self.__meta.name, '@apostrophecms/page' ]
}
);
},
async restore(req) {
if (!Array.isArray(req.body._ids)) {
throw self.apos.error('invalid');
}
const ids = req.body._ids.map(_id => {
return self.inferIdLocaleAndMode(req, _id);
});
const patches = await self.getBatchRestorePatches(req, ids);
return self.apos.modules['@apostrophecms/job'].runBatch(
req,
patches.map(patch => patch._id),
async function(req, id) {
const patch = patches.find(patch => patch._id === id);
await self.patch(
req.clone({
mode: 'draft',
body: patch.body
}),
patch._id
);
},
{
action: 'restore',
docTypes: [ self.__meta.name, '@apostrophecms/page' ]
}
);
},
localize(req) {
req.body.type = 'apostrophe:pages';
return self.apos.modules['@apostrophecms/job'].run(
req,
(req, reporting) => self.apos.modules['@apostrophecms/i18n']
.localizeBatch(req, self, reporting),
{
action: 'localize',
ids: req.body._ids,
docTypes: [ self.__meta.name, '@apostrophecms/page' ]
}
);
}
},
get: {
':_id/locales': async (req) => {
const _id = self.inferIdLocaleAndMode(req, req.params._id);
return {
results: await self.apos.doc.getLocales(req, _id)
};
}
}
};
},
routes(self) {
return {
get: {
// Redirects to the URL of the document in the specified alternate
// locale. Issues a 404 if the document not found, a 400 if the
// document has no URL
':_id/locale/:toLocale': self.apos.i18n.toLocaleRouteFactory(self)
}
};
},
handlers(self) {
return {
'@apostrophecms/page-type:beforeSave': {
handleParkedFieldsOverride(req, doc) {
if (!doc.parkedId) {
return;
}
const parked = self.parked.find(p => p.parkedId === doc.parkedId);
if (!parked) {
return;
}
const parkedFields = Object.keys(parked).filter(field => field !== '_defaults');
for (const parkedField of parkedFields) {
doc[parkedField] = parked[parkedField];
}
}
},
beforeSend: {
async addLevelAttributeToBody(req) {
// Add level as a data attribute on the body tag
// The admin bar uses this to stay open if configured by the user
if (typeof _.get(req, 'data.page.level') === 'number') {
self.apos.template.addBodyDataAttribute(req, { 'apos-level': req.data.page.level });
}
},
async attachHomeBeforeSend(req) {
// Did something else already set it?
if (req.data.home) {
return;
}
// Was this explicitly disabled?
if (self.options.home === false) {
return;
}
// Avoid redundant work when ancestors are available. They won't be
// if they are not enabled OR we're not on a regular CMS page at the
// moment
if (req.data.page && req.data.page._ancestors && req.data.page._ancestors[0]) {
req.data.home = req.data.page._ancestors[0];
return;
}
// Fetch the home page with the same builders used to fetch
// ancestors, for consistency. If builders for ancestors are not
// configured, then by default we still fetch the children of the home
// page, so that tabs are easy to implement. However allow this to be
// expressly shut off:
//
// home: { children: false }
const builders = self.getServePageBuilders().ancestors ||
{
children: !(self.options.home && self.options.home.children === false)
};
const query = self.find(req, { level: 0 }).ancestorPerformanceRestrictions();
_.each(builders, function (val, key) {
query[key](val);
});
req.data.home = await query.toObject();
}
},
'apostrophe:modulesRegistered': {
validateTypeChoices() {
for (const choice of self.typeChoices) {
if (!choice.name) {
throw new Error('One of the page types specified for your types option has no name property.');
}
if (!choice.label) {
throw new Error('One of the page types specified for your types option has no label property.');
}
if (!self.apos.modules[choice.name]) {
let error = `There is no module named ${choice.name}, but it is configured as a page type\nin your types option.`;
if (choice.name === 'home-page') {
error += '\n\nYou probably meant @apostrophecms/home-page.';
}
throw new Error(error);
}
}
},
detectSchemaConflicts() {
for (const left of self.typeChoices) {
for (const right of self.typeChoices) {
const diff = compareSchema(left, right);
if (diff.size) {
self.apos.util.warnDev(`The page type "${left.name}" has a conflict with "${right.name}" (${formatDiff(diff)}). This may cause errors or other problems when an editor switches page types.`);
}
}
}
function compareSchema(left, right) {
const conflicts = new Map();
if (left.name === right.name) {
return conflicts;
}
const leftSchema = self.apos.modules[left.name].schema;
const rightSchema = self.apos.modules[right.name].schema;
for (const leftField of leftSchema) {
const rightField = rightSchema.find(field => field.name === leftField.name);
if (rightField && leftField.type !== rightField.type) {
conflicts.set(leftField.name, [ leftField.type, rightField.type ]);
}
}
return conflicts;
}
function formatDiff(diff) {
return Array.from(diff.entries())
.map(([ entry, [ left, right ] ]) => `${entry}:${left} vs ${entry}:${right}`);
}
},
async manageOrphans() {
const managed = self.apos.doc.getManaged();
const parkedTypes = self.getParkedTypes();
for (const type of parkedTypes) {
if (!_.includes(managed, type)) {
self.apos.util.warnDev(`The park option of the @apostrophecms/page module contains type
${type} but there is no module that manages that type. You must
implement a module of that name that extends @apostrophecms/piece-type
or @apostrophecms/page-type, or remove the entry from park.`);
}
}
const distinct = await self.apos.doc.db.distinct('type');
for (const type of distinct) {
if (!_.includes(managed, type)) {
self.apos.util.warnDev(`The aposDocs mongodb collection contains docs with the type ${type || 'undefined or null'}
but there is no module that manages that type. You must implement
a module of that name that extends @apostrophecms/piece-type or
@apostrophecms/page-type, or remove these documents from the
database.`);
self.apos.doc.managers[type] = {
// Do-nothing placeholder manager
schema: [],
options: {
editRole: 'admin',
publishRole: 'admin'
},
permissions: {},
find(req) {
return [];
},
isLocalized() {
return false;
}
};
}
}
},
composeBatchOperations() {
const groupedOperations = Object.entries(self.batchOperations)
.reduce((acc, [ opName, properties ]) => {
// Check if there is a required schema field for this batch
// operation.
const requiredFieldNotFound = properties.requiredField && !self.schema
.some((field) => field.name === properties.requiredField);
if (requiredFieldNotFound) {
return acc;
}
// Find a group for the operation, if there is one.
const associatedGroup = getAssociatedGroup(opName);
const currentOperation = {
action: opName,
...properties
};
const { action, ...props } = getOperationOrGroup(
currentOperation,
associatedGroup,
acc
);
return {
...acc,
[action]: {
...props
}
};
}, {});
self.batchOperations = Object.entries(groupedOperations)
.map(([ action, properties ]) => ({
action,
...properties
}));
function getOperationOrGroup (currentOp, [ groupName, groupProperties ], acc) {
if (!groupName) {
// Operation is not grouped. Return it as it is.
return currentOp;
}
// Return the operation group with the new operation added.
return {
action: groupName,
...groupProperties,
operations: [
...(acc[groupName] && acc[groupName].operations) || [],
currentOp
]
};
}
// Returns the object entry, e.g., `[groupName, { ...groupProperties
// }]`
function getAssociatedGroup (operation) {
return Object.entries(self.batchOperationsGroups)
.find(([ _key, { operations } ]) => {
return operations.includes(operation);
}) || [];
}
},
composeUtilityOperations() {
self.utilityOperations = Object.entries(self.utilityOperations || {})
.map(([ action, properties ]) => ({
action,
...properties
}));
}
},
'apostrophe:ready': {
addServeRoute() {
self.apos.app.get('*',
(req, res, next) => {
return self.apos.expressCacheOnDemand
? self.apos.expressCacheOnDemand(req, res, next)
: next();
},
self.serve
);
}
}
};
},
methods(self) {
return {
find(req, criteria = {}, options = {}) {
return self.apos.modules['@apostrophecms/any-page-type'].find(req, criteria, options);
},
getIdCriteria(_id) {
return (_id === '_home')
? {
level: 0
}
: (_id === '_archive')
? {
level: 1,
archived: true
}
: {
_id
};
},
// Implementation of the PATCH route. Factored as a method to allow
// it to be called from the universal @apostrophecms/doc PATCH route
// as well.
//
// However if you plan to submit many patches over a period of time while
// editing you may also want to use the advisory lock mechanism.
//
// If `_advisoryLock: { tabId: 'xyz', lock: true }` is passed, the
// operation will begin by obtaining an advisory lock on the document for
// the given context id, and no other items in the patch will be addressed
// unless that succeeds. The client must then refresh the lock frequently
// (by default, at least every 30 seconds) with repeated PATCH requests of
// the `_advisoryLock` property with the same context id. If
// `_advisoryLock: { tabId: 'xyz', lock: false }` is passed, the advisory
// lock will be released *after* addressing other items in the same patch.
// If `force: true` is added to the `_advisoryLock` object it will always
// remove any competing advisory lock.
//
// `_advisoryLock` is only relevant if you plan to make ongoing edits
// over a period of time and wish to avoid conflict with other users. You
// do not need it for one-time patches.
//
// If `input._patches` is an array of patches to the same document, this
// method will iterate over those patches as if each were `input`,
// applying all of them within a single lock and without redundant network
// operations. This greatly improves the performance of saving all changes
// to a document at once after accumulating a number of changes in patch
// form on the front end. If _targetId and _position are present only the
// last such values given in the array of patches are applied.
//
// fetchRelationships can be set to false when utilizing this code
// as part of trusted logic that will address missing documents in
// relationships later.
async patch(req, _id, { fetchRelationships = true } = {}) {
return self.withLock(req, async () => {
const input = req.body;
const keys = Object.keys(input);
let possiblePatchedFields;
if (input._advisoryLock && keys.length === 1) {
possiblePatchedFields = false;
} else if (keys.length === 0) {
possiblePatchedFields = false;
} else {
possiblePatchedFields = true;
}
const page = await self.findOneForEditing(req, { _id });
let result;
if (!page) {
throw self.apos.error('notfound');
}
if (!page._edit) {
throw self.apos.error('forbidden');
}
const patches = Array.isArray(input._patches) ? input._patches : [ input ];
// Conventional for loop so we can handle the last one specially
for (let i = 0; (i < patches.length); i++) {
const input = patches[i];
let tabId = null;
let lock = false;
let force;
if (input._advisoryLock && ((typeof input._advisoryLock) === 'object')) {
tabId = self.apos.launder.string(input._advisoryLock.tabId);
lock = self.apos.launder.boolean(input._advisoryLock.lock);
force = self.apos.launder.boolean(input._advisoryLock.force);
}
if (tabId && lock) {
await self.apos.doc.lock(req, page, tabId, {
force
});
}
self.enforceParkedProperties(req, page, input);
if (possiblePatchedFields) {
await self.applyPatch(req, page, input, { fetchRelationships });
}
if (i === (patches.length - 1)) {
if (possiblePatchedFields) {
await self.update(req, page);
let modified;
if (input._targetId) {
const targetId = self.apos.launder.string(input._targetId);
const position = self.apos.launder.string(input._position);
modified = await self.move(req, page._id, targetId, position);
}
result = await self
.findOneForEditing(req, { _id }, { attachments: true });
if (modified) {
result.__changed = modified.changed;
}
}
}
if (tabId && !lock) {
await self.apos.doc.unlock(req, page, tabId);
}
}
if (!result) {
// Edge case: empty `_patches` array. Don't be a pain,
// return the document as-is
return self.findOneForEditing(req, { _id }, { attachments: true });
}
return result;
});
},
// Apply a single patch to the given page without saving. An
// implementation detail of the patch method, also used by the undo
// mechanism to simulate patches. Does not handle _targetId, that is
// implemented in the patch method.
//
// fetchRelationships can be set to false when utilizing this code
// as part of trusted logic that will address missing documents in
// relationships later.
async applyPatch(req, page, input, { fetchRelationships = true } = {}) {
const manager = self.apos.doc
.getManager(self.apos.launder.string(input.type) || page.type);
if (!manager) {
throw self.apos.error('invalid');
}
self.apos.schema.implementPatchOperators(input, page);
const parentPage = page._ancestors.length &&
page._ancestors[page._ancestors.length - 1];
const schema = self.apos.schema.subsetSchemaForPatch(manager.allowedSchema(req, {
...page,
type: manager.name
}, parentPage), input);
await self.apos.schema.convert(req, schema, input, page, { fetchRelationships });
await manager.emit('afterConvert', req, input, page);
},
// True delete. Will throw an error if the page
// has descendants
async delete(req, page, options = {}) {
return self.apos.doc.delete(req, page, options);
},
getBrowserData(req) {
const browserOptions = _.pick(self, 'action', 'schema', 'types');
_.defaults(browserOptions, {
label: 'apostrophe:page',
pluralLabel: 'apostrophe:pages',
components: {}
});
_.defaults(browserOptions.components, {
editorModal: self.getComponentName('editorModal', 'AposDocEditor'),
managerModal: self.getComponentName('managerModal', 'AposPagesManager')
});
if (req.data.bestPage) {
browserOptions.page = self.pruneCurrentPageForBrowser(req.data.bestPage);
}
browserOptions.name = self.__meta.name;
browserOptions.filters = self.filters;
browserOptions.canPublish = self.apos.permission.can(req, 'publish', '@apostrophecms/any-page-type');
browserOptions.canCreate = self.apos.permission.can(req, 'create', '@apostrophecms/any-page-type', 'draft');
browserOptions.quickCreate = self.options.quickCreate && self.apos.permission.can(req, 'create', '@apostrophecms/any-page-type', 'draft');
browserOptions.localized = true;
browserOptions.autopublish = false;
// A list of all valid page types, including parked pages etc. This is
// not a menu of choices for creating a page manually
browserOptions.validPageTypes = self.apos.instancesOf('@apostrophecms/page-type').map(module => module.__meta.name);
browserOptions.canEdit = self.apos.permission.can(req, 'edit', '@apostrophecms/any-page-type', 'draft');
browserOptions.canLocalize = browserOptions.canEdit &&
browserOptions.localized &&
Object.keys(self.apos.i18n.locales).length > 1 &&
Object.values(self.apos.i18n.locales).some(locale => locale._edit);
browserOptions.batchOperations = self.checkBatchOperationsPermissions(req);
browserOptions.utilityOperations = self.utilityOperations;
browserOptions.canDeleteDraft = self.apos.permission.can(req, 'delete', '@apostrophecms/any-page-type', 'draft');
return browserOptions;