UNPKG

39.2 kBJavaScriptView Raw
1/* jshint node:true */
2var async = require('async');
3var _ = require('lodash');
4var extend = require('extend');
5
6function ApostropheSchemas(options, callback) {
7 var self = this;
8 self._apos = options.apos;
9 self._app = options.app;
10
11 // Mix in the ability to serve assets and templates
12 self._apos.mixinModuleAssets(self, 'schemas', __dirname, options);
13
14 self.pushAsset('script', 'editor', { when: 'user' });
15 self.pushAsset('stylesheet', 'editor', { when: 'user' });
16
17 // We get constructed first so we need a method to inject the pages
18 // module
19 self.setPages = function(pages) {
20 self._pages = pages;
21 };
22
23 // Compose a schema based on addFields, removeFields, orderFields
24 // and, occasionally, alterFields options. This method is great for
25 // merging the schema requirements of subclasses with the schema
26 // requirements of a superclass. See the apostrophe-schemas documentation
27 // for a thorough explanation of the use of each option. The
28 // alterFields option should be avoided if your needs can be met
29 // via another option.
30
31 self.compose = function(options) {
32 var schema = [];
33
34 if (options.addFields) {
35 var nextSplice = schema.length;
36 _.each(options.addFields, function(field) {
37 var i;
38 for (i = 0; (i < schema.length); i++) {
39 if (schema[i].name === field.name) {
40 schema.splice(i, 1);
41 if (!(field.before || field.after)) {
42 // Replace it in its old position if none was explicitly requested
43 schema.splice(i, 0, field);
44 return;
45 }
46 // before or after requested, so fall through and let them work
47 break;
48 }
49 }
50 if (field.start) {
51 nextSplice = 0;
52 }
53 if (field.end) {
54 nextSplice = schema.length;
55 }
56 if (field.after) {
57 for (i = 0; (i < schema.length); i++) {
58 if (schema[i].name === field.after) {
59 nextSplice = i + 1;
60 break;
61 }
62 }
63 }
64 if (field.before) {
65 for (i = 0; (i < schema.length); i++) {
66 if (schema[i].name === field.before) {
67 nextSplice = i;
68 break;
69 }
70 }
71 }
72 schema.splice(nextSplice, 0, field);
73 nextSplice++;
74 });
75 }
76
77 if (options.removeFields) {
78 schema = _.filter(schema, function(field) {
79 return !_.contains(options.removeFields, field.name);
80 });
81 }
82
83 if (options.orderFields) {
84 var fieldsObject = {};
85 var copied = {};
86 _.each(schema, function(field) {
87 fieldsObject[field.name] = field;
88 });
89 schema = [];
90 _.each(options.orderFields, function(name) {
91 if (fieldsObject[name]) {
92 schema.push(fieldsObject[name]);
93 }
94 copied[name] = true;
95 });
96 _.each(fieldsObject, function(field, name) {
97 if (!copied[name]) {
98 schema.push(field);
99 }
100 });
101 }
102
103 if (options.requireFields) {
104 _.each(options.requireFields, function(name) {
105 var field = _.find(schema, function(field) {
106 return field.name === name;
107 });
108 if (field) {
109 field.required = true;
110 }
111 });
112 }
113
114 if (options.alterFields) {
115 options.alterFields(schema);
116 }
117
118 // Convenience option for grouping fields
119 // together (visually represented as tabs). Any
120 // fields that are not grouped go to the top and
121 // appear above the tabs
122 if (options.groupFields) {
123 // Drop any previous groups, we're overriding them
124 schema = _.filter(schema, function(field) {
125 return (field.type !== 'group');
126 });
127 _.each(schema, function(field) {
128 delete field.group;
129 });
130
131 // Check for groups and fields with the same name, which is
132 // forbidden because groups are internally represented as fields
133 var nameMap = {};
134 _.each(schema, function(field) {
135 nameMap[field.name] = true;
136 });
137 _.each(options.groupFields, function(group) {
138 if (_.has(nameMap, group.name)) {
139 throw new Error('The group ' + group.name + ' has the same name as a field. Group names must be distinct from field names.');
140 }
141 });
142
143 var ungrouped = [];
144 _.each(options.groupFields, function(group) {
145 _.each(group.fields || [], function(name) {
146 var field = _.find(schema, function(field) {
147 return (field.name === name);
148 });
149 if (field) {
150 field.group = group.name;
151 } else {
152 // Tolerate nonexistent fields in groupFields. This
153 // will happen if a subclass uses removeFields and
154 // doesn't set up a new groupFields option, which
155 // is reasonable
156 return;
157 }
158 });
159 });
160
161 var newSchema = _.map(options.groupFields, function(group) {
162 return {
163 type: 'group',
164 name: group.name,
165 label: group.label,
166 icon: group.label
167 };
168 });
169
170 ungrouped = _.filter(schema, function(field) {
171 return !field.group;
172 });
173
174 newSchema = newSchema.concat(ungrouped);
175
176 _.each(options.groupFields, function(group) {
177 newSchema = newSchema.concat(_.filter(schema, function(field) {
178 return (field.group === group.name);
179 }));
180 });
181
182 schema = newSchema;
183 }
184
185 _.each(schema, function(field) {
186 if (field.template) {
187 if (typeof(field.template) === 'string') {
188 field.render = self.renderer(field.template);
189 delete field.template;
190 } else {
191 field.render = field.template;
192 delete field.template;
193 }
194 }
195 });
196
197 _.each(schema, function(field) {
198 if (field.type === 'select') {
199 _.each(field.choices, function(choice){
200 if (choice.showFields) {
201 if (!_.isArray(choice.showFields)) {
202 throw new Error('The \'showFields\' property in the choices of a select field needs to be an array.');
203 }
204 _.each(choice.showFields, function(showFieldName){
205 if (!_.find(schema, function(schemaField){ return schemaField.name == showFieldName; })) {
206 console.error('WARNING: The field \'' + showFieldName + '\' does not exist in your schema, but you tried to toggle it\'s display with a select field using showFields. STAAAHHHHPP!');
207 }
208 });
209 }
210 });
211 }
212 });
213
214 return schema;
215 };
216
217 // refine is like compose, but it starts with an existing schema array
218 // and amends it via the same options as compose.
219 self.refine = function(schema, _options) {
220 var options = {};
221 extend(true, options, _options);
222 options.addFields = schema.concat(options.addFields || []);
223 return self.compose(options);
224 };
225
226 // Return a new schema containing only the fields named in the
227 // `fields` array, while maintaining existing group relationships.
228 // Any empty groups are dropped. Do NOT include group names
229 // in `fields`.
230
231 self.subset = function(schema, fields) {
232
233 var schemaSubset = _.filter(schema, function(field) {
234 return _.contains(fields, field.name) || (field.type === 'group');
235 });
236
237 // Drop empty tabs
238 var fieldsByGroup = _.groupBy(schemaSubset, 'group');
239 schemaSubset = _.filter(schemaSubset, function(field) {
240 return (field.type !== 'group') || (_.has(fieldsByGroup, field.name));
241 });
242
243 // Drop references to empty tabs
244 _.each(schemaSubset, function(field) {
245 if (field.group && (!_.find(schemaSubset, function(group) {
246 return ((group.type === 'group') && (group.name === field.group));
247 }))) {
248 delete field.group;
249 }
250 });
251
252 return schemaSubset;
253 };
254
255 // Return a new object with all default settings defined in the schema
256 self.newInstance = function(schema) {
257 var def = {};
258 _.each(schema, function(field) {
259 if (field.def !== undefined) {
260 def[field.name] = field.def;
261 }
262 });
263 return def;
264 };
265
266 self.subsetInstance = function(schema, instance) {
267 var subset = {};
268 _.each(schema, function(field) {
269 if (field.type === 'group') {
270 return;
271 }
272 if (!self.copiers[field]) {
273 // These rules suffice for our standard fields
274 subset[field.name] = instance[field.name];
275 if (field.idField) {
276 subset[field.idField] = instance[field.idField];
277 }
278 if (field.idsField) {
279 subset[field.idsField] = instance[field.idsField];
280 }
281 } else {
282 self.copiers[field](name, instance, subset, field);
283 }
284 });
285 return subset;
286 };
287
288 // Determine whether an object is empty according to the schema.
289 // Note this is not the same thing as matching the defaults. A
290 // nonempty string or array is never considered empty. A numeric
291 // value of 0 is considered empty
292
293 self.empty = function(schema, object) {
294 return !_.find(schema, function(field) {
295 // Return true if not empty
296 var value = object[field.name];
297 if ((value !== null) && (value !== undefined) && (value !== false)) {
298 if (!self.empties[field.type]) {
299 // Type has no method to check emptiness, so assume not empty
300 return true;
301 }
302 return !self.empties[field.type](field, value);
303 }
304 });
305 };
306
307 self.empties = {
308 string: function(field, value) {
309 // Protect ourselves from all the things that aren't stringlike and won't support .length
310 var v = self._apos.sanitizeString(value);
311 return !v.length;
312 },
313 boolean: function(field, value) {
314 return !value;
315 },
316 array: function(field, value) {
317 if (typeof(value) !== 'object') {
318 return true;
319 }
320 return !value.length;
321 },
322 area: function(field, value) {
323 return self._apos._aposLocals.aposAreaIsEmpty({ area: value });
324 },
325 singleton: function(field, value) {
326 return self._apos._aposLocals.aposSingletonIsEmpty({ area: value, type: field.widgetType });
327 },
328 select: function(field, value) {
329 // A select element is considered empty if it doesn't
330 // contain one of its allowed choices, which can happen
331 // in various import situations
332 return (!_.contains(_.pluck(field.choices, 'value'), value));
333 }
334 };
335
336 self.renders = {};
337
338 // BEGIN CONVERTERS
339
340 // Converters from various formats for various types. Define them all
341 // for the csv importer, then copy that as a starting point for
342 // regular forms and override those that are different (areas)
343 self.converters = {};
344 self.converters.csv = {
345 area: function(req, data, name, snippet, field, callback) {
346 snippet[name] = self._apos.textToArea(data[name]);
347 return setImmediate(callback);
348 },
349 string: function(req, data, name, snippet, field, callback) {
350 snippet[name] = self._apos.sanitizeString(data[name], field.def);
351 return setImmediate(callback);
352 },
353 slug: function(req, data, name, snippet, field, callback) {
354 snippet[name] = self._apos.slugify(self._apos.sanitizeString(data[name], field.def));
355 return setImmediate(callback);
356 },
357 tags: function(req, data, name, snippet, field, callback) {
358 var tags;
359 tags = self._apos.sanitizeString(data[name]);
360 tags = self._apos.tagsToArray(tags);
361 snippet[name] = tags;
362 return setImmediate(callback);
363 },
364 boolean: function(req, data, name, snippet, field, callback) {
365 snippet[name] = self._apos.sanitizeBoolean(data[name], field.def);
366 return setImmediate(callback);
367 },
368 checkboxes: function(req, data, name, object, field, callback) {
369 data[name] = self._apos.sanitizeString(data[name]).split(',');
370
371 if (!Array.isArray(data[name])) {
372 object[name] = [];
373 return setImmediate(callback);
374 }
375
376 object[name] = _.filter(data[name], function(choice) {
377 return _.contains(_.pluck(field.choices, 'value'), choice);
378 });
379
380 return setImmediate(callback);
381 },
382 select: function(req, data, name, snippet, field, callback) {
383 snippet[name] = self._apos.sanitizeSelect(data[name], field.choices, field.def);
384 return setImmediate(callback);
385 },
386 integer: function(req, data, name, snippet, field, callback) {
387 snippet[name] = self._apos.sanitizeInteger(data[name], field.def, field.min, field.max);
388 return setImmediate(callback);
389 },
390 float: function(req, data, name, snippet, field, callback) {
391 snippet[name] = self._apos.sanitizeFloat(data[name], field.def, field.min, field.max);
392 return setImmediate(callback);
393 },
394 url: function(req, data, name, snippet, field, callback) {
395 snippet[name] = self._apos.sanitizeUrl(data[name], field.def);
396 return setImmediate(callback);
397 },
398 date: function(req, data, name, snippet, field, callback) {
399 snippet[name] = self._apos.sanitizeDate(data[name], field.def);
400 return setImmediate(callback);
401 },
402 time: function(req, data, name, snippet, field, callback) {
403 snippet[name] = self._apos.sanitizeTime(data[name], field.def);
404 return setImmediate(callback);
405 },
406 password: function(req, data, name, snippet, field, callback) {
407 // Change the stored password hash only if a new value
408 // has been offered
409 var _password = self._apos.sanitizeString(data.password);
410 if (_password.length) {
411 snippet[name] = self._apos.hashPassword(data.password);
412 }
413 return setImmediate(callback);
414 },
415 group: function(req, data, name, snippet, field, callback) {
416 // This is a visual grouping element and has no data
417 return setImmediate(callback);
418 },
419 array: function(req, data, name, snippet, field, callback) {
420 // We don't do arrays in CSV, it would be painful to work with
421 return setImmediate(callback);
422 },
423 // Support for one-to-one joins in CSV imports,
424 // by title or id of item joined with. Title match
425 // is tolerant
426 joinByOne: function(req, data, name, snippet, field, callback) {
427 var manager = self._pages.getManager(field.withType);
428 if (!manager) {
429 return callback(new Error('join with type ' + field.withType + ' unrecognized'));
430 }
431 var titleOrId = self._apos.sanitizeString(data[name]);
432 var criteria = { $or: [ { sortTitle: self._apos.sortify(titleOrId) }, { _id: titleOrId } ] };
433 return manager.get(req, criteria, { fields: { _id: 1 } }, function(err, results) {
434 if (err) {
435 return callback(err);
436 }
437 results = results.pages || results.snippets;
438 if (!results.length) {
439 return callback(null);
440 }
441 snippet[field.idField] = results[0]._id;
442 return callback(null);
443 });
444 },
445 // Support for array joins in CSV imports,
446 // by title or id of items joined with, in a comma-separated
447 // list. Title match is tolerant, but you must NOT supply any
448 // commas that may appear in the titles of the individual items,
449 // since commas are reserved for separating items in the list
450 joinByArray: function(req, data, name, snippet, field, callback) {
451 var manager = self._pages.getManager(field.withType);
452 if (!manager) {
453 return callback(new Error('join with type ' + field.withType + ' unrecognized'));
454 }
455 var titlesOrIds = self._apos.sanitizeString(data[name]).split(/\s*,\s*/);
456 if ((!titlesOrIds) || (titlesOrIds[0] === undefined)) {
457 return setImmediate(callback);
458 }
459 var clauses = [];
460 _.each(titlesOrIds, function(titleOrId) {
461 clauses.push({ sortTitle: self._apos.sortify(titleOrId) });
462 clauses.push({ _id: titleOrId });
463 });
464 return manager.get(req, { $or: clauses }, { fields: { _id: 1 }, withJoins: false }, function(err, results) {
465 if (err) {
466 return callback(err);
467 }
468 results = results.pages || results.snippets;
469 if (field.limit !== undefined) {
470 results = results.slice(0, field.limit);
471 }
472 snippet[field.idsField] = _.pluck(results, '_id');
473 return callback(null);
474 });
475 },
476 joinByOneReverse: function(req, data, name, snippet, field, callback) {
477 // Importable as part of the *other* type
478 return setImmediate(callback);
479 },
480 joinByArrayReverse: function(req, data, name, snippet, field, callback) {
481 // Importable as part of the *other* type
482 return setImmediate(callback);
483 },
484 };
485 // As far as the server is concerned a singleton is just an area
486 self.converters.csv.singleton = self.converters.csv.area;
487
488 self.converters.form = {};
489 extend(self.converters.form, self.converters.csv, true);
490
491 self.converters.form.singleton = self.converters.form.area = function(req, data, name, snippet, field, callback) {
492 var content = [];
493 try {
494 // If this is a full-fledged area object with a type property,
495 // we're interested in the items property. For bc, if it's just an array,
496 // assume it is already an array of items.
497 content = (data[name].type === 'area') ? data[name].items : data[name];
498 } catch (e) {
499 // Always recover graciously and import something reasonable, like an empty area
500 }
501 return self._apos.sanitizeItems(req, content, function(err, items) {
502 if (err) {
503 return callback(err);
504 }
505 snippet[name] = { items: items, type: 'area' };
506 return callback(null);
507 });
508 };
509
510 // An array of objects with their own schema
511 self.converters.form.array = function(req, data, name, snippet, field, callback) {
512 var schema = field.schema;
513 data = data[name];
514 if (!Array.isArray(data)) {
515 data = [];
516 }
517 var results = [];
518 return async.eachSeries(data, function(datum, callback) {
519 var result = {};
520 result.id = self._apos.sanitizeId(datum.id) || self._apos.generateId();
521 return self.convertFields(req, schema, 'form', datum, result, function(err) {
522 if (err) {
523 return callback(err);
524 }
525 results.push(result);
526 return callback(null);
527 });
528 }, function(err) {
529 snippet[name] = results;
530 return callback(err);
531 });
532 };
533
534 self.converters.form.joinByOne = function(req, data, name, snippet, field, callback) {
535 snippet[field.idField] = self._apos.sanitizeId(data[field.idField]);
536 return setImmediate(callback);
537 };
538
539 self.converters.form.joinByOneReverse = function(req, data, name, snippet, field, callback) {
540 // Not edited on this side of the relation
541 return setImmediate(callback);
542 };
543
544 self.converters.form.joinByArray = function(req, data, name, snippet, field, callback) {
545 snippet[field.idsField] = self._apos.sanitizeIds(data[field.idsField]);
546
547 if (field.limit !== undefined) {
548 snippet[field.idsField] = snippet[field.idsField].slice(0, field.limit);
549 }
550
551 snippet[field.relationshipsField] = {};
552
553 _.each(snippet[field.idsField], function(id) {
554 var e = data[field.relationshipsField] && data[field.relationshipsField][id];
555 if (!e) {
556 e = {};
557 }
558 // Validate the relationship (aw)
559 var validatedRelationship = {};
560 _.each(field.relationship, function(attr) {
561 if (attr.type === 'string') {
562 validatedRelationship[attr.name] = self._apos.sanitizeString(e[attr.name]);
563 } else if (attr.type === 'boolean') {
564 validatedRelationship[attr.name] = self._apos.sanitizeBoolean(e[attr.name]);
565 } else if (attr.type === 'select') {
566 validatedRelationship[attr.name] = self._apos.sanitizeSelect(e[attr.name], attr.choices);
567 } else if (attr.type === 'tags') {
568 validatedRelationship[attr.name] = self._apos.sanitizeTags(e[attr.name]);
569 } else {
570 console.error(snippet.name + ': unknown type for attr attribute of relationship ' + name + ', ignoring');
571 }
572 });
573 snippet[field.relationshipsField][id] = validatedRelationship;
574 });
575 return setImmediate(callback);
576 };
577
578 self.converters.form.joinByArrayReverse = function(req, data, name, snippet, field, callback) {
579 // Not edited on this side of the relation
580 return setImmediate(callback);
581 };
582
583 self.converters.form.tags = function(req, data, name, snippet, field, callback) {
584 var tags = self._apos.sanitizeTags(data[name]);
585 if (!self._apos.options.lockTags) {
586 snippet[field.name] = tags;
587 return setImmediate(callback);
588 }
589 return self._apos.getTags({ tags: tags }, function(err, tags) {
590 if (err) {
591 return callback(err);
592 }
593 //enforce limit if provided, take first N elements
594 if (field.options && field.options.limit) {
595 tags = tags.slice(0, field.options.limit);
596 }
597 snippet[field.name] = tags;
598 return callback(null);
599 });
600 };
601
602 self.converters.form.checkboxes = function(req, data, name, object, field, callback) {
603 if (!Array.isArray(data[name])) {
604 object[name] = [];
605 return setImmediate(callback);
606 }
607
608 object[name] = _.filter(data[name], function(choice) {
609 return _.contains(_.pluck(field.choices, 'value'), choice);
610 });
611
612 return setImmediate(callback);
613 };
614
615 // END CONVERTERS
616
617
618 // BEGIN EPORTERS
619
620 // Exporters from various formats for CSV, plain text output.
621 self.exporters = {};
622 self.exporters.csv = {
623 string: function(req, snippet, field, name, output, callback) {
624 // no formating, set the field
625 output[name] = snippet[name];
626 return setImmediate(callback);
627 },
628 select: function(req, snippet, field, name, output, callback) {
629 output[name] = snippet[name] || '';
630 return setImmediate(callback);
631 },
632 slug: function(req, snippet, field, name, output, callback) {
633 // no formating, set the field
634 output[name] = snippet[name];
635 return setImmediate(callback);
636 },
637 tags: function(req, snippet, field, name, output, callback) {
638 output[name] = snippet[name].toString();
639 return setImmediate(callback);
640 },
641 boolean: function(req, snippet, field, name, output, callback) {
642 output[name] = self._apos.sanitizeBoolean(snippet[name]).toString();
643 return setImmediate(callback);
644 },
645 group: function(req, snippet, field, name, output, callback) {
646 // This is a visual grouping element and has no data
647 return setImmediate(callback);
648 },
649 a2Groups: function(req, snippet, field, name, output, callback) {
650 // This is a visual grouping element and has no data
651 return setImmediate(callback);
652 },
653 password: function(req, snippet, field, name, output, callback) {
654 // don't export
655 return setImmediate(callback);
656 },
657 a2Permissions: function(req, snippet, field, name, output, callback) {
658 // don't export
659 return setImmediate(callback);
660 },
661 }
662
663 // Make each type of schema field searchable. You can shut this off
664 // for any field by setting its `search` option to false. Not all
665 // field types make sense for search. Areas and singletons are always
666 // searchable. The `weight` option makes a property more significant
667 // in search; in the current implementation weights greater than 10
668 // are treated more prominently. By default all schema fields are
669 // treated as more important than ordinary body text. You can change
670 // that by setting a lower weight. The "silent" option, which is true
671 // by default, prevents the field from showing up in the summary of
672 // the item presented with search results.
673
674 self.indexers = {
675 string: function(value, field, texts) {
676 var silent = (field.silent === undefined) ? true : field.silent;
677 texts.push({ weight: field.weight || 15, text: value, silent: silent });
678 },
679 checkboxes: function(value, field, texts) {
680 var silent = (field.silent === undefined) ? true : field.silent;
681 texts.push({ weight: field.weight || 15, text: (value || []).join(' '), silent: silent });
682 },
683 select: function(value, field, texts) {
684 var silent = (field.silent === undefined) ? true : field.silent;
685 texts.push({ weight: field.weight || 15, text: value, silent: silent });
686 }
687 // areas and singletons are always indexed by apostrophe-pages
688 };
689
690 // Index the object's fields for participation in Apostrophe search
691 self.indexFields = function(schema, object, lines) {
692 _.each(schema, function(field) {
693 if (field.search === false) {
694 return;
695 }
696 if (!self.indexers[field.type]) {
697 return;
698 }
699 self.indexers[field.type](object[field.name], field, lines);
700 });
701 };
702
703 // Convert submitted `data`, sanitizing it and populating `object` with it
704 self.convertFields = function(req, schema, from, data, object, callback) {
705 if (arguments.length !== 6) {
706 throw new Error("convertFields now takes 6 arguments, with req added in front and callback added at the end");
707 }
708 if (!req) {
709 throw new Error("convertFields invoked without a req, do you have one in your context?");
710 }
711
712 // Allow alternate names for fields. Very useful when importing
713 // from a previous schema. -Tom
714 _.each(schema, function(field) {
715 if ((!_.has(data, field.name)) && field.alternate && _.has(data, field.alternate)) {
716 data[field.alternate] = data[field.name];
717 }
718 });
719
720 return async.eachSeries(schema, function(field, callback) {
721 // Fields that are contextual are edited in the context of a
722 // show page and do not appear in regular schema forms. They are
723 // however legitimate in imports, so we make sure it's a form
724 // and not a CSV that we're skipping it for.
725 if (field.contextual && (from === 'form')) {
726 return callback();
727 }
728 if (!self.converters[from][field.type]) {
729 throw new Error("No converter exists for schema field type " + field.type + ", field definition was: " + JSON.stringify(field));
730 }
731 if (self.converters[from][field.type].length !== 6) {
732 console.error(self.converters[from][field.type].toString());
733 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.");
734 }
735 return self.converters[from][field.type](req, data, field.name, object, field, function(err) {
736 return callback(err);
737 });
738 }, function(err) {
739 return callback(err);
740 });
741 };
742
743 // Export santized 'snippet' into 'object'
744 self.exportFields = function(req, schema, to, snippet, object, callback) {
745 if (arguments.length !== 6) {
746 throw new Error("exportFields now takes 6 arguments, with req added in front and callback added at the end");
747 }
748 if (!req) {
749 throw new Error("exportFields invoked without a req, do you have one in your context?");
750 }
751 return async.eachSeries(schema, function(field, callback) {
752
753 if (!self.exporters[to][field.type]) {
754 console.log("ERROR: No exporter exists for schema field type " + field.type + ", field definition was: " + JSON.stringify(field));
755 console.log("You can add support for this field type in schemas.exporters");
756 return callback(null);
757 }
758 if (self.exporters[to][field.type].length !== 6) {
759 console.error(self.exporters[to][field.type].toString());
760 throw new Error("Schema export methods must now take the following arguments: req, snippet, field, field.name, output, callback. They must also invoke the callback.");
761 }
762 return self.exporters[to][field.type](req, snippet, field, field.name, object, function(err) {
763 return callback(err);
764 });
765 }, function(err) {
766 return callback(err);
767 });
768 };
769
770 // Used to implement 'join', below
771 self.joinrs = {
772 joinByOne: function(req, field, options, objects, callback) {
773 return self._apos.joinByOne(req, objects, field.idField, field.name, options, callback);
774 },
775 joinByOneReverse: function(req, field, options, objects, callback) {
776 return self._apos.joinByOneReverse(req, objects, field.idField, field.name, options, callback);
777 },
778 joinByArray: function(req, field, options, objects, callback) {
779 return self._apos.joinByArray(req, objects, field.idsField, field.relationshipsField, field.name, options, callback);
780 },
781 joinByArrayReverse: function(req, field, options, objects, callback) {
782 return self._apos.joinByArrayReverse(req, objects, field.idsField, field.relationshipsField, field.name, options, callback);
783 }
784 };
785
786 // Carry out all the joins in the schema on the specified object or array
787 // of objects. The withJoins option may be omitted.
788 //
789 // If withJoins is omitted, null or undefined, all the joins in the schema
790 // are performed, and also any joins specified by the 'withJoins' option of
791 // each join field in the schema, if any. And that's where it stops. Infinite
792 // recursion is not possible.
793 //
794 // If withJoins is specified and set to "false", no joins at all are performed.
795 //
796 // If withJoins is set to an array of join names found in the schema, then
797 // only those joins are performed, ignoring any 'withJoins' options found in
798 // the schema.
799 //
800 // If a join name in the withJoins array uses dot notation, like this:
801 //
802 // _events._locations
803 //
804 // Then the objects are joined with events, and then the events are further
805 // joined with locations, assuming that _events is defined as a join in the
806 // schema and _locations is defined as a join in the schema for the events
807 // module. Multiple "dot notation" joins may share a prefix.
808 //
809 // Joins are also supported in the schemas of array fields.
810
811 self.join = function(req, schema, objectOrArray, withJoins, callback) {
812 if (arguments.length === 3) {
813 callback = withJoins;
814 withJoins = undefined;
815 }
816
817 if (withJoins === false) {
818 // Joins explicitly deactivated for this call
819 return callback(null);
820 }
821
822 var objects = _.isArray(objectOrArray) ? objectOrArray : [ objectOrArray ];
823 if (!objects.length) {
824 // Don't waste effort
825 return callback(null);
826 }
827
828 // build an array of joins of interest, found at any level
829 // in the schema, even those nested in array schemas. Add
830 // an _arrays property to each one which contains the names
831 // of the array fields leading to this join, if any, so
832 // we know where to store the results. Also set a
833 // _dotPath property which can be used to identify relevant
834 // joins when the withJoins option is present
835
836 var joins = [];
837
838 function findJoins(schema, arrays) {
839 var _joins = _.filter(schema, function(field) {
840 return !!self.joinrs[field.type];
841 });
842 _.each(_joins, function(join) {
843 if (!arrays.length) {
844 join._dotPath = join.name;
845 } else {
846 join._dotPath = arrays.join('.') + '.' + join.name;
847 }
848 // If we have more than one object we're not interested in joins
849 // with the ifOnlyOne restriction right now.
850 if ((objects.length > 1) && join.ifOnlyOne) {
851 return;
852 }
853 join._arrays = _.clone(arrays);
854 });
855 joins = joins.concat(_joins);
856 _.each(schema, function(field) {
857 if (field.type === 'array') {
858 findJoins(field.schema, arrays.concat(field.name));
859 }
860 });
861 }
862
863 findJoins(schema, []);
864
865 // The withJoins option allows restriction of joins. Set to false
866 // it blocks all joins. Set to an array, it allows the joins named within.
867 // Dot notation can be used to specify joins in array properties,
868 // or joins reached via other joins.
869 //
870 // By default, all configured joins will take place, but withJoins: false
871 // will be passed when fetching the objects on the other end of the join,
872 // so that infinite recursion never takes place.
873
874 var withJoinsNext = {};
875 // Explicit withJoins option passed to us
876 if (Array.isArray(withJoins)) {
877 joins = _.filter(joins, function(join) {
878 var dotPath = join._dotPath;
879 var winner;
880 _.each(withJoins, function(withJoinName) {
881 if (withJoinName === dotPath) {
882 winner = true;
883 return;
884 }
885 if (withJoinName.substr(0, dotPath.length + 1) === (dotPath + '.')) {
886 if (!withJoinsNext[dotPath]) {
887 withJoinsNext[dotPath] = [];
888 }
889 withJoinsNext[dotPath].push(withJoinName.substr(dotPath.length + 1));
890 winner = true;
891 }
892 });
893 return winner;
894 });
895 } else {
896 // No explicit withJoins option for us, so we do all the joins
897 // we're configured to do, and pass on the withJoins options we
898 // have configured for those
899 _.each(joins, function(join) {
900 if (join.withJoins) {
901 withJoinsNext[join._dotPath] = join.withJoins;
902 }
903 });
904 }
905
906 return async.eachSeries(joins, function(join, callback) {
907 var arrays = join._arrays;
908
909 function findObjectsInArrays(objects, arrays) {
910 if (!arrays) {
911 return [];
912 }
913 if (!arrays.length) {
914 return objects;
915 }
916 var array = arrays[0];
917 var _objects = [];
918 _.each(objects, function(object) {
919 _objects = _objects.concat(object[array] || []);
920 });
921 return findObjectsInArrays(_objects, arrays.slice(1));
922 }
923
924 var _objects = findObjectsInArrays(objects, arrays);
925
926 if (!join.name.match(/^_/)) {
927 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));
928 }
929 var manager = self._pages.getManager(join.withType);
930 if (!manager) {
931 return callback('I cannot find the instance type ' + join.withType + ', maybe you said "map" where you should have said "mapLocation"?');
932 }
933
934 var getter;
935 if (manager._instance) {
936 // Snippet type manager, has instance and index types, figure out
937 // which one we are looking for
938 if (manager._instance === join.withType) {
939 getter = manager.get;
940 } else {
941 getter = manager.getIndexes;
942 }
943 } else {
944 // If it has a getter, use it, otherwise supply one
945 getter = manager.get || function(req, _criteria, filters, callback) {
946 var criteria = {
947 $and: [
948 {
949 type: join.withType
950 },
951 _criteria
952 ]
953 };
954 return self._apos.get(req, criteria, filters, callback);
955 };
956 }
957
958 var options = {
959 // Support joining with both instance and index types. If the manager's
960 // instance type matches, use .get, otherwise use .getIndexes
961 get: getter,
962 getOptions: {
963 withJoins: withJoinsNext[join._dotPath] || false,
964 permalink: true
965 }
966 };
967
968 // Allow options to the get() method to be
969 // specified in the join configuration
970 if (join.getOptions) {
971 _.extend(options.getOptions, join.getOptions);
972 }
973
974 // Allow options to the getter to be specified in the schema,
975 // notably editable: true
976 _.extend(options.getOptions, join.getOptions || {});
977 return self.joinrs[join.type](req, join, options, _objects, callback);
978 }, callback);
979 };
980
981 // Add a new field type. Note that the template property of the type object
982 // should be a function that renders a template, not a template filename.
983
984 self.addFieldType = function(type) {
985 // template is accepted for bc but it was always a function, so
986 // render is a much better name
987 self.renders[type.name] = type.render || type.template;
988 self.converters.csv[type.name] = type.converters.csv;
989 self.converters.form[type.name] = type.converters.form;
990
991 self.indexers[type.name] = type.indexer;
992 self.empties[type.name] = type.empty;
993 self.copiers[type.name] = self.copier;
994 if (type.exporters && type.exporters.csv){
995 self.exporters.csv[type.name] = type.exporters.csv;
996 }
997
998 };
999
1000 // Render a field from nunjucks
1001 self._apos.addLocal('aposSchemaField', function(field) {
1002 // Alow custom renderers for types and for individual fields
1003 var render = field.render || self.renders[field.type];
1004 if (!render) {
1005 // Look for a standard render template in the views folder
1006 // of this module
1007 return self.renderer(field.type)(field).trim();
1008 }
1009 return render(field).trim();
1010 });
1011
1012 self._apos.addLocal('aposMonthChoices', function() {
1013 return [
1014 {
1015 // These labels are passed through internationalization
1016 label: 'Month',
1017 value: ''
1018 },
1019 {
1020 label: 'January',
1021 value: '01'
1022 },
1023 {
1024 label: 'February',
1025 value: '02'
1026 },
1027 {
1028 label: 'March',
1029 value: '03'
1030 },
1031 {
1032 label: 'April',
1033 value: '04'
1034 },
1035 {
1036 label: 'May',
1037 value: '05'
1038 },
1039 {
1040 label: 'June',
1041 value: '06'
1042 },
1043 {
1044 label: 'July',
1045 value: '07'
1046 },
1047 {
1048 label: 'August',
1049 value: '08'
1050 },
1051 {
1052 label: 'September',
1053 value: '09'
1054 },
1055 {
1056 label: 'October',
1057 value: '10'
1058 },
1059 {
1060 label: 'November',
1061 value: '11'
1062 },
1063 {
1064 label: 'December',
1065 value: '12'
1066 }
1067 ]
1068 });
1069
1070 self._apos.addLocal('aposDayChoices', function() {
1071 var choices = [
1072 {
1073 label: 'Day',
1074 value: ''
1075 }
1076 ];
1077 var i;
1078 for (i = 1; (i <= 31); i++) {
1079 var formatted = self._apos.padInteger(i, 2);
1080 choices.push({
1081 label: formatted,
1082 value: formatted
1083 });
1084 }
1085 return choices;
1086 });
1087
1088 // yearFrom and yearTo can be offsets from the current year, like -50 and +10 or
1089 // -120 and -18, or they can be four-digit absolute years. -Tom
1090
1091 self._apos.addLocal('aposYearChoices', function(yearFrom, yearTo) {
1092 if (yearFrom === undefined) {
1093 yearFrom = -120;
1094 }
1095 if (yearTo === undefined) {
1096 yearTo = +10;
1097 }
1098 var i;
1099 var year = (new Date()).getFullYear();
1100 if (yearFrom < 500) {
1101 yearFrom = year + yearFrom;
1102 }
1103 if (yearTo < 500) {
1104 yearTo = year + yearTo;
1105 }
1106 var choices = [
1107 {
1108 label: 'Year',
1109 value: ''
1110 }
1111 ];
1112 for (i = yearFrom; (i <= yearTo); i++) {
1113 choices.push({
1114 label: i.toString(),
1115 value: i.toString()
1116 });
1117 }
1118 return choices;
1119 });
1120
1121 self.copiers = {};
1122
1123 if (callback) {
1124 return callback(null);
1125 }
1126}
1127
1128module.exports = function(options, callback) {
1129 return new ApostropheSchemas(options, callback);
1130};