UNPKG

45.2 kBJavaScriptView Raw
1var async = require('async');
2var _ = require('lodash');
3var fancyPage = require('apostrophe-fancy-page');
4var RSS = require('rss');
5var url = require('url');
6var absolution = require('absolution');
7var moment = require('moment');
8var util = require('util');
9
10module.exports = blog2;
11
12function blog2(options, callback) {
13 return new blog2.Blog2(options, callback);
14}
15
16blog2.Blog2 = function(options, callback) {
17 var self = this;
18
19 options.modules = (options.modules || []).concat([ { dir: __dirname, name: 'blog-2' } ]);
20
21 self.name = options.name || 'Blog2';
22 self._apos = options.apos;
23 self._action = '/apos-' + self._apos.cssName(self.name);
24 self._app = options.app;
25 self._pages = options.pages;
26 self._schemas = options.schemas;
27 self._options = options;
28 self._perPage = options.perPage || 10;
29 self._dateInSlug = (options.dateInSlug === undefined) ? true : options.dateInSlug;
30 self.pieceName = options.pieceName || 'blogPost';
31 self.pieceLabel = options.pieceLabel || 'Blog Post';
32 self.pluralPieceLabel = options.pluralPieceLabel || self.pieceLabel + 's';
33 self.indexName = options.indexName || 'blog';
34 self.indexLabel = options.indexLabel || 'Blog';
35 self.pluralIndexLabel = options.pluralIndexLabel || self.indexLabel + 's';
36
37 self._browser = options.browser || {};
38
39 // Mix in the ability to serve assets and templates
40 self._apos.mixinModuleAssets(self, 'blog-2', __dirname, options);
41
42 // Set defaults for feeds, but respect it if self._options.feed has been
43 // explicitly set false
44 if (self._options.feed === undefined) {
45 self._options.feed = {};
46 }
47 if (self._options.feed) {
48 var defaultPrefix;
49 // Let apostrophe-site clue us in to the name of the site so our feed title
50 // is not as bare as "Blog" or "Calendar"
51 if (self._options.site && self._options.site.title) {
52 // endash
53 defaultPrefix = self._options.site.title + (self._options.feed.titleSeparator || ' – ');
54 } else {
55 defaultPrefix = '';
56 }
57 _.defaults(self._options.feed, {
58 // Show the thumbnail singleton if available
59 thumbnail: true,
60 // If the thumbnail is not available and the body contains an image,
61 // show that instead
62 alternateThumbnail: true,
63 titlePrefix: defaultPrefix
64 });
65 }
66
67 self.setupIndexes = function() {
68 self.indexes = {};
69
70 var indexesOptions = options.indexes || {};
71 self.indexes.options = indexesOptions;
72
73 _.defaults(indexesOptions, {
74 name: self.indexName,
75 label: self.indexLabel,
76 pluralLabel: self.pluralIndexLabel,
77 apos: options.apos,
78 app: options.app,
79 pages: options.pages,
80 schemas: options.schemas,
81 modules: options.modules,
82 // Greedy so we can add year and month to the URL
83 greedy: true,
84 browser: {
85 baseConstruct: 'AposFancyPage',
86 // Construct it on next tick so we have a
87 // chance to create our own subclass in manager.js
88 afterYield: true,
89 options: {}
90 },
91 pageSettingsTemplate: 'indexPageSettings',
92 // So far manager.js seems sufficient, but we could
93 // use these options to provide separate files for
94 // each of these components within the blog-2 module
95 // editorScript: 'indexEditor',
96 // contentScript: 'indexContent'
97 });
98 indexesOptions.addFields = [
99 {
100 name: '_andFromPages',
101 label: 'And From These Blogs',
102 type: 'joinByArray',
103 idsField: 'andFromPagesIds',
104 relationship: [
105 {
106 name: 'tag',
107 label: 'With this tag (optional)',
108 type: 'string',
109 }
110 ],
111 relationshipsField: 'andFromPagesRelationships',
112 withType: 'blog'
113 }
114 ].concat(indexesOptions.addFields || []);
115
116 _.defaults(indexesOptions, {
117 // Rebuild the context menu, renaming items and
118 // throwing in a new one
119 contextMenu: [
120 {
121 name: 'new-' + self._apos.cssName(self.pieceName),
122 label: 'New ' + self.pieceLabel
123 },
124 ].concat(options.allowSubpagesOfIndex ?
125 [
126 {
127 name: 'new-page',
128 label: 'New Page'
129 }
130 ] :
131 []
132 ).concat([
133 {
134 name: 'edit-page',
135 label: self.indexLabel + ' Settings'
136 },
137 {
138 name: 'versions-page',
139 label: 'Page Versions'
140 },
141 {
142 name: 'rescue-' + self._apos.cssName(self.pieceName),
143 label: 'Browse Trash'
144 },
145 {
146 name: 'delete-page',
147 label: 'Move Entire ' + self.indexLabel + ' to Trash'
148 }
149 // the reorg menu used to be added here but is now added
150 // in the pages module only in the case of `req.permissions.admin = true`
151 ])
152 });
153 fancyPage.FancyPage.call(self.indexes, indexesOptions, null);
154
155 // When an index page is visited, fetch the pieces and
156 // call self.index to render it
157 self.indexes.dispatch = function(req, callback) {
158 var criteria = {};
159 var options = {};
160 var results;
161 self.addPager(req, options);
162 self.addCriteria(req, criteria, options);
163 return async.series({
164 get: function(callback) {
165 return self.pieces.get(req, criteria, options, function(err, _results) {
166 if (err) {
167 return callback(err);
168 }
169 results = _results;
170
171 return callback(null);
172 });
173 },
174 tags: function(callback) {
175 if (self.pieces.options.tags === false) {
176 return setImmediate(callback);
177 }
178 var distinctOptions = _.cloneDeep(options);
179 distinctOptions.getDistinct = 'tags';
180 return self.pieces.get(req, criteria, distinctOptions, function(err, tags) {
181 if (err) {
182 return callback(err);
183 }
184 // These weren't being passed to the template.
185 // Now they're stuffed in piecesTags. Arguably,
186 // the name should derive from piecesPlural. - Joel
187 req.extras.piecesTags = tags;
188 return callback(null);
189 });
190 }
191 }, function(err) {
192 if (err) {
193 return callback(err);
194 }
195 self.setPagerTotal(req, results.total);
196 return self.index(req, results.pages, callback);
197 });
198 };
199 };
200
201 // Sets up req.extras.pager and adds skip and limit to the criteria.
202 // YOU MUST ALSO CALL setPagerTotal after the total number of items available
203 // is known (results.total in the get callback). Also sets an appropriate
204 // limit if an RSS feed is to be generated.
205
206 self.addPager = function(req, options) {
207 var pageNumber = self._apos.sanitizeInteger(req.query.page, 1, 1);
208 req.extras.pager = {
209 page: pageNumber
210 };
211 if (req.query.feed) {
212 // RSS feeds are not paginated and generally shouldn't contain more than
213 // 50 entries because many feedreaders will reject overly large feeds,
214 // but provide an option to override this. Leave req.extras.pager in place
215 // to avoid unduly upsetting code that primarily deals with pages
216 options.skip = 0;
217 options.limit = self._options.feed.limit || 50;
218 return;
219 }
220 options.skip = self._perPage * (pageNumber - 1);
221 options.limit = self._perPage;
222 };
223
224 self.setPagerTotal = function(req, total) {
225 req.extras.pager.total = Math.ceil(total / self._perPage);
226 if (req.extras.pager.total < 1) {
227 req.extras.pager.total = 1;
228 }
229 };
230
231 // Called to decide what the index template name is.
232 // "index" is the default. If the request is an AJAX request, we assume
233 // infinite scroll and render "indexAjax". If req.query.feed is present, we render an RSS feed
234 self.setIndexTemplate = function(req) {
235 if (req.query.feed && self._options.feed) {
236 // No layout wrapped around our RSS please
237 req.decorate = false;
238 req.contentType = self.feedContentType(req.query.feed);
239 req.template = self.renderFeed;
240 } else {
241 if ((req.xhr || req.query.xhr) && (!req.query.apos_refresh)) {
242 req.template = self.renderer('indexAjax');
243 } else {
244 req.template = self.renderer('index');
245 }
246 }
247 };
248
249 // The standard implementation of an 'index' page template for many pieces, for your
250 // overriding convenience
251 self.index = function(req, pieces, callback) {
252 // The infinite scroll plugin is expecting a 404 if it requests
253 // a page beyond the last one. Without it we keep trying to load
254 // more stuff forever
255 if (req.xhr && (req.query.page > 1) && (!pieces.length)) {
256 req.notfound = true;
257 return callback(null);
258 }
259 self.setIndexTemplate(req);
260 // Generic noun so we can more easily inherit templates
261 req.extras.pieces = pieces;
262 return self.beforeIndex(req, pieces, callback);
263 };
264
265 // For easier subclassing, these callbacks are invoked at the last
266 // minute before the template is rendered. You may use them to extend
267 // the data available in req.extras, etc. To completely override
268 // the "index" behavior, override self.index or self.dispatch.
269 self.beforeIndex = function(req, pieces, callback) {
270 return callback(null);
271 };
272
273 // Given the value of the "feed" query parameter, return the appropriate
274 // content type. Right now feed is always rss and the return value is always
275 // application/rss+xml, but you can override to handle more types of feeds
276 self.feedContentType = function(feed) {
277 return 'application/rss+xml';
278 };
279
280 // Render a feed as a string, using the same data that we'd otherwise pass
281 // to the index template, notably data.pieces. req.query.feed specifies the
282 // type of feed, currently we assume RSS
283 self.renderFeed = function(data, req) {
284 // Lots of information we don't normally have in a page renderer.
285 var feedOptions = {
286 title: self._options.feed.title || ((self._options.feed.titlePrefix || '') + data.page.title),
287 description: self._options.feed.description,
288 generator: self._options.feed.generator || 'Apostrophe 2',
289 feed_url: req.absoluteUrl,
290 // Strip the ?feed=rss back off, in a way that works if there are other query parameters too
291 site_url: self._apos.build(req.absoluteUrl, { feed: null }),
292 image_url: self._options.feed.imageUrl
293 };
294 _.defaults(feedOptions, {
295 description: feedOptions.title
296 });
297 var feed = new RSS(feedOptions);
298 _.each(data.pieces, function(piece) {
299 feed.item(self.renderFeedPiece(piece, req));
300 });
301 return feed.xml(' ');
302 };
303
304 // Returns an object ready to be passed to the .item method of the rss module
305 self.renderFeedPiece = function(piece, req) {
306 var feedPiece = {
307 title: piece.title,
308 description: self.renderFeedPieceDescription(piece, req),
309 // Make it absolute
310 url: url.resolve(req.absoluteUrl, piece.url),
311 guid: piece._id,
312 author: piece.author || piece._author || undefined,
313 // A bit of laziness that covers derivatives of our blog, our events,
314 // and everything else
315 date: piece.publishedAt || piece.start || piece.createdAt
316 };
317 return feedPiece;
318 };
319
320 /**
321 * Given an piece and a req object, should return HTML suitable for use in an RSS
322 * feed to represent the body of the piece. Note that any URLs must be absolute.
323 * Hint: req.absoluteUrl is useful to resolve relative URLs. Also the
324 * absolution module.
325 * @param {Object} piece The snippet in question
326 * @param {Object} req Express request object
327 * @return {String} HTML representation of the body of the piece
328 */
329 self.renderFeedPieceDescription = function(piece, req) {
330 // Render a partial for this individual feed piece. This lets us use
331 // aposArea and aposSingleton normally etc.
332 var result = self.renderer('feedPiece')({
333 page: req.page,
334 piece: piece,
335 url: req.absoluteUrl,
336 options: self._options.feed
337 });
338 // We have to resolve all the relative URLs that might be kicking around
339 // in the output to generate valid HTML for use in RSS
340 result = absolution(result, req.absoluteUrl).trim();
341 return result;
342 };
343
344 // This method extends the mongodb criteria used to fetch pieces
345 // based on query parameters and general rules that should be applied
346 // to the normal view of content.
347
348 self.addCriteria = function(req, criteria, options) {
349 if (req.query.tag) {
350 options.tags = self._apos.sanitizeTags([ req.query.tag ]);
351 }
352 if (req.query.tags) {
353 options.tags = self._apos.sanitizeTags(req.query.tags) ;
354 }
355
356 self.addDateCriteria(req, criteria, options);
357
358 options.fromPages = [ req.page ];
359
360 if (req.query.search) {
361 options.search = self._apos.sanitizeString(req.query.search);
362 }
363
364 // Admins have to be able to see unpublished content because they have to get
365 // to it to edit it and there is no "manage" dialog needed anymore
366 // criteria.published = true;
367 };
368
369 // Extract year and month from the URL if present, add
370 // them to criteria as needed, set up thisYear and thisMonth
371
372 self.addDateCriteria = function(req, criteria, options) {
373 if (req.remainder.length) {
374 // Spot a year and month in the URL to implement filtering by month
375 matches = req.remainder.match(/^\/(\d+)\/(\d+)$/);
376
377 // activeYear and activeMonth = we are filtering to that month.
378 // thisYear and thisMonth = now (as in today).
379
380 // TODO: consider whether blog and events should share more logic
381 // around this and if so whether to use a mixin module or shove it
382 // into the (dangerously big already) base class, the snippets module
383 // or possibly even have events subclass blog after all.
384
385 if (matches) {
386 // force to integer
387 req.extras.activeYear = matches[1];
388 req.extras.activeMonth = matches[2];
389 // set up the next and previous urls for our calendar
390 var nextYear = req.extras.activeYear;
391 // Note use of - 0 to force a number
392 var nextMonth = req.extras.activeMonth - 0 + 1;
393 if (nextMonth > 12) {
394 nextMonth = 1;
395 nextYear = req.extras.activeYear - 0 + 1;
396 }
397 nextMonth = pad(nextMonth, 2);
398 req.extras.nextYear = nextYear;
399 req.extras.nextMonth = nextMonth;
400
401 var prevYear = req.extras.activeYear;
402 var prevMonth = req.extras.activeMonth - 0 - 1;
403 if (prevMonth < 1) {
404 prevMonth = 12;
405 prevYear = req.extras.activeYear - 0 - 1;
406 }
407 prevMonth = pad(prevMonth, 2);
408 req.extras.prevYear = prevYear;
409 req.extras.prevMonth = prevMonth;
410 // Make sure the default dispatcher considers the job done
411 req.remainder = '';
412 }
413 } else {
414 // Nothing extra in the URL
415 req.extras.defaultView = true;
416 // The current month and year, for switching to navigating by month
417 var now = moment(new Date());
418 req.extras.thisYear = now.format('YYYY');
419 req.extras.thisMonth = now.format('MM');
420 }
421 if (req.extras.activeYear) {
422 // force to integer
423 var year = req.extras.activeYear - 0;
424 // month is 0-11 because javascript is wacky because Unix is wacky
425 var month = req.extras.activeMonth - 1;
426 // this still works if the month is already 11, you can roll over
427 criteria.publishedAt = { $gte: new Date(year, month, 1), $lt: new Date(year, month + 1, 1) };
428 // When showing content by month we switch to ascending dates
429 options.sort = { publishedAt: 1 };
430 }
431 function pad(s, n) {
432 return self._apos.padInteger(s, n);
433 }
434 };
435
436 // For easier subclassing, these callbacks are invoked at the last
437 // minute before the template is rendered. You may use them to extend
438 // the data available in req.extras, etc. To completely override
439 // the "show" behavior, override self.show or self.dispatch.
440 self.beforeShow = function(req, page, callback) {
441 return callback(null);
442 };
443
444 self.setupPieces = function() {
445 self.pieces = {};
446
447 var piecesOptions = options.pieces || {};
448 self.pieces.options = piecesOptions;
449
450 _.defaults(piecesOptions, {
451 name: self.pieceName,
452 label: self.pieceLabel,
453 pluralLabel: self.pluralPieceLabel,
454 apos: options.apos,
455 app: options.app,
456 pages: options.pages,
457 schemas: options.schemas,
458 modules: options.modules,
459 // Always an orphan page (not in conventional navigation)
460 orphan: true,
461 browser: {
462 baseConstruct: 'AposFancyPage',
463 // Construct it on next tick so we have a
464 // chance to create our own subclass in manager.js
465 afterYield: true
466 },
467 pageSettingsTemplate: 'piecePageSettings',
468 // So far manager.js seems sufficient, but we could
469 // use these options to provide separate files for
470 // each of these components within the blog-2 module
471 // editorScript: 'pieceEditor',
472 // contentScript: 'pieceContent'
473 });
474
475 piecesOptions.addFields = [
476 {
477 // Add these new fields after the "published" field
478 after: 'published',
479 name: 'publicationDate',
480 label: 'Publication Date',
481 type: 'date'
482 },
483 {
484 name: 'publicationTime',
485 label: 'Publication Time',
486 type: 'time'
487 },
488 {
489 name: 'body',
490 type: 'area',
491 label: 'Body',
492 // Don't show it in page settings, we'll edit it on the
493 // show page
494 contextual: true
495 },
496 // This is a virtual join allowing the user to pick a new
497 // parent blog for this post. If the user chooses to populate
498 // it, then a beforePutOne override will take care of
499 // calling self._pages.move to do the real work
500 {
501 name: '_parent',
502 type: 'joinByOne',
503 label: 'Move to Another Blog',
504 placeholder: 'Type the name of the blog',
505 withType: self.indexName,
506 idField: '_newParentId',
507 getOptions: {
508 editable: true
509 }
510 }
511 ].concat(piecesOptions.addFields || []);
512
513 _.defaults(piecesOptions, {
514 // Rebuild the context menu, removing items that
515 // make a blog post seem overly page-y and renaming
516 // items in a way that feels more intuitive
517 contextMenu: [
518 {
519 name: 'new-' + self._apos.cssName(piecesOptions.name),
520 label: 'New ' + piecesOptions.label
521 },
522 {
523 name: 'edit-page',
524 label: piecesOptions.label + ' Settings'
525 },
526 {
527 name: 'versions-page',
528 label: piecesOptions.label + ' Versions'
529 },
530 {
531 name: 'delete-' + self._apos.cssName(self.pieceName),
532 label: 'Move to Trash'
533 },
534 {
535 name: 'rescue-' + self._apos.cssName(self.pieceName),
536 label: 'Rescue ' + self.pieceLabel + ' From Trash'
537 }
538 ]
539 });
540 fancyPage.FancyPage.call(self.pieces, piecesOptions, null);
541
542 // Given an array of index pages, yields a mongodb
543 // criteria object which will pass both the pieces for the
544 // original pages and the pieces for any pages they aggregate
545 // with, taking tag filtering into account.
546
547 self.aggregateCriteria = function(req, _pages, callback) {
548
549 // Shallow copy to avoid modifying argument
550 pages = _.clone(_pages);
551
552 var done = false;
553
554 var fetched = {};
555 var tagsByPageId = {};
556 var recursed = {};
557
558 _.each(pages, function(page) {
559 fetched[page._id] = true;
560 if (page._andFromPages) {
561 _.each(page._andFromPages || [], function(pair) {
562 pages.push(pair.item);
563 fetched[pair.item._id] = true;
564 });
565 recursed[page._id] = true;
566 }
567 });
568
569 return async.whilst(
570 function() { return !done; },
571 function(callback) {
572 var ids = [];
573 _.each(pages, function(page) {
574 if (_.has(recursed, page._id)) {
575 return;
576 }
577 recursed[page._id] = true;
578 _.each(page.andFromPagesIds || [], function(id) {
579 if (_.has(fetched, id)) {
580 return;
581 }
582 ids.push(id);
583 fetched[id] = true;
584 });
585 });
586 if (!ids.length) {
587 done = true;
588 return setImmediate(callback);
589 }
590 return self.indexes.get(req, { _id: { $in: ids } }, { fields: { title: 1, slug: 1, path: 1, level: 1, rank: 1, andFromPagesIds: 1, andFromPagesRelationships: 1 }, withJoins: false }, function(err, results) {
591 if (err) {
592 return callback(err);
593 }
594 pages = pages.concat(results.pages);
595 return callback(null);
596 });
597 },
598 function(err) {
599 if (err) {
600 return callback(err);
601 }
602
603 // Recurse through the pages to build all the
604 // possible tag lists by which a blog might
605 // be filtered. If A aggregates B, filtering by "dog",
606 // and B aggregates C, filtering by "cat", then
607 // A should only aggregate posts in C that are
608 // tagged both "dog" and "cat". If A and C both
609 // aggregate D, things are even more interesting.
610
611 var pagesById = {};
612 var visited = {};
613 _.each(pages, function(page) {
614 pagesById[page._id] = page;
615 });
616
617 // Recurse everything aggregated with the original
618 // group of pages
619 _.each(_pages, function(page) {
620 recurseTags(page, [], []);
621 });
622
623 function recurseTags(page, tags, antecedents) {
624 if (_.contains(antecedents, page._id)) {
625 // Avoid infinite loops. We don't forbid
626 // visiting the same page twice along different
627 // tag paths, but we do have to avoid getting
628 // caught in situations where A aggregates C and
629 // C aggregates A.
630 return;
631 }
632 _.each(page.andFromPagesIds || [], function(id) {
633 var _page = pagesById[id];
634 if (_page) {
635 var rel = ((page.andFromPagesRelationships || {})[id]) || {};
636 var newTags;
637 if (rel.tag) {
638 // Calling filterTag here is a lazy workaround
639 // for not having a true tag field type for
640 // relationships. Makes sure the case is right.
641 // -Tom
642 newTags = [ self._apos.filterTag(rel.tag) ];
643 } else {
644 newTags = [];
645 }
646 recurseTags(pagesById[id], tags.concat(newTags), antecedents.concat(page._id));
647 }
648 });
649 if (!tagsByPageId[page._id]) {
650 tagsByPageId[page._id] = [];
651 }
652 tagsByPageId[page._id].push(tags);
653 }
654
655 // Yield a series of "or" clauses for the
656 // pages we've found
657 var clauses = [];
658 _.each(pages, function(page) {
659 var clause = {
660 path: new RegExp('^' + RegExp.quote(page.path + '/')),
661 level: page.level + 1
662 };
663 var tagPaths = tagsByPageId[page._id];
664 if (tagPaths) {
665 if (_.find(tagPaths, function(tagPath) {
666 return !tagPath.length;
667 })) {
668 // Along at least one path this page's
669 // children are unrestricted
670 } else {
671 // TODO: we could optimize these
672 // queries more, reducing more redundancies.
673 // Then again mongodb might do it for us and
674 // it's rare for this to get really out of hand
675 _.each(tagPaths, function(tagPath) {
676 clause.tags = { $all: tagPath };
677 });
678 }
679 }
680 clauses.push(clause);
681 });
682 return callback(null, clauses);
683 }
684 );
685 };
686
687 var superPiecesGet = self.pieces.get;
688
689 // The get method for pieces supports a "fromPageIds" option,
690 // which retrieves only pieces that are children of the
691 // specified index pages. "fromPages" is also supported,
692 // allowing objects (only the path and level
693 // properties are needed).
694 //
695 // When "fromPages" is used, any content that the specified
696 // blogs aggregate from other blogs is also automatically
697 // included. You do NOT need to specify all of the
698 // aggregated blogs manually in fromPages.
699 //
700 // The get method for pieces also implements "publishedAt"
701 // which can be set to "any" to return material that has not
702 // reached its publication date yet.
703 //
704 // If the sort option has not been passed in, it will be set
705 // to blog order (reverse chronological on publishedAt).
706
707 self.pieces.get = function(req, userCriteria, options, callback) {
708 var criteria;
709 var filterCriteria = {};
710
711 if (options.fromPageIds) {
712 // Trick aggregateCriteria into handling
713 // this case for us by inventing a page object
714 // that aggregates the page IDs the widget
715 // is really interested in
716 var page = {};
717 if (!options.fromPageIds.length) {
718 filterCriteria = { _never: true };
719 } else {
720 var ids = options.fromPageIds;
721 if (typeof(ids[0]) === 'object') {
722 page.andFromPagesIds = _.pluck(ids, 'value');
723 var relationships = {};
724 page.andFromPagesRelationships = relationships;
725 _.each(ids, function(id) {
726 relationships[id.value] = { tag: id.tag };
727 });
728 } else {
729 page.andFromPagesIds = ids;
730 page.andFromPagesRelationships = {};
731 }
732 options.fromPages = [ page ];
733 }
734 }
735
736 var results;
737
738 return async.series({
739 fromPages: function(callback) {
740 if (!options.fromPages) {
741 return setImmediate(callback);
742 }
743 return self.aggregateCriteria(req, options.fromPages, function(err, clauses) {
744 if (err) {
745 return callback(err);
746 }
747 if (clauses.length) {
748 filterCriteria.$or = clauses;
749 }
750 return callback(null);
751 });
752 },
753 get: function(callback) {
754 // If options.publishedAt is 'any', we're in the admin interface and should be
755 // able to see articles whose publication date has not yet arrived. Otherwise,
756 // show only published stuff
757 if (options.publishedAt === 'any') {
758 // Do not add our usual criteria for publication date. Note
759 // that userCriteria may still examine publication date
760 } else {
761 filterCriteria.publishedAt = { $lte: new Date() };
762 }
763
764 if (!options.sort) {
765 options.sort = { publishedAt: -1 };
766 }
767
768 criteria = {
769 $and: [
770 userCriteria,
771 filterCriteria
772 ]
773 };
774 return superPiecesGet(req, criteria, options, function(err, _results) {
775 if (err) {
776 return callback(err);
777 }
778 results = _results;
779 return callback(null);
780 });
781 }
782 }, function(err) {
783 if (err) {
784 return callback(err);
785 }
786 return callback(null, results);
787 });
788 };
789
790 self.pieces.dispatch = function(req, callback) {
791 req.template = self.renderer('show');
792 return callback(null);
793 };
794
795 // Denormalize the publication date and time.
796 // Set the "orphan" and "reorganize" flags.
797 // Force the slug to incorporate the
798 // publication date.
799
800 self.pieces.beforePutOne = function(req, slug, options, piece, callback) {
801 // Pieces are always orphans - they don't appear
802 // as subpages in navigation (because there are way too
803 // many of them and you need to demonstrate some clue about
804 // that by deliberately querying for them)
805 piece.orphan = true;
806
807 // Pieces should not clutter up the "reorganize" tree, that's why
808 // equivalent features are provided in the context menu and the
809 // piece settings to move between index pages, browse trash, etc.
810 piece.reorganize = false;
811
812 if (piece.publicationTime === null) {
813 // Make sure we specify midnight, if we leave off the time entirely we get
814 // midnight UTC, not midnight local time
815 piece.publishedAt = new Date(piece.publicationDate + ' 00:00:00');
816 } else {
817 piece.publishedAt = new Date(piece.publicationDate + ' ' + piece.publicationTime);
818 }
819
820 if (self._dateInSlug) {
821 var datePath = piece.publicationDate.replace(/-/g, '/');
822 var dateSlugRegex = /\/(\d\d\d\d\/\d\d\/\d\d\/)?([^\/]+)$/;
823 var matches = piece.slug.match(dateSlugRegex);
824 if (!matches) {
825 // This shouldn't happen, but don't crash over it
826 console.error("I don't understand how to add a date to this slug: " + piece.slug);
827 } else {
828 piece.slug = piece.slug.replace(dateSlugRegex, '/' + datePath + '/' + matches[2]);
829 }
830 }
831
832 return callback(null);
833 };
834
835 // If the user specifies a new parent via the _newParentId virtual
836 // join, use the pages module to change the parent of the piece.
837 self.pieces.afterPutOne = function(req, slug, options, piece, callback) {
838 var newParent;
839 return async.series({
840 getNewParent: function(callback) {
841 if (!piece._newParentId) {
842 return callback(null);
843 }
844 return self.indexes.getOne(req, { _id: piece._newParentId }, {}, function(err, page) {
845 if (err) {
846 return callback(err);
847 }
848 newParent = page;
849 return callback(null);
850 });
851 },
852 move: function(callback) {
853 if (!newParent) {
854 return callback(null);
855 }
856 return self._pages.move(req, piece, newParent, 'inside', callback);
857 }
858 }, callback);
859 };
860 };
861
862 // Invoke the loaders for the two fancy pages we're implementing
863 self.loader = function(req, callback) {
864 return async.series({
865 indexes: function(callback) {
866 return self.indexes.loader(req, function(err) {
867 return callback(err);
868 });
869 },
870 pieces: function(callback) {
871 return self.pieces.loader(req, function(err) {
872 return callback(err);
873 });
874 }
875 }, callback);
876 };
877
878 self.setupIndexes();
879 self.setupPieces();
880
881 // By default we do want a widget for the blog
882 var widgetOptions = {};
883 if (self._options.widget === false) {
884 widgetOptions = false;
885 } else if (typeof(self._options.widget) === 'object') {
886 widgetOptions = self._options.widget;
887 }
888
889 // Data to push to browser-side manager object
890 var args = {
891 name: self.name,
892 pieceName: self.pieceName,
893 pieceLabel: self.pieceLabel,
894 indexName: self.indexName,
895 indexLabel: self.indexLabel,
896 action: self._action,
897 widget: widgetOptions
898 };
899
900 // Synthesize a constructor for the manager object on the browser side
901 // if there isn't one. This allows trivial subclassing of the blog for
902 // cases where no custom browser side code is actually needed
903 self._apos.pushGlobalCallWhen('user', 'AposBlog2.subclassIfNeeded(?, ?, ?)', getBrowserConstructor(), getBaseBrowserConstructor(), args);
904 self._apos.pushGlobalCallWhen('user', '@ = new @(?)', getBrowserInstance(), getBrowserConstructor(), args);
905
906 function getBrowserInstance() {
907 if (self._browser.instance) {
908 return self._browser.instance;
909 }
910 var c = getBrowserConstructor();
911 return c.charAt(0).toLowerCase() + c.substr(1);
912 }
913
914 function getBrowserConstructor() {
915 return self._browser.construct || 'Apos' + self.name.charAt(0).toUpperCase() + self.name.substr(1);
916 }
917
918 // Figure out the name of the base class constructor on the browser side. If
919 // it's not explicitly set we assume we're subclassing snippets
920 function getBaseBrowserConstructor() {
921 return self._browser.baseConstruct || 'AposBlogManager';
922 }
923
924 if (widgetOptions) {
925 // We want widgets, so construct a manager object for them.
926 // Make sure it can see the main manager for the blog
927 var widget = {
928 _manager: self
929 };
930 (function(options) {
931 var self = widget;
932 self._apos = self._manager._apos;
933 self.icon = options.icon || 'icon-blog';
934 self.name = options.name || self._manager.indexes.name;
935 self.label = options.label || self._manager.indexes.label;
936 self.widget = true;
937 self.css = self._apos.cssName(self.name);
938
939 // For use in titling the "type part of a title" field
940 var titleField = _.find(self._manager.pieces.schema, function(field) {
941 return field.name === 'title';
942 }) || { label: 'Title' };
943
944 var widgetData = {
945 widgetEditorClass: 'apos-' + self.css + '-widget-editor',
946 pieceLabel: self._manager.pieces.label,
947 pluralPieceLabel: self._manager.pieces.pluralLabel,
948 indexLabel: self._manager.indexes.label,
949 pluralIndexLabel: self._manager.indexes.pluralLabel,
950 titleLabel: titleField.label
951 };
952
953 // Include our editor template in the markup when aposTemplates is called
954 self._manager.pushAsset('template', 'widgetEditor', {
955 when: 'user',
956 data: widgetData
957 });
958
959 // So far we've always kept this in the same file with the rest of the module's CSS,
960 // so don't clutter up the console with 404s in dev
961 // self.pushAsset('stylesheet', 'widget');
962
963 self.addCriteria = function(item, criteria, options) {
964 if (item.by === 'tag') {
965 if (item.tags && item.tags.length) {
966 criteria.tags = { $in: item.tags };
967 }
968 if (item.limitByTag) {
969 options.limit = item.limitByTag;
970 } else {
971 options.limit = 5;
972 }
973 } else if (item.by === 'id') {
974 // Specific IDs were selected, so a limit doesn't make sense
975 criteria._id = { $in: item.ids || []};
976 } else if (item.by === 'fromPageIds') {
977 if (item.limitFromPageIds) {
978 options.limit = item.limitFromPageIds;
979 } else {
980 options.limit = 5;
981 }
982 if (item.fromPageIds && item.fromPageIds.length) {
983 options.fromPageIds = item.fromPageIds;
984 }
985 }
986 };
987
988 self.sanitize = function(item) {
989 item.by = self._apos.sanitizeSelect(item.by, [ 'id', 'tag', 'fromPageIds' ], 'fromPageIds');
990 item.tags = self._apos.sanitizeTags(item.tags);
991 item.ids = self._apos.sanitizeIds(item.ids);
992 var fromPageIds = [];
993 if (Array.isArray(item.fromPageIds)) {
994 _.each(item.fromPageIds, function(pageId) {
995 if (typeof(pageId) === 'object') {
996 fromPageIds.push(_.pick(pageId, [ 'value', 'tag' ]));
997 } else {
998 fromPageIds.push(apos.sanitizeId(pageid));
999 }
1000 });
1001 item.fromPageIds = fromPageIds;
1002 }
1003 item.limit = self._apos.sanitizeInteger(item.limit, 5, 1, 1000);
1004 };
1005
1006 self.renderWidget = function(data) {
1007 return self._manager.render('widget', data);
1008 };
1009
1010 self.addDiffLines = function(item, lines) {
1011 if (item.by === 'id') {
1012 lines.push(self.label + ': items selected: ' + ((item.ids && item.ids.length) || 0));
1013 } else if (item.by === 'tag') {
1014 lines.push(self.label + ': tags selected: ' + item.tags.join(', '));
1015 } else if (item.by === 'fromPageIds') {
1016 lines.push(self.label + ': sources selected: ' + ((item.fromPageIds && item.fromPageIds.length) || 0));
1017 }
1018 };
1019
1020 // Asynchronously load the content of the pieces we're displaying.
1021 // The properties you add should start with an _ to denote that
1022 // they shouldn't become data attributes or get stored back to MongoDB
1023
1024 self.load = function(req, item, callback) {
1025 var criteria = {};
1026 var options = {};
1027
1028 self.addCriteria(item, criteria, options);
1029
1030 return self._manager.pieces.get(req, criteria, options, function(err, results) {
1031 if (err) {
1032 item._pieces = [];
1033 console.error(err);
1034 return callback(err);
1035 }
1036 var pieces = results.pages;
1037 if (item.by === 'id') {
1038 pieces = self._apos.orderById(item.ids, pieces);
1039 }
1040 item._pieces = pieces;
1041 return callback(null);
1042 });
1043 };
1044
1045 self.empty = function(item) {
1046 return (!item._pieces) || (!item._pieces.length);
1047 };
1048
1049
1050 })(widgetOptions);
1051
1052 // This widget should be part of the default set of widgets for areas
1053 // (note devs can still override the list)
1054 self._apos.defaultControls.push(widget.name);
1055 self._apos.addWidgetType(widget.name, widget);
1056
1057 // For your overriding convenience; override to change the
1058 // server side behavior of the widget
1059 self.extendWidget = function(widget) {
1060 };
1061
1062 // Call extendWidget on next tick so that there is time to
1063 // override it in a subclass
1064 process.nextTick(function() {
1065 self.extendWidget(widget);
1066 });
1067 }
1068
1069 self._apos.on('beforeEndAssets', function() {
1070 self.pushAllAssets();
1071 });
1072
1073 self.pushAllAssets = function() {
1074 self.pushAsset('script', 'manager', {
1075 when: 'user',
1076 data: {
1077 pieceName: self.pieceName,
1078 pieceLabel: self.pieceLabel
1079 }
1080 });
1081 self.pushAsset('template', 'browseTrash', {
1082 when: 'user',
1083 data: {
1084 browseTrashClass: 'apos-browse-trash-' + self._apos.cssName(self.pieceName),
1085 pluralLabel: self.pieces.pluralLabel
1086 }
1087 });
1088 };
1089
1090 // Fetch blog posts the current user is allowed to see.
1091 // Accepts skip, limit, trash, search and other options
1092 // supported by the "get" method via req.query
1093
1094 self._app.get(self._action + '/get', function(req, res) {
1095 var criteria = {};
1096 var options = {};
1097 self.addApiCriteria(req.query, criteria, options);
1098 self.pieces.get(req, criteria, options, function(err, results) {
1099 if (err) {
1100 console.error(err);
1101 return res.send({ status: 'error' });
1102 }
1103 results.status = 'ok';
1104 return res.send(results);
1105 });
1106 });
1107
1108 self.addApiCriteria = function(queryArg, criteria, options) {
1109
1110 // Most of the "criteria" that come in via an API call belong in options
1111 // (skip, limit, titleSearch, published, etc). Handle any cases that should
1112 // go straight to the mongo criteria object
1113
1114 var query = _.cloneDeep(queryArg);
1115
1116 var slug = self._apos.sanitizeString(query.slug);
1117 if (slug.length) {
1118 criteria.slug = query.slug;
1119 // Don't let it become an option too
1120 delete query.slug;
1121 }
1122
1123 var _id = self._apos.sanitizeString(query._id);
1124 if (_id.length) {
1125 criteria._id = query._id;
1126 // Don't let it become an option too
1127 delete query._id;
1128 }
1129
1130 // Everything else is assumed to be an option
1131 _.extend(options, query);
1132
1133 // Make sure these are converted to numbers, but only if they are present at all
1134 if (options.skip !== undefined) {
1135 options.skip = self._apos.sanitizeInteger(options.skip);
1136 }
1137 if (options.limit !== undefined) {
1138 options.limit = self._apos.sanitizeInteger(options.limit);
1139 }
1140 options.editable = true;
1141 };
1142
1143 // Move a piece to the trash. Requires 'slug' and 'trash' as
1144 // POST parameters. If 'trash' is true then the piece is
1145 // trashed, otherwise it is rescued from the trash.
1146 //
1147 // Separate from the regular trashcan for pages because blog posts
1148 // should remain children of their blog when they are in the trash.
1149
1150 self._app.post(self._action + '/delete', function(req, res) {
1151 var piece;
1152 var parent;
1153 return async.series({
1154 get: function(callback) {
1155 return self.pieces.getOne(req, { type: self.pieceName, slug: self._apos.sanitizeString(req.body.slug) }, { trash: 'any' }, function(err, _piece) {
1156 piece = _piece;
1157 if (!piece) {
1158 return res.send({ status: 'notfound' });
1159 }
1160 return callback(err);
1161 });
1162 },
1163 update: function(callback) {
1164 var trash = self._apos.sanitizeBoolean(req.body.trash);
1165 var oldSlug = piece.slug;
1166 if (trash) {
1167 if (piece.trash) {
1168 return callback(null);
1169 }
1170 piece.trash = true;
1171 // Mark it in the slug too, mainly to free up the original
1172 // slug for new pieces, but also because it's nice for
1173 // debugging
1174 piece.slug = piece.slug.replace(/\/[^\/]+$/, function(match) {
1175 return match.replace(/^\//, '/♻');
1176 });
1177 } else {
1178 if (!piece.trash) {
1179 return callback(null);
1180 }
1181 piece.slug = piece.slug.replace(/\/♻[^\/]+$/, function(match) {
1182 return match.replace(/^\/♻/, '/');
1183 });
1184 delete piece.trash;
1185 }
1186 return self.pieces.putOne(req, oldSlug, {}, piece, callback);
1187 },
1188 findParent: function(callback) {
1189 self._pages.getParent(req, piece, function(err, _parent) {
1190 if (err || (!_parent)) {
1191 return callback(err || new Error('No parent'));
1192 }
1193 parent = _parent;
1194 return callback(null);
1195 });
1196 }
1197 }, function(err) {
1198 if (err) {
1199 console.error(err);
1200 return res.send({ status: 'error' });
1201 }
1202 return res.send({ status: 'ok', parent: parent.slug, slug: piece.slug });
1203 });
1204 });
1205
1206 self._apos.addMigration('blog2AddReorganizeFlag', function(callback) {
1207 var needed = false;
1208 return self._apos.forEachPage({ type: 'blogPost', reorganize: { $ne: false } }, function(page, callback) {
1209 if (!needed) {
1210 needed = true;
1211 console.log('Hiding blog posts from reorganize');
1212 }
1213 return self._apos.pages.update({ _id: page._id }, { $set: { reorganize: false } }, callback);
1214 }, callback);
1215 });
1216
1217
1218 self._apos.on('tasks:register', function(taskGroups) {
1219 // Task name depends on what we're generating! Otherwise
1220 // subclasses with a different piece type crush the generate-blog-posts task
1221 var taskName = self._apos.camelName('generate ' + self.pieces.pluralLabel);
1222 taskGroups.apostrophe[taskName] = function(apos, argv, callback) {
1223 if (argv._.length !== 2) {
1224 return callback('Usage: node app apostrophe:' + taskName + ' /slug/of/parent/blog');
1225 }
1226 var req = self._apos.getTaskReq();
1227 var parentSlug = argv._[1];
1228 var parent;
1229
1230 return async.series({
1231 getParent: function(callback) {
1232 return self.indexes.getOne(req, { slug: parentSlug }, {}, function(err, _parent) {
1233 if (err) {
1234 return callback(err);
1235 }
1236 if (!_parent) {
1237 return callback('No such parent blog page found');
1238 }
1239 parent = _parent;
1240 return callback(null);
1241 });
1242 },
1243 posts: function(callback) {
1244 var randomWords = require('random-words');
1245 var i;
1246 var posts = [];
1247 for (i = 0; (i < 100); i++) {
1248 var title = randomWords({ min: 5, max: 10, join: ' ' });
1249 var at = new Date();
1250 // Many past publication times and a few in the future
1251 // 86400 = one day in seconds, 1000 = milliseconds to seconds
1252 at.setTime(at.getTime() + (10 - 90 * Math.random()) * 86400 * 1000);
1253 var post = {
1254 type: 'blogPost',
1255 title: title,
1256 publishedAt: at,
1257 publicationDate: moment(at).format('YYYY-MM-DD'),
1258 publicationTime: moment(at).format('HH:MM'),
1259 body: {
1260 type: 'area',
1261 items: [
1262 {
1263 type: 'richText',
1264 content: randomWords({ min: 50, max: 200, join: ' ' })
1265 }
1266 ]
1267 }
1268 };
1269 if (Math.random() > 0.2) {
1270 post.published = true;
1271 }
1272 posts.push(post);
1273 }
1274 return async.eachSeries(posts, function(post, callback) {
1275 return self.pieces.putOne(req,
1276 undefined,
1277 { parent: parent },
1278 post,
1279 callback);
1280 }, callback);
1281 }
1282 }, callback);
1283 };
1284 });
1285
1286 self._apos.on('addSearchFilters', function(searchFilters) {
1287 searchFilters.push({
1288 name: self.pieceName,
1289 label: self.pieces.pluralLabel
1290 });
1291 });
1292
1293 if (callback) {
1294 process.nextTick(function() {
1295 return callback();
1296 });
1297 }
1298};