UNPKG

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