UNPKG

apostrophe-schemas

Version:

Schemas for easy editing of properties in Apostrophe objects

764 lines (695 loc) 28.1 kB
var async = require('async'); var _ = require('lodash'); var extend = require('extend'); var fs = require('fs'); var moment = require('moment'); function ApostropheSchemas(options, callback) { var self = this; self._apos = options.apos; self._app = options.app; // Mix in the ability to serve assets and templates self._apos.mixinModuleAssets(self, 'schemas', __dirname, options); self.pushAsset('script', 'editor', { when: 'user' }); self.pushAsset('stylesheet', 'editor', { when: 'user' }); // We get constructed first so we need a method to inject the pages // module self.setPages = function(pages) { self._pages = pages; }; // 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. self.compose = function(options) { var schema = []; if (options.addFields) { var nextSplice = schema.length; _.each(options.addFields, function(field) { var i; for (i = 0; (i < schema.length); i++) { if (schema[i].name === field.name) { schema.splice(i, 1); if (!(field.before || field.after)) { // Replace it in its old position if none was explicitly requested schema.splice(i, 0, field); return; } // before or after requested, so fall through and let them work break; } } if (field.start) { nextSplice = 0; } if (field.end) { nextSplice = schema.length; } if (field.after) { for (i = 0; (i < schema.length); i++) { if (schema[i].name === field.after) { nextSplice = i + 1; break; } } } if (field.before) { for (i = 0; (i < schema.length); i++) { if (schema[i].name === field.before) { nextSplice = i; break; } } } schema.splice(nextSplice, 0, field); nextSplice++; }); } if (options.removeFields) { schema = _.filter(schema, function(field) { return !_.contains(options.removeFields, field.name); }); } if (options.orderFields) { var fieldsObject = {}; var copied = {}; _.each(schema, function(field) { fieldsObject[field.name] = field; }); schema = []; _.each(options.orderFields, function(name) { if (fieldsObject[name]) { schema.push(fieldsObject[name]); } copied[name] = true; }); _.each(fieldsObject, function(field, name) { if (!copied[name]) { schema.push(field); } }); } if (options.requireFields) { _.each(options.requireFields, function(name) { var field = _.find(schema, function(field) { return field.name === name; }); if (field) { field.required = true; } }); } if (options.alterFields) { options.alterFields(schema); } // Convenience option for grouping fields // together (visually represented as tabs). Any // fields that are not grouped go to the top and // appear above the tabs if (options.groupFields) { // Drop any previous groups, we're overriding them schema = _.filter(schema, function(field) { return (field.type !== 'group'); }); // Check for groups and fields with the same name, which is // forbidden because groups are internally represented as fields var nameMap = {}; _.each(schema, function(field) { nameMap[field.name] = true; }); _.each(options.groupFields, function(group) { if (_.has(nameMap, group.name)) { throw new Error('The group ' + group.name + ' has the same name as a field. Group names must be distinct from field names.'); } }); var ungrouped = []; var grouped = []; _.each(options.groupFields, function(group) { _.each(group.fields || [], function(name) { var field = _.find(schema, function(field) { return (field.name === name); }); if (field) { field.group = group.name; } else { throw new Error('Nonexistent field ' + name + ' referenced by groups option in schemas.compose'); } }); }); var newSchema = _.map(options.groupFields, function(group) { return { type: 'group', name: group.name, label: group.label, icon: group.label }; }); ungrouped = _.filter(schema, function(field) { return !field.group; }); newSchema = newSchema.concat(ungrouped); _.each(options.groupFields, function(group) { newSchema = newSchema.concat(_.filter(schema, function(field) { return (field.group === group.name); })); }); schema = newSchema; } _.each(schema, function(field) { if (field.template) { if (typeof(field.template) === 'string') { field.render = self.renderer(field.template); delete field.template; } else { field.render = field.template; delete field.template; } } }); return schema; }; // 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) { var options = {}; extend(true, options, _options); options.addFields = schema.concat(options.addFields || []); return self.compose(options); }; // For custom types. For the builtin types we use macros. self.renders = {}; // BEGIN CONVERTERS // Converters from various formats for various types. Define them all // for the csv importer, then copy that as a starting point for // regular forms and override those that are different (areas) self.converters = {}; self.converters.csv = { area: function(req, data, name, snippet, field, callback) { snippet[name] = self._apos.textToArea(data[name]); return setImmediate(callback); }, string: function(req, data, name, snippet, field, callback) { snippet[name] = self._apos.sanitizeString(data[name], field.def); return setImmediate(callback); }, slug: function(req, data, name, snippet, field, callback) { snippet[name] = self._apos.slugify(self._apos.sanitizeString(data[name], field.def)); return setImmediate(callback); }, tags: function(req, data, name, snippet, field, callback) { var tags; tags = self._apos.sanitizeString(data[name]); tags = self._apos.tagsToArray(tags); snippet[name] = tags; return setImmediate(callback); }, boolean: function(req, data, name, snippet, field, callback) { snippet[name] = self._apos.sanitizeBoolean(data[name], field.def); return setImmediate(callback); }, checkboxes: function(req, data, name, object, field, callback) { data[name] = self._apos.sanitizeString(data[name]).split(','); if (typeof(data[name]) !== 'array') { object[name] = []; return setImmediate(callback); } object[name] = _.filter(data[name], function(choice) { return _.contains(_.pluck(field.choices, 'value'), choice); }); return setImmediate(callback); }, select: function(req, data, name, snippet, field, callback) { snippet[name] = self._apos.sanitizeSelect(data[name], field.choices, field.def); return setImmediate(callback); }, integer: function(req, data, name, snippet, field, callback) { snippet[name] = self._apos.sanitizeInteger(data[name], field.def, field.min, field.max); return setImmediate(callback); }, float: function(req, data, name, snippet, field, callback) { snippet[name] = self._apos.sanitizeFloat(data[name], field.def, field.min, field.max); return setImmediate(callback); }, url: function(req, data, name, snippet, field, callback) { snippet[name] = self._apos.sanitizeUrl(data[name], field.def); return setImmediate(callback); }, date: function(req, data, name, snippet, field, callback) { snippet[name] = self._apos.sanitizeDate(data[name], field.def); return setImmediate(callback); }, time: function(req, data, name, snippet, field, callback) { snippet[name] = self._apos.sanitizeTime(data[name], field.def); return setImmediate(callback); }, password: function(req, data, name, snippet, field, callback) { // Change the stored password hash only if a new value // has been offered var _password = self._apos.sanitizeString(data.password); if (_password.length) { snippet[name] = self._apos.hashPassword(data.password); } return setImmediate(callback); }, group: function(req, data, name, snippet, field, callback) { // This is a visual grouping element and has no data return setImmediate(callback); }, array: function(req, data, name, snippet, field, callback) { // We don't do arrays in CSV, it would be painful to work with return setImmediate(callback); }, // Support for one-to-one joins in CSV imports, // by title or id of item joined with. Title match // is tolerant joinByOne: function(req, data, name, snippet, field, callback) { var manager = self._pages.getManager(field.withType); if (!manager) { return callback(new Error('join with type ' + field.withType + ' unrecognized')); } var titleOrId = self._apos.sanitizeString(data[name]); var criteria = { $or: [ { sortTitle: self._apos.sortify(titleOrId) }, { _id: titleOrId } ] }; return manager.get(req, criteria, { fields: { _id: 1 } }, function(err, results) { if (err) { return callback(err); } results = results.pages || results.snippets; if (!results.length) { return callback(null); } snippet[field.idField] = results[0]._id; return callback(null); }); }, // Support for array joins in CSV imports, // by title or id of items joined with, in a comma-separated // list. Title match is tolerant, but you must NOT supply any // commas that may appear in the titles of the individual items, // since commas are reserved for separating items in the list joinByArray: function(req, data, name, snippet, field, callback) { var manager = self._pages.getManager(field.withType); if (!manager) { return callback(new Error('join with type ' + field.withType + ' unrecognized')); } var titlesOrIds = self._apos.sanitizeString(data[name]).split(/\s*,\s*/); if ((!titlesOrIds) || (titlesOrIds[0] === undefined)) { return setImmediate(callback); } var clauses = []; _.each(titlesOrIds, function(titleOrId) { clauses.push({ sortTitle: self._apos.sortify(titleOrId) }); clauses.push({ _id: titleOrId }); }); return manager.get(req, { $or: clauses }, { fields: { _id: 1 }, withJoins: false }, function(err, results) { if (err) { return callback(err); } results = results.pages || results.snippets; snippet[field.idsField] = _.pluck(results, '_id'); return callback(null); }); }, joinByOneReverse: function(req, data, name, snippet, field, callback) { // Importable as part of the *other* type return setImmediate(callback); }, joinByArrayReverse: function(req, data, name, snippet, field, callback) { // Importable as part of the *other* type return setImmediate(callback); }, }; // As far as the server is concerned a singleton is just an area self.converters.csv.singleton = self.converters.csv.area; self.converters.form = {}; extend(self.converters.form, self.converters.csv, true); self.converters.form.singleton = self.converters.form.area = function(req, data, name, snippet, field, callback) { var content = []; try { // If this is a full-fledged area object with a type property, // we're interested in the items property. For bc, if it's just an array, // assume it is already an array of items. content = (data[name].type === 'area') ? data[name].items : data[name]; } catch (e) { // Always recover graciously and import something reasonable, like an empty area } return self._apos.sanitizeItems(req, content, function(err, items) { if (err) { return callback(err); } snippet[name] = { items: items, type: 'area' }; return callback(null); }); }; // An array of objects with their own schema self.converters.form.array = function(req, data, name, snippet, field, callback) { var schema = field.schema; data = data[name]; if (!Array.isArray(data)) { data = []; } var results = []; return async.eachSeries(data, function(datum, callback) { var result = {}; return self.convertFields(req, schema, 'form', datum, result, function(err) { if (err) { return callback(err); } results.push(result); return callback(null); }); }, function(err) { snippet[name] = results; return callback(err); }); }; self.converters.form.joinByOne = function(req, data, name, snippet, field, callback) { snippet[field.idField] = self._apos.sanitizeId(data[field.idField]); return setImmediate(callback); }; self.converters.form.joinByOneReverse = function(req, data, name, snippet, field, callback) { // Not edited on this side of the relation return setImmediate(callback); }; self.converters.form.joinByArray = function(req, data, name, snippet, field, callback) { snippet[field.idsField] = self._apos.sanitizeIds(data[field.idsField]); snippet[field.relationshipField] = {}; _.each(snippet[field.idsField], function(id) { var e = data[field.relationshipField] && data[field.relationshipField][id]; if (!e) { e = {}; } // Validate the relationship (aw) var validatedRelationship = {}; _.each(field.relationship, function(attr) { if (attr.type === 'string') { validatedRelationship[attr.name] = self._apos.sanitizeString(e[attr.name]); } else if (attr.type === 'boolean') { validatedRelationship[attr.name] = self._apos.sanitizeBoolean(e[attr.name]); } else if (attr.type === 'select') { validatedRelationship[attr.name] = self._apos.sanitizeSelect(e[attr.name], attr.choices); } else { console.log(snippet.name + ': unknown type for attr attribute of relationship ' + name + ', ignoring'); } }); snippet[field.relationshipField][id] = validatedRelationship; }); return setImmediate(callback); }; self.converters.form.joinByArrayReverse = function(req, data, name, snippet, field, callback) { // Not edited on this side of the relation return setImmediate(callback); }; self.converters.form.tags = function(req, data, name, snippet, field, callback) { snippet[name] = self._apos.sanitizeTags(data[name]); return setImmediate(callback); }; self.converters.form.checkboxes = function(req, data, name, object, field, callback) { if (!typeof(data[name]) == 'array') { object[name] = []; return setImmediate(callback); } object[name] = _.filter(data[name], function(choice) { return _.contains(_.pluck(field.choices, 'value'), choice); }); return setImmediate(callback); }; // END CONVERTERS // Make each type of schema field searchable. You can shut this off // for any field by setting its `search` option to false. Not all // field types make sense for search. Areas and singletons are always // searchable. The `weight` option makes a property more significant // in search; in the current implementation weights greater than 10 // are treated more prominently. By default all schema fields are // treated as more important than ordinary body text. You can change // that by setting a lower weight. The "silent" option, which is true // by default, prevents the field from showing up in the summary of // the item presented with search results. self.indexers = { string: function(value, field, texts) { var silent = (field.silent === undefined) ? true : field.silent; texts.push({ weight: field.weight || 15, text: value, silent: silent }); }, checkboxes: function(value, field, texts) { var silent = (field.silent === undefined) ? true : field.silent; texts.push({ weight: field.weight || 15, text: (value || []).join(' '), silent: silent }); }, select: function(value, field, texts) { var silent = (field.silent === undefined) ? true : field.silent; texts.push({ weight: field.weight || 15, text: value, silent: silent }); } // areas and singletons are always indexed by apostrophe-pages }; // Index the object's fields for participation in Apostrophe search self.indexFields = function(schema, object, lines) { _.each(schema, function(field) { if (field.search === false) { return; } if (!self.indexers[field.type]) { return; } self.indexers[field.type](object[field.name], field, lines); }); }; // Convert submitted `data`, sanitizing it and populating `object` with it self.convertFields = function(req, schema, from, data, object, callback) { if (arguments.length !== 6) { throw new Error("convertFields now takes 6 arguments, with req added in front and callback added at the end"); } if (!req) { throw new Error("convertFields invoked without a req, do you have one in your context?"); } var i; return async.eachSeries(schema, function(field, 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 // and not a CSV that we're skipping it for. if (field.contextual && (from === 'form')) { return callback(); } if (!self.converters[from][field.type]) { throw new Error("No converter exists for schema field type " + field.type + ", field definition was: " + JSON.stringify(field)); } if (self.converters[from][field.type].length !== 6) { console.error(self.converters[from][field.type].toString()); throw new Error("Schema converter methods must now take the following arguments: req, data, field.name, object, field, callback. They must also invoke the callback."); } return self.converters[from][field.type](req, data, field.name, object, field, function(err) { return callback(err); }); }, function(err) { return callback(err); }); }; // Used to implement 'join', below self.joinrs = { joinByOne: function(req, field, options, objects, callback) { return self._apos.joinByOne(req, objects, field.idField, field.name, options, callback); }, joinByOneReverse: function(req, field, options, objects, callback) { return self._apos.joinByOneReverse(req, objects, field.idField, field.name, options, callback); }, joinByArray: function(req, field, options, objects, callback) { return self._apos.joinByArray(req, objects, field.idsField, field.relationshipsField, field.name, options, callback); }, joinByArrayReverse: function(req, field, options, objects, callback) { return self._apos.joinByArrayReverse(req, objects, field.idsField, field.relationshipsField, field.name, options, 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.joinrs[field.type]; }); _.each(_joins, function(join) { // 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); join._dotPath = arrays.join('.') + '.' + join.name; }); joins = joins.concat(_joins); _.each(schema, function(field) { if (field.type === 'array') { 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; } if (withJoinName.substr(0, dotPath + 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.length) { return objects; } var array = arrays[0]; var _objects = []; _.each(objects, function(object) { _objects = _objects.concat(object[array] || []); }); return findObjectsInArrays(_objects, arrays.slice(1)); } _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)); } var manager = self._pages.getManager(join.withType); if (!manager) { return callback('I cannot find the instance type ' + join.withType + ', maybe you said "map" where you should have said "mapLocation"?'); } var getter; if (manager._instance) { // Snippet type manager, has instance and index types, figure out // which one we are looking for if (manager._instance === join.withType) { getter = manager.get; } else { getter = manager.getIndexes; } } else { // If it has a getter, use it, otherwise supply one getter = manager.get || function(req, _criteria, filters, callback) { var criteria = { $and: [ { type: join.withType }, _criteria ] }; return apos.get(req, criteria, filters, callback); }; } var options = { // Support joining with both instance and index types. If the manager's // instance type matches, use .get, otherwise use .getIndexes get: getter, getOptions: { withJoins: withJoinsNext[join._dotPath] || false, permalink: true } }; // Allow options to the getter to be specified in the schema, // notably editable: true _.extend(options.getOptions, join.getOptions || {}); return self.joinrs[join.type](req, join, options, _objects, callback); }, function(err) { return callback(err); }); }; // Add a new field type. Note that the template property of the type object // should be a function that renders a template, not a template filename. self.addFieldType = function(type) { // template is accepted for bc but it was always a function, so // render is a much better name self.renders[type.name] = type.render || type.template; self.converters.csv[type.name] = type.converters.csv; self.converters.form[type.name] = type.converters.form; self.indexers[type.name] = type.indexer; }; // Render a custom field from nunjucks self._apos.addLocal('aposSchemaField', function(field) { // Alow custom renderers for types and for individual fields var render = field.render || self.renders[field.type]; if (!render) { // Look for a standard render template in the views folder // of this module return self.renderer(field.type)(field); } return render(field); }); if (callback) { return callback(null); } } module.exports = function(options, callback) { return new ApostropheSchemas(options, callback); };