UNPKG

39.4 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 if (Array.isArray(snippet[name])) {
639 output[name] = snippet[name].toString();
640 }
641 return setImmediate(callback);
642 },
643 boolean: function(req, snippet, field, name, output, callback) {
644 output[name] = self._apos.sanitizeBoolean(snippet[name]).toString();
645 return setImmediate(callback);
646 },
647 group: function(req, snippet, field, name, output, callback) {
648 // This is a visual grouping element and has no data
649 return setImmediate(callback);
650 },
651 a2Groups: function(req, snippet, field, name, output, callback) {
652 // This is a visual grouping element and has no data
653 return setImmediate(callback);
654 },
655 password: function(req, snippet, field, name, output, callback) {
656 // don't export
657 return setImmediate(callback);
658 },
659 a2Permissions: function(req, snippet, field, name, output, callback) {
660 // don't export
661 return setImmediate(callback);
662 },
663 }
664
665 // Make each type of schema field searchable. You can shut this off
666 // for any field by setting its `search` option to false. Not all
667 // field types make sense for search. Areas and singletons are always
668 // searchable. The `weight` option makes a property more significant
669 // in search; in the current implementation weights greater than 10
670 // are treated more prominently. By default all schema fields are
671 // treated as more important than ordinary body text. You can change
672 // that by setting a lower weight. The "silent" option, which is true
673 // by default, prevents the field from showing up in the summary of
674 // the item presented with search results.
675
676 self.indexers = {
677 string: function(value, field, texts) {
678 var silent = (field.silent === undefined) ? true : field.silent;
679 texts.push({ weight: field.weight || 15, text: value, silent: silent });
680 },
681 checkboxes: function(value, field, texts) {
682 var silent = (field.silent === undefined) ? true : field.silent;
683 texts.push({ weight: field.weight || 15, text: (value || []).join(' '), silent: silent });
684 },
685 select: function(value, field, texts) {
686 var silent = (field.silent === undefined) ? true : field.silent;
687 texts.push({ weight: field.weight || 15, text: value, silent: silent });
688 }
689 // areas and singletons are always indexed by apostrophe-pages
690 };
691
692 // Index the object's fields for participation in Apostrophe search
693 self.indexFields = function(schema, object, lines) {
694 _.each(schema, function(field) {
695 if (field.search === false) {
696 return;
697 }
698 if (!self.indexers[field.type]) {
699 return;
700 }
701 self.indexers[field.type](object[field.name], field, lines);
702 });
703 };
704
705 // Convert submitted `data`, sanitizing it and populating `object` with it
706 self.convertFields = function(req, schema, from, data, object, callback) {
707 if (arguments.length !== 6) {
708 throw new Error("convertFields now takes 6 arguments, with req added in front and callback added at the end");
709 }
710 if (!req) {
711 throw new Error("convertFields invoked without a req, do you have one in your context?");
712 }
713
714 schema = _.filter(schema, function(field) {
715 if(!field.permission) {
716 return true;
717 }
718
719 return self._apos.permissions.can(req, field.permission);
720 });
721
722 // Allow alternate names for fields. Very useful when importing
723 // from a previous schema. -Tom
724 _.each(schema, function(field) {
725 if ((!_.has(data, field.name)) && field.alternate && _.has(data, field.alternate)) {
726 data[field.alternate] = data[field.name];
727 }
728 });
729
730 return async.eachSeries(schema, function(field, callback) {
731 // Fields that are contextual are edited in the context of a
732 // show page and do not appear in regular schema forms. They are
733 // however legitimate in imports, so we make sure it's a form
734 // and not a CSV that we're skipping it for.
735 if (field.contextual && (from === 'form')) {
736 return callback();
737 }
738 if (!self.converters[from][field.type]) {
739 throw new Error("No converter exists for schema field type " + field.type + ", field definition was: " + JSON.stringify(field));
740 }
741 if (self.converters[from][field.type].length !== 6) {
742 console.error(self.converters[from][field.type].toString());
743 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.");
744 }
745 return self.converters[from][field.type](req, data, field.name, object, field, function(err) {
746 return callback(err);
747 });
748 }, function(err) {
749 return callback(err);
750 });
751 };
752
753 // Export santized 'snippet' into 'object'
754 self.exportFields = function(req, schema, to, snippet, object, callback) {
755 if (arguments.length !== 6) {
756 throw new Error("exportFields now takes 6 arguments, with req added in front and callback added at the end");
757 }
758 if (!req) {
759 throw new Error("exportFields invoked without a req, do you have one in your context?");
760 }
761 return async.eachSeries(schema, function(field, callback) {
762
763 if (!self.exporters[to][field.type]) {
764 console.log("ERROR: No exporter exists for schema field type " + field.type + ", field definition was: " + JSON.stringify(field));
765 console.log("You can add support for this field type in schemas.exporters");
766 return callback(null);
767 }
768 if (self.exporters[to][field.type].length !== 6) {
769 console.error(self.exporters[to][field.type].toString());
770 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.");
771 }
772 return self.exporters[to][field.type](req, snippet, field, field.name, object, function(err) {
773 return callback(err);
774 });
775 }, function(err) {
776 return callback(err);
777 });
778 };
779
780 // Used to implement 'join', below
781 self.joinrs = {
782 joinByOne: function(req, field, options, objects, callback) {
783 return self._apos.joinByOne(req, objects, field.idField, field.name, options, callback);
784 },
785 joinByOneReverse: function(req, field, options, objects, callback) {
786 return self._apos.joinByOneReverse(req, objects, field.idField, field.name, options, callback);
787 },
788 joinByArray: function(req, field, options, objects, callback) {
789 return self._apos.joinByArray(req, objects, field.idsField, field.relationshipsField, field.name, options, callback);
790 },
791 joinByArrayReverse: function(req, field, options, objects, callback) {
792 return self._apos.joinByArrayReverse(req, objects, field.idsField, field.relationshipsField, field.name, options, callback);
793 }
794 };
795
796 // Carry out all the joins in the schema on the specified object or array
797 // of objects. The withJoins option may be omitted.
798 //
799 // If withJoins is omitted, null or undefined, all the joins in the schema
800 // are performed, and also any joins specified by the 'withJoins' option of
801 // each join field in the schema, if any. And that's where it stops. Infinite
802 // recursion is not possible.
803 //
804 // If withJoins is specified and set to "false", no joins at all are performed.
805 //
806 // If withJoins is set to an array of join names found in the schema, then
807 // only those joins are performed, ignoring any 'withJoins' options found in
808 // the schema.
809 //
810 // If a join name in the withJoins array uses dot notation, like this:
811 //
812 // _events._locations
813 //
814 // Then the objects are joined with events, and then the events are further
815 // joined with locations, assuming that _events is defined as a join in the
816 // schema and _locations is defined as a join in the schema for the events
817 // module. Multiple "dot notation" joins may share a prefix.
818 //
819 // Joins are also supported in the schemas of array fields.
820
821 self.join = function(req, schema, objectOrArray, withJoins, callback) {
822 if (arguments.length === 3) {
823 callback = withJoins;
824 withJoins = undefined;
825 }
826
827 if (withJoins === false) {
828 // Joins explicitly deactivated for this call
829 return callback(null);
830 }
831
832 var objects = _.isArray(objectOrArray) ? objectOrArray : [ objectOrArray ];
833 if (!objects.length) {
834 // Don't waste effort
835 return callback(null);
836 }
837
838 // build an array of joins of interest, found at any level
839 // in the schema, even those nested in array schemas. Add
840 // an _arrays property to each one which contains the names
841 // of the array fields leading to this join, if any, so
842 // we know where to store the results. Also set a
843 // _dotPath property which can be used to identify relevant
844 // joins when the withJoins option is present
845
846 var joins = [];
847
848 function findJoins(schema, arrays) {
849 var _joins = _.filter(schema, function(field) {
850 return !!self.joinrs[field.type];
851 });
852 _.each(_joins, function(join) {
853 if (!arrays.length) {
854 join._dotPath = join.name;
855 } else {
856 join._dotPath = arrays.join('.') + '.' + join.name;
857 }
858 // If we have more than one object we're not interested in joins
859 // with the ifOnlyOne restriction right now.
860 if ((objects.length > 1) && join.ifOnlyOne) {
861 return;
862 }
863 join._arrays = _.clone(arrays);
864 });
865 joins = joins.concat(_joins);
866 _.each(schema, function(field) {
867 if (field.type === 'array') {
868 findJoins(field.schema, arrays.concat(field.name));
869 }
870 });
871 }
872
873 findJoins(schema, []);
874
875 // The withJoins option allows restriction of joins. Set to false
876 // it blocks all joins. Set to an array, it allows the joins named within.
877 // Dot notation can be used to specify joins in array properties,
878 // or joins reached via other joins.
879 //
880 // By default, all configured joins will take place, but withJoins: false
881 // will be passed when fetching the objects on the other end of the join,
882 // so that infinite recursion never takes place.
883
884 var withJoinsNext = {};
885 // Explicit withJoins option passed to us
886 if (Array.isArray(withJoins)) {
887 joins = _.filter(joins, function(join) {
888 var dotPath = join._dotPath;
889 var winner;
890 _.each(withJoins, function(withJoinName) {
891 if (withJoinName === dotPath) {
892 winner = true;
893 return;
894 }
895 if (withJoinName.substr(0, dotPath.length + 1) === (dotPath + '.')) {
896 if (!withJoinsNext[dotPath]) {
897 withJoinsNext[dotPath] = [];
898 }
899 withJoinsNext[dotPath].push(withJoinName.substr(dotPath.length + 1));
900 winner = true;
901 }
902 });
903 return winner;
904 });
905 } else {
906 // No explicit withJoins option for us, so we do all the joins
907 // we're configured to do, and pass on the withJoins options we
908 // have configured for those
909 _.each(joins, function(join) {
910 if (join.withJoins) {
911 withJoinsNext[join._dotPath] = join.withJoins;
912 }
913 });
914 }
915
916 return async.eachSeries(joins, function(join, callback) {
917 var arrays = join._arrays;
918
919 function findObjectsInArrays(objects, arrays) {
920 if (!arrays) {
921 return [];
922 }
923 if (!arrays.length) {
924 return objects;
925 }
926 var array = arrays[0];
927 var _objects = [];
928 _.each(objects, function(object) {
929 _objects = _objects.concat(object[array] || []);
930 });
931 return findObjectsInArrays(_objects, arrays.slice(1));
932 }
933
934 var _objects = findObjectsInArrays(objects, arrays);
935
936 if (!join.name.match(/^_/)) {
937 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));
938 }
939 var manager = self._pages.getManager(join.withType);
940 if (!manager) {
941 return callback('I cannot find the instance type ' + join.withType + ', maybe you said "map" where you should have said "mapLocation"?');
942 }
943
944 var getter;
945 if (manager._instance) {
946 // Snippet type manager, has instance and index types, figure out
947 // which one we are looking for
948 if (manager._instance === join.withType) {
949 getter = manager.get;
950 } else {
951 getter = manager.getIndexes;
952 }
953 } else {
954 // If it has a getter, use it, otherwise supply one
955 getter = manager.get || function(req, _criteria, filters, callback) {
956 var criteria = {
957 $and: [
958 {
959 type: join.withType
960 },
961 _criteria
962 ]
963 };
964 return self._apos.get(req, criteria, filters, callback);
965 };
966 }
967
968 var options = {
969 // Support joining with both instance and index types. If the manager's
970 // instance type matches, use .get, otherwise use .getIndexes
971 get: getter,
972 getOptions: {
973 withJoins: withJoinsNext[join._dotPath] || false,
974 permalink: true
975 }
976 };
977
978 // Allow options to the get() method to be
979 // specified in the join configuration
980 if (join.getOptions) {
981 _.extend(options.getOptions, join.getOptions);
982 }
983
984 // Allow options to the getter to be specified in the schema,
985 // notably editable: true
986 _.extend(options.getOptions, join.getOptions || {});
987 return self.joinrs[join.type](req, join, options, _objects, callback);
988 }, callback);
989 };
990
991 // Add a new field type. Note that the template property of the type object
992 // should be a function that renders a template, not a template filename.
993
994 self.addFieldType = function(type) {
995 // template is accepted for bc but it was always a function, so
996 // render is a much better name
997 self.renders[type.name] = type.render || type.template;
998 self.converters.csv[type.name] = type.converters.csv;
999 self.converters.form[type.name] = type.converters.form;
1000
1001 self.indexers[type.name] = type.indexer;
1002 self.empties[type.name] = type.empty;
1003 self.copiers[type.name] = self.copier;
1004 if (type.exporters && type.exporters.csv){
1005 self.exporters.csv[type.name] = type.exporters.csv;
1006 }
1007
1008 };
1009
1010 // Render a field from nunjucks
1011 self._apos.addLocal('aposSchemaField', function(field) {
1012 // Alow custom renderers for types and for individual fields
1013 var render = field.render || self.renders[field.type];
1014 if (!render) {
1015 // Look for a standard render template in the views folder
1016 // of this module
1017 return self.renderer(field.type)(field).trim();
1018 }
1019 return render(field).trim();
1020 });
1021
1022 self._apos.addLocal('aposMonthChoices', function() {
1023 return [
1024 {
1025 // These labels are passed through internationalization
1026 label: 'Month',
1027 value: ''
1028 },
1029 {
1030 label: 'January',
1031 value: '01'
1032 },
1033 {
1034 label: 'February',
1035 value: '02'
1036 },
1037 {
1038 label: 'March',
1039 value: '03'
1040 },
1041 {
1042 label: 'April',
1043 value: '04'
1044 },
1045 {
1046 label: 'May',
1047 value: '05'
1048 },
1049 {
1050 label: 'June',
1051 value: '06'
1052 },
1053 {
1054 label: 'July',
1055 value: '07'
1056 },
1057 {
1058 label: 'August',
1059 value: '08'
1060 },
1061 {
1062 label: 'September',
1063 value: '09'
1064 },
1065 {
1066 label: 'October',
1067 value: '10'
1068 },
1069 {
1070 label: 'November',
1071 value: '11'
1072 },
1073 {
1074 label: 'December',
1075 value: '12'
1076 }
1077 ]
1078 });
1079
1080 self._apos.addLocal('aposDayChoices', function() {
1081 var choices = [
1082 {
1083 label: 'Day',
1084 value: ''
1085 }
1086 ];
1087 var i;
1088 for (i = 1; (i <= 31); i++) {
1089 var formatted = self._apos.padInteger(i, 2);
1090 choices.push({
1091 label: formatted,
1092 value: formatted
1093 });
1094 }
1095 return choices;
1096 });
1097
1098 // yearFrom and yearTo can be offsets from the current year, like -50 and +10 or
1099 // -120 and -18, or they can be four-digit absolute years. -Tom
1100
1101 self._apos.addLocal('aposYearChoices', function(yearFrom, yearTo) {
1102 if (yearFrom === undefined) {
1103 yearFrom = -120;
1104 }
1105 if (yearTo === undefined) {
1106 yearTo = +10;
1107 }
1108 var i;
1109 var year = (new Date()).getFullYear();
1110 if (yearFrom < 500) {
1111 yearFrom = year + yearFrom;
1112 }
1113 if (yearTo < 500) {
1114 yearTo = year + yearTo;
1115 }
1116 var choices = [
1117 {
1118 label: 'Year',
1119 value: ''
1120 }
1121 ];
1122 for (i = yearFrom; (i <= yearTo); i++) {
1123 choices.push({
1124 label: i.toString(),
1125 value: i.toString()
1126 });
1127 }
1128 return choices;
1129 });
1130
1131 self.copiers = {};
1132
1133 if (callback) {
1134 return callback(null);
1135 }
1136}
1137
1138module.exports = function(options, callback) {
1139 return new ApostropheSchemas(options, callback);
1140};