UNPKG

@lykmapipo/mongoose-taggable

Version:

mongoose plugin to add tags and taggable behaviour

452 lines (433 loc) 12.9 kB
import _ from 'lodash'; import traverse from 'traverse'; import moment from 'moment'; import stopwords from 'stopwords-iso'; import { getString, getStrings } from '@lykmapipo/env'; import { eachPath, isObjectId, isMap, isInstance, } from '@lykmapipo/mongoose-common'; /* constants */ const defaultTaggableOptions = { path: 'tags', blacklist: [], index: true, duplicate: false, searchable: true, exportable: false, hide: true, fresh: false, hook: 'validate', }; /** * @function words * @name words * @description extract words from a phrase * @param {string} phrase a phrase to extract words from * @returns {string[]} array of words from a phrase * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.1.0 * @private * @example * words('Hello World') * //=> ['Hello', 'World']; */ function words(phrase) { const $words = phrase ? String(phrase).match(/\w+/g) : []; return $words; } /** * @function removeStopwords * @name removeStopwords * @description remove stop words from phrases using all languages stopwords * @param {...string} phrases phrases to remove stopwords from * @returns {string[]} set of words from a phrases without stopwords * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.1.0 * @private * @example * removeStopwords('Mongo and Node') * //=> ['Mongo', 'Node'] */ function removeStopwords(...phrases) { const $phrases = [...phrases].join(' '); const $words = words($phrases); const $stopwords = _.flattenDeep(_.values(stopwords)); const $keywords = _.difference($words, $stopwords); return $keywords; } /** * @function normalizeTags * @name normalizeTags * @description clear, compact and lowercase tags * @param {...string} tags set of tags * @returns {string[]} set of normalized tags * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.1.0 * @private * @example * normalizeTags('Node and Mongo') * //=> ['node', 'mongo'] */ function normalizeTags(...tags) { // collect tags let $tags = [...tags]; // remove falsey tags $tags = _.compact($tags); // convert tags to lowercase $tags = _.map($tags, _.toLower); // convert tags to discrete words $tags = _.flattenDeep(_.map($tags, words)); // ensure unique tags $tags = _.uniq($tags); // return normalized tags return $tags; } /** * @function removeBlacklist * @name removeBlacklist * @description remove blacklist words from a phrase * @param {string | string[]} phrase valid phrase * @param {...string} blacklist words to remove from a phrase * @returns {string[]} set of words from a phrase without blacklist words * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.2.0 * @version 0.1.0 * @private * @example * removeBlacklist('Mongo and Node', 'Node') * //=> ['Mongo'] */ function removeBlacklist(phrase, ...blacklist) { const $blacklist = normalizeTags(...blacklist); const $phrase = normalizeTags(phrase); const $whitelist = _.difference($phrase, $blacklist); return $whitelist; } /** * @function tagFromAnyField * @name tagFromAnyField * @description derive tags from other schematype * @param {string | number | Array} value valid value * @param {Function} [extract] field tag extractor * @returns {string[]} set of tags * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.2.0 * @private * @example * tagFromAnyField(<val>); * //=> ['js', 'node'] * * tagFromAnyField(<val>, <extractor>); * //=> ['js', 'node'] */ function tagFromAnyField(value, extract) { let $tags = []; const isValidField = value && (_.isString(value) || _.isNumber(value) || _.isArray(value)); if (isValidField) { let $value = _.clone(value); $value = _.isFunction(extract) ? extract($value) : $value; $tags = [...$tags].concat($value); } return $tags; } /** * @function tagFromDateField * @name tagFromDateField * @description derive tags from date schematype * @param {string | number} value valid date value * @param {Function} [extract] field tag extractor * @returns {string[]} set of tags * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.3.0 * @version 0.1.0 * @private * @example * tagFromDateField('2019-01-01'); * //=> ['2019', 'January', 'Tuesday'] * * tagFromDateField('2019-01-01', <extractor>); * //=> ['2019', 'January', 'Tuesday'] */ function tagFromDateField(value, extract) { let $tags = []; const DATE_FORMAT = getString('TAGGABLE_DATE_FORMAT', 'dddd MMMM YYYY'); const isValidField = value && _.isDate(value); if (isValidField) { let $value = moment(value).clone().format(DATE_FORMAT); $value = _.isFunction(extract) ? extract($value) : $value; $tags = [...$tags].concat($value); } return $tags; } /** * @function tagFromMapField * @name tagFromMapField * @description derive tags from map schematype * @param {object} mapVal valid instance of MongooseMap * @param {Function} [extract] field tag extractor * @returns {string[]} set of tags * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.1.0 * @private * @example * tagFromMapField(<val>); * //=> ['js', 'node'] * * tagFromMapField(<val>, <extractor>); * //=> ['js', 'node'] */ function tagFromMapField(mapVal, extract) { let $tags = []; if (mapVal && isMap(mapVal)) { const $mapVal = _.merge({}, mapVal.toJSON()); traverse($mapVal).forEach(function tagMapVal(value) { if (_.isString(value)) { let $value = _.clone(value); $value = _.isFunction(extract) ? extract($value) : $value; $tags = [...$tags].concat($value); } }); } return $tags; } /** * @function tagFromInstanceField * @name tagFromInstanceField * @description derive tags from ref which are model instance with taggable * behaviour * @param {object} [instance] valid instance of mongoose model which is taggable * @param {Function} [extract] field tag extractor * @returns {string[]} set of tags * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.1.0 * @private * @example * tagFromInstanceField(<instance>); * //=> ['js'] * * tagFromInstanceField(<instance>, <extractor>); * //=> ['js'] */ function tagFromInstanceField(instance, extract) { let $tags = []; if (instance && isInstance(instance) && _.isFunction(instance.tag)) { instance.tag(); let $value = [].concat(instance.tags); $value = _.isFunction(extract) ? extract($value) : $value; $tags = [...$tags].concat($value); } return $tags; } /** * @function tagFromFields * @name tagFromFields * @description derive tags from instance fields * @param {object} instance valid instance of mongoose model which is taggable * @param {object[]} taggables valid taggable paths * @returns {string[]} set of tags * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.2.0 * @private * @example * tagFromFields(user, taggables, 'tags'); * //=> ['js', 'node'] */ function tagFromFields(instance, taggables) { // tag set let $tags = []; // collect tags from taggable fields _.forEach(taggables, function tagFromField(extract, pathName) { // obtain field value const value = _.get(instance, pathName); // ignore objectid's if (isObjectId(value)) { return; } // tag from map field $tags = [...$tags, ...tagFromMapField(value, extract)]; // tag from model instance $tags = [...$tags, ...tagFromInstanceField(value, extract)]; // tag from date field $tags = [...$tags, ...tagFromDateField(value, extract)]; // tag from primitive field $tags = [...$tags, ...tagFromAnyField(value, extract)]; // TODO handle array of subdoc }); // return tags return $tags; } /** * @function collectTaggables * @name collectTaggables * @description Recursively collect taggagle path * @param {object} schema valid mongose schema instance * @param {string} tagsPath valid tags path, default to `tags` * @returns {object} hash of all schema taggable paths * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.1.0 * @private * @example * collectTaggables(schema, 'tags'); * //=> { ... } */ function collectTaggables(schema, tagsPath) { // taggable map const taggables = {}; // collect taggable schema paths eachPath(schema, function collectTaggablePath(pathName, schemaType) { // check if path is taggable const isTaggable = schemaType.options && schemaType.options.taggable; // if taggable collect if (isTaggable && pathName !== tagsPath) { // obtain taggable options const optns = _.get(schemaType.options, 'taggable'); // collect taggable schema path taggables[pathName] = _.isFunction(optns) ? optns : words; } }); // return collect taggable schema paths return taggables; } /** * @function taggable * @name taggable * @description mongoose plugin to add tags and taggable behaviour * @param {object} schema valid mongoose schema * @param {object} [optns] plugin options * @param {string} [optns.path=tags] schema path where tags will be stored. * @param {string} [optns.blacklist=[]] list of words to remove from tags. * @param {string} [optns.fresh=false] whether to recompute fresh tags. * @param {string} [optns.hook=validate] when to run tagging hook. * @param {boolean | string} [optns.index=true] whether to index tags. * @param {boolean} [optns.searchable=true] whether to allow search on tags. * @param {boolean} [optns.hide=true] whether to hide tags on toJSON. * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.2.0 * @public * @example * * import taggable from '@lykmapipo/mongoose-taggable'; * const UserSchema = new Schema({ name: { type: String, taggable:true } }); * UserSchema.plugin(taggable); */ function taggable(schema, optns) { // ensure options const BLACKLIST = getStrings('TAGGABLE_BLACKLIST', []); const options = _.merge({}, defaultTaggableOptions, optns); const blacklist = [...BLACKLIST, ...options.blacklist]; // add tags schema paths const { path, index, duplicate, searchable, exportable, hide, fresh, hook } = options; const type = [String]; // eslint-disable-next-line no-param-reassign schema.add({ [path]: { type, index, duplicate, searchable, exportable, hide, default: undefined, }, }); // collect taggable schema paths const taggables = collectTaggables(schema, path); // eslint-disable-next-line no-param-reassign schema.statics.TAGGABLE_FIELDS = taggables; /** * @function tag * @name tag * @description add tags to a model instance * @param {...string} [tags] set of tags to add to model instance * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.1.0 * @instance * @example * const user = new User(); * user.tag('js ninja', 'nodejs', 'expressjs'); */ // eslint-disable-next-line no-param-reassign schema.methods.tag = function tag(...tags) { // reference const instance = this; // obtain existing tags const oldTags = this[path] ? [...this[path]] : []; let $tags = fresh ? [] : oldTags; // merge provided tags $tags = [...$tags, ...tags]; // collect tags from taggable fields $tags = [...$tags, ...tagFromFields(instance, taggables)]; // remove blacklist $tags = removeBlacklist($tags, ...blacklist); // remove stopwords $tags = removeStopwords(...$tags); // set and update tags this[path] = $tags; }; /** * @function untag * @name untag * @description remove tags from a model instance * @param {...string} tags set of tags to remove from model instance * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.1.0 * @instance * @example * const user = new User(); * user.untag('js', 'angular'); */ // eslint-disable-next-line no-param-reassign schema.methods.untag = function untag(...tags) { // normalize provided tags let $tags = normalizeTags(...tags); // remove from tags $tags = _.difference(this[path], $tags); // set and update tags this[path] = $tags; }; /** * @function preValidate * @name preValidate * @description generate tags from taggable paths and set into tags path * @author lally elias <lallyelias87@mail.com> * @license MIT * @since 0.1.0 * @version 0.1.0 * @private */ // eslint-disable-next-line no-param-reassign schema.pre(hook, function preValidate() { this.tag(); }); } /* exports taggable plugin */ export default taggable;