UNPKG

apostrophe

Version:
1,401 lines (1,343 loc) 53.6 kB
const _ = require('lodash'); module.exports = { extend: '@apostrophecms/doc-type', cascades: [ 'filters', 'columns', 'batchOperations', 'utilityOperations' ], options: { perPage: 50, quickCreate: true, previewDraft: true, showCreate: true, // By default a piece type may be optionally // optionally selected by the user as a related document // when localizing a document that references it // (null means "no opinion"). If set to `true` in your // subclass it is selected by default, if set to `false` // it is not offered at all relatedDocument: null // By default there is no public REST API, but you can configure a // projection to enable one: // publicApiProjection: { // title: 1, // _url: 1, // }, // By default the manager modal only fetches these fields: // { // _id: 1, // _url: 1, // aposDocId: 1, // aposLocale: 1, // aposMode: 1, // docPermissions: 1, // slug: 1, // title: 1, // type: 1, // visibility: 1 // } // plus any fields you’ve added via your `columns()` definitions. // To customize or narrow this, supply your own projection in: // options.managerApiProjection = { /* desired fields here */ } }, fields(self) { return { add: { slug: { type: 'slug', label: 'apostrophe:slug', following: [ 'title', 'archived' ], required: true } }, remove: self.options.singletonAuto ? [ 'title', 'slug', 'archived', 'visibility' ] : [] }; }, columns(self) { return { add: { title: { label: 'apostrophe:title', name: 'title', component: 'AposCellButton' }, labels: { name: 'labels', label: '', component: 'AposCellLabels' }, updatedAt: { name: 'updatedAt', label: 'apostrophe:lastEdited', component: 'AposCellLastEdited' } } }; }, filters: { add: { visibility: { label: 'apostrophe:visibility', inputType: 'radio', choices: [ { value: 'public', label: 'apostrophe:public' }, { value: 'loginRequired', label: 'apostrophe:loginRequired' }, { value: null, label: 'apostrophe:any' } ], // TODO: Delete `allowedInChooser` if not used. allowedInChooser: false, def: null }, 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(${self.options.label})` }, 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:batchPublishFailed' }, 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:batchPublishFailed' }, 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' ] } } }, init(self) { if (!self.options.name) { throw new Error('@apostrophecms/pieces require name option'); } const badFieldName = Object.keys(self.fields).indexOf('type') !== -1; if (badFieldName) { throw new Error(`The ${self.__meta.name} module contains a forbidden field property name: "type".`); } if (!self.options.label) { // Englishify it self.options.label = _.startCase(self.options.name); } self.options.pluralLabel = self.options.pluralLabel || self.options.label + 's'; self.name = self.options.name; self.label = self.options.label; self.pluralLabel = self.options.pluralLabel; self.composeFilters(); self.composeColumns(); self.addToAdminBar(); self.addManagerModal(); self.addEditorModal(); }, restApiRoutes(self) { return { getAll: [ ...self.apos.expressCacheOnDemand ? [ self.apos.expressCacheOnDemand ] : [], async (req) => { await self.publicApiCheckAsync(req); const query = self.getRestQuery(req); const dynamicChoices = self.apos.launder.strings(req.query.dynamicChoices); if (!query.get('perPage')) { query.perPage( self.options.perPage ); } const result = {}; // Also populates totalPages when perPage is present const count = await query.toCount(); if (self.apos.launder.boolean(req.query.count)) { return { count }; } result.pages = query.get('totalPages'); result.currentPage = query.get('page') || 1; result.results = (await query.toArray()) .map(doc => self.removeForbiddenFields(req, doc)); const renderAreas = req.query['render-areas']; const inline = renderAreas === 'inline'; if (inline || self.apos.launder.boolean(renderAreas)) { await self.apos.area.renderDocsAreas(req, result.results, { inline }); } const filterDynamicChoices = await self.apos.schema.getFilterDynamicChoices( req, dynamicChoices, self ); const choicesResults = query.get('choicesResults') || {}; const choices = Object.assign(filterDynamicChoices, choicesResults); if (Object.keys(choices).length) { result.choices = choices; } const countsResult = query.get('countsResults'); if (countsResult) { result.counts = countsResult; } if ( self.options.cache && self.options.cache.api && self.options.cache.api.maxAge ) { self.setMaxAge(req, self.options.cache.api.maxAge); } return result; } ], getOne: [ ...self.apos.expressCacheOnDemand ? [ self.apos.expressCacheOnDemand ] : [], async (req, _id) => { _id = self.inferIdLocaleAndMode(req, _id); await self.publicApiCheckAsync(req); const doc = self.removeForbiddenFields( req, await self.getRestQuery(req).and({ _id }).toObject() ); if ( self.options.cache && self.options.cache.api && 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, doc, maxAge)) { return {}; } } if (!doc) { 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, [ doc ], { inline }); } self.apos.attachment.all(doc, { annotate: true }); return doc; } ], async post(req) { await self.publicApiCheckAsync(req); if (req.body._newInstance) { const { _newInstance, ...body } = req.body; const newInstance = { ...self.newInstance(), ...body }; newInstance._previewable = self.addUrlsViaModule && (await self.addUrlsViaModule.readyToAddUrlsToPieces(req, self.name)); delete newInstance._url; return newInstance; } return await self.convertInsertAndRefresh(req, req.body); }, async put(req, _id) { _id = self.inferIdLocaleAndMode(req, _id); await self.publicApiCheckAsync(req); return self.convertUpdateAndRefresh(req, req.body, _id); }, async delete(req, _id) { _id = self.inferIdLocaleAndMode(req, _id); await self.publicApiCheckAsync(req); const piece = await self.findOneForEditing(req, { _id }); return self.delete(req, piece); }, // 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 } = {}) { _id = self.inferIdLocaleAndMode(req, _id); await self.publicApiCheckAsync(req); return self.convertPatchAndRefresh(req, req.body, _id, { fetchRelationships }); } }; }, apiRoutes(self) { return { get: { // Returns an object with a `results` array containing all locale names // for which the given document has been localized ':_id/locales': async (req) => { const _id = self.inferIdLocaleAndMode(req, req.params._id); return { results: await self.apos.doc.getLocales(req, _id) }; } }, 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); }, async 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 ] } ); }, async archive(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'); } piece.archived = true; await self.update(req, piece); }, { action: 'archive', docTypes: [ self.__meta.name ] } ); }, async restore(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'); } piece.archived = false; await self.update(req, piece); }, { action: 'restore', docTypes: [ self.__meta.name ] } ); }, localize(req) { if (!Array.isArray(req.body._ids)) { throw self.apos.error('invalid'); } if (!Array.isArray(req.body.toLocales)) { throw self.apos.error('invalid'); } req.body.type = req.body._ids.length === 1 ? self.options.label : self.options.pluralLabel; 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 ] } ); }, ':_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); if ((!toLocale) || (toLocale === req.locale)) { throw self.apos.error('invalid'); } const update = self.apos.launder.boolean(req.body.update); 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.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'); } return self.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'); } return self.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; } } }; }, 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 { beforeInsert: { ensureType(req, piece, options) { piece.type = self.name; } }, 'apostrophe:modulesRegistered': { composeBatchOperations() { const groupedOperations = Object.entries(self.batchOperations) .reduce((acc, [ opName, properties ]) => { const disableOperation = self.disableBatchOperation(opName, properties); if (disableOperation) { 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 })); } }, '@apostrophecms/search:determineTypes': { checkSearchable(types) { self.searchDetermineTypes(types); } } }; }, methods(self) { return { // Accepts a doc, a preliminary draft, and the options // originally passed to insert(). Default implementation // inserts `draft` in the database normally. This method is // called only when a draft is being created on the fly // for a published document that does not yet have a draft. // Apostrophe only has one corresponding draft at a time // per published document. `options` is passed on to the // insert operation. async insertDraftOf(req, doc, draft, options) { options = { ...options, setModified: false }; const inserted = await self.insert( req.clone({ mode: 'draft' }), draft, options ); return inserted; }, // Similar to insertDraftOf, invoked on first publication. insertPublishedOf(req, doc, published, options) { // Check publish permission up front because we won't check it // in insert if (!self.apos.permission.can(req, 'publish', doc)) { throw self.apos.error('forbidden'); } return self.insert( req.clone({ mode: 'published' }), published, { ...options, permissions: false } ); }, // Returns one editable piece matching the criteria, throws `notfound` // if none match requireOneForEditing(req, criteria) { const piece = self.findForEditing(req, criteria).toObject(); if (!piece) { throw self.apos.error('notfound'); } return piece; }, // Insert a piece. Convenience wrapper for `apos.doc.insert`. // Returns the piece. `beforeInsert`, `beforeSave`, `afterInsert` // and `afterSave` async events are emitted by this module. async insert(req, piece, options) { piece.type = self.name; return self.apos.doc.insert(req, piece, options); }, // // Update a piece. Convenience wrapper for `apos.doc.insert`. // Returns the piece. `beforeUpdate`, `beforeSave`, `afterUpdate` // and `afterSave` async events are emitted by this module. async update(req, piece, options) { return self.apos.doc.update(req, piece, options); }, // True delete async delete(req, piece, options = {}) { return self.apos.doc.delete(req, piece, options); }, // Enable inclusion of this type in sitewide search results searchDetermineTypes(types) { if (self.options.searchable !== false) { types.push(self.name); } }, addToAdminBar() { self.apos.adminBar.add( `${self.__meta.name}:manager`, self.pluralLabel, { action: 'edit', type: self.name } ); }, addManagerModal() { self.apos.modal.add( `${self.__meta.name}:manager`, self.getComponentName('managerModal', 'AposDocsManager'), { moduleName: self.__meta.name } ); }, addEditorModal() { self.apos.modal.add( `${self.__meta.name}:editor`, self.getComponentName('editorModal', 'AposDocEditor'), { moduleName: self.__meta.name } ); }, // Add `._url` properties to the given pieces, if possible. async addUrls(req, pieces) { if (self.addUrlsViaModule) { return self.addUrlsViaModule.addUrlsToPieces(req, pieces); } }, // Typically called by a piece-page-type to register itself as the // module providing `_url` properties to this type of piece. The addUrls // method will invoke the addUrlsToPieces method of that type. addUrlsVia(module) { self.addUrlsViaModule = module; }, // Implements a simple batch operation like publish or unpublish. // Pass `req`, the `name` of a configured batch operation, and // and a function that accepts (req, piece, data), // and returns a promise to perform the modification on that // one piece (including calling `update` if appropriate). // // `data` is an object containing any schema fields specified // for the batch operation. If there is no schema it will be // an empty object. // // Replies immediately to the request with `{ jobId: 'xxxxx' }`. // This can then be passed to appropriate browser-side APIs // to monitor progress. // // To avoid RAM issues with very large selections while ensuring // that all lifecycle events are fired correctly, the current // implementation processes the pieces in series. // TODO: restore this method when fully implemented. // async batchSimpleRoute(req, name, change) { // const batchOperation = _.find(self.batchOperations, { name: name }); // const schema = batchOperation.schema || []; // const data = self.apos.schema.newInstance(schema); // await self.apos.schema.convert(req, schema, req.body, data); // await self.apos.modules['@apostrophecms/job'].runBatch(req, one, { // // TODO: Update with new progress notification config // }); // async function one(req, id) { // const piece = self.findForEditing(req, { _id: id }).toObject(); // if (!piece) { // throw self.apos.error('notfound'); // } // await change(req, piece, data); // } // }, // Accept a piece as untrusted input potentially // found in `input` (hint: you can pass `req.body` // if your route accepts the piece via POST), using // schema-based convert mechanisms. // // In addition to fields defined in the schema, additional // `area` properties are accepted at the root level. // // Inserts it into the database, fetches it again to get all // relationships, and returns the result (note it is an async function). // // If `input._copyingId` is present, fetches that // piece and, if we have permission to view it, copies any schema // properties not defined in `input`. `_copyingId` becomes the `copyOfId` // property of the doc, which may be watched for in event handlers to // detect copies. // // Only fields that are not undefined in `input` are // considered. The rest respect their defaults. To intentionally // erase a field's contents use `null` for that input field or another // representation appropriate to the type, i.e. an empty string for a // string. // // The module emits the `afterConvert` async event with `(req, input, // piece)` before inserting the piece. async convertInsertAndRefresh(req, input, options) { const piece = self.newInstance(); const copyingId = self.apos.launder.id(input._copyingId); const createId = self.apos.launder.id(input._createId); await self.convert(req, input, piece, { copyingId, createId }); await self.emit('afterConvert', req, input, piece); await self.insert(req, piece); return self.findOneForEditing( req, { _id: piece._id }, { attachments: true, permission: 'create' } ); }, // Similar to `convertInsertAndRefresh`. Update the piece with the given // _id, based on the `input` object (which may be untrusted input such as // req.body). Fetch the updated piece to populate all relationships and // return it. // // Any fields not present in `input` are regarded as empty, if permitted // (REST PUT semantics). For partial updates use convertPatchAndRefresh. // Employs a lock to avoid overwriting the work of concurrent PUT and // PATCH calls or getting into race conditions with their side effects. // // 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 convertUpdateAndRefresh(req, input, _id) { return self.apos.lock.withLock(`@apostrophecms/${_id}`, async () => { const piece = await self.findOneForEditing(req, { _id }); if (!piece) { throw self.apos.error('notfound'); } if (!piece._edit) { throw self.apos.error('forbidden'); } 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, piece, tabId, { force }); } await self.convert(req, input, piece); await self.emit('afterConvert', req, input, piece); await self.update(req, piece); if (tabId && !lock) { await self.apos.doc.unlock(req, piece, tabId); } return self.findOneForEditing(req, { _id }, { attachments: true }); }); }, // Similar to `convertUpdateAndRefresh`. Patch the piece with the given // _id, based on the `input` object (which may be untrusted input such as // req.body). Fetch the updated piece to populate all relationships and // return it. Employs a lock to avoid overwriting the work of simultaneous // PUT and PATCH calls or getting into race conditions with their side // effects. 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 `input._publish` launders to a truthy boolean and the type is // subject to draft/publish workflow, it is automatically published at the // end of the patch operation. // // As an optimization, and to prevent unnecessary updates of `updatedAt`, // no calls to `self.update()` are made when only `_advisoryLock` is // present in `input` or it contains no properties at all. // // You can pass fetchRelationships: false to skip the check for whether // related documents in relationships actually exist. async convertPatchAndRefresh(req, input, _id, { fetchRelationships = true } = {}) { 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; } return self.apos.lock.withLock(`@apostrophecms/${_id}`, async () => { const piece = await self.findOneForEditing(req, { _id }); let result; if (!piece) { throw self.apos.error('notfound'); } 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 = 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, piece, tabId, { force }); } if (possiblePatchedFields) { await self.applyPatch(req, piece, input, { force: self.apos.launder.boolean(input._advisory) }, { fetchRelationships }); } if (i === patches.length - 1) { if (possiblePatchedFields) { await self.update(req, piece); } result = self.findOneForEditing( req, { _id }, { attachments: true } ); } if (tabId && !lock) { await self.apos.doc.unlock(req, piece, 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 }); } if (self.apos.launder.boolean(input._publish)) { if (self.options.localized && !self.options.autopublish) { if (piece.aposLocale.includes(':draft')) { await self.publish(req, piece, {}); } } } return result; }); }, // Apply a single patch to the given piece without saving. // An implementation detail of convertPatchAndRefresh, // also used by the undo mechanism to simulate patches. // // `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, piece, input, { fetchRelationships = true } = {}) { self.apos.schema.implementPatchOperators(input, piece); const schema = self.apos.schema.subsetSchemaForPatch( self.allowedSchema(req), input ); await self.apos.schema.convert(req, schema, input, piece, { fetchRelationships }); await self.emit('afterConvert', req, input, piece); }, // Generate a sample piece of this type. The `i` counter // is used to distinguish it from other samples. Useful // for things like testing pagination, see the // `your-piece-type:generate` task. generate(i) { const piece = self.newInstance(); piece.title = 'Generated #' + (i + 1); return piece; }, // Can be extended on a project level with `_super(req, true)` to disable // permission check and public API projection. You shouldn't do this // if you're not sure what you're doing. getRestQuery(req, omitPermissionCheck = false) { const query = self.find(req).attachments(true); query.applyBuildersSafely(req.query); if (!omitPermissionCheck && !self.canAccessApi(req)) { if (!self.options.publicApiProjection) { // Shouldn't be needed thanks to publicApiCheck, but be sure query.and({ _id: null }); } else if (!query.state.project) { query.project({ ...self.options.publicApiProjection, cacheInvalidatedAt: 1 }); } } return query; }, // Throws a `notfound` exception if a public API projection is // not specified and the user does not have the `view-draft` permission, // which all roles capable of editing the site at all will have. // This is needed because although all API calls check permissions // specifically where appropriate, we also want to flunk all public access // to REST APIs if not specifically configured. publicApiCheck(req) { if (!self.options.publicApiProjection) { if (!self.canAccessApi(req)) { throw self.apos.error('notfound'); } } }, // An async version of the above. It can be overridden to implement // an asynchronous check of the public API permissions. async publicApiCheckAsync(req) { return self.publicApiCheck(req); }, // If the piece does not yet have a slug, add one based on the // title; throw an error if there is no title ensureSlug(piece) { if (!piece.slug || piece.slug === 'none') { if (piece.title) { piece.slug = self.apos.util.slugify(piece.title); } else if (piece.slug !== 'none') { throw self.apos.error( 'invalid', 'Document has neither slug nor title, giving up' ); } } }, async flushInsertsAndDeletes(inserts, deletes, { force = false }) { if (inserts.length > 100 || (force && inserts.length)) { await self.apos.doc.db.insertMany(inserts); inserts.splice(0); } if (deletes.length > 100 || (force && deletes.length)) { await self.apos.doc.db.deleteMany({ _id: { $in: deletes } }); deletes.splice(0); } }, checkBatchOperationsPermissions(req) { return self.batchOperations.filter(batchOperation => { if (batchOperation.permission) { return self.apos.permission.can(req, batchOperation.permission, self.name); } return true; }); }, getManagerApiProjection(req) { // If not configured at all, return null to fetch everything if (self.options.managerApiProjection === undefined) { return null; } // Start from the configured projection, or fall back // to the base essential fields const essentialFields = { _id: 1, _url: 1, aposDocId: 1, aposLocale: 1, aposMode: 1, docPermissions: 1, slug: 1, title: 1, type: 1, visibility: 1 }; // Handle special case where user passes `true` to get minimal defaults let configuredProjection; if (self.options.managerApiProjection === true) { configuredProjection = {}; } else { configuredProjection = self.options.managerApiProjection; } // Always add essential fields, even if not in user's projection const projection = { ...configuredProjection, ...essentialFields }; self.columns.forEach(({ name }) => { // Strip “draft:” or “published:” prefixes if present const column = name.replace(/^(draft|published):/, ''); projection[column] = 1; }); return projection; }, async insertIfMissing() { if (!self.options.singletonAuto) { return; } // Insert at startup const req = self.apos.task.getReq(); const criteria = { type: self.name }; if (self.options.localized) { criteria.aposLocale = { $in: Object.keys(self.apos.i18n.locales).map(locale => [ `${locale}:published`, `${locale}:draft` ]).flat() }; } const existing = await self.apos.doc.db.findOne(criteria, { _id: 1 }); if (!existing) { const _new = { ...self.newInstance(), aposDocId: await self.apos.doc.bestAposDocId({ type: self.name }) }; await self.insert(req, _new); } }, disableBatchOperation(name, properties) { const shouldDisablePublish = name === 'publish' && self.options.autopublish; if (shouldDisablePublish) { return true; } // 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 true; } return false; } }; }, extendMethods(self) { return { getBrowserData(_super, req) { const browserOptions = _super(req); // Options specific to pieces and their manage modal browserOptions.filters = self.filters; browserOptions.columns = self.columns; browserOptions.batchOperations = self.checkBatchOperationsPermissions(req); browserOptions.utilityOperations = self.utilityOperations; browserOptions.insertViaUpload = self.options.insertViaUpload; browserOptions.quickCreate = !self.options.singleton && self.options.quickCreate && browserOptions.canCreate; browserOptions.singleton = self.options.singleton; browserOptions.showCreate = !self.options.singleton && self.options.showCreate; browserOptions.showDismissSubmission = self.options.showDismissSubmission; browserOptions.showArchive = self.options.showArchive; browserOptions.showDiscardDraft = self.options.showDiscardDraft; browserOptions.canDeleteDraft = self.apos.permission.can( req, 'delete', self.name, 'draft' ); browserOptions.contentChangedRefresh = self.options .contentChangedRefresh !== false; _.defaults(browserOptions, { components: {} }); _.defaults(browserOptions.components, { editorModal: self.getComponentName('editorModal', 'AposDocEditor'), managerModal: self.getComponentName('managerModal', 'AposDocsManager') }); browserOptions.managerApiProjection = self.getManagerApiProjection(req); browserOptions.emptyState = self.options.emptyState; return browserOptions; }, find(_super, req, criteria, options) { return _super(req, criteria, options) .defaultSort(self.options.sort || { updatedAt: -1 }); }, newInstance(_super) { if (!self.options.singletonAuto) { return _super(); } const slug = self.apos.util .slugify(self.options.singletonAuto?.slug || self.name); return { ..._super(), // These fields are removed from the editable schema of singletons, // but we assign them directly for broader compatibility slug, title: slug, archived: false, visibility: 'public' }; } }; }, tasks(self) { return (self.options.editRole === 'admin') ? {} : { generate: { usage: 'Invoke this task to generate sample docs of this type. Use the --total option to control how many are added to the database.\nYou can remove them all later with the --remove option.', async task(argv) { if (argv.remove) { return remove(); } else { return generate(); } async function generate() { const total = argv.total || 10; const req = self.apos.task.getReq(); for (let i = 0; i < total; i++) { const piece = self.generate(i); piece.aposSampleData = true; await self.insert(req, piece); } } async function remove() { return self.apos.doc.db.deleteMany({ type: self.name, aposSampleData: true }); } } }, localize: { usage: 'Add draft version documents for each locale when a module has the "localized" option.' + '\nExample: node app [moduleName]:localize', async task() { if (!self.options.localized) { throw new Error('Localized option not set to true, so the module cannot be localized.'); } console.log('Adding drafts and locales to documents'); const locales = Object.keys(self.apos.i18n.locales); const lastPublishedAt = new Date(); const inserts = []; const deletes = []; await self.apos.migration.eachDoc({ type: self.name }, async doc => { if (doc.aposDocId && !doc._id.endsWith('published') && !doc._id.endsWith('draft')) { deletes.push(doc._id); for (const locale of locales) { const newDraft = { ...doc, aposLocale: `${locale}:draft`, aposMode: 'draft', aposDocId: doc._id, _id: `${doc.aposDocId}:${locale}:draft` }; const newPublished = { ...doc, aposLocale: `${locale}:published`, aposMode: 'published', aposDocId: doc._id, _id: `${doc.aposDocId}:${locale}:published`, lastPublishedAt }; inserts.push(newDraft); inserts.push(newPublished); await self.flushInserts