apostrophe
Version:
Apostrophe is a user-friendly content management system. You'll need more than this core module. See apostrophenow.org to get started.
353 lines (322 loc) • 13.2 kB
JavaScript
var async = require('async');
var _ = require('lodash');
var extend = require('extend');
/**
* tags
* @augments Augments the apos object with methods, routes and
* properties supporting the management of tags by Apostrophe.
* @see static
*/
module.exports = {
construct: function(self) {
// Returns all tags used on pages, snippets, videos, files, etc.
// Sanitizes options. Use `options.prefix` for autocomplete
// with prefix only (fastest) or `options.contains` for
// autocomplete matching at any point in the tag. Use
// `options.tags` to limit results to an array of tag names of
// interest. You may not combine these options. The `options`
// argument may be skipped.
//
// Tags are searched for in the pages, files, videos, and
// allowedTags collections.
//
// Additional collections can be added via the `addTaggables`
// option when configuring Apostrophe.
//
// All of this probably suggests we should have normalized tags.
self.getTags = function(options, callback) {
if (!callback) {
callback = options;
options = {};
}
var prefix = self.sanitizeString(options.prefix);
var contains = self.sanitizeString(options.contains);
var results = [];
var resultsMap = {};
return async.eachSeries(self.getTaggables(), function(taggable, callback) {
var criteria = {};
if (prefix.length) {
criteria.tags = new RegExp('^' + RegExp.quote(self.filterTag(prefix)));
} else if (contains.length) {
criteria.tags = new RegExp(RegExp.quote(self.filterTag(contains)));
} else if (options.tags) {
criteria.tags = { $in: options.tags };
}
return taggable.distinct("tags", criteria, function(err, tags) {
if (err) {
return callback(err);
}
// "Why do we have to apply the criteria twice?"
// The query above just limits the documents whose distinct tags are
// returned. If one of the documents that has at least one tag
// starting with "m" also has other tags not starting with "m," we
// still have them at this point. The query is still worthwhile
// because it cuts back the number of documents examined.
if (prefix.length || contains.length) {
tags = _.filter(tags, function(tag) {
return tag.toString().match(criteria.tags);
});
} else if (options.tags) {
tags = _.intersection(options.tags, tags);
}
_.each(tags, function(tag) {
if ((tag === null) || (tag === undefined)) {
// "distinct" will return undefined for the records
// that didn't have the property at all and null for
// the records in which the property was empty.
// Because that's useful, I guess, if you're expecting
// it, which I wasn't
return;
}
if (!_.has(resultsMap, tag)) {
resultsMap[tag] = true;
results.push(tag);
}
});
return callback(null);
});
}, function(err) {
if (err) {
return callback(err);
}
results.sort();
return callback(null, results);
});
};
// Remove a tag from all taggable collections.
self.deleteTag = function(tag, callback) {
var criteria = { tags: { $in: [ tag ] } };
return async.eachSeries(self.getTaggables(), function(taggable, callback) {
return taggable.update(criteria, { $pull: { tags: tag } }, { multi: true }, callback);
}, callback);
};
// Rename a tag across all taggable collections. This serves
// as a merge operation too; double-tagging will not occur if you
// rename a tag to match another tag.
self.renameTag = function(tag, newTag, callback) {
var criteria = { tags: { $in: [ tag ] } };
return async.eachSeries(self.getTaggables(), function(taggable, callback) {
return async.series({
addToSet: function(callback) {
return taggable.update(criteria, { $addToSet: { tags: newTag } }, { multi: true }, callback);
},
pull: function(callback) {
return taggable.update(criteria, { $pull: { tags: tag } }, { multi: true }, callback);
}
}, callback);
}, callback);
};
// You DON'T have to do this to add a tag on the fly when
// inserting a snippet, page, etc. This method is used by the
// tag admin interface to add tags without reference to a
// particular object. It inserts an object into the allowedTags
// collection, which exists just to house these.
//
// When the lockTags option is in effect throughout Apostrophe,
// this becomes only way to add a new tag to the system. That
// rule is enforced by the sanitizer for tag fields in schemas,
// and by the frontend UI of course.
self.addTag = function(tag, callback) {
self.allowedTags.update({ tags: [ tag ] }, { tags: [ tag ] }, { upsert: true }, callback);
};
// Accept tags as a comma-separated string and sanitize them,
// returning an array of zero or more nonempty strings. Although our
// browser-side code submits tags as arrays, this is still useful for
// import operations.
//
// If a filterTag function is passed as an option when initializing
// Apostrophe, then all tags are passed through it (as individual
// strings, one per call) and the return value is used instead. This
// is useful if you wish to force all-uppercase or all-lowercase
// tags for a particular project. Be sure to update any existing
// database entries for tags before making such a change.
self.tagsToArray = function(tags) {
if (typeof(tags) === 'number') {
tags += '';
}
if (typeof(tags) !== 'string') {
return [];
}
tags += '';
tags = tags.split(/,\s*/);
// split returns an array of one empty string for an empty source string ):
tags = _.filter(tags, function(tag) { return tag.length > 0; });
// Make them all strings
tags = _.map(tags, function(tag) {
return tag.toString();
});
if (self.filterTag) {
tags = _.map(tags, self.filterTag);
}
return tags;
};
// Returns an array of collections which should be considered taggable
// by the `apos.getTags` method, etc. This list can be expanded by
// setting the `addTaggables` option when initializing the Apostrophe module.
self.getTaggables = function() {
if (!self._taggables) {
self._taggables = [ self.pages, self.files, self.videos, self.allowedTags ];
if (self.options.addTaggables) {
self._taggables = self._taggables.concat(self.options.addTaggables);
}
}
return self._taggables;
};
},
init: function(self) {
// Default is now to coerce tags to lowercase
self.filterTag = self.options.filterTag || function(tag) {
tag = tag.trim();
return tag.toLowerCase();
};
// Fetch all tags. Accepts options supported by `apos.getTags`
// as query parameters. Useful for creating tag admin tools.
// Always responds with a JSON object. If the `status` property
// is `ok` then the `tags` property will contain the retrieved tags.
self.app.get('/apos/tags', function(req, res) {
return self.getTags(req.query, function(err, tags) {
if (err) {
console.error(err);
return res.send({ status: 'error' });
}
return res.send({ status: 'ok', tags: tags });
});
});
// Delete a tag. The tag should be the `tag` POST parameter.
// Always responds with a JSON object. If the `status` property
// is `ok` then the tag was deleted.
self.app.post('/apos/delete-tag', function(req, res) {
if (!(req.user && req.user.permissions.admin)) {
res.statusCode = 403;
return res.send('forbidden');
}
var tag = self.sanitizeString(req.body.tag);
if (!tag.length) {
return res.send({ status: 'error' });
}
return self.deleteTag(tag, function(err) {
if (err) {
console.error(err);
return res.send({ status: 'error' });
}
return res.send({ status: 'ok' });
});
});
// Rename a tag. The old tag name should be the `tag` POST parameter,
// and the new tag name should be the `newTag parameter.
//
// Always responds with a JSON object. If the `status` property
// is `ok` then the tag was renamed.
self.app.post('/apos/rename-tag', function(req, res) {
if (!(req.user && req.user.permissions.admin)) {
res.statusCode = 403;
return res.send('forbidden');
}
// Don't filter the old one, let them fix things
// that used to not respect the current filter
var tag = self.sanitizeString(req.body.tag);
if (!tag.length) {
return res.send({ status: 'error' });
}
var newTag = self.filterTag(self.sanitizeString(req.body.newTag));
if (!newTag.length) {
return res.send({ status: 'error' });
}
return self.renameTag(tag, newTag, function(err) {
if (err) {
console.error(err);
return res.send({ status: 'error' });
}
return res.send({ status: 'ok', oldTag: tag, newTag: newTag });
});
});
// Add a tag.
//
// Always responds with a JSON object. If the `status` property
// is `ok` then the tag was added.
self.app.post('/apos/add-tag', function(req, res) {
if (!(req.user && req.user.permissions.admin)) {
res.statusCode = 403;
return res.send('forbidden');
}
var tag = self.filterTag(self.sanitizeString(req.body.tag));
if (!tag.length) {
console.log('no tag in body');
return res.send({ status: 'error' });
}
return self.addTag(tag, function(err) {
if (err) {
console.error(err);
return res.send({ status: 'error' });
}
return res.send({ status: 'ok' });
});
});
// Provides tag autocomplete in the format expected by jQuery selective.
// If the `values` parameter is present, it is expected to be an array
// of tags already selected, and the response simply pushes that array
// back to the browser in JSON format with the tag name as both the
// `value` and `label` properties as jQuery selective expects.
//
// If the `term` parameter is present, the response is a JSON array of
// tags beginning with that string; again, each is represented as an
// object with `value` and `label` properties both set to the name of
// the tag.
self.app.all('/apos/autocomplete-tag', function(req, res) {
// Special case: selective is asking for complete objects with
// label and value properties for existing values. For tags these
// are one and the same so just do a map call
var data = (req.method === 'POST') ? req.body : req.query;
if (data.values) {
return res.send(_.map(data.values, function(value) {
return { value: value, label: value };
}));
}
return self.getTags({ contains: data.term }, function(err, tags) {
if (err) {
return self.fail(req, res);
}
tags = _.map(tags, function(tag) {
return { value: tag, label: tag };
});
if (tags.length > 100) {
tags = tags.slice(0, 100);
}
return res.send(tags);
});
});
// Prior to 2014-08-05 Apostrophe had no filterTag function by
// default. Beginning with 2014-08-05 lowercase is the default.
// This migration runs the current filterTag function on all
// widely used tag properties. If you have others in schema fields
// in an existing site from prior to this point, you should
// convert those to lowercase yourself.
self.addMigration('filterTags', function filterTags(callback) {
var needed = false;
return async.eachSeries(self.getTaggables(), function(taggable, callback) {
return self.forEachDocumentInCollection(taggable, {}, function(doc, callback) {
var changed = false;
var properties = [ 'tags', 'notTags', 'withTags' ];
_.each(properties, function(property) {
if (!Array.isArray(doc[property])) {
return;
}
var tags = _.map(doc[property], self.filterTag);
if (JSON.stringify(doc[property]) !== JSON.stringify(tags)) {
changed = true;
doc[property] = tags;
}
});
if (!changed) {
return callback(null);
}
if (!needed) {
needed = true;
console.log('Filtering tags');
}
return taggable.update({ _id: doc._id }, doc, callback);
}, callback);
}, callback);
});
}
};