UNPKG

apostrophe

Version:

The Apostrophe Content Management System.

1,360 lines (1,252 loc) 104 kB
// This module provides schemas, a flexible and fast way to create new data types // by specifying the fields that should make them up. Schemas power // [apostrophe-pieces](/reference/modules/apostrophe-pieces), // [apostrophe-widgets](/reference/modules/apostrophe-widgets), custom field // types in page settings for [apostrophe-custom-pages](/reference/modules/apostrophe-custom-pages) // and more. // // A schema is simply an array of "plain old objects." Each object describes one field in the schema // via `type`, `name`, `label` and other properties. // // See the [schema guide](/advanced-topics/schema-guide.md) for a complete // overview and list of schema field types. The methods documented here on this page are most often // used when you choose to work independently with schemas, such as in a custom project // that requires forms. var joinr = require('joinr'); var _ = require('@sailshq/lodash'); var async = require('async'); var moment = require('moment'); var tinycolor = require('tinycolor2'); var Promise = require('bluebird'); module.exports = { alias: 'schemas', afterConstruct: function(self) { self.createRoutes(); self.pushAssets(); self.pushCreateSingleton(); }, construct: function(self, options) { require('./lib/routes')(self, options); self.pushAssets = function() { self.pushAsset('script', 'user', { when: 'user' }); self.pushAsset('script', 'array-modal', { when: 'user' }); self.pushAsset('stylesheet', 'user', { when: 'user' }); }; self.pushCreateSingleton = function() { self.apos.push.browserCall('user', 'apos.create("apostrophe-schemas", ?)', { action: self.action }); }; // Compose a schema based on addFields, removeFields, orderFields // and, occasionally, alterFields options. This method is great for // merging the schema requirements of subclasses with the schema // requirements of a superclass. See the apostrophe-schemas documentation // for a thorough explanation of the use of each option. The // alterFields option should be avoided if your needs can be met // via another option. // // If present, the `module` option is used to resolve method // names lacking a module name, for instance when a method name // is given for the `choices` property of a `select` field. self.compose = function(options, module) { var schema = []; // Useful for finding good unit test cases // self.apos.utils.log(JSON.stringify(_.pick(options, 'addFields', 'removeFields', 'arrangeFields'), null, ' ')); if (options.addFields) { // loop over our addFields _.each(options.addFields, function(field) { var i; // remove it from the schema if we've already added it, last one wins for (i = 0; (i < schema.length); i++) { if (schema[i].name === field.name) { schema.splice(i, 1); break; } } // add the new field to the schema schema.push(field); }); } if (options.removeFields) { schema = _.filter(schema, function(field) { return !_.contains(options.removeFields, field.name); }); } if (options.requireFields) { _.each(options.requireFields, function(name) { var field = _.find(schema, function(field) { return field.name === name; }); if (field) { field.required = true; } }); } // If nothing else will do, just modify the schema with a function if (options.alterFields) { options.alterFields(schema); } // always make sure there is a default group var defaultGroup = self.options.defaultGroup || {}; var groups = [ { name: defaultGroup.name || 'default', label: defaultGroup.label || 'Info', fields: _.pluck(schema, 'name') } ]; // if we are getting arrangeFields and it's not empty if (options.arrangeFields && options.arrangeFields.length > 0) { // if it's full of strings, use them for the default group if (_.isString(options.arrangeFields[0])) { groups[0].fields = options.arrangeFields; // if it's full of objects, those are groups, so use them } else if (_.isPlainObject(options.arrangeFields[0])) { // reset the default group's fields, but keep it around, // in case they have fields they forgot to put in a group groups[0].fields = []; groups = groups.concat(options.arrangeFields); } } // If there is a later group with the same name, the later // one wins and the earlier is forgotten. Otherwise you can't // ever toss a field out of a group without putting it into // another one, which makes it impossible to un-group a // field and have it appear outside of tabs in the interface. // // A reconfigured group is ordered to the bottom of the list // of groups again, which has the intended effect if you // arrange all of the groups in your module config. However // it comes before any groups with the `last: true` flag that // were not reconfigured. Reconfiguring a group without that // flag clears it. var newGroups = []; _.each(groups, function(group) { var index = _.findIndex(newGroups, { name: group.name }); if (index !== -1) { newGroups.splice(index, 1); } var i = _.findIndex(newGroups, { last: true }); if (i === -1) { i = groups.length; } newGroups.splice(i, 0, group); }); groups = newGroups; // all fields in the schema will end up in this variable var newSchema = []; // loop over any groups and orders we want to respect _.each(groups, function(group) { _.each(group.fields, function(field) { // find the field we are ordering var f = _.find(schema, { name: field }); if (!f) { // May have already been migrated due to subclasses re-grouping fields f = _.find(newSchema, { name: field }); } // make sure it exists if (f) { // set the group for this field var g = _.clone(group, true); delete g.fields; f.group = g; // push the field to the new schema, if it is a // duplicate due to subclasses pushing more // groupings, remove the earlier instance var fIndex = _.findIndex(newSchema, { name: field }); if (fIndex !== -1) { newSchema.splice(fIndex, 1); } newSchema.push(f); // remove the field from the old schema, if that is where we got it from fIndex = _.findIndex(schema, { name: field }); if (fIndex !== -1) { schema.splice(fIndex, 1); } } }); }); // put remaining fields in the default group _.each(schema, function(field) { var g = _.clone(groups[0], true); delete g.fields; field.group = g; }); // add any fields not in defined groups to the end of the schema schema = newSchema.concat(schema); // If a field is not consecutive with other fields in its group, // move it after the last already encountered in its group, // to simplify rendering logic newSchema = []; var groupIndexes = {}; _.each(schema, function(field) { if (field.group && field.group.name) { if (_.has(groupIndexes, field.group.name)) { newSchema.splice(groupIndexes[field.group.name], 0, field); groupIndexes[field.group.name]++; } else { newSchema.push(field); groupIndexes[field.group.name] = newSchema.length; } } }); schema = newSchema; // Move the default group to the end, it's just too // obnoxious otherwise with one-off fields popping up // before title etc. schema = _.filter(schema, function(field) { return !(field.group && (field.group.name === 'default')); }).concat(_.filter(schema, function(field) { return field.group && (field.group.name === 'default'); })); _.each(schema, function(field) { // A field can have a custom template, which can be a // template name (relative to the apostrophe-schemas module) // or a function (called to render it) if (field.template) { if (typeof (field.template) === 'string') { field.partial = self.partialer(field.template); delete field.template; } else { field.partial = field.template; delete field.template; } } }); // Shallowly clone the fields. This allows modules // like workflow to patch schema fields of various modules // without inadvertently impacting other apos instances // when running with apostrophe-multisite schema = _.map(schema, function(field) { return _.clone(field); }); _.each(schema, function(field) { // For use in resolving options like "choices" when they // contain a method name. For bc don't mess with possible // existing usages in custom schema field types predating // this feature self.setModuleName(field, module); }); return schema; }; // Recursively set moduleName property of the field and any subfields, // as might be found in array or object fields. `module` is an actual module self.setModuleName = function(field, module) { field.moduleName = field.moduleName || (module && module.__meta.name); if ((field.type === 'array') || (field.type === 'object')) { _.each(field.schema || [], function(subfield) { self.setModuleName(subfield, module); }); } }; // refine is like compose, but it starts with an existing schema array // and amends it via the same options as compose. self.refine = function(schema, _options) { // Don't modify the original schema which may be in use elsewhere schema = _.cloneDeep(schema); // Deep clone is not required here, we just want // to modify the addFields property var options = _.clone(_options); options.addFields = schema.concat(options.addFields || []); // The arrangeFields option is trickier because we've already // done a compose() and so the groups are now denormalized as // properties of each field. Reconstruct the old // arrangeFields option so we can concatenate the new one var oldArrangeFields = []; _.each(schema, function(field) { if (field.group) { var group = _.find(oldArrangeFields, { name: field.group.name }); if (!group) { group = _.clone(field.group); group.fields = []; oldArrangeFields.push(group); } group.fields.push(field.name); } }); options.arrangeFields = oldArrangeFields.concat(options.arrangeFields || []); return self.compose(options); }; // Converts a flat schema (array of field objects) into a two // dimensional schema, broken up by groups self.toGroups = function(fields) { // bail on empty schemas if (fields.length === 0) { return []; } // bail if we're already in groups if (fields[0].type === 'group') { return fields; } var groups = []; var currentGroup = -1; _.each(fields, function(field) { if (field.contextual) { return; } if (self.fieldTypes[field.type].ui === false) { return; } if (!field.group) { field.group = { name: 'default', label: 'info' }; } // first group, or not the current group if ((groups.length === 0) || (groups[currentGroup].name !== field.group.name)) { groups.push({ type: 'group', name: field.group.name, label: field.group.label, fields: [] }); currentGroup++; } // add field to current group groups[currentGroup].fields.push(field); }); return groups; }; // Return a new schema containing only the fields named in the // `fields` array, while maintaining existing group relationships. // Any empty groups are dropped. Do NOT include group names // in `fields`. If `fields` contains a property that is not a // field name but does match the `idField` or `idsField` property // of a field, that also includes the field in the subset. This is // convenient when basing this call on the keys in `req.body`. self.subset = function(schema, fields) { var groups; if (!schema.length) { // Do not crash on empty input return []; } // check if we're already grouped if (schema[0].type === 'group') { // Don't modify the original schema which may be in use elsewhere groups = _.cloneDeep(schema); // loop over each group and remove fields from them that aren't in this subset _.each(groups, function(group) { group.fields = _.filter(group.fields, function(field) { return includes(fields, field); }); }); // remove empty groups groups = _.filter(groups, function(group) { return group.fields.length > 0; }); return groups; } else { // otherwise this is a simple filter return _.filter(schema, function(field) { return includes(fields, field); }); } function includes(fields, field) { return _.includes(fields, field.name) || (field.idField && _.includes(fields, field.idField)) || (field.idsField && _.includes(fields, field.idsField)); } }; // Return a new object with all default settings // defined in the schema self.newInstance = function(schema) { var def = {}; _.each(schema, function(field) { var fieldType = self.fieldTypes[field.type]; if (field.def !== undefined) { def[field.name] = field.def; } else if (fieldType.getDefault) { def[field.name] = fieldType.getDefault(); } }); return def; }; self.subsetInstance = function(schema, instance) { var subset = {}; _.each(schema, function(field) { if (field.type === 'group') { return; } var subsetCopy = self.fieldTypes[field.type].subsetCopy; if (!subsetCopy) { // These rules suffice for our standard fields subset[field.name] = instance[field.name]; if (field.idField) { subset[field.idField] = instance[field.idField]; } if (field.idsField) { subset[field.idsField] = instance[field.idsField]; } if (field.relationshipsField) { subset[field.relationshipsField] = instance[field.relationshipsField]; } } else { subsetCopy(field.name, instance, subset, field); } }); return subset; }; // Determine whether an object is empty according to the schema. // Note this is not the same thing as matching the defaults. A // nonempty string or array is never considered empty. A numeric // value of 0 is considered empty self.empty = function(schema, object) { return !_.find(schema, function(field) { // Return true if not empty var value = object[field.name]; if ((value !== null) && (value !== undefined) && (value !== false)) { var emptyTest = self.fieldTypes[field.type].empty; if (!emptyTest) { // Type has no method to check emptiness, so assume not empty return true; } return !emptyTest(field, value); } }); }; // Index the object's fields for participation in Apostrophe search unless // `searchable: false` is set for the field in question self.indexFields = function(schema, object, texts) { _.each(schema, function(field) { if (field.searchable === false) { return; } var fieldType = self.fieldTypes[field.type]; if (!fieldType.index) { return; } fieldType.index(object[field.name], field, texts); }); }; // Convert submitted `data`, sanitizing it and populating `object` with it. self.convert = function(req, schema, from, data, object, callback) { if (!req) { throw new Error("convert invoked without a req, do you have one in your context?"); } // bc if (from === 'csv') { from = 'string'; } var errors = {}; return async.eachSeries(schema, function(field, callback) { if (field.readOnly) { return setImmediate(callback); } // Fields that are contextual are edited in the context of a // show page and do not appear in regular schema forms. They are // however legitimate in imports, so we make sure it's a form import // and not a string import that we're skipping it for. We also have to // accept them when contextualConvertArea causes them to be // kicked upstairs into a contextual area save operation. So // if they are defined in the data, sanitize them normally; // otherwise leave them untouched. -Tom and Jimmy if (field.contextual && (from === 'form') && (!_.has(data, field.name))) { return setImmediate(callback); } var convert = self.fieldTypes[field.type].converters && self.fieldTypes[field.type].converters[from]; if (!convert) { // whatever, some field types are not supported in some formats return setImmediate(callback); } return convert(req, data, field.name, object, field, function(err) { if (err) { errors[field.name] = err; return callback(null); } return callback(err); }); }, function(err) { if (err) { // Not expected, errors accumulate in the errors object return callback(err); } var finalError; _.each(errors, function(err, name) { if ((typeof err) === 'string') { // We care if it's a "required" error, ignore any nested // property name prepended to it var visibilityError = err.split('.').pop(); if ((visibilityError === 'required') && (!self.isVisible(schema, object, name))) { // It is not reasonable to enforce required for // fields hidden via showFields } else if (req.tolerantSanitization && (err !== 'error')) { data[name] = _.find(schema, { name: name }).def; } else { finalError = name + '.' + err; } } else { finalError = err; } if (finalError) { return false; } }); return callback(finalError); }); }; // Determine whether the given field is visible // based on showFields options of all fields self.isVisible = function(schema, object, name) { var hidden = {}; _.each(schema, function(field) { if (!_.find(field.choices || [], function(choice) { return choice.showFields; })) { return; } _.each(field.choices, function(choice) { if (choice.showFields) { if (field.type === 'checkboxes') { if (!object[field.name].includes(choice.value)) { _.each(choice.showFields, hide); } } else if (object[field.name] !== choice.value) { _.each(choice.showFields, hide); } } }); }); return !hidden[name]; function hide(name) { hidden[name] = true; // Cope with nested showFields var field = _.find(schema, { name: name }); if (!field) { // Do not crash. The linter at startup also catches this, // but is a nonfatal warning for bc, so we should also catch it self.apos.utils.warnDev('⚠️ showFields misconfigured, attempts to show/hide ' + name + ' which does not exist'); return; } _.each(field.choices || [], function(choice) { _.each(choice.showFields || [], function(name) { hide(name); }); }); } }; // Export sanitized 'object' into 'output' self.export = function(req, schema, to, object, output, callback) { return async.eachSeries(schema, function(field, callback) { var exporter = self.fieldTypes[field.type].exporters && self.fieldTypes[field.type].exporters[to]; if (!exporter) { // A type without an explicit exporter is not exported return setImmediate(callback); } if (exporter.length !== 6) { self.apos.utils.error(exporter.toString()); throw new Error("Schema export methods must now take the following arguments: req, object, field, field.name, output, callback. They must also invoke the callback."); } return exporter(req, object, field, field.name, output, function(err) { return callback(err); }); }, function(err) { return callback(err); }); }; // Driver invoked by the "join" methods of the standard // join field types. // // All arguments must be present, however relationshipsField // may be undefined to indicate none is needed. self.joinDriver = function(req, method, reverse, items, idField, relationshipsField, objectField, options, callback) { if (!options) { options = {}; } var find = options.find; var filters = options.filters || {}; var hints = options.hints || {}; var getCriteria = options.getCriteria || {}; // Some joinr methods don't take relationshipsField if (method.length === 5) { var realMethod = method; method = function(items, idField, relationshipsField, objectField, getter, callback) { return realMethod(items, idField, objectField, getter, callback); }; } return method(items, idField, relationshipsField, objectField, function(ids, callback) { var idsCriteria = {}; if (reverse) { idsCriteria[idField] = { $in: ids }; } else { idsCriteria._id = { $in: ids }; } var criteria = { $and: [ getCriteria, idsCriteria ] }; var cursor = find(req, criteria); // Filters hardcoded as part of this join's blessed options don't // require any sanitization _.each(filters, function(val, key) { cursor[key](val); }); // Hints, on the other hand, don't go through the blessing mechanism // so they must be sanitized cursor.queryToFilters(hints, 'manage'); return cursor.toArray(callback); }, callback); }; // Carry out all the joins in the schema on the specified object or array // of objects. The withJoins option may be omitted. // // If withJoins is omitted, null or undefined, all the joins in the schema // are performed, and also any joins specified by the 'withJoins' option of // each join field in the schema, if any. And that's where it stops. Infinite // recursion is not possible. // // If withJoins is specified and set to "false", no joins at all are performed. // // If withJoins is set to an array of join names found in the schema, then // only those joins are performed, ignoring any 'withJoins' options found in // the schema. // // If a join name in the withJoins array uses dot notation, like this: // // _events._locations // // Then the objects are joined with events, and then the events are further // joined with locations, assuming that _events is defined as a join in the // schema and _locations is defined as a join in the schema for the events // module. Multiple "dot notation" joins may share a prefix. // // Joins are also supported in the schemas of array fields. self.join = function(req, schema, objectOrArray, withJoins, callback) { if (arguments.length === 3) { callback = withJoins; withJoins = undefined; } if (withJoins === false) { // Joins explicitly deactivated for this call return callback(null); } var objects = _.isArray(objectOrArray) ? objectOrArray : [ objectOrArray ]; if (!objects.length) { // Don't waste effort return callback(null); } // build an array of joins of interest, found at any level // in the schema, even those nested in array schemas. Add // an _arrays property to each one which contains the names // of the array fields leading to this join, if any, so // we know where to store the results. Also set a // _dotPath property which can be used to identify relevant // joins when the withJoins option is present var joins = []; function findJoins(schema, arrays) { var _joins = _.filter(schema, function(field) { return !!self.fieldTypes[field.type].join; }); _.each(_joins, function(join) { if (!arrays.length) { join._dotPath = join.name; } else { join._dotPath = arrays.join('.') + '.' + join.name; } // If we have more than one object we're not interested in joins // with the ifOnlyOne restriction right now. if ((objects.length > 1) && join.ifOnlyOne) { return; } join._arrays = _.clone(arrays); }); joins = joins.concat(_joins); _.each(schema, function(field) { if (field.type === 'array' || field.type === 'object') { findJoins(field.schema, arrays.concat(field.name)); } }); } findJoins(schema, []); // The withJoins option allows restriction of joins. Set to false // it blocks all joins. Set to an array, it allows the joins named within. // Dot notation can be used to specify joins in array properties, // or joins reached via other joins. // // By default, all configured joins will take place, but withJoins: false // will be passed when fetching the objects on the other end of the join, // so that infinite recursion never takes place. var withJoinsNext = {}; // Explicit withJoins option passed to us if (Array.isArray(withJoins)) { joins = _.filter(joins, function(join) { var dotPath = join._dotPath; var winner; _.each(withJoins, function(withJoinName) { if (withJoinName === dotPath) { winner = true; return; } if (withJoinName.substr(0, dotPath.length + 1) === (dotPath + '.')) { if (!withJoinsNext[dotPath]) { withJoinsNext[dotPath] = []; } withJoinsNext[dotPath].push(withJoinName.substr(dotPath.length + 1)); winner = true; } }); return winner; }); } else { // No explicit withJoins option for us, so we do all the joins // we're configured to do, and pass on the withJoins options we // have configured for those _.each(joins, function(join) { if (join.withJoins) { withJoinsNext[join._dotPath] = join.withJoins; } }); } return async.eachSeries(joins, function(join, callback) { var arrays = join._arrays; function findObjectsInArrays(objects, arrays) { if (!arrays) { return []; } if (!arrays.length) { return objects; } var array = arrays[0]; var _objects = []; _.each(objects, function(object) { _objects = _objects.concat(object[array] || []); }); return findObjectsInArrays(_objects, arrays.slice(1)); } var _objects = findObjectsInArrays(objects, arrays); if (!join.name.match(/^_/)) { return callback(new Error('Joins should always be given names beginning with an underscore (_). Otherwise we would waste space in your database storing the results statically. There would also be a conflict with the array field withJoins syntax. Join name is: ' + join._dotPath)); } if (Array.isArray(join.withType)) { // Polymorphic join return async.eachSeries(join.withType, function(type, callback) { var manager = self.apos.docs.getManager(type); if (!manager) { return callback(new Error('I cannot find the instance type ' + type)); } var find; if ((join.type === 'joinByOne') || (join.type === 'joinByArray')) { find = function(req, criteria, projection) { // For these types, the order is implicit (there is one match) // or explicit (handpicked id order), and sort() is not // useful in either case; the default sort can lead MongoDB // to make bad index choices return manager.find(req, criteria, projection).sort(false); }; } else { find = manager.find; } var options = { find: find, filters: { joins: withJoinsNext[join._dotPath] || false }, hints: {} }; var subname = join.name + ':' + type; var _join = _.assign({}, join, { name: subname, withType: type }); // Allow options to the get() method to be // specified in the join configuration if (_join.filters) { _.extend(options.filters, _join.filters); } if (_join.filtersByType && _join.filtersByType[type]) { _.extend(options.filters, _join.filtersByType[type]); } if (_join.hints) { _.extend(options.hints, _join.hints); } if (_join.hintsByType && _join.hintsByType[type]) { _.extend(options.hints, _join.hints); _.extend(options.hints, _join.hintsByType[type]); } // Allow options to the getter to be specified in the schema, // notably editable: true return self.fieldTypes[_join.type].join(req, _join, _objects, options, function(err) { if (err) { return callback(); } _.each(_objects, function(object) { if (object[subname]) { if (Array.isArray(object[subname])) { object[join.name] = (object[join.name] || []).concat(object[subname]); } else { object[join.name] = object[subname]; } } }); return callback(null); }); }, function(err) { if (err) { return callback(err); } if (join.idsField) { _.each(_objects, function(object) { if (object[join.name]) { object[join.name] = self.apos.utils.orderById(object[join.idsField], object[join.name]); } }); } return callback(null); }); } var manager = self.apos.docs.getManager(join.withType); if (!manager) { return callback(new Error('I cannot find the instance type ' + join.withType)); } // If it has a getter, use it, otherwise supply one var find = manager.find; var options = { find: find, filters: { joins: withJoinsNext[join._dotPath] || false }, hints: {} }; // Allow options to the get() method to be // specified in the join configuration if (join.filters) { _.extend(options.filters, join.filters); } if (join.hints) { _.extend(options.hints, join.hints); } // For reverse joins with a projection, it is common for // developers to leave out the idField or idsField without // which the join can't work, especially since `reverseOf` // was introduced, making it less likely that the developer // is thinking about these automatic properties if (join.type === 'joinByOneReverse') { const projection = options.filters && options.filters.projection; if (projection) { const keys = Object.keys(projection); // If there is a nonempty projection and the first // thing projected is omitted (falsy) rather than included // (truthy), then we should not try to add an inclusion, // because mongo only allows one or the other if ((!keys.length) || projection[keys[0]]) { projection[join.idField] = 1; } } } else if (join.type === 'joinByArrayReverse') { const projection = options.filters && options.filters.projection; if (projection) { const keys = Object.keys(projection); // If there is a nonempty projection and the first // thing projected is omitted (falsy) rather than included // (truthy), then we should not try to add an inclusion, // because mongo only allows one or the other if ((!keys.length) || projection[keys[0]]) { projection[join.idsField] = 1; } } } // Allow options to the getter to be specified in the schema, // notably editable: true return self.fieldTypes[join.type].join(req, join, _objects, options, callback); }, function(err) { if (err) { return callback(err); } _.each(joins, function(join) { // Don't confuse the blessing mechanism delete join._arrays; delete join._dotPath; }); return callback(err); }); }; self.fieldTypes = {}; // Add a new field type. The `type` object may contain the following properties: // // ### `name` // // Required. The name of the field type, such as `select`. Use a unique prefix to avoid // collisions with future official Apostrophe field types. // // ### `converters` // // Required. An object with `string` and `form` sub-properties, functions which are invoked for // strings (as often needed for imports) and Apostrophe-specific form submissions respectively. // These are functions which accept: // // `req, data, name, object, field, callback` // // Sanitize the contents of `data[name]` and copy values // known to be safe to `object[name]`. Then invoke the callback. // // `field` contains the schema field definition, useful to access // `def`, `min`, `max`, etc. // // If `form` can use the same logic as `string` you may write: // // form: 'string' // // To reuse it. // // ### `empty` // // Optional. A function which accepts `field, value` and returns // true only if the field should be considered empty, for purposes of // deciding if the entire object is empty or not. // // ### `bless` // // Optional. A function which accepts `req, field` and calls `self.apos.utils.bless` // on any schemas nested within `field`, so that editors are allowed to edit content. See // the implementation of the `areas` field type for an example. // // ### `index` // // Optional. A function which accepts `value, field, texts` and pushes // objects containing search engine-friendly text onto `texts`, if desired: // // ```javascript // index: function(value, field, texts) { // var silent = (field.silent === undefined) ? true : field.silent; // texts.push({ weight: field.weight || 15, text: (value || []).join(' '), silent: silent }); // } // ``` // // Note that areas are *always* indexed. self.addFieldType = function(type) { var fieldType = type; if (type.extend) { // Allow a field type to extend another field type and merge // in some differences. fieldType = _.cloneDeep(self.fieldTypes[type.extend]); _.merge(fieldType, type); } // For bc. csv was a bad name for the string converter, but // we need to accept it, and even keep the property around // for bc with those extending in sneaky ways if (fieldType.converters) { fieldType.converters.string = fieldType.converters.string || fieldType.converters.csv; fieldType.converters.csv = fieldType.converters.string; // Allow a field type to reuse another converter by specifying // its name. Allows 'form' to expressly reuse 'string' _.each(_.keys(fieldType.converters), function(key) { var value = fieldType.converters[key]; if (typeof (value) === 'string') { if (value === 'csv') { // bc value = 'string'; } fieldType.converters[key] = fieldType.converters[value]; } }); } // bc with the old method name `empty()` fieldType.isEmpty = fieldType.isEmpty || fieldType.empty; fieldType.empty = fieldType.isEmpty; self.fieldTypes[type.name] = fieldType; }; self.getFieldType = function(typeName) { return self.fieldTypes[typeName]; }; self.addHelpers({ toGroups: function(fields) { return self.toGroups(fields); }, field: function(field, readOnly) { if (readOnly) { field.readOnly = true; } // Allow custom partials for types and for individual fields var partial = field.partial || self.fieldTypes[field.type].partial; if (!partial) { // Look for a standard partial template in the views folder // of this module return self.partialer(field.type)(field); } return partial(field); } }); // Given a schema and a cursor, add filter methods to the cursor // for each of the fields in the schema, based on their field type, // if supported by the field type. If a field name exists in `options.override` // this is done even if such a filter is already present on the cursor object. self.addFilters = function(schema, options, cursor) { _.each(schema, function(field) { var fieldType = self.fieldTypes[field.type]; if (cursor[field.name] && (!_.contains(options.override || [], field.name))) { // Don't override filters that exist in the base // apostrophe-cursors type, for instance `published` return; } if (fieldType.addFilter) { fieldType.addFilter(field, cursor); } }); }; self.addFieldType({ name: 'area', converters: { string: function(req, data, name, object, field, callback) { if (field.importAsRichText) { object[name] = self.apos.areas.fromRichText(data[name]); } else { object[name] = self.apos.areas.fromPlaintext(data[name]); } return setImmediate(callback); }, form: function(req, data, name, object, field, callback) { var items = []; // accept either an array of items, or a complete // area object try { items = (data[name].type === 'area') ? data[name].items : data[name]; if (!Array.isArray(items)) { items = []; } } catch (e) { // Always recover graciously and import something reasonable, like an empty area } return self.apos.areas.sanitizeItems(req, items, function(err, items) { if (err) { return callback(err); } object[name] = { items: items, type: 'area' }; return callback(null); }); } }, isEmpty: function(field, value) { return self.apos.areas.isEmpty({ area: value }); }, bless: function(req, field) { if (field.options && field.options.widgets) { _.each(field.options.widgets || {}, function(options, type) { self.apos.utils.bless(req, options, 'widget', type); }); } } }); self.addFieldType({ name: 'singleton', extend: 'area', isEmpty: function(field, value) { return self.apos.areas.isEmptySingleton({ area: value, type: field.widgetType }); }, bless: function(req, field) { self.apos.utils.bless(req, field.options || {}, 'widget', field.widgetType); } }); self.addFieldType({ name: 'string', converters: { string: function(req, data, name, object, field, callback) { object[name] = self.apos.launder.string(data[name]); if (object[name] && field.min && (object[name].length < field.min)) { // Would be unpleasant, but shouldn't happen since the browser // also implements this. We're just checking for naughty scripts return callback('min'); } // If max is longer than allowed, trim the value down to the max length if (object[name] && field.max && (object[name].length > field.max)) { object[name] = object[name].substr(0, field.max); } // If field is required but empty (and client side didn't catch that) // This is new and until now if JS client side failed, then it would // allow the save with empty values -Lars if (field.required && ((data[name] == null) || !data[name].toString().length)) { return callback('required'); } return setImmediate(callback); }, form: 'string' }, exporters: { string: function(req, object, field, name, output, callback) { // no formatting, set the field output[name] = object[name]; return setImmediate(callback); } }, index: function(value, field, texts) { var silent = (field.silent === undefined) ? true : field.silent; texts.push({ weight: field.weight || 15, text: value, silent: silent }); }, isEmpty: function(field, value) { return !value.length; }, addFilter: function(field, cursor) { cursor.addFilter(field.name, { finalize: function() { if (self.cursorFilterInterested(cursor, field.name)) { var criteria = {}; criteria[field.name] = new RegExp(self.apos.utils.regExpQuote(cursor.get(field.name)), 'i'); cursor.and(criteria); } }, safeFor: 'manage', launder: function(s) { return self.apos.launder.string(s); }, choices: function(callback) { return self.sortedDistinct(field.name, cursor, callback); } }); } }); self.addFieldType({ name: 'slug', extend: 'string', converters: { // if field.page is true, expect a page slug (slashes allowed, // leading slash required). Otherwise, expect a object-style slug // (no slashes at all) string: function(req, data, name, object, field, callback) { var options = {}; if (field.page) { options.allow = '/'; } object[name] = self.apos.utils.slugify(self.apos.launder.string(data[name]), options); if (field.page) { if (!(object[name].charAt(0) === '/')) { object[name] = '/' + object[name]; } // No runs of slashes object[name] = object[name].replace(/\/+/g, '/'); // No trailing slashes (except for root) if (object[name] !== '/') { object[name] = object[name].replace(/\/$/, ''); } } if (field.prefix && object[name].length) { if (object[name].substring(0, field.prefix.length) !== field.prefix) { return callback('prefix'); } } return setImmediate(callback); }, form: 'string' }, addFilter: function(field, cursor) { cursor.addFilter(field.name, { finalize: function() { if (self.cursorFilterInterested(cursor, field.name)) { var criteria = {}; var slugifyOptions = {}; if (field.page) { slugifyOptions = { allow: '/' }; } criteria[field.name] = new RegExp(self.apos.utils.regExpQuote(self.apos.utils.slugify(cursor.get(field.name), slugifyOptions))); cursor.and(criteria); } }, safeFor: 'manage', launder: function(s) { return self.apos.launder.string(s); }, choices: function(callback) { return self.sortedDistinct(field.name, cursor, callback); } }); } }); self.addFieldType({ name: 'tags', converters: { string: function(req, data, name, object, field, callback) { var tags; tags = self.apos.launder.tags(data[name]); object[name] = tags; return setImmediate(callback); }, form: function(req, data, name, object, field, callback) { var tags = self.apos.launder.tags(data[name]); // enforce limit if provided, take first N elements if (field.options && field.options.limit) { tags = tags.slice(0, field.options.limit); } if (!self.apos.tags.options.lock) { // It's OK to specify a tag that doesn't exist yet object[field.name] = tags; return setImmediate(callback); } // tags must exist return self.apos.tags.get(req, { tags: tags }, function(err, tags) { if (err) { return callback(err); } object[field.name] = tags; return callback(null); }); } }, addFilter: function(field, cursor) { cursor.addFilter(field.name, { finalize: function() { if (self.cursorFilterInterested(cursor, field.name)) { var criteria = {}; criteria[field.name] = { $in: cursor.get(field.name) }; cursor.and(criteria); } }, safeFor: 'manage', launder: function(tags) { tags = self.apos.launder.tags(tags); if (!tags.length) { tags = null; } return tags; }, choices: function(callback) { return self.sortedDistinct(field.name, cursor, callback); } }); }, index: function(value, field, texts) { // Make sure fields of type "tags" that aren't the default "tags" field participate // in search at some level var silent = (field.silent === undefined) ? true : field.silent; if (!Array.isArray(value)) { value = []; } texts.push({ weight: field.weight || 15, text: value.join(' '), silent: silent }); }, exporters: { string: function(req, object, field, name, output, callback) { // no formating, set the field output[name] = object[name].toString(); return setImmediate(callback); } } }); self.addFieldType({ name: 'boolean', converters: { string: function(req, data, name, object, field, callback) { object[name] = self.apos.launder.boolean(data[name]); if (field.mandatory && object[name] === false) { return callback('mandatory'); } return setImmediate(callback); }, form: 'string' }, isEmpty: function(field, value) { return !value; }, exporters: { string: function(req, object, field, name, output, callback) { output[name] = self.apos.launder.boolean(object[name]).toString(); return setImmediate(callback); } }, addFilter: function(field, cursor) { var criteria; cursor.addFilter(field.name, { finalize: function() { if (cursor.get(field.name) === false) { criteria = {}; criteria[field.name] = { $ne: true }; cursor.and(criteria); } else if (cursor.get(field.name) === true) { criteria = {}; criteria[field.name] = true; cursor.and(criteria); } else { // Don't care (null/undefined) } }, safeFor: 'manage', launder: function(b) { return self.apos.launder.booleanOrNull(b); }, choices: