UNPKG

apostrophe

Version:
1,361 lines (1,297 loc) • 133 kB
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;