apostrophe
Version:
The Apostrophe Content Management System.
1,332 lines (1,287 loc) • 129 kB
JavaScript
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