UNPKG

apostrophe

Version:
1,332 lines (1,287 loc) • 129 kB
const { SemanticAttributes } = require('@opentelemetry/semantic-conventions'); const _ = require('lodash'); const util = require('util'); const extendQueries = require('./lib/extendQueries'); module.exports = { options: { localized: true, contextBar: true, editRole: 'contributor', publishRole: 'editor', viewRole: false, previewDraft: true, relatedDocType: null, relationshipSuggestionLabel: 'apostrophe:relationshipSuggestionLabel', relationshipSuggestionHelp: 'apostrophe:relationshipSuggestionHelp', relationshipSuggestionLimit: 25, relationshipSuggestionSort: { updatedAt: -1 }, relationshipSuggestionIcon: 'text-box-icon', relationshipSuggestionFields: [ 'slug' ] }, // Adding permissions for advanced permissions to allow modules to use it // without being forced to check if the module is used with advanced // permissions or not. cascades: [ 'fields', 'permissions' ], fields(self) { return { add: { title: { type: 'string', label: 'apostrophe:title', required: true, // Generate a titleSort property which can be sorted // in a human-friendly way (case insensitive, ignores the // same stuff slugs ignore) sortify: true }, slug: { type: 'slug', label: 'apostrophe:slug', following: [ 'title', 'archived' ], required: true }, archived: { type: 'boolean', label: 'apostrophe:archived', contextual: true, def: false }, visibility: { type: 'select', label: 'apostrophe:visibility', help: 'apostrophe:visibilityHelp', def: 'public', required: true, choices: [ { value: 'public', label: 'apostrophe:public' }, { value: 'loginRequired', label: 'apostrophe:loginRequired' } ] } }, group: { basics: { label: 'apostrophe:basics', fields: [ 'title' ] }, utility: { fields: [ 'slug', 'visibility' ] } } }; }, commands(self) { if ( self.__meta.name === '@apostrophecms/any-doc-type' || self.__meta.name === '@apostrophecms/global' || self.apos.instanceOf(self, '@apostrophecms/any-page-type') || self.apos.instanceOf(self, '@apostrophecms/page-type') || self.options.showCreate === false || self.options.showPermissions === false ) { return null; } return { add: { [`${self.__meta.name}:manager`]: { type: 'item', label: self.options.label, 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(self.options.label).slice(0, 1)}` }, [`${self.__meta.name}:create-new`]: { type: 'item', label: { key: 'apostrophe:commandMenuCreateNew', type: self.options.label }, action: { type: 'command-menu-manager-create-new' }, permission: { action: 'create', type: self.__meta.name }, shortcut: 'C' }, [`${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' ] } } } }; }, init(self) { if (!self.options.name) { self.options.name = self.__meta.name; } if (self.options.singletonAuto) { self.options.singleton = true; } if (self.options.replicate === undefined) { self.options.replicate = self.options.localized && self.options.singletonAuto; } self.name = self.options.name; // Each doc-type has an array of fields which will be updated // if the document is moved to the archive. In most cases 'slug' // might suffice. For users, for instance, the email field should // be prefixed (de-duplicated) so that the email address is available. // An archive prefix should always be used for fields that have no bearing // on page tree relationships. A suffix should always be used for fields // that do (`slug` and `path`). // // For suffixes, @apostrophecms/page will take care of adding and removing // them from earlier components in the path or slug as required. self.deduplicatePrefixFields = [ 'slug' ]; self.deduplicateSuffixFields = []; self.composeSchema(); self.apos.doc.setManager(self.name, self); self.enableBrowserData(); self.addContextMenu(); // force autopublish to false when not localized to avoid bizarre // configuration if (!self.options.localized) { self.options.autopublish = false; } }, handlers(self) { return { beforeSave: { prepareForStorage(req, doc, options) { self.apos.schema.prepareForStorage(req, doc, options); }, async updateCacheField(req, doc) { await self.updateCacheField(req, doc); }, slugPrefix(req, doc) { const prefix = self.options.slugPrefix; if (prefix) { if (!doc.slug) { doc.slug = 'none'; } let archivePrefix; const archivedRegexp = new RegExp(`^deduplicate-[a-z0-9]+-${self.apos.util.regExpQuote(prefix)}`); // The doc may be going from archived to published, so it won't have // doc.archived === true. Remove the dedupe prefix, check the slug // prefix, then reapply the dedupe prefix. if (doc.slug.match(archivedRegexp)) { archivePrefix = doc.slug.match(/^deduplicate-[a-z0-9]+-/); doc.slug = doc.slug.replace(archivePrefix, ''); } if (!doc.slug.startsWith(prefix)) { doc.slug = `${prefix}${doc.slug}`; } if (archivePrefix) { doc.slug = `${archivePrefix}${doc.slug}`; } } } }, afterSave: { async emitAfterArchiveOrAfterRescue(req, doc) { if (doc.archived && (!doc.aposWasArchived)) { await self.apos.doc.db.updateOne({ _id: doc._id }, { $set: { aposWasArchived: true } }); return self.emit('afterArchive', req, doc); } else if ((!doc.archived) && (doc.aposWasArchived)) { await self.apos.doc.db.updateOne({ _id: doc._id }, { $set: { aposWasArchived: false } }); return self.emit('afterRescue', req, doc); } }, async autopublish(req, doc, options) { if (!self.options.autopublish) { return; } if (doc.aposLocale.includes(':draft')) { return self.publish(req, doc, { ...options, autopublishing: true }); } } }, afterArchive: { async retainOnlyAsDraft(req, doc) { if (!self.options.localized) { return; } if (self.options.autopublish) { return; } if (!doc._id.includes(':draft')) { return; } if (doc.parkedId === 'archive') { // The root trash can exists in both draft and published to // avoid overcomplicating parked pages return; } if (doc.modified) { doc = await self.revertDraftToPublished(req, doc, { overrides: { archived: true } }); } return self.unpublish(req, doc, { descendantsMustNotBePublished: false }); }, async deduplicate(req, doc) { const $set = await self.getDeduplicationSet(req, doc); Object.assign(doc, $set); if (Object.keys($set).length) { return self.apos.doc.db.updateOne({ _id: doc._id }, { $set }); } } }, afterDelete: { async deleteRelatedReverseId(req, doc) { // When deleting an unlocalized or draft document, // we remove related reverse IDs of documents having a relation to // the deleted one if (!doc.aposMode || doc.aposMode === 'draft') { await self.deleteRelatedReverseId(doc, true); } } }, afterRescue: { async revertDeduplication(req, doc) { const $set = await self.getRevertDeduplicationSet(req, doc); if (Object.keys($set).length) { Object.assign(doc, $set); return self.apos.doc.db.updateOne({ _id: doc._id }, { $set }); } } }, '@apostrophecms/search:index': { // When a doc is indexed for search, this method adds // additional search texts to the `texts` array (modify it in place by // pushing new objects to it). These texts influence search results. // The default behavior is quite useful, so you won't often need to // override this. // // Each "text" is an *object* and must have at least `weight` and // `text` properties. If `weight` is >= 10, the text will be included in // autocomplete searches and given higher priority in full-text // searches. Otherwise it will be included only in full-text searches. // // If `silent` is `true`, the `searchSummary` property will not contain // the text. // // By default this method invokes `schemas.indexFields`, which will push // texts for all of the schema fields that support this unless they are // explicitly set `searchable: false`. // // In any case, the text of rich text widgets is always included as // lower-priority search text. async searchIndexBySchema(doc, texts) { if (doc.type !== self.name) { return; } await self.apos.schema.indexFields(self.schema, doc, texts); } } }; }, methods(self) { return { async deleteRelatedReverseId(doc, deleting = false) { const locales = doc.aposLocale && deleting ? [ doc.aposLocale.replace(':draft', ':published'), doc.aposLocale.replace(':published', ':draft') ] : [ doc.aposLocale ]; return self.apos.doc.db.updateMany({ relatedReverseIds: { $in: [ doc.aposDocId ] }, aposLocale: { $in: [ ...locales, null ] } }, { $pull: { relatedReverseIds: doc.aposDocId }, $set: { cacheInvalidatedAt: doc.updatedAt } }); }, async updateCacheField(req, doc) { const relatedDocsIds = self.getRelatedDocsIds(req, doc); // - Remove current doc reference from docs that include it // - Update these docs' cache field await this.deleteRelatedReverseId(doc); if (relatedDocsIds.length) { // - Add current doc reference to related docs // - Update related docs' cache field await self.apos.doc.db.updateMany({ aposDocId: { $in: relatedDocsIds }, aposLocale: { $in: [ doc.aposLocale, null ] } }, { $push: { relatedReverseIds: doc.aposDocId }, $set: { cacheInvalidatedAt: doc.updatedAt } }); } if (doc.relatedReverseIds && doc.relatedReverseIds.length) { // Update related reverse docs' cache field await self.apos.doc.db.updateMany({ aposDocId: { $in: doc.relatedReverseIds }, aposLocale: { $in: [ doc.aposLocale, null ] } }, { $set: { cacheInvalidatedAt: doc.updatedAt } }); } if (doc._parentSlug) { // Update piece index page's cache field await self.apos.doc.db.updateOne({ slug: doc._parentSlug, aposLocale: { $in: [ doc.aposLocale, null ] } }, { $set: { cacheInvalidatedAt: doc.updatedAt } }); } }, addContextMenu() { self.apos.doc.addContextOperation({ action: 'shareDraft', context: 'update', label: 'apostrophe:shareDraft', modal: 'AposModalShareDraft', manuallyPublished: true, hasUrl: true, conditions: [ 'canShareDraft' ] }); }, getRelatedDocsIds(req, doc) { const relatedDocsIds = []; const handlers = { relationship: (field, doc) => { relatedDocsIds.push(...(doc[field.idsStorage] || [])); } }; self.apos.doc.walkByMetaType(doc, handlers); return relatedDocsIds; }, sanitizeFieldList(choices) { if ((typeof choices) === 'string') { return choices.split(/\s*,\s*/); } else { return self.apos.launder.strings(choices); } }, addDeduplicatePrefixFields(fields) { self.deduplicatePrefixFields = self.deduplicatePrefixFields.concat(fields); }, removeDeduplicatePrefixFields(fields) { self.deduplicatePrefixFields = _.difference(self.deduplicatePrefixFields, fields); }, addDeduplicateSuffixFields(fields) { self.deduplicateSuffixFields = self.deduplicateSuffixFields.concat(fields); }, removeDeduplicateSuffixFields(fields) { self.deduplicateSuffixFields = _.difference(self.deduplicateSuffixFields, fields); }, // Returns a query that will only yield docs of the appropriate type // as determined by the `name` option of the module. // `criteria` is the MongoDB criteria object, and any properties of // `options` are invoked as query builder methods on the query with // their values. find(req, criteria, options) { const query = { state: {}, methods: {}, builders: {}, afters: {} }; for (const name of self.__meta.chain.map(entry => entry.name)) { const fn = self.queries[name]; if (fn) { const result = fn(self, query); if (result.builders) { Object.assign(query.builders, result.builders); } if (result.methods) { Object.assign(query.methods, result.methods); } } if (self.extendQueries[name]) { const extendedQueries = self.extendQueries[name](self, query); extendQueries(query.builders, extendedQueries.builders || {}); extendQueries(query.methods, extendedQueries.methods || {}); } } Object.assign(query, query.methods); for (const [ name, definition ] of Object.entries(query.builders)) { query.addBuilder(name, definition); } // Add query builders for each field in the schema that doesn't // yet have one based on its schema field type self.apos.schema.addQueryBuilders(self.schema, query); query.handleFindArguments({ req, criteria, options }); query.type(self.options.name); return query; }, // Returns a new instance of the doc type, with the appropriate default // values for each schema field. newInstance() { const doc = self.apos.schema.newInstance(self.schema); doc.type = self.name; return doc; }, // Returns a MongoDB projection object to be used when querying // for this type if all that is needed is a title for display // in an autocomplete menu. Default behavior is to // return only the `title`, `_id` and `slug` properties. // Removing any of these three is not recommended. // `aposDocId` is required for building template filters when static // url's are enabled, `type` is required for various features including // permissions - do not remove these unless you know what you are doing. // // `query.field` will contain the schema field definition for // the relationship the user is attempting to match titles from. getRelationshipQueryBuilderChoicesProjection(query) { const projection = self.getAutocompleteProjection(query); return { ...projection, title: 1, type: 1, _id: 1, aposDocId: 1, _url: 1, slug: 1 }; }, // Returns a MongoDB projection object to be used when querying // for this type if all that is needed is a title for display // in an autocomplete menu. Default behavior is to // return only the `title`, `_id` and `slug` properties. // Removing any of these three is not recommended. // // `query.field` will contain the schema field definition for // the relationship the user is attempting to match titles from. getAutocompleteProjection(query) { return { title: 1, _id: 1, slug: 1 }; }, // Returns a string to represent the given `doc` in an // autocomplete menu. `doc` will contain only the fields returned // by `getAutocompleteProjection`. `query.field` will contain // the schema field definition for the relationship the user is attempting // to match titles from. The default behavior is to return // the `title` property. This is sometimes extended to include // event start dates and similar information that helps the // user distinguish between docs. getAutocompleteTitle(doc, query) { // TODO Remove in next major version. self.apos.util.warnDevOnce( 'deprecate-get-autocomplete-title', 'self.getAutocompleteTitle() is deprecated. Use the autocomplete(\'...\') query builder instead. More info at https://v3.docs.apostrophecms.org/reference/query-builders.html#autocomplete' ); return doc.title; }, // Used by `@apostrophecms/version` to label changes that // are made to relationships by ID. Set `change.text` to the // desired text. decorateChange(doc, change) { change.text = doc.title; }, // Return a new schema containing only fields for which the // current user has the permission specified by the `editPermission` // property of the schema field, or there is no // `editPermission`|`viewPermission` property for the field. allowedSchema(req) { let disabled; let type; const schema = _.filter(self.schema, function (field) { const canEdit = () => self.apos.permission.can( req, field.editPermission.action, field.editPermission.type ); const canView = () => self.apos.permission.can( req, field.viewPermission.action, field.viewPermission.type ); return (!field.editPermission && !field.viewPermission) || (field.editPermission && canEdit()) || (field.viewPermission && canView()) || false; }); const typeIndex = _.findIndex(schema, { name: 'type' }); if (typeIndex !== -1) { // This option exists so that the // @apostrophecms/option-overrides and @apostrophecms/workflow // modules, if present, can be used together to disable various types // based on locale settings disabled = self.apos.page.getOption(req, 'disabledTypes'); if (disabled) { // Take care to clone so we don't wind up modifying // the original schema, the allowed schema is only // a shallow clone of the array so far type = _.cloneDeep(schema[typeIndex]); type.choices = _.filter(type.choices, function (choice) { return !_.includes(disabled, choice.value); }); // Make sure the allowed schema refers to the clone, // not the original schema[typeIndex] = type; } } return schema; }, composeSchema() { self.schema = self.apos.schema.compose({ addFields: self.apos.schema.fieldsToArray(`Module ${self.__meta.name}`, self.fields), arrangeFields: self.apos.schema.groupsToArray(self.fieldsGroups) }, self); if (self.options.slugPrefix) { if (self.options.slugPrefix === 'deduplicate-') { const req = self.apos.task.getReq(); throw self.apos.error('invalid', req.t('apostrophe:deduplicateSlugReserved')); } const slug = self.schema.find(field => field.name === 'slug'); if (slug) { slug.prefix = self.options.slugPrefix; } } // Extend `composeSchema` to flag the use of field names // that are forbidden or nonfunctional in all doc types, i.e. // properties that will be overwritten by non-schema-driven // logic, such as `_id` or `docPermissions` const forbiddenFields = [ '_id', 'titleSortified', 'highSearchText', 'highSearchWords', 'lowSearchText', 'searchSummary' ]; _.each(self.schema, function (field) { if (_.includes(forbiddenFields, field.name)) { throw new Error('Doc type ' + self.name + ': the field name ' + field.name + ' is forbidden'); } }); }, isLocalized() { return this.options.localized; }, // This method provides the back end of /autocomplete routes. // For the implementation of the autocomplete() query builder see // autocomplete.js. // // "query" must contain a "field" property which is the schema // relationship field that describes the relationship we're adding items // to. // // "query" must also contain a "term" property, which is a partial // string to be autocompleted; otherwise an empty array is returned. // // We don't launder the input here, see the 'autocomplete' route. async autocomplete(req, query) { // TODO Remove in next major version. self.apos.util.warnDevOnce( 'deprecate-autocomplete', 'self.autocomplete() is deprecated. Use the autocomplete(\'...\') query builder instead. More info at https://v3.docs.apostrophecms.org/reference/query-builders.html#autocomplete' ); const _query = query.find(req, {}).sort('search'); if (query.extendAutocompleteQuery) { query.extendAutocompleteQuery(_query); } _query.project(self.getAutocompleteProjection(), query); // Try harder not to call autocomplete with something that doesn't // result in a search if (query.term && query.term.toString && query.term.toString().length) { const term = self.apos.launder.string(query.term); _query.autocomplete(term); } else { return []; } if (!(query.builders && query.builders.limit)) { _query.limit(10); } _query.applyBuildersSafely(query.field.builders || {}); // Format it as value & label properties for compatibility with // our usual assumptions on the front end let docs = await _query.toArray(); // Put the snippets in id order if (query.values) { docs = self.apos.util.orderById(query.values, docs); } const results = _.map(docs, doc => { const response = { label: self.getAutocompleteTitle(doc, query), value: doc._id }; _.defaults(response, _.omit(doc, 'title', '_id')); return response; }); return results; }, // Fields required to compute the `_url` property. // Used to implement a "projection" for `_url` if // requested by the developer getUrlFields() { return [ 'type', 'slug' ]; }, // Most of the time, this is called for you. Any schema field // with `sortify: true` will automatically get a migration to // ensure that, if the field is named `lastName`, then // `lastNameSortified` exists. // // Adds a migration that takes the given field, such as `lastName`, and // creates a parallel `lastNameSortified` field, formatted with // `apos.util.sortify` so that it sorts and compares in a more // intuitive, case-insensitive way. // // After adding such a migration, you can add `sortify: true` to the // schema field declaration for `field`, and any calls to // the `sort()` query builder for `lastName` will automatically // use `lastNameSortified`. You can also do that explicitly of course. // // Note that you want to do both things (add the migration, and // add `sortify: true`) because `sortify: true` guarantees that // `lastNameSortified` gets updated on all saves of a doc of this type. // The migration is a one-time fix for existing data. addSortifyMigration(field) { if (!self.name) { return; } return self.apos.migration.addSortify( self.__meta.name, { type: self.name }, field ); }, // Convert the untrusted data supplied in `input` via the schema and // update the doc object accordingly. // // If `options.presentFieldsOnly` is true, only fields that exist in // `input` are affected. Otherwise any absent fields get their default // values. // // To intentionally erase a field's contents when this option // is present, use `null` for that input field or another representation // appropriate to the type, i.e. an empty string for a string. // // If `options.copyingId` is present, the doc with the given id is // fetched and used as defaults for any schema fields not defined // in `input`. This overrides `presentFieldsOnly` as long as the fields // in question exist in the doc being copied. Also, the _id of the copied // doc is copied to the `copyOfId` property of doc. async convert(req, input, doc, options = { presentFieldsOnly: false, fetchRelationships: true, type: null, copyingId: null, createId: null }) { const fullSchema = self.apos.doc.getManager(options.type || self.name) .allowedSchema(req, doc); let schema; let copyOf; if (options.presentFieldsOnly) { schema = self.apos.schema.subset(fullSchema, self.fieldsPresent(input)); } else { schema = fullSchema; } if (options.copyingId) { copyOf = await self.findOneForCopying(req, { _id: options.copyingId }); if (!copyOf) { throw self.apos.error('notfound'); } input = { ...copyOf, ...input }; } const convertOptions = { fetchRelationships: options.fetchRelationships !== false }; await self.apos.schema.convert(req, schema, input, doc, convertOptions); if (options.createId) { doc.aposDocId = options.createId; } if (copyOf) { if (copyOf._id) { doc.copyOfId = copyOf._id; } self.apos.schema.regenerateIds(req, fullSchema, doc); } }, // Return the names of all schema fields present in the `input` object, // taking into account issues like relationship fields keeping their data // in a separate ids property, etc. fieldsPresent(input) { return self.schema .filter((field) => _.has(input, field.name)) .map((field) => field.name); }, // Returns a query that finds docs the current user can edit. Unlike // find(), this query defaults to including docs in the archive. // Subclasses of @apostrophecms/piece-type often extend this to remove // more default filters findForEditing(req, criteria, builders) { const query = self.find(req, criteria).permission('edit').archived(null); if (builders) { for (const [ key, value ] of Object.entries(builders)) { query[key](value); } } return query; }, // Returns one editable doc matching the criteria, null if none match. // If `builders` is an object its properties are invoked as // query builders, for instance `{ attachments: true }`. async findOneForEditing(req, criteria, builders) { return self.findForEditing(req, criteria, builders).toObject(); }, // Identical to findOneForEditing by default, but could be // overridden usefully in subclasses. async findOneForCopying(req, criteria) { return self.findOneForEditing(req, criteria); }, // Identical to findOneForEditing by default, but could be // overridden usefully in subclasses. async findOneForLocalizing(req, criteria) { return self.findOneForEditing(req, criteria); }, // Submit the current draft for review. The identity // of `req.user` is associated with the submission. // Returns the `submitted` object, with `by`, `byId`, // and `at` properties. async submit(req, draft) { if (!self.apos.permission.can(req, 'edit', draft)) { throw self.apos.error('forbidden'); } const submitted = { by: req.user && req.user.title, byId: req.user && req.user._id, at: new Date() }; await self.apos.doc.db.updateOne({ _id: draft._id }, { $set: { submitted } }); return submitted; }, // Dismisses a previous submission of the given draft for review. // The draft is unchanged; it simply is no longer marked as needing // review. async dismissSubmission(req, draft) { if (!self.apos.permission.can(req, 'publish', draft)) { if (!self.apos.permission.can(req, 'edit', draft)) { throw self.apos.error('forbidden'); } if (!(draft.submitted && (draft.submitted.byId === req.user._id))) { throw self.apos.error('forbidden'); } } // Don't use "return" here, that could leak mongodb details await self.apos.doc.db.updateOne({ _id: draft._id }, { $unset: { submitted: 1 } }); }, // Publish the given draft. If `options.permissions` is explicitly // set to `false`, permissions checks are bypassed. If // `options.autopublishing` is true, then the `edit` permission is // sufficient, otherwise the `publish` permission is checked for. Returns // the draft with its new `lastPublishedAt` value. async publish(req, draft, options = {}) { let firstTime = false; if (!self.isLocalized()) { throw new Error(`${self.__meta.name} is not a localized type, cannot be published`); } const publishedLocale = draft.aposLocale.replace(':draft', ':published'); const publishedId = `${draft.aposDocId}:${publishedLocale}`; let previousPublished; // pages can change type, so don't use a doc-type-specific find method const find = self.apos.page.isPage(draft) ? self.apos.page.findOneForEditing : self.findOneForEditing; let published = await find(req, { _id: publishedId }, { locale: publishedLocale }); const lastPublishedAt = new Date(); if (!published) { firstTime = true; published = { _id: publishedId, aposDocId: draft.aposDocId, aposLocale: publishedLocale, lastPublishedAt }; // Might be omitted for editing purposes, but must exist // in the database (global doc for instance) published.slug = draft.slug; self.copyForPublication(req, draft, published); await self.emit('beforePublish', req, { draft, published, options, firstTime }); published = await self.insertPublishedOf(req, draft, published, options); } else { const oldPreviousPublished = await self.apos.doc.db.findOne({ _id: published._id.replace(':published', ':previous') }); // As found in db, not with relationships etc. previousPublished = await self.apos.doc.db.findOne({ _id: published._id }); // Update "previous" so we can revert the most recent publication if // desired. Do this first so we don't mistakenly think all references // to the attachments are already gone before we do it if (previousPublished) { previousPublished._id = previousPublished._id.replace(':published', ':previous'); previousPublished.aposLocale = previousPublished.aposLocale.replace(':published', ':previous'); previousPublished.aposMode = 'previous'; Object.assign( previousPublished, await self.getDeduplicationSet(req, previousPublished) ); await self.apos.doc.db.replaceOne({ _id: previousPublished._id }, previousPublished, { upsert: true }); await self.apos.attachment.updateDocReferences(previousPublished); } self.copyForPublication(req, draft, published); await self.emit('beforePublish', req, { draft, published, options, firstTime }); published.lastPublishedAt = lastPublishedAt; try { published = await self.update(req.clone({ mode: 'published' }), published, options); } catch (e) { if (oldPreviousPublished) { await self.apos.doc.db.replaceOne({ _id: oldPreviousPublished._id }, oldPreviousPublished); await self.apos.attachment.updateDocReferences(oldPreviousPublished); } throw e; } } draft.modified = false; draft.lastPublishedAt = lastPublishedAt; await self.apos.doc.db.updateOne({ _id: draft._id }, { $set: { modified: false, lastPublishedAt }, $unset: { submitted: 1 } }); await self.emit('afterPublish', req, { draft, published, options, firstTime }); return draft; }, // Unpublish a document as well as its previous version if any, // and update the draft version. // This method accepts the draft or the published version of the document // to achieve this. async unpublish(req, doc, options) { if (self.options.singleton) { throw self.apos.error('forbidden'); } const DRAFT_SUFFIX = ':draft'; const PUBLISHED_SUFFIX = ':published'; const isDocDraft = doc._id.endsWith(DRAFT_SUFFIX); const isDocPublished = doc._id.endsWith(PUBLISHED_SUFFIX); if (!isDocDraft && !isDocPublished) { return; } const published = isDocPublished ? doc : await self.apos.doc.db.findOne({ _id: doc._id.replace(DRAFT_SUFFIX, PUBLISHED_SUFFIX) }); if (!published) { return; } const draft = isDocDraft ? doc : await self.apos.doc.db.findOne({ _id: doc._id.replace(PUBLISHED_SUFFIX, DRAFT_SUFFIX) }); if (!draft) { return; } await self.emit('beforeUnpublish', req, published, options); await self.apos.doc.db.updateOne( { _id: draft._id }, { $set: { modified: true, lastPublishedAt: null } } ); const updatedDraft = await self.apos.doc.db.findOne({ _id: draft._id }); // Note: calling `apos.doc.delete` removes the previous version of the // document const clonedReq = req.clone({ mode: 'published' }); await self.apos.doc.delete(clonedReq, published, { checkForChildren: false }); return updatedDraft; }, // Localize (export) the given draft to another locale, creating the // document in the other locale if necessary. By default, if the document // already exists in the other locale, it is not overwritten. Use the // `update: true` option to change that. You can localize starting from // either draft or published content. Either way what gets created or // updated in the other locale is a draft. async localize(req, draft, toLocale, options = { update: false }) { if (!self.isLocalized()) { throw new Error(`${self.__meta.name} is not a localized type, cannot be localized`); } const toReq = req.clone({ locale: toLocale, mode: 'draft' }); const toId = draft._id.replace(`:${draft.aposLocale}`, `:${toLocale}:draft`); const actionModule = self.apos.page.isPage(draft) ? self.apos.page : self; // Use findForEditing so that we are successful even for edge cases // like doc templates that don't appear in public renderings, but // also use permission('view') so that we are not actually restricted // to what we can edit, avoiding any confusion about whether there // is really an existing localized doc or not and preventing the // possibility of inserting an unwanted duplicate. The update() call // will still stop us if edit permissions are an issue const existing = await actionModule.findForEditing(toReq, { _id: toId }).permission('view').toObject(); const eventOptions = { source: draft.aposLocale.split(':')[0], target: toLocale, existing: Boolean(existing) }; // We only want to copy schema properties, leave non-schema // properties of the source document alone const data = Object.fromEntries(Object.entries(draft) .filter( ([ key, value ]) => key === 'type' || self.schema.find(field => field.name === key) )); // We need a slug even if removed from the schema for editing purposes data.slug = draft.slug; let result; if (!existing) { if (self.apos.page.isPage(draft)) { if (!draft.level) { const insert = { ...data, aposDocId: draft.aposDocId, aposLocale: `${toLocale}:draft`, _id: toId, path: draft.path, level: draft.level, rank: draft.rank, parked: draft.parked, parkedId: draft.parkedId }; await self.emit('beforeLocalize', req, insert, eventOptions); // Replicating the home page for the first time result = await self.apos.doc.insert(toReq, insert); } else { // A page that is not the home page, being replicated for the // first time let { lastTargetId, lastPosition } = await self.apos.page .inferLastTargetIdAndPosition(draft); let localizedTargetId = lastTargetId.replace(`:${draft.aposLocale}`, `:${toLocale}:draft`); // When fetching the target (parent or peer), always use // findForEditing so we don't miss doc templates and other edge // cases, but also use .permission('view') because we are not // actually editing the target and should not be blocked over edit // permissions. Later change this check to 'create' ("can create a // child of this doc"), but not until we're ready to do it for all // creation attempts const localizedTarget = await actionModule .findForEditing(toReq, self.apos.page.getIdCriteria(localizedTargetId)) .permission('view') .archived(null) .areas(false) .relationships(false) .toObject(); if (!localizedTarget) { if ((lastPosition === 'firstChild') || (lastPosition === 'lastChild')) { throw self.apos.error('notfound', req.t('apostrophe:parentNotLocalized'), { // Also provide as data for code that prefers to localize // client side when it is certain an error message is user // friendly parentNotLocalized: true }); } else { const originalTarget = await actionModule .findForEditing(req, self.apos.page.getIdCriteria(lastTargetId)) .permission('view') .archived(null) .areas(false) .relationships(false) .toObject(); if (!originalTarget) { // Almost impossible (race conditions like someone removing // it while we're in the modal) throw self.apos.error('notfound'); } const criteria = { path: self.apos.page.getParentPath(originalTarget) }; const localizedTarget = await actionModule .findForEditing(toReq, criteria) .permission('view') .archived(null) .areas(false) .relationships(false) .toObject(); if (!localizedTarget) { throw self.apos.error('notfound', req.t('apostrophe:parentNotLocalized'), { // Also provide as data for code that prefers to localize // client side when it is certain an error message is user // friendly parentNotLocalized: true }); } localizedTargetId = localizedTarget._id; lastPosition = 'lastChild'; } } const insert = { ...data, aposLocale: `${toLocale}:draft`, _id: toId, parked: draft.parked, parkedId: draft.parkedId }; await self.emit('beforeLocalize', req, insert, eventOptions); result = await actionModule.insert(toReq, localizedTargetId, lastPosition, insert ); } } else { const insert = { ...data, aposDocId: draft.aposDocId, aposLocale: `${toLocale}:draft`, _id: toId }; await self.emit('beforeLocalize', req, insert, eventOptions); result = await actionModule.insert(toReq, insert); } } else { if (!options.update) { throw self.apos.error('conflict'); } const update = { ...existing, ...data, _id: toId, aposDocId: draft.aposDocId, aposLocale: `${toLocale}:draft`, metaType: 'doc' }; await self.emit('beforeLocalize', req, update, eventOptions); result = await actionModule.update(toReq, update); } await self.emit('afterLocalize', req, draft, result, eventOptions); return result; }, // Reverts the given draft to the most recent publication. // // Returns the draft's new value, or `false` if the draft // was not modified from the published version (`modified: false`) // or no published version exists yet. // // This is *not* the on-page `undo/redo` backend. This is the // "Revert to Published" feature. // // Emits the `afterRevertDraftToPublished` event before // returning, which receives `req, { draft }` and may // replace the `draft` property to alter the returned value. // // If you need to keep certain properties that would otherwise // revert, you can pass values for those properties in an // `options.overrides` object. async revertDraftToPublished(req, draft, options = {}) { if (!draft.modified) { return false; } const published = await self.apos.doc.db.findOne({ _id: draft._id.replace(':draft', ':published') }); if (!published) { return false; } // We must load relationships as if we had done a regular find // because relationships are read/write in A3, // but we don't have to call widget loaders const query = self.find(req).areas(false); await query.finalize(); await query.after([ published ]); // Draft and published roles intentionally reversed self.copyForPublication(req, published, draft); draft.modified = false; delete draft.submitted; if (options.overrides) { Object.assign(draft, options.overrides); } // Setting it this way rather than setting it to published.updatedAt // guarantees no small discrepancy breaking equality comparisons draft.updatedAt = draft.lastPublishedAt; draft.cacheInvalidatedAt = draft.lastPublishedAt; draft.updatedBy = published.updatedBy; draft = await self.update(req.clone({ mode: 'draft' }), draft, { setModified: false, setUpdatedAtAndBy: false }); const result = { draft }; await self.emit('afterRevertDraftToPublished', req, result); return result.draft; }, // Used to implement "Undo Publish." // // Revert the doc `published` to its content as of its most recent // previous publication. If this has already been done or // there is no previous publication, throws an `invalid` exception. async revertPublishedTo