UNPKG

45.6 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 // The standard implementation of an 'show' page template for one piece, for your
437 // overriding convenience
438 self.show = function(req, callback) {
439 req.template = self.renderer('show');
440 return self.beforeShow(req, req.page, callback);
441 };
442
443 // For easier subclassing, these callbacks are invoked at the last
444 // minute before the template is rendered. You may use them to extend
445 // the data available in req.extras, etc. To completely override
446 // the "show" behavior, override self.show or self.dispatch.
447 self.beforeShow = function(req, page, callback) {
448 return callback(null);
449 };
450
451 self.setupPieces = function() {
452 self.pieces = {};
453
454 var piecesOptions = options.pieces || {};
455 self.pieces.options = piecesOptions;
456
457 _.defaults(piecesOptions, {
458 name: self.pieceName,
459 label: self.pieceLabel,
460 pluralLabel: self.pluralPieceLabel,
461 apos: options.apos,
462 app: options.app,
463 pages: options.pages,
464 schemas: options.schemas,
465 modules: options.modules,
466 // Always an orphan page (not in conventional navigation)
467 orphan: true,
468 browser: {
469 baseConstruct: 'AposFancyPage',
470 // Construct it on next tick so we have a
471 // chance to create our own subclass in manager.js
472 afterYield: true
473 },
474 pageSettingsTemplate: 'piecePageSettings',
475 // So far manager.js seems sufficient, but we could
476 // use these options to provide separate files for
477 // each of these components within the blog-2 module
478 // editorScript: 'pieceEditor',
479 // contentScript: 'pieceContent'
480 });
481
482 piecesOptions.addFields = [
483 {
484 // Add these new fields after the "published" field
485 after: 'published',
486 name: 'publicationDate',
487 label: 'Publication Date',
488 type: 'date'
489 },
490 {
491 name: 'publicationTime',
492 label: 'Publication Time',
493 type: 'time'
494 },
495 {
496 name: 'body',
497 type: 'area',
498 label: 'Body',
499 // Don't show it in page settings, we'll edit it on the
500 // show page
501 contextual: true
502 },
503 // This is a virtual join allowing the user to pick a new
504 // parent blog for this post. If the user chooses to populate
505 // it, then a beforePutOne override will take care of
506 // calling self._pages.move to do the real work
507 {
508 name: '_parent',
509 type: 'joinByOne',
510 label: 'Move to Another Blog',
511 placeholder: 'Type the name of the blog',
512 withType: self.indexName,
513 idField: '_newParentId',
514 getOptions: {
515 editable: true
516 }
517 }
518 ].concat(piecesOptions.addFields || []);
519
520 _.defaults(piecesOptions, {
521 // Rebuild the context menu, removing items that
522 // make a blog post seem overly page-y and renaming
523 // items in a way that feels more intuitive
524 contextMenu: [
525 {
526 name: 'new-' + self._apos.cssName(piecesOptions.name),
527 label: 'New ' + piecesOptions.label
528 },
529 {
530 name: 'edit-page',
531 label: piecesOptions.label + ' Settings'
532 },
533 {
534 name: 'versions-page',
535 label: piecesOptions.label + ' Versions'
536 },
537 {
538 name: 'delete-' + self._apos.cssName(self.pieceName),
539 label: 'Move to Trash'
540 },
541 {
542 name: 'rescue-' + self._apos.cssName(self.pieceName),
543 label: 'Rescue ' + self.pieceLabel + ' From Trash'
544 }
545 ]
546 });
547 fancyPage.FancyPage.call(self.pieces, piecesOptions, null);
548
549 // Given an array of index pages, yields a mongodb
550 // criteria object which will pass both the pieces for the
551 // original pages and the pieces for any pages they aggregate
552 // with, taking tag filtering into account.
553
554 self.aggregateCriteria = function(req, _pages, callback) {
555
556 // Shallow copy to avoid modifying argument
557 pages = _.clone(_pages);
558
559 var done = false;
560
561 var fetched = {};
562 var tagsByPageId = {};
563 var recursed = {};
564
565 _.each(pages, function(page) {
566 fetched[page._id] = true;
567 if (page._andFromPages) {
568 _.each(page._andFromPages || [], function(pair) {
569 pages.push(pair.item);
570 fetched[pair.item._id] = true;
571 });
572 recursed[page._id] = true;
573 }
574 });
575
576 return async.whilst(
577 function() { return !done; },
578 function(callback) {
579 var ids = [];
580 _.each(pages, function(page) {
581 if (_.has(recursed, page._id)) {
582 return;
583 }
584 recursed[page._id] = true;
585 _.each(page.andFromPagesIds || [], function(id) {
586 if (_.has(fetched, id)) {
587 return;
588 }
589 ids.push(id);
590 fetched[id] = true;
591 });
592 });
593 if (!ids.length) {
594 done = true;
595 return setImmediate(callback);
596 }
597 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) {
598 if (err) {
599 return callback(err);
600 }
601 pages = pages.concat(results.pages);
602 return callback(null);
603 });
604 },
605 function(err) {
606 if (err) {
607 return callback(err);
608 }
609
610 // Recurse through the pages to build all the
611 // possible tag lists by which a blog might
612 // be filtered. If A aggregates B, filtering by "dog",
613 // and B aggregates C, filtering by "cat", then
614 // A should only aggregate posts in C that are
615 // tagged both "dog" and "cat". If A and C both
616 // aggregate D, things are even more interesting.
617
618 var pagesById = {};
619 var visited = {};
620 _.each(pages, function(page) {
621 pagesById[page._id] = page;
622 });
623
624 // Recurse everything aggregated with the original
625 // group of pages
626 _.each(_pages, function(page) {
627 recurseTags(page, [], []);
628 });
629
630 function recurseTags(page, tags, antecedents) {
631 if (_.contains(antecedents, page._id)) {
632 // Avoid infinite loops. We don't forbid
633 // visiting the same page twice along different
634 // tag paths, but we do have to avoid getting
635 // caught in situations where A aggregates C and
636 // C aggregates A.
637 return;
638 }
639 _.each(page.andFromPagesIds || [], function(id) {
640 var _page = pagesById[id];
641 if (_page) {
642 var rel = ((page.andFromPagesRelationships || {})[id]) || {};
643 var newTags;
644 if (rel.tag) {
645 // Calling filterTag here is a lazy workaround
646 // for not having a true tag field type for
647 // relationships. Makes sure the case is right.
648 // -Tom
649 newTags = [ self._apos.filterTag(rel.tag) ];
650 } else {
651 newTags = [];
652 }
653 recurseTags(pagesById[id], tags.concat(newTags), antecedents.concat(page._id));
654 }
655 });
656 if (!tagsByPageId[page._id]) {
657 tagsByPageId[page._id] = [];
658 }
659 tagsByPageId[page._id].push(tags);
660 }
661
662 // Yield a series of "or" clauses for the
663 // pages we've found
664 var clauses = [];
665 _.each(pages, function(page) {
666 var clause = {
667 path: new RegExp('^' + RegExp.quote(page.path + '/')),
668 level: page.level + 1
669 };
670 var tagPaths = tagsByPageId[page._id];
671 if (tagPaths) {
672 if (_.find(tagPaths, function(tagPath) {
673 return !tagPath.length;
674 })) {
675 // Along at least one path this page's
676 // children are unrestricted
677 } else {
678 // TODO: we could optimize these
679 // queries more, reducing more redundancies.
680 // Then again mongodb might do it for us and
681 // it's rare for this to get really out of hand
682 _.each(tagPaths, function(tagPath) {
683 clause.tags = { $all: tagPath };
684 });
685 }
686 }
687 clauses.push(clause);
688 });
689 return callback(null, clauses);
690 }
691 );
692 };
693
694 var superPiecesGet = self.pieces.get;
695
696 // The get method for pieces supports a "fromPageIds" option,
697 // which retrieves only pieces that are children of the
698 // specified index pages. "fromPages" is also supported,
699 // allowing objects (only the path and level
700 // properties are needed).
701 //
702 // When "fromPages" is used, any content that the specified
703 // blogs aggregate from other blogs is also automatically
704 // included. You do NOT need to specify all of the
705 // aggregated blogs manually in fromPages.
706 //
707 // The get method for pieces also implements "publishedAt"
708 // which can be set to "any" to return material that has not
709 // reached its publication date yet.
710 //
711 // If the sort option has not been passed in, it will be set
712 // to blog order (reverse chronological on publishedAt).
713
714 self.pieces.get = function(req, userCriteria, options, callback) {
715 var criteria;
716 var filterCriteria = {};
717
718 if (options.fromPageIds) {
719 // Trick aggregateCriteria into handling
720 // this case for us by inventing a page object
721 // that aggregates the page IDs the widget
722 // is really interested in
723 var page = {};
724 if (!options.fromPageIds.length) {
725 filterCriteria = { _never: true };
726 } else {
727 var ids = options.fromPageIds;
728 if (typeof(ids[0]) === 'object') {
729 page.andFromPagesIds = _.pluck(ids, 'value');
730 var relationships = {};
731 page.andFromPagesRelationships = relationships;
732 _.each(ids, function(id) {
733 relationships[id.value] = { tag: id.tag };
734 });
735 } else {
736 page.andFromPagesIds = ids;
737 page.andFromPagesRelationships = {};
738 }
739 options.fromPages = [ page ];
740 }
741 }
742
743 var results;
744
745 return async.series({
746 fromPages: function(callback) {
747 if (!options.fromPages) {
748 return setImmediate(callback);
749 }
750 return self.aggregateCriteria(req, options.fromPages, function(err, clauses) {
751 if (err) {
752 return callback(err);
753 }
754 if (clauses.length) {
755 filterCriteria.$or = clauses;
756 }
757 return callback(null);
758 });
759 },
760 get: function(callback) {
761 // If options.publishedAt is 'any', we're in the admin interface and should be
762 // able to see articles whose publication date has not yet arrived. Otherwise,
763 // show only published stuff
764 if (options.publishedAt === 'any') {
765 // Do not add our usual criteria for publication date. Note
766 // that userCriteria may still examine publication date
767 } else {
768 filterCriteria.publishedAt = { $lte: new Date() };
769 }
770
771 if (!options.sort) {
772 options.sort = { publishedAt: -1 };
773 }
774
775 criteria = {
776 $and: [
777 userCriteria,
778 filterCriteria
779 ]
780 };
781 return superPiecesGet(req, criteria, options, function(err, _results) {
782 if (err) {
783 return callback(err);
784 }
785 results = _results;
786 return callback(null);
787 });
788 }
789 }, function(err) {
790 if (err) {
791 return callback(err);
792 }
793 return callback(null, results);
794 });
795 };
796
797 self.pieces.dispatch = function(req, callback) {
798 return self.show(req, callback);
799 };
800
801 // Denormalize the publication date and time.
802 // Set the "orphan" and "reorganize" flags.
803 // Force the slug to incorporate the
804 // publication date.
805
806 self.pieces.beforePutOne = function(req, slug, options, piece, callback) {
807 // Pieces are always orphans - they don't appear
808 // as subpages in navigation (because there are way too
809 // many of them and you need to demonstrate some clue about
810 // that by deliberately querying for them)
811 piece.orphan = true;
812
813 // Pieces should not clutter up the "reorganize" tree, that's why
814 // equivalent features are provided in the context menu and the
815 // piece settings to move between index pages, browse trash, etc.
816 piece.reorganize = false;
817
818 if (piece.publicationTime === null) {
819 // Make sure we specify midnight, if we leave off the time entirely we get
820 // midnight UTC, not midnight local time
821 piece.publishedAt = new Date(piece.publicationDate + ' 00:00:00');
822 } else {
823 piece.publishedAt = new Date(piece.publicationDate + ' ' + piece.publicationTime);
824 }
825
826 if (self._dateInSlug) {
827 var datePath = piece.publicationDate.replace(/-/g, '/');
828 var dateSlugRegex = /\/(\d\d\d\d\/\d\d\/\d\d\/)?([^\/]+)$/;
829 var matches = piece.slug.match(dateSlugRegex);
830 if (!matches) {
831 // This shouldn't happen, but don't crash over it
832 console.error("I don't understand how to add a date to this slug: " + piece.slug);
833 } else {
834 piece.slug = piece.slug.replace(dateSlugRegex, '/' + datePath + '/' + matches[2]);
835 }
836 }
837
838 return callback(null);
839 };
840
841 // If the user specifies a new parent via the _newParentId virtual
842 // join, use the pages module to change the parent of the piece.
843 self.pieces.afterPutOne = function(req, slug, options, piece, callback) {
844 var newParent;
845 return async.series({
846 getNewParent: function(callback) {
847 if (!piece._newParentId) {
848 return callback(null);
849 }
850 return self.indexes.getOne(req, { _id: piece._newParentId }, {}, function(err, page) {
851 if (err) {
852 return callback(err);
853 }
854 newParent = page;
855 return callback(null);
856 });
857 },
858 move: function(callback) {
859 if (!newParent) {
860 return callback(null);
861 }
862 return self._pages.move(req, piece, newParent, 'inside', callback);
863 }
864 }, callback);
865 };
866 };
867
868 // Invoke the loaders for the two fancy pages we're implementing
869 self.loader = function(req, callback) {
870 return async.series({
871 indexes: function(callback) {
872 return self.indexes.loader(req, function(err) {
873 return callback(err);
874 });
875 },
876 pieces: function(callback) {
877 return self.pieces.loader(req, function(err) {
878 return callback(err);
879 });
880 }
881 }, callback);
882 };
883
884 self.setupIndexes();
885 self.setupPieces();
886
887 // By default we do want a widget for the blog
888 var widgetOptions = {};
889 if (self._options.widget === false) {
890 widgetOptions = false;
891 } else if (typeof(self._options.widget) === 'object') {
892 widgetOptions = self._options.widget;
893 }
894
895 // Data to push to browser-side manager object
896 var args = {
897 name: self.name,
898 pieceName: self.pieceName,
899 pieceLabel: self.pieceLabel,
900 indexName: self.indexName,
901 indexLabel: self.indexLabel,
902 action: self._action,
903 widget: widgetOptions
904 };
905
906 // Synthesize a constructor for the manager object on the browser side
907 // if there isn't one. This allows trivial subclassing of the blog for
908 // cases where no custom browser side code is actually needed
909 self._apos.pushGlobalCallWhen('user', 'AposBlog2.subclassIfNeeded(?, ?, ?)', getBrowserConstructor(), getBaseBrowserConstructor(), args);
910 self._apos.pushGlobalCallWhen('user', '@ = new @(?)', getBrowserInstance(), getBrowserConstructor(), args);
911
912 function getBrowserInstance() {
913 if (self._browser.instance) {
914 return self._browser.instance;
915 }
916 var c = getBrowserConstructor();
917 return c.charAt(0).toLowerCase() + c.substr(1);
918 }
919
920 function getBrowserConstructor() {
921 return self._browser.construct || 'Apos' + self.name.charAt(0).toUpperCase() + self.name.substr(1);
922 }
923
924 // Figure out the name of the base class constructor on the browser side. If
925 // it's not explicitly set we assume we're subclassing snippets
926 function getBaseBrowserConstructor() {
927 return self._browser.baseConstruct || 'AposBlogManager';
928 }
929
930 if (widgetOptions) {
931 // We want widgets, so construct a manager object for them.
932 // Make sure it can see the main manager for the blog
933 var widget = {
934 _manager: self
935 };
936 (function(options) {
937 var self = widget;
938 self._apos = self._manager._apos;
939 self.icon = options.icon || 'icon-blog';
940 self.name = options.name || self._manager.indexes.name;
941 self.label = options.label || self._manager.indexes.label;
942 self.widget = true;
943 self.css = self._apos.cssName(self.name);
944
945 // For use in titling the "type part of a title" field
946 var titleField = _.find(self._manager.pieces.schema, function(field) {
947 return field.name === 'title';
948 }) || { label: 'Title' };
949
950 var widgetData = {
951 widgetEditorClass: 'apos-' + self.css + '-widget-editor',
952 pieceLabel: self._manager.pieces.label,
953 pluralPieceLabel: self._manager.pieces.pluralLabel,
954 indexLabel: self._manager.indexes.label,
955 pluralIndexLabel: self._manager.indexes.pluralLabel,
956 titleLabel: titleField.label
957 };
958
959 // Include our editor template in the markup when aposTemplates is called
960 self._manager.pushAsset('template', 'widgetEditor', {
961 when: 'user',
962 data: widgetData
963 });
964
965 // So far we've always kept this in the same file with the rest of the module's CSS,
966 // so don't clutter up the console with 404s in dev
967 // self.pushAsset('stylesheet', 'widget');
968
969 self.addCriteria = function(item, criteria, options) {
970 if (item.by === 'tag') {
971 if (item.tags && item.tags.length) {
972 criteria.tags = { $in: item.tags };
973 }
974 if (item.limitByTag) {
975 options.limit = item.limitByTag;
976 } else {
977 options.limit = 5;
978 }
979 } else if (item.by === 'id') {
980 // Specific IDs were selected, so a limit doesn't make sense
981 criteria._id = { $in: item.ids || []};
982 } else if (item.by === 'fromPageIds') {
983 if (item.limitFromPageIds) {
984 options.limit = item.limitFromPageIds;
985 } else {
986 options.limit = 5;
987 }
988 if (item.fromPageIds && item.fromPageIds.length) {
989 options.fromPageIds = item.fromPageIds;
990 }
991 }
992 };
993
994 self.sanitize = function(item) {
995 item.by = self._apos.sanitizeSelect(item.by, [ 'id', 'tag', 'fromPageIds' ], 'fromPageIds');
996 item.tags = self._apos.sanitizeTags(item.tags);
997 item.ids = self._apos.sanitizeIds(item.ids);
998 var fromPageIds = [];
999 if (Array.isArray(item.fromPageIds)) {
1000 _.each(item.fromPageIds, function(pageId) {
1001 if (typeof(pageId) === 'object') {
1002 fromPageIds.push(_.pick(pageId, [ 'value', 'tag' ]));
1003 } else {
1004 fromPageIds.push(apos.sanitizeId(pageid));
1005 }
1006 });
1007 item.fromPageIds = fromPageIds;
1008 }
1009 item.limit = self._apos.sanitizeInteger(item.limit, 5, 1, 1000);
1010 };
1011
1012 self.renderWidget = function(data) {
1013 return self._manager.render('widget', data);
1014 };
1015
1016 self.addDiffLines = function(item, lines) {
1017 if (item.by === 'id') {
1018 lines.push(self.label + ': items selected: ' + ((item.ids && item.ids.length) || 0));
1019 } else if (item.by === 'tag') {
1020 lines.push(self.label + ': tags selected: ' + item.tags.join(', '));
1021 } else if (item.by === 'fromPageIds') {
1022 lines.push(self.label + ': sources selected: ' + ((item.fromPageIds && item.fromPageIds.length) || 0));
1023 }
1024 };
1025
1026 // Asynchronously load the content of the pieces we're displaying.
1027 // The properties you add should start with an _ to denote that
1028 // they shouldn't become data attributes or get stored back to MongoDB
1029
1030 self.load = function(req, item, callback) {
1031 var criteria = {};
1032 var options = {};
1033
1034 self.addCriteria(item, criteria, options);
1035
1036 return self._manager.pieces.get(req, criteria, options, function(err, results) {
1037 if (err) {
1038 item._pieces = [];
1039 console.error(err);
1040 return callback(err);
1041 }
1042 var pieces = results.pages;
1043 if (item.by === 'id') {
1044 pieces = self._apos.orderById(item.ids, pieces);
1045 }
1046 item._pieces = pieces;
1047 return callback(null);
1048 });
1049 };
1050
1051 self.empty = function(item) {
1052 return (!item._pieces) || (!item._pieces.length);
1053 };
1054
1055
1056 })(widgetOptions);
1057
1058 // This widget should be part of the default set of widgets for areas
1059 // (note devs can still override the list)
1060 self._apos.defaultControls.push(widget.name);
1061 self._apos.addWidgetType(widget.name, widget);
1062
1063 // For your overriding convenience; override to change the
1064 // server side behavior of the widget
1065 self.extendWidget = function(widget) {
1066 };
1067
1068 // Call extendWidget on next tick so that there is time to
1069 // override it in a subclass
1070 process.nextTick(function() {
1071 self.extendWidget(widget);
1072 });
1073 }
1074
1075 self._apos.on('beforeEndAssets', function() {
1076 self.pushAllAssets();
1077 });
1078
1079 self.pushAllAssets = function() {
1080 self.pushAsset('script', 'manager', {
1081 when: 'user',
1082 data: {
1083 pieceName: self.pieceName,
1084 pieceLabel: self.pieceLabel
1085 }
1086 });
1087 self.pushAsset('template', 'browseTrash', {
1088 when: 'user',
1089 data: {
1090 browseTrashClass: 'apos-browse-trash-' + self._apos.cssName(self.pieceName),
1091 pluralLabel: self.pieces.pluralLabel
1092 }
1093 });
1094 };
1095
1096 // Fetch blog posts the current user is allowed to see.
1097 // Accepts skip, limit, trash, search and other options
1098 // supported by the "get" method via req.query
1099
1100 self._app.get(self._action + '/get', function(req, res) {
1101 var criteria = {};
1102 var options = {};
1103 self.addApiCriteria(req.query, criteria, options);
1104 self.pieces.get(req, criteria, options, function(err, results) {
1105 if (err) {
1106 console.error(err);
1107 return res.send({ status: 'error' });
1108 }
1109 results.status = 'ok';
1110 return res.send(results);
1111 });
1112 });
1113
1114 self.addApiCriteria = function(queryArg, criteria, options) {
1115
1116 // Most of the "criteria" that come in via an API call belong in options
1117 // (skip, limit, titleSearch, published, etc). Handle any cases that should
1118 // go straight to the mongo criteria object
1119
1120 var query = _.cloneDeep(queryArg);
1121
1122 var slug = self._apos.sanitizeString(query.slug);
1123 if (slug.length) {
1124 criteria.slug = query.slug;
1125 // Don't let it become an option too
1126 delete query.slug;
1127 }
1128
1129 var _id = self._apos.sanitizeString(query._id);
1130 if (_id.length) {
1131 criteria._id = query._id;
1132 // Don't let it become an option too
1133 delete query._id;
1134 }
1135
1136 // Everything else is assumed to be an option
1137 _.extend(options, query);
1138
1139 // Make sure these are converted to numbers, but only if they are present at all
1140 if (options.skip !== undefined) {
1141 options.skip = self._apos.sanitizeInteger(options.skip);
1142 }
1143 if (options.limit !== undefined) {
1144 options.limit = self._apos.sanitizeInteger(options.limit);
1145 }
1146 options.editable = true;
1147 };
1148
1149 // Move a piece to the trash. Requires 'slug' and 'trash' as
1150 // POST parameters. If 'trash' is true then the piece is
1151 // trashed, otherwise it is rescued from the trash.
1152 //
1153 // Separate from the regular trashcan for pages because blog posts
1154 // should remain children of their blog when they are in the trash.
1155
1156 self._app.post(self._action + '/delete', function(req, res) {
1157 var piece;
1158 var parent;
1159 return async.series({
1160 get: function(callback) {
1161 return self.pieces.getOne(req, { type: self.pieceName, slug: self._apos.sanitizeString(req.body.slug) }, { trash: 'any' }, function(err, _piece) {
1162 piece = _piece;
1163 if (!piece) {
1164 return res.send({ status: 'notfound' });
1165 }
1166 return callback(err);
1167 });
1168 },
1169 update: function(callback) {
1170 var trash = self._apos.sanitizeBoolean(req.body.trash);
1171 var oldSlug = piece.slug;
1172 if (trash) {
1173 if (piece.trash) {
1174 return callback(null);
1175 }
1176 piece.trash = true;
1177 // Mark it in the slug too, mainly to free up the original
1178 // slug for new pieces, but also because it's nice for
1179 // debugging
1180 piece.slug = piece.slug.replace(/\/[^\/]+$/, function(match) {
1181 return match.replace(/^\//, '/♻');
1182 });
1183 } else {
1184 if (!piece.trash) {
1185 return callback(null);
1186 }
1187 piece.slug = piece.slug.replace(/\/♻[^\/]+$/, function(match) {
1188 return match.replace(/^\/♻/, '/');
1189 });
1190 delete piece.trash;
1191 }
1192 return self.pieces.putOne(req, oldSlug, {}, piece, callback);
1193 },
1194 findParent: function(callback) {
1195 self._pages.getParent(req, piece, function(err, _parent) {
1196 if (err || (!_parent)) {
1197 return callback(err || new Error('No parent'));
1198 }
1199 parent = _parent;
1200 return callback(null);
1201 });
1202 }
1203 }, function(err) {
1204 if (err) {
1205 console.error(err);
1206 return res.send({ status: 'error' });
1207 }
1208 return res.send({ status: 'ok', parent: parent.slug, slug: piece.slug });
1209 });
1210 });
1211
1212 self._apos.addMigration('blog2AddReorganizeFlag', function(callback) {
1213 var needed = false;
1214 return self._apos.forEachPage({ type: 'blogPost', reorganize: { $ne: false } }, function(page, callback) {
1215 if (!needed) {
1216 needed = true;
1217 console.log('Hiding blog posts from reorganize');
1218 }
1219 return self._apos.pages.update({ _id: page._id }, { $set: { reorganize: false } }, callback);
1220 }, callback);
1221 });
1222
1223
1224 self._apos.on('tasks:register', function(taskGroups) {
1225 // Task name depends on what we're generating! Otherwise
1226 // subclasses with a different piece type crush the generate-blog-posts task
1227 var taskName = self._apos.camelName('generate ' + self.pieces.pluralLabel);
1228 taskGroups.apostrophe[taskName] = function(apos, argv, callback) {
1229 if (argv._.length !== 2) {
1230 return callback('Usage: node app apostrophe:' + taskName + ' /slug/of/parent/blog');
1231 }
1232 var req = self._apos.getTaskReq();
1233 var parentSlug = argv._[1];
1234 var parent;
1235
1236 return async.series({
1237 getParent: function(callback) {
1238 return self.indexes.getOne(req, { slug: parentSlug }, {}, function(err, _parent) {
1239 if (err) {
1240 return callback(err);
1241 }
1242 if (!_parent) {
1243 return callback('No such parent blog page found');
1244 }
1245 parent = _parent;
1246 return callback(null);
1247 });
1248 },
1249 posts: function(callback) {
1250 var randomWords = require('random-words');
1251 var i;
1252 var posts = [];
1253 for (i = 0; (i < 100); i++) {
1254 var title = randomWords({ min: 5, max: 10, join: ' ' });
1255 var at = new Date();
1256 // Many past publication times and a few in the future
1257 // 86400 = one day in seconds, 1000 = milliseconds to seconds
1258 at.setTime(at.getTime() + (10 - 90 * Math.random()) * 86400 * 1000);
1259 var post = {
1260 type: 'blogPost',
1261 title: title,
1262 publishedAt: at,
1263 publicationDate: moment(at).format('YYYY-MM-DD'),
1264 publicationTime: moment(at).format('HH:MM'),
1265 body: {
1266 type: 'area',
1267 items: [
1268 {
1269 type: 'richText',
1270 content: randomWords({ min: 50, max: 200, join: ' ' })
1271 }
1272 ]
1273 }
1274 };
1275 if (Math.random() > 0.2) {
1276 post.published = true;
1277 }
1278 posts.push(post);
1279 }
1280 return async.eachSeries(posts, function(post, callback) {
1281 return self.pieces.putOne(req,
1282 undefined,
1283 { parent: parent },
1284 post,
1285 callback);
1286 }, callback);
1287 }
1288 }, callback);
1289 };
1290 taskName = self._apos.camelName('import wordpress ' + self.pieces.pluralLabel);
1291 taskGroups.apostrophe[taskName] = function(apos, argv, callback) {
1292 return require('./wordpress.js')(self, argv, callback);
1293 };
1294 });
1295
1296 self._apos.on('addSearchFilters', function(searchFilters) {
1297 searchFilters.push({
1298 name: self.pieceName,
1299 label: self.pieces.pluralLabel
1300 });
1301 });
1302
1303 if (callback) {
1304 process.nextTick(function() {
1305 return callback();
1306 });
1307 }
1308};