UNPKG

apostrophe

Version:

Apostrophe is a user-friendly content management system. You'll need more than this core module. See apostrophenow.org to get started.

937 lines (847 loc) • 34.2 kB
var path = require('path'); var extend = require('extend'); var _ = require('lodash'); var moment = require('moment'); var findTheFat = require('find-the-fat'); /** * aposLocals * @augments Augments the apos object with methods providing locals for Express */ module.exports = function(self) { self._aposLocals = { aposPrefix: self.prefix, // aposStylesheets renders markup to load CSS that is needed on // any page that will use Apostrophe. Examples: editor.less, content.less // and stylesheet assets pushed by other modules. `when` can be set to // either `user` or `anon` and signifies whether a user is logged in or not; // when users are logged in editing-related stylesheets are sent, otherwise not. // The `when` parameter is made available to your page templates, so typically you // just write this in your base layout template in the head element: // // `{{ aposScripts(when) }}` // // If `minify` is set to `true` in `data/local.js` then minification and combination of // files is automatically performed. // // See `base.html` in the sandbox project. aposStylesheets: function(when) { if (!self._endAssetsCalled) { throw new Error('CODE CHANGE REQUIRED: you must call apos.endAssets once after the last call to apos.pushAsset. Add apos.endAssets to the end of the list in your initApos function.'); } if (self.options.minify) { return '<link href="' + self.prefix + '/apos-minified/' + when + '-' + self._generation + '.css" rel="stylesheet" />'; } else { if (self._lessMasters && self._lessMasters[when]) { return '<link href="' + self.prefix + self._lessMasters[when].web + '" rel="stylesheet" />'; } return _.map(self.filterAssets(self._assets['stylesheets'], when), function(stylesheet) { return '<link href="' + self.prefix + stylesheet.web + '" rel="stylesheet" />'; }).join("\n"); } }, // aposScripts renders markup to load browser-side javascript that is needed on // any page that will use Apostrophe. Examples: content.js, editor.js, various // jquery plugins, jquery itself. `when` can be set to either `user` or `anon` and // signifies whether a user is logged in or not; when users are logged in editing-related // stylesheets are sent, otherwise not. The `when` parameter is made available to your // page templates, so typically you just write this in your base layout template // in the head element: // // `{{ aposScripts(when) }}` // // If `minify` is set to `true` in `data/local.js` then minification and combination of // files is automatically performed. // // See `base.html` in the sandbox project. aposScripts: function(when) { if (!self._endAssetsCalled) { throw new Error('CODE CHANGE REQUIRED: you must call apos.endAssets once after the last call to apos.pushAsset. Add apos.endAssets to the end of the list in your initApos function.'); } if (!when) { // Backwards compatibility with older layouts when = 'all'; } if (self.options.minify) { var unminifiable = self.filterAssets(self._assets['scripts'], when, false); var result = prefix() + scriptTags(unminifiable); result += '<script src="' + self.prefix + '/apos-minified/' + when + '-' + self._generation + '.js"></script>\n' + setScene(); return result; } else { var minifiable = self.filterAssets(self._assets['scripts'], when); return prefix() + scriptTags(minifiable) + setScene(); } function scriptTags(scripts) { return _.map(scripts, function(script) { return '<script src="' + self.prefix + script.web + '"></script>'; }).join("\n"); } // We don't want to use pushGlobalData for this in the usual way because // we want to be certain of the timing function setScene() { return "<script>apos.scene = " + JSON.stringify(when) + ";</script>\n"; } // apos.data.prefix must be available before any // .js files load, notably beforeCkeditor.js function prefix() { return "<script>\n" + "if (!window.apos) {\n" + " window.apos = {};\n" + "}\n" + "window.apos.data = { prefix: " + JSON.stringify(self.prefix) + " };\n</script>\n"; } }, // aposTemplates renders templates that are needed on any page that will // use apos. Examples: slideshowEditor.html, codeEditor.html, // etc. These lie dormant in the page until they are needed as prototypes to // be cloned by jQuery. `when` can be set to either `user` or `anon` and signifies whether // a user is logged in or not; when users are logged in editing-related stylesheets are sent, // otherwise not.The `when` parameter is made available to your page templates, so typically // you just write this in your base layout template at the end of the body element: // // `{{ aposTemplates(when) }}` // // See `base.html` in the sandbox project. aposTemplates: function(when) { if (!when) { when = 'all'; } var templates = self._assets['templates']; templates = self.filterAssets(templates, when); return _.map(templates, function(template) { return self.renderTemplateAsset(template).trim(); }).join(''); }, /** * Renders a content area. Three syntaxes are supported. We recommend * the first one for most uses: * * `aposArea(page, 'body', { ... other options ... })` * * `aposArea({ page: page, name: 'body', ... other options ... })` * * `aposArea({ slug: page.slug + ':body', area: page.body, ... other options ... })` * * Other supported options include `controls` and an option named for each * widget type, which is passed on as the options to that widget. * * Note that you do not have to specify the `edit` option if you use the first two syntaxes, * but you may if you wish to specifically forbid editing for a user who normally could. */ aposArea: function(page, name, options) { // Boil the newer syntax options down to the old first before // we actually try to access anything in options. -Tom if (arguments.length >= 2) { if (!options) { options = {}; } options.page = arguments[0]; options.name = arguments[1]; } else { options = arguments[0]; } if (options.page && options.name) { options.slug = options.page.slug + ':' + options.name; options.area = options.page[options.name]; if (options.edit === undefined) { options.edit = options.page._edit; } // So we don't choke the browser with JSON of the entire page in // the area options delete options.page; } // If the slug is not explicitly given, try to get it from // the area. This works for areas that already exist, but // cannot be relied upon for new areas obviously if (options.area && (!options.slug)) { options.slug = options.area.slug; } /* OK, now we can acccess options for other things. */ if (!options.controls) { options.controls = self.defaultControls; } options.styles = options.styles || self.controlTypes.style.choices; if (!options.initialContent) { if (options.textOnly) { options.initialContent = '<p data-initial-content>Click the pencil to get started.</p>'; } else { options.initialContent = '<p data-initial-content>Use the Add Content button to get started.</p>'; } } if (!options.initialContent.match(/^<p data-initial-content/)) { // We must be able to recognize and remove it. -Tom options.initialContent = '<p data-initial-content>' + options.initialContent + '</p>'; } var area = options.area; delete options.area; if (!area) { // Invent the area if it doesn't exist yet, so we can // edit pages not previously edited area = { items: [] }; if (options.textOnly) { // If the area is text only then it should initially contain an // empty text widget as there is no way to add another and we want to // be able to apply the apos-empty class immediately // area.items.push({ type: 'richText', content: '' }); } if (options.initialContent && options.edit) { area.items.push({ type: 'richText', content: options.initialContent }); } } // Keep options and area separate, area is much too big to stuff into // the options attribute of every area element, whoops return self.partial('area', { options: options, area: area }); }, /** * Renders a singleton (a standalone widget). Three syntaxes are supported. We recommend * the first one for most uses: * * `aposSingleton(page, 'body', 'slideshow', { ... other options ... })` * * `aposSingleton({ page: page, name: 'body', type: 'slideshow', ... other options ... })` * * `aposSingleton({ slug: page.slug + ':body', area: page.body, type: 'slideshow', ... other options ... })` * * Other options are passed on to the widget. * * Note that you do not have to specify the `edit` option if you use the first two syntaxes, * but you may if you wish to specifically forbid editing for a user who normally could. */ aposSingleton: function(page, name, type, options) { // Transform the first syntax to the second if (arguments.length >= 3) { if (!options) { options = {}; } options.page = page; options.name = name; options.type = type; } else { options = arguments[0]; } if (!self.itemTypes[options.type]) { console.error("Attempt to insert a singleton of a nonexistent type: " + options.type); return; } // Find area based on page and name if (options.page && options.name) { options.slug = options.page.slug + ':' + options.name; options.area = options.page[options.name]; if (options.edit === undefined) { options.edit = options.page._edit; } // So we don't choke the browser with JSON of the entire page in // the area options delete options.page; } // If the slug is not explicitly given, try to get it from // the area. This works for areas that already exist, but // cannot be relied upon for new areas obviously if (options.area && (!options.slug)) { options.slug = options.area.slug; } if (!options.area) { // Invent the area if it doesn't exist yet, so we can // edit pages not previously edited options.area = { items: [] }; } // If someone transforms an existing area into a singleton, do a reasonable thing by // taking the first existing item of the proper type var item = _.find(options.area.items, function(item) { return item.type === options.type; }); options.itemType = self.itemTypes[options.type]; options.item = item; if (options.item) { options.item.position = 'middle'; options.item.size = 'full'; } // Options to pass on to the widget options.options = _.omit(options, 'area', 'item', 'slug', 'type', 'page', 'name', 'edit'); // Prune this back so it's not circular options.options.itemType = self.jsonItemType(options.itemType); // Options to pass on to the DOM return self.partial('singleton', options); }, // Returns true if the specified area is empty. Expects a page // object and an area name, or an options object containing an // object with an `area` property. aposAreaIsEmpty: function(page, name) { var area; if (arguments.length === 2) { area = page[name]; } else { // "page" is an options object area = page.area; } if (!area) { return true; } return !_.some(area.items, function(item) { if (self.itemTypes[item.type] && self.itemTypes[item.type].empty) { return !self.itemTypes[item.type].empty(item); } else { return true; } }); }, // Returns true if the specified area is empty when considered as a // singleton of the specified type. Expects a page object, an area name // and a widget type name. Alternatively you may pass a single object // with an `area` property and a `type` property, in which case the // `area` property is the actual area object. aposSingletonIsEmpty: function(page, name, type) { var options; // Wrong syntax - warn the developer if (arguments.length === 2) { throw new Error("aposSingletonIsEmpty takes 3 arguments (page, name, type) "); } // Transform the first syntax to the second else if (arguments.length >= 3) { if (!options) { options = {}; } options.area = page[name]; options.type = type; } else { options = arguments[0]; } if (!options.area) { return true; } return !_.some(options.area.items , function(item) { if (self.itemTypes[item.type] && (item.type === options.type) && self.itemTypes[item.type].empty) { return !self.itemTypes[item.type].empty(item); } else { return true; } }); }, aposSlugify: function(string) { return self.slugify(string); }, // Outputs the normal views of all of the content items (widgets and/or // rich text blocks) specified by the first argument, passing on the specified // options. Typically invoked for you by `aposArea` or `aposSingleton`, but can // be called directly with `area.items` when you do not wish to render the // wrapper markup for the area as a whole (permissible only if editing is // not a possibility here). The `allowed` option, if present, should be a list // of permitted item types; any items not on that list are not rendered. // // If a widget has a lockup property the widget and the text item following it // are wrapped in an apos-lockup div. If the item following the widget is not // a text item the lockup is ignored. // // Options for individual widget types can be passed as follows: // `{ slideshow: { ... options ... } }` aposAreaContent: function(items, options) { var result = ''; var allowed = options.allowed; var lockupOpen = false; var i; var lockup; var limit = items.length; if (options.limit && (options.limit < items.length)) { limit = options.limit; } for (i = 0; (i < limit); i++) { var item = items[i]; if (allowed && (!_.contains(allowed, item.type))) { continue; } var itemOptionsIn = options ? options[item.type] : {}; var itemOptions = {}; extend(true, itemOptions, itemOptionsIn); if (options.editView) { itemOptions.editView = true; } // LOCKUPS: if a widget has the lockup property, do some sanity checks, // then output that widget and the rich text item that follows it inside // a wrapper div with the apos-lockup class as well as a class derived // from the name of the lockup lockup = false; if (item.lockup && self.lockups[item.lockup] && _.contains(self.lockups[item.lockup].widgets, item.type) && ((i + 1) < items.length) && (items[i + 1].type === 'richText')) { lockup = self.lockups[item.lockup]; } if (lockup) { result += '<div class="apos-lockup ' + self.cssName(item.lockup) + '">'; // Lockups can contain custom options per widget type extend(true, itemOptions, lockup[item.type] || {}); } result += self._aposLocals.aposItemNormalView(item, itemOptions).trim(); if (lockupOpen) { result += "</div>\n"; lockupOpen = false; } if (lockup) { lockupOpen = true; } } return result; }, // Find an image referenced within an area, such as an image in a slideshow widget. // Returns the first image matching the criteria. Only GIF, JPEG and PNG images // will ever be returned. // // EASY SYNTAX: // // aposAreaImage(page, 'body') // // You may also add options, such as "extension" to force the results to // include JPEGs only: // // aposAreaImage(page, 'body', { extension: 'jpg' }) // // (Note Apostrophe always uses .jpg for JPEGs.) // // CLASSIC SYNTAX (this is the hard way): // // aposAreaImage({ area: page.body }) // // OPTIONS: // // You may specify `extension` or `extensions` (an array of extensions) // to filter the results. aposAreaImage: function(options /* OR page, name, options */) { var result = self.areaImage.apply(self, arguments); return result; }, // Find images referenced within an area, such as images in a slideshow widget. // Returns all the files matching the criteria unless the "limit" option is used. // // EASY SYNTAX: // // aposAreaImages(page, 'body') // // Now you can loop over them with "for". // // You may also add options: // // aposAreaImages(page, 'body', { extension: 'jpg', limit: 2 }) // // Note that Apostrophe always uses three-letter lowercase extensions. // // CLASSIC SYNTAX: // // aposAreaImage({ area: page.body }) // // OPTIONS: // // You may specify `extension`, or `extensions` (an array of extensions). // // The `limit` option limits the number of results returned. Note that // `areaImage` is more convenient than `aposAreaImages` if limit is 1. // // See also aposAreaFiles. aposAreaImages: function(options /* or page, name, _options */) { return self.areaImages.apply(self, arguments); }, // Find a file referenced within an area, such as an image in a slideshow widget, // or a PDF in a file widget. // // Returns the first file matching the criteria. // // EASY SYNTAX: // // aposAreaFile(page, 'body') // // You may also add options: // // aposAreaFile(page, 'body', { extension: 'jpg' }) // // CLASSIC SYNTAX: // // aposAreaFile({ area: page.body }) // // OPTIONS: // // You may specify `extension`, `extensions` (an array of extensions) // or `group` to filter the results. By default the `images` and `office` groups // are available. // // If you are using `group: "images"` consider calling aposAreaImage instead. // This is convenient and protects you from accidentally getting a PDF file. aposAreaFile: function(options /* or page, name, _options */) { return self.areaFile.apply(self, arguments); }, // Find files referenced within an area, such as images in a slideshow widget. // Returns all the files matching the criteria unless the "limit" option is used. // // EASY SYNTAX: // // aposAreaFiles(page, 'body') // // Now you can loop over them with "for". // // You may also add options: // // aposAreaFiles(page, 'body', { extension: 'jpg', limit: 2 }) // // CLASSIC SYNTAX: // // aposAreaFile({ area: page.body }) // // OPTIONS: // // You may specify `extension`, `extensions` (an array of extensions) // or `group` to filter the results. By default the `images` and `office` groups // are available. See also `aposAreaImage` and `aposAreaImages` for // convenience. // // The `limit` option limits the number of results returned. Note that // `aposAreaFile` is more convenient than `aposAreaFiles` if you want // just one file. // // If you are using `group: "images"` consider calling aposAreaImages instead. // This is convenient and protects you from accidentally getting a PDF file. aposAreaFiles: function(options /* or page, name, _options */) { return self.areaFiles.apply(self, arguments); }, // bc wrapper for aposAreaFile, see aposAreaFile aposAreaFindFile: function(options /* or page, name, _options */) { return self.areaFile.apply(self, arguments); }, // Outputs markup for a button that accesses the media // admin interface. aposMediaMenu: function(permissions) { if (self.permissions.can({ user: { permissions: permissions } }, 'edit-file')) { return self.partial('mediaMenu', permissions); } }, // Outputs markup for a button that access the tag // admin interface. TODO: in 0.6 switch to a permissions // object as the only argument. aposTagsMenu: function(options) { return self.partial('tagsMenu', options); }, // Renders the normal, public view of a widget or rich text item. Normally invoked // by aposAreaContent, which is usually invoked by aposArea or aposSingleton. // Typically you will not need to call this yourself. aposItemNormalView: function(item, options) { if (!options) { options = {}; } if (!self.itemTypes[item.type]) { console.error("Unknown item type: " + item.type); // Empty string so concatenation does not crash the server return ''; } var itemType = self.itemTypes[item.type]; options.widget = itemType.widget; if (options.bodyOnly) { options.widget = false; } // The content property doesn't belong in a data attribute, // and neither does any property beginning with an _, unless // whitelisted var attributes = {}; if (itemType.json) { // Type has a custom JSON filter attributes = itemType.json(item); } else { // use forOwn rather than each() to avoid being faked out // by any object that happens to have a "length" property -Tom and Joel _.forOwn(item, function(value, key) { if (_.contains(itemType.jsonProperties, key)) { // Whitelisted } else { // By default, all properties that do not start with _ and // are not the "content" property are made available on // the element as JSON attributes if ((typeof(key) === 'string') && ((key === 'content') || (key.substr(0, 1) === '_'))) { return; } } attributes[key] = value; }); } // findTheFat(options, 'options: ' + item.type, 50000); // findTheFat(attributes, 'attributes: ' + item.type, 50000); // All options must be made available on the browser side, // otherwise it is not possible to see the widget properly // after it is saved without a page refresh. Corollary: // giant things like page objects should never be passed // as options. Let the loader function get them var empty = itemType.empty ? itemType.empty(item) : false; var response = self.partial('itemNormalView', { item: item, itemType: itemType, empty: empty, options: options, attributes: attributes }); return response; }, // Given a file object (as found in a slideshow widget for instance), // return the file URL. If options.size is set, return the URL for // that size (one-third, one-half, two-thirds, full). full is // "full width" (1140px), not the original. For the original, don't pass size // There is a matching client-side implementation accessible as apos.filePath aposFilePath: function(file, options) { return self.filePath(file, options); }, // Convenience method for the file path of a slideshow's first image. aposAreaImagePath: function(page, name, options) { options = options || {}; return self._aposLocals.aposFilePath( self.areaImage.apply(self, arguments), options); }, // Log a message to the console from a Nunjucks template. Great for debugging. aposLog: function(m) { console.log(m); return ''; }, // Generate a globally unique ID aposGenerateId: function() { return self.generateId(); }, // Generate the right range of page numbers to display in the pager. // Just a little too much math to be comfortable in pure Nunjucks aposPageRange: function(options) { var pages = []; var fromPage = options.page - 2; if (fromPage < 2) { fromPage = 2; } for (var page = fromPage; (page < (fromPage + options.shown)); page++) { pages.push(page); } return pages; }, // For an array of versions, return either the last 7 days's or this months versions. aposVersionsDateRange: function(versions, range){ var stop; if (range === 'week'){ stop = moment().subtract(7, 'days'); } else { stop = moment().subtract(1, 'month').endOf('month'); } return _.filter(versions, function(version){ return moment(version.createdAt).isAfter(stop); }); }, // Test whether the specified date object refers to a date in the current year. // The events module utilizes this aposIsCurrentYear: function(date) { var now = new Date(); return date.getYear() === now.getYear(); }, // Returns true if the list contains the specified value. // If value is an array, returns true if the list contains // *any of* the specified values aposContains: function(list, value) { if (_.isArray(value)) { for (var i = 0; i < value.length; i++) { var valueItem = value[i]; if (_.contains(list, valueItem)){ return true; } } return false; } else{ return _.contains(list, value); } }, // Returns true if the list contains at least one // object with the named property. // The first parameter may also be a single object, in // which case this function returns true if that object // has the named property. aposContainsProperty: function(list, property) { if (_.isArray(list)) { return _.some(list, function(item){ return _.has(item, property); }); } else { return _.has(list, property); } }, // Reverses the order of the array. This MODIFIES the array // in addition to returning it aposReverse: function(array){ return array.reverse(); }, // If the `list` argument is a string, returns true if it begins // with `value`. If the `list` argument is an array, returns // true if at least one of its elements begins with `value`. aposBeginsWith: function(list, value) { if (_.isArray(list)) { for (var i = 0; i < list.length; i++) { var listItem = list[i]; if (listItem.indexOf(value) === 0) { return true; } } } else { if (list.indexOf(value) === 0) { return true; } } return false; }, // Pass as many objects as you want; they will get merged via // `extend` into a new object, without modifying any of them, and // the resulting object will be returned. If several objects have // a property, the last object wins. // // This is useful to add one more option to an options object // which was passed to you. // // If any argument is null, it is skipped gracefully. This allows // you to pass in an options object without checking if it is null. aposMerge: function() { var result = {}; var i; for (i = 0; (i < arguments.length); i++) { if (!arguments[i]) { continue; } extend(true, result, arguments[i]); } return result; }, // Find the first array element, if any, that has the specified value for // the specified property. aposFind: function(arr, property, value) { return _.find(arr, function(item) { return (item[property] === value); }); }, // Find all the array elements, if any, that have the specified value for // the specified property. aposFilter: function(arr, property, value) { return _.filter(arr, function(item) { return (item[property] === value); }); }, // Find all the array elements, if any, for which the specified property // is truthy. aposFilterNonempty: function(arr, property) { return _.filter(arr, function(item) { return (item[property]); }); }, // Given an array of objects with the given property, return an array with // the value of that property for each object. aposPluck: function(arr, property) { return _.pluck(arr, property); }, // Concatenate all of the given arrays and/or values // into a single array. If an argument is an array, all // of its elements are individually added to the // resulting array. If an argument is a value, it is // added directly to the array. aposConcat: function(arrOrObj1, arrOrObj2 /* , ... */) { // I tried to implement this with call() but I kept // getting arrays in my values. We still get the // benefit of concat() and its built-in support for // treating arrays and scalar values differently. -Tom var result = []; var i; for (i = 0; (i < arguments.length); i++) { result = result.concat(arguments[i]); } return result; }, // Convert an area to plaintext. This will only contain text // for items that clearly have an appropriate plaintext // representation for the public, so most widgets will not want // to be represented as they have no reasonable plaintext // equivalent, but you can define the 'getPlaintext' method // for any widget to return one (see self.itemTypes for the // richText example). // Plaintext means truly plain, so if you want to output the // text with nunjucks, be sure to use the "e" filter. // If the truncate option is present, it is used as a character // limit. The plaintext is cut at the closest word boundary // before that length. If this cannot be done a hard cutoff is // applied so that the result is never longer than // options.truncate characters. // You may call with the page, areaName, options syntax: // {{ aposAreaPlaintext(page, 'body', { truncate: 200 }) }} // Or a single options object: // {{ aposAreaPlaintext({ area: page.body, truncate: 200 }) }} aposAreaPlaintext: function(page, name, options) { if (!options) { options = {}; } if (arguments.length === 1) { options = page; } else { options.area = page[name]; } return self.getAreaPlaintext(options); }, // Groups by the property named by 'key' on each of the values. // If the property referred to by the string 'key' is found to be // an array property of the first object, aposGroupByArray is called. // // Usage: {{ aposGroupBy(people, 'age') }} or {{ aposGroupBy(items, 'tags') }} aposGroupBy: function(items, key){ if(items.length && Array.isArray(items[0][key])) { return self._groupByArray(items, key); } return _.groupBy(items, key); }, // Returns true if the permissions specified indicate that the // user can edit *something*, it doesn't matter what. This includes // being able to edit your profile. Useful to // decide whether to show the admin bar. aposCanEditSomething: function(permissions) { return _.some(permissions, function(val, key) { return key.match(/^(guest|edit|admin)/); }); }, // Given a series of alternating keys and values, this // function returns an object with those values for // those keys. For instance, aposObject('name', 'bob') // returns { name: 'bob' }. This is useful because // Nunjucks does not allow you to create an object with // a property whose name is unknown at the time the // template is written. aposObject: function(/* key, value, ... */) { var o = {}; var i = 0; while (i < arguments.length) { o[arguments[i]] = arguments[i + 1]; i += 2; } return o; } }; // Add more locals for Apostrophe later. Used by extension modules // like apostrophe-pages. Can be used to push flags as well as functions. self.addLocal = function(name, fn) { var add = fn; if (typeof(fn) === 'function') { // Nunjucks discards information about the true location of an error that // happens in a function invoked inside {{ }}. Make this information visible // again with our own wrapper function. var _fn = function() { try { return fn.apply(this, arguments); } catch (e) { console.error('Error occurred in a nunjucks local (an apos* function or one of your own locals)'); if (e.stack) { console.error(e.stack); } else { console.error('Error has no stack trace, when throwing exceptions always use new Error("message")'); } console.error('^^^^^ HEY LOOK UP HERE FIRST\n'); throw e; } }; add = _fn; } self._aposLocals[name] = add; self.app.locals[name] = add; }; // An extra bit of trickery to support arrays in self._locals.groupBy self._groupByArray =function(items, arrayName) { var results = {}; _.each(items, function(item) { _.each(item[arrayName] || [], function(inner) { if (!results[inner]) { results[inner] = []; } results[inner].push(item); }); }); return results; }; };