This repository has been archived by the owner on Aug 21, 2020. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 9
/
index.js
2450 lines (2241 loc) · 84.4 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*jshint undef:true */
/*jshint node:true */
var async = require('async');
var _ = require('lodash');
var extend = require('extend');
var path = require('path');
RegExp.quote = require('regexp-quote');
module.exports = function(options, callback) {
return new pages(options, callback);
};
function pages(options, callback) {
var apos = options.apos;
var app = options.app;
var self = this;
var aposPages = this;
self._action = '/apos-pages';
self._apos = apos;
// Usage: app.get('*', pages.serve({ typePath: __dirname + '/views/pages' }))
//
// If you use this global wildcard route, make it your LAST route,
// as otherwise it overrides everything else.
//
// If you want to mount your pages as a "subdirectory:"
//
// app.get('/pages/*', pages.serve({ ... }))
//
// You can use other route patterns, as long as req.params[0] contains the
// page slug.
//
// self.serve will automatically prepend a / to the slug if
// req.params[0] does not contain one.
//
// The page object is passed to the Nunjucks type as `page`.
//
// If you want to also load all areas on the "global" page, for instance
// to fetch shared headers and footers used across a site, supply a
// `load` callback:
//
// app.get('/pages/*', pages.serve({ load: [ 'global' ] }, ...))
//
// The page with the slug `global` then becomes visible to the Nunjucks
// type as `global`. Note that it may not exist yet, in which case
// `global` is not set. Your type code must allow for this.
//
// You can include functions in the load: array. If you do so, those
// functions are invoked as callbacks, and receive 'req' as their first
// parameter. They should add additional page objects as properties of the
// req.extras object, then invoke the callback they receive as their
// second parameter with null, or with an error if they have failed in a
// way that should result in a 500 error. All such extra pages are made
// visible to Nunjucks. For instance, if you load req.extras.department,
// then a variable named department containing that page is visible to Nunjucks.
//
// It is is also acceptable to pass a single function rather than an
// array as the `load` property.
//
// The type name used to render the page is taken from
// the type property of the req.page object. You will need to set the
// directory from which page type templates are loaded:
//
// app.get('*', pages.serve({ typePath: __dirname + '/views/pages' })
//
// You can also override individual type paths. Any paths you don't
// override continue to respect typePath. Note that you are still
// specifying a folder's path, which must contain a nunjucks type
// named home.html to render a page with that type property:
//
// app.get('*', pages.serve({ ..., typePaths: { home: __dirname + '/views/pages' } })
//
// In the event the page slug requested is not found, the notfound type
// is rendered. You can override the notfound type path like any other.
//
// Loaders can access the page loaded by `page.serve` as `req.page`. This will
// be null if no page slug matched the URL exactly. However, if there is a page
// that matches a leading portion of the URL when followed by `/`, that page
// is also made available as `req.bestPage`. In this case the remainder of the
// URL after the slug of the best page is returned as `req.remainder`. If more
// than one page partially matches the URL the longest match is provided.
//
// Loaders can thus implement multiple-page experiences of almost any complexity
// by paying attention to `req.remainder` and choosing to set `req.template` to
// something that suits their purposes. If `req.template` is set by a loader it is
// used instead of the original type of the page to select a template. Usually this
// process begins by examining `req.bestPage.type` to determine whether it is suitable
// for this treatment (a blog page, for example, might need to implement virtual
// subpages for articles in this way).
//
// Loaders can also set req.page to req.bestPage, and should do so when electing
// to accept a partial match, because this makes the page available to templates.
//
// Page type templates will want to render areas, passing along the slug and the
// edit permission flag:
//
// {{ aposArea({ slug: slug + ':main', area: page.main, edit: edit }) }}
//
// {{ aposArea({ slug: 'global:footer', area: global.footer, edit: edit }) }}
//
// You can access all properties of the page via the 'page' object. Any pages
// added to extras by `load` callbacks are also visible, like `global` above.
//
// If you want to create pages dynamically when nonexistent page slugs are visited,
// you can supply a notfound handler:
//
// // Just create an empty page object like a wiki would
// app.get('*', pages.serve({
// notfound: function(req, callback) {
// req.page = { };
// callback(null);
// }
// });
//
// If you do not set req.page the normal page-not-found behavior is applied.
// If you do not supply a type property, 'default' is assumed.
//
// A JSON interface is also built in for each page: if you add
// ?pageInformation=json to the URL, a JSON description of the page
// is returned, including any information added to the page object
// by loader functions. This is available only to users with
// editing permissions.
self.serve = function(options) {
if(!options) {
options = {};
}
_.defaults(options, {
root: ''
});
return function(req, res) {
// we can use the __ function here, since we're in a request
var __ = res.__;
// let's push translations for the page types for this specific request
// the reason we do it here, as opposed to a global push is because
// we can't know which language the user wants until the request is served
var pageTypesLocaleStrings = {};
_.each(self.types, function(type){
pageTypesLocaleStrings[type.label] = __(type.label);
if(type.pluralLabel)
pageTypesLocaleStrings[type.pluralLabel] = __(type.pluralLabel);
if(type.instanceLabel)
pageTypesLocaleStrings[type.instanceLabel] = __(type.instanceLabel);
});
apos.pushLocaleStrings(pageTypesLocaleStrings, req);
function time(fn, name) {
return function(callback) {
req.traceIn(name);
return fn(function(err) {
req.traceOut();
return callback(err);
});
};
}
function timeSync(fn, name) {
req.traceIn(name);
fn();
req.traceOut();
}
// Let's defer various types of widget joins
// until the last possible minute for all
// content loaded as part of this request, so
// we can do it with one efficient query
// per type instead of many queries
req.deferredLoads = {};
req.deferredLoaders = {};
// Express doesn't provide the absolute URL the user asked for by default.
// TODO: move this to middleware for even more general availability in Apostrophe.
// See: https://github.com/visionmedia/express/issues/1377
if (!req.absoluteUrl) {
req.absoluteUrl = req.protocol + '://' + req.get('Host') + apos.prefix + req.url;
}
// allow earlier middleware to start populating req.extras if
// it wants to
req.extras = req.extras || {};
req.traceIn('TOTAL');
return async.series([time(page, 'page'), time(secondChanceLogin, 'secondChanceLogin'), time(relatives, 'relatives'), time(load, 'load'), time(notfound, 'notfound'), time(executeDeferredLoads, 'deferred loads')], main);
function page(callback) {
// Get content for this page
req.slug = req.params[0];
// Fix common screwups in URLs: leading/trailing whitespace,
// presence of trailing slashes (but always restore the
// leading slash). Express leaves escape codes uninterpreted
// in the path, so look for %20, not ' '.
req.slug = req.slug.trim();
req.slug = req.slug.replace(/\/+$/, '');
if ((!req.slug.length) || (req.slug.charAt(0) !== '/')) {
req.slug = '/' + req.slug;
}
// Had to change the URL, so redirect to it. TODO: this
// contains an assumption that we are mounted at /
if (req.slug !== req.params[0]) {
return res.redirect(req.slug);
}
apos.getPage(req, req.slug, function(e, page, bestPage, remainder) {
if (e) {
return callback(e);
}
// Set on exact slug matches only
// "What if there is no page?" We'll note that later
// and send the 404 type. We still want to load all
// the global stuff first
req.page = page;
// Set on partial slug matches followed by a / and on
// exact matches as well
req.bestPage = bestPage;
// Set to the empty string on exact matches, otherwise
// to the portion of the URL after the slug of req.bestPage. Note
// that any trailing / has already been removed. A leading
// / is always present, even if the page is the home page.
req.remainder = remainder;
if (req.bestPage) {
req.bestPage.url = self._apos.slugToUrl(req.bestPage.slug);
}
return callback(null);
});
}
function secondChanceLogin(callback) {
if (!options.secondChanceLogin) {
return callback(null);
}
if (req.user) {
return callback(null);
}
if (req.page) {
return callback(null);
}
// Try again with admin privs. If we get a better page,
// note the URL in the session and redirect to login.
return apos.getPage(apos.getTaskReq(), req.slug, { fields: { slug: 1 } }, function(e, page, bestPage, remainder) {
if (e) {
return callback(e);
}
if (page || (bestPage && req.bestPage && req.bestPage.slug < bestPage.slug)) {
res.cookie('aposAfterLogin', req.url);
return res.redirect('/login');
}
return callback(null);
});
}
function relatives(callback) {
if(!req.bestPage) {
return callback(null);
}
async.series({
ancestors: time(function(callback) {
// ancestors are always fetched. You need 'em
// for tabs, you need 'em for breadcrumb, you
// need 'em for the admin UI. You just need 'em.
var ancestorOptions = options.ancestorOptions ? _.cloneDeep(options.ancestorOptions) : {};
if (!ancestorOptions.childrenOptions) {
ancestorOptions.childrenOptions = {};
}
ancestorOptions.childrenOptions.orphan = false;
return self.getAncestors(req, req.bestPage, options.ancestorCriteria || {}, ancestorOptions || {}, function(err, ancestors) {
req.bestPage.ancestors = ancestors;
if (ancestors.length) {
// Also set parent as a convenience
req.bestPage.parent = req.bestPage.ancestors.slice(-1)[0];
}
return callback(err);
});
}, 'ancestors'),
peers: time(function(callback) {
if (options.peers || true) {
var ancestors = req.bestPage.ancestors;
if (!ancestors.length) {
// The only peer of the homepage is itself.
//
// Avoid a circular reference that crashes
// extend() later when we try to pass the homepage
// as the .permalink option to a loader. This
// happens if the homepage is a blog.
var selfAsPeer = {};
extend(true, selfAsPeer, req.bestPage);
req.bestPage.peers = [ selfAsPeer ];
return callback(null);
}
var parent = ancestors[ancestors.length - 1];
var peerOptions = options.peerOptions ? _.cloneDeep(options.peerOptions) : {};
peerOptions.orphan = false;
self.getDescendants(req, parent, peerOptions, function(err, pages) {
req.bestPage.peers = pages;
return callback(err);
});
} else {
return callback(null);
}
}, 'peers'),
descendants: time(function(callback) {
if (options.descendants || true) {
var descendantOptions = options.descendantOptions ? _.cloneDeep(options.descendantOptions) : {};
descendantOptions.orphan = false;
return self.getDescendants(req, req.bestPage, options.descendantCriteria || {}, descendantOptions, function(err, children) {
req.bestPage.children = children;
return callback(err);
});
} else {
return callback(null);
}
}, 'descendants'),
tabs: time(function(callback) {
if (options.tabs || true) {
var tabOptions = options.tabOptions ? _.cloneDeep(options.tabOptions) : {};
tabOptions.orphan = false;
self.getDescendants(req, req.bestPage.ancestors[0] ? req.bestPage.ancestors[0] : req.bestPage, options.tabCriteria || {}, tabOptions, function(err, pages) {
req.bestPage.tabs = pages;
return callback(err);
});
} else {
return callback(null);
}
}, 'tabs')
}, callback);
}
function load(callback) {
// Get any shared pages like global footers, also
// invoke load callbacks if needed
var loadList = options.load ? options.load : [];
// Be tolerant if they pass just one function
if (typeof(loadList) === 'function') {
loadList = [ loadList ];
}
// Turn any slugs into callbacks to fetch those slugs.
// This is a little lazy: if we turn out to need multiple
// pages of shared stuff we could coalesce them into a
// single mongo query. However we typically don't, or
// we're loading some of them only in certain situations.
// So let's not prematurely optimize
loadList = loadList.map(function(item) {
if (typeof(item) !== 'function') {
return function(callback) {
// Hardcoded slugs of virtual pages to be loaded for every user every time
// imply we're not concerned with permissions. Avoiding them saves us the
// hassle of precreating pages like "global" just to set published: true etc.
apos.getPage(req, item, { permissions: false }, function(err, page) {
if (err) {
return callback(err);
}
// The new syntax for aposArea() requires a more convincing fake page!
// Populate slug and permissions correctly
req.extras[item] = page ? page : { slug: item };
if (!page && req.user && req.user.permissions.admin) {
req.extras[item]._edit = true;
}
return callback(null);
});
};
} else {
// Already a callback, now wrap it in a function that can
// see the req variable
return function(callback) {
return item(req, callback);
};
}
});
// series lets later modules' loaders see the results of earlier ones
return async.series(loadList, callback);
}
function notfound(callback) {
// Implement the automatic redirect mechanism for pages whose
// slugs have changed, unless an alternate mechanism has been specified
if ((!req.page) || (req.notfound)) {
if (options.notfound) {
return options.notfound(req, function(err) {
return callback(err);
});
} else {
// Check for a redirect from an old slug before giving up
apos.redirects.findOne({from: req.slug }, function(err, redirect) {
if (redirect) {
return res.redirect(options.root + redirect.to);
} else {
return callback(null);
}
});
}
} else {
return callback(null);
}
}
function executeDeferredLoads(callback) {
// Keep making passes until there are
// no more recursive loads to do; loads
// may do joins that require more loads, etc.
var deferredLoads;
var deferredLoaders;
return async.whilst(function() {
deferredLoads = req.deferredLoads;
deferredLoaders = req.deferredLoaders;
req.deferredLoads = {};
req.deferredLoaders = {};
return !_.isEmpty(deferredLoads);
}, function(callback) {
return async.eachSeries(
_.keys(deferredLoads),
function(type, callback) {
return deferredLoaders[type](req, deferredLoads[type], callback);
},
callback);
}, callback);
}
function main(err) {
var providePage = true;
// Rendering errors isn't much different from
// rendering other stuff. We still get access
// to shared stuff loaded via `load`.
// If the load functions already picked a type respect it,
// whether it is on the allowed list for manual type choices
// or not. Otherwise implement standard behaviors
// pages.serve treats the request object as a repository of everything
// we know about this request so far, including simple hints about the
// desired response. This is different from the default paradigm
// of Express.
if (req.contentType) {
res.setHeader('Content-Type', req.contentType);
}
if (req.redirect) {
return res.redirect(req.redirect);
}
if (req.notfound) {
// A loader asked us to 404
res.statusCode = 404;
req.template = 'notfound';
providePage = false;
} else if (!req.template) {
if (err) {
console.log(err);
req.template = 'serverError';
res.statusCode = 500;
providePage = false;
} else if (req.loginRequired) {
req.template = 'loginRequired';
providePage = false;
} else if (req.insufficient) {
req.template = 'insufficient';
providePage = false;
} else if (req.page) {
// Make sure the type is allowed
req.template = req.page.type;
// This check was coded incorrectly and never
// actually flunked a missing template. I have
// fixed the check, but I don't want to break 0.5 sites.
// TODO: revive this code in 0.6 and test more.
//
// -Tom
//
// if (!_.some(aposPages.types, function(item) {
// return item.name === req.template;
// })) {
// req.template = 'default';
// }
} else {
res.statusCode = 404;
req.template = 'notfound';
providePage = false;
}
}
if (req.template === undefined) {
// Supply a default template name
req.template = 'default';
}
if (providePage) {
req.traceIn('prune page');
req.pushData({
aposPages: {
// Prune the page back so we're not sending everything
// we know about every event in every widget etc., which
// is redundant and results in slow page loads and
// high bandwidth usage
page: apos.prunePage(req.bestPage)
}
});
req.traceOut();
}
if (typeof(req.contextMenu) === 'function') {
// Context menu can be generated on the fly
// by a function
req.contextMenu = req.contextMenu(req);
}
var args = {
edit: providePage ? req.bestPage._edit : null,
slug: providePage ? req.bestPage.slug : null,
page: providePage ? req.bestPage : null,
// Allow page loaders to set the context menu
contextMenu: req.contextMenu
};
if (args.page && args.edit && (!args.contextMenu)) {
// Standard context menu for a regular page
args.contextMenu = [
{
name: 'new-page',
label: 'New Page'
},
{
name: 'edit-page',
label: 'Page Settings'
},
{
name: 'versions-page',
label: 'Page Versions'
},
{
name: 'delete-page',
label: 'Move to Trash'
},
{
name: 'reorganize-page',
label: 'Reorganize'
}
];
}
else if (args.contextMenu && req.user) {
// This user does NOT have permission to see reorg,
// but it might exist already in the contextMenu (why??),
// so we have to remove it explicitly.
args.contextMenu = _.filter(args.contextMenu, function(item) {
return item.name !== 'reorganize-page';
});
}
if (args.page) {
var type = self.getType(args.page.type);
if (type && type.childTypes && (!type.childTypes.length)) {
// Snip out add page if no
// child page types are allowed
args.contextMenu = _.filter(args.contextMenu, function(item) {
return item.name !== 'new-page';
});
}
}
_.extend(args, req.extras);
// A simple way to access everything we know about
// the page in JSON format. Allow this only if we
// have editing privileges on the page.
if ((req.query.pageInformation === 'json') && args.page && (args.page._edit)) {
return res.send(args.page);
}
var path;
if (typeof(req.template) === 'string') {
path = __dirname + '/views/' + req.template;
if (options.templatePath) {
path = options.templatePath + '/' + req.template;
}
}
var result;
timeSync(function() {
result = self.renderPage(req, path ? path : req.template, args);
if (req.statusCode) {
res.statusCode = req.statusCode;
}
}, 'render');
req.traceOut();
self._apos.traceReport(req);
if (!req.user) {
// Most recent Apostrophe page they saw is a good
// candidate to redirect them to if they choose to
// log in.
//
// However several types of URLs are not really of
// interest for this purpose:
//
// * AJAX loads of partial pages
// * 404 and other error pages
// * Static asset URLs that may or may not
// actually exist (file extension is present)
if (options.updateAposAfterLogin && ((!res.statusCode) || (res.statusCode === 200)) && (!req.xhr) && (!req.query.xhr) && (!(req.url.match(/\.\w+$/)))) {
res.cookie('aposAfterLogin', req.url);
}
}
return res.send(result);
}
};
};
// Fetch ancestors of the specified page. We need req to
// determine permissions. Normally areas associated with
// ancestors are not returned. If you specify options.areas as
// `true`, all areas will be returned. If you specify options.areas
// as an array of area names, areas in that list will be returned.
//
// You may use options.getOptions to pass additional options
// directly to apos.get, notably trash: 'any' for use when
// implementing reorganize, trashcan, etc.
//
// You may use the criteria parameter to directly specify additional
// MongoDB criteria ancestors must match to be returned.
//
// You may skip the criteria and options arguments.
self.getAncestors = function(req, page, criteriaArg, options, callback) {
if (arguments.length === 4) {
callback = arguments[3];
options = arguments[2];
criteriaArg = {};
}
if (arguments.length === 3) {
callback = arguments[2];
criteriaArg = {};
options = {};
}
_.defaults(options, {
root: ''
});
var paths = [];
// Pages that are not part of the tree and the home page of the tree
// have no ancestors
if ((!page.path) || (page.path.indexOf('/') === -1)) {
return callback(null, paths);
}
var components = page.path.split('/');
var path = '';
_.each(components, function(component) {
path += component;
// Don't redundantly load ourselves
if (path === page.path) {
return;
}
paths.push(path);
path += '/';
});
var getOptions = {
fields: {
lowSearchText: 0, highSearchText: 0, searchSummary: 0
},
sort: {
path: 1
}
};
if (options.areas) {
getOptions.areas = options.areas;
} else {
// We can't populate the fields option because we can't know the names
// of all the area properties in the world in order to exclude them.
// Use the `areas` option which filters them after fetching so we at least
// don't pay to run their loaders
getOptions.areas = false;
}
if (options.getOptions) {
extend(true, getOptions, options.getOptions);
}
var criteria = {
$and: [
{ path: { $in: paths } },
criteriaArg
]
};
var pages;
return async.series({
getAncestors: function(callback) {
// Get metadata about the related pages, skipping expensive stuff.
// Sorting by path works because longer strings sort
// later than shorter prefixes
return apos.get(req, criteria, getOptions, function(err, results) {
if (err) {
return callback(err);
}
pages = results.pages;
_.each(pages, function(page) {
page.url = self._apos.slugToUrl(page.slug);
});
return callback(null);
});
},
getChildrenOfAncestors: function(callback) {
if (!options.children) {
return callback(null);
}
// TODO: there is a clever mongo query to avoid
// separate invocations of getDescendants
return async.eachSeries(pages, function(page, callback) {
var childrenOptions = options.childrenOptions || {};
return self.getDescendants(req, page, {}, childrenOptions, function(err, pages) {
if (err) {
return callback(err);
}
page.children = pages;
return callback(null);
});
}, callback);
}
}, function (err) {
if (err) {
return callback(err);
}
return callback(null, pages);
});
};
// We need req to determine permissions
self.getParent = function(req, page, options, callback) {
if (arguments.length === 3) {
callback = arguments[2];
options = {};
}
return self.getAncestors(req, page, options, function(err, ancestors) {
if (err) {
return callback(err);
}
if (!ancestors.length) {
return callback(null);
}
return callback(null, ancestors[ancestors.length - 1]);
});
};
// The `trash` option controls whether pages with the trash flag are
// included. If true, only trash is returned. If false, only non-trash
// is returned. If null, both are returned. false is the default.
//
// The `orphan` option works the same way. `orphan` pages are
// normally accessible, but are snot shown in subnav, tabs, etc., so
// this is the only method for which `orphan` defaults to false.
//
// Normally areas associated with ancestors are not returned.
// If you specify `options.areas` as `true`, all areas will be returned.
// If you specify `options.areas` as an array of area names, areas on that
// list will be returned.
//
// Specifying options.depth = 1 fetches immediate children only.
// You may specify any depth. The default depth is 1.
//
// You may also pass arbitrary mongodb criteria as the criteria parameter.
//
// You may skip the criteria argument, or both criteria and options.
self.getDescendants = function(req, ofPage, criteriaArg, optionsArg, callback) {
if (arguments.length === 4) {
callback = arguments[3];
optionsArg = arguments[2];
criteriaArg = {};
}
if (arguments.length === 3) {
callback = arguments[2];
optionsArg = {};
criteriaArg = {};
}
var options = {};
extend(true, options, optionsArg);
_.defaults(options, {
root: ''
});
var depth = options.depth;
// Careful, let them specify a depth of 0 but still have a good default
if (depth === undefined) {
depth = 1;
}
var criteria = {
$and: [
{
path: new RegExp('^' + RegExp.quote(ofPage.path + '/')),
level: { $gt: ofPage.level, $lte: ofPage.level + depth }
}, criteriaArg
]
};
// Skip expensive things
options.fields = { lowSearchText: 0, highSearchText: 0, searchSummary: 0 };
if (!options.areas) {
// Don't fetch areas at all unless we're interested in a specific
// subset of them
options.areas = false;
}
options.sort = { level: 1, rank: 1 };
apos.get(req, criteria, options, function(err, results) {
if (err) {
return callback(err);
}
var pages = results.pages;
var children = [];
var pagesByPath = {};
_.each(pages, function(page) {
page.children = [];
page.url = self._apos.slugToUrl(page.slug);
pagesByPath[page.path] = page;
var last = page.path.lastIndexOf('/');
var parentPath = page.path.substr(0, last);
if (pagesByPath[parentPath]) {
pagesByPath[parentPath].children.push(page);
} else if (page.level === (ofPage.level + 1)) {
children.push(page);
} else {
// The parent of this page is hidden from us, so we shouldn't
// include this page in the results as viewed from here
}
});
return callback(null, children);
});
};
// Get all pages that have the mentioned tag. 'options' parameter, if present,
// may contain an 'areas' flag indicating that the content areas should be returned,
// otherwise only metadata is returned. Pages are sorted by rank, which is helpful
// if you are using tags to display a subset of child pages and wish to preserve their
// natural order. Pages are not returned in a tree structure, pages of any level
// may appear anywhere in the result
self.getByTag = function(req, tag, options, callback) {
return self.getByTags(req, [tag], options, callback);
};
// Get all pages that have at least one of the mentioned tags. 'options' parameter,
// if present, may contain an 'areas' flag indicating that the content areas should
// be returned, otherwise only metadata is returned.
//
// Pages are sorted by rank, which is helpful if you are using tags to display a subset
// of child pages and wish to preserve their natural order. Pages are not returned in a tree
// structure, pages of any level may appear anywhere in the result
self.getByTags = function(req, tags, options, callback) {
if (!callback) {
callback = options;
options = {};
}
if (!options.areas) {
options.areas = false;
}
var criteria = { path: { $exists: 1 }, tags: { $in: tags }};
return apos.get(req, criteria, options, function(err, results) {
if (err) {
return callback(err);
}
var pages;
if (results) {
pages = results.pages;
}
return callback(null, results);
});
};
// Filter the pages array to those pages that have the specified tag. Returns directly,
// has no callback
self.filterByTag = function(pages, tag) {
return self.filterByTags(pages, [tag]);
};
// Filter the pages array to those pages that have at least one of the specified tags.
// Returns directly, has no callback
self.filterByTags = function(pages, tags) {
return _.filter(pages, function(page) {
return page.tags && _.some(page.tags, function(tag) {
return _.contains(tags, tag);
});
});
};
// position can be 'before', 'after' or 'inside' and determines the
// moved page's new relationship to the target page. You may pass
// page objects instead of slugs if you have them. The callback
// receives an error and, if there is no error, also an array of
// objects with _id and slug properties, indicating the new slugs
// of all modified pages
self.move = function(req, movedSlug, targetSlug, position, callback) {
var moved, target, parent, oldParent, changed = [];
if (typeof(movedSlug) === 'object') {
moved = movedSlug;
}
if (typeof(targetSlug) === 'object') {
target = targetSlug;
}
var rank;
var originalPath;
var originalSlug;
async.series([getMoved, getTarget, getOldParent, getParent, permissions, nudgeNewPeers, moveSelf, updateRedirects, moveDescendants, trashDescendants ], finish);
function getMoved(callback) {
if (moved) {
return callback(null);
}
if (movedSlug.charAt(0) !== '/') {
return callback('not a tree page');
}
apos.pages.findOne({ slug: movedSlug }, function(err, page) {
if (!page) {
return callback('no such page');
}
moved = page;
if (!moved.level) {
return callback('cannot move root');
}
// You can't move the trashcan itself, but you can move its children
if (moved.trash && (moved.level === 1)) {
return callback('cannot move trashcan');
}
return callback(null);
});
}
function getTarget(callback) {
if (target) {
return callback(null);
}
if (targetSlug.charAt(0) !== '/') {
return callback('not a tree page');
}
apos.pages.findOne({ slug: targetSlug }, function(err, page) {
if (!page) {
return callback('no such page');
}
target = page;
if ((target.trash) && (target.level === 1) && (position === 'after')) {
return callback('trash must be last');
}
return callback(null);
});
}
function getOldParent(callback) {
self.getParent(req, moved, { getOptions: { permissions: false, trash: 'any' } }, function(err, parentArg) {
oldParent = parentArg;
return callback(err);
});
}
function getParent(callback) {
if (position === 'inside') {
parent = target;
rank = 0;
return callback(null);
}
if (position === 'before') {
rank = target.rank;
if (rank >= 1000000) {
// It's legit to move a page before search or trash, but we
// don't want its rank to wind up in the reserved range. Find
// the rank of the next page down and increment that.
return self.pages.find({ slug: /^\//, path: /^home\/[^\/]$/ }, { rank: 1 }).sort({ rank: -1 }).limit(1).toArray(function(err, pages) {
if (err) {
return callback(err);
}
if (!pages.length) {
rank = 1;
return callback(null);