Source: core/tags.js

Object.assign(pc, (function () {
    var TagsCache = function (key) {
        this._index = { };
        this._key = key || null;
    };

    Object.assign(TagsCache.prototype, {
        addItem: function (item) {
            var tags = item.tags._list;

            for (var i = 0; i < tags.length; i++)
                this.add(tags[i], item);
        },

        removeItem: function (item) {
            var tags = item.tags._list;

            for (var i = 0; i < tags.length; i++)
                this.remove(tags[i], item);
        },

        add: function (tag, item) {
            // already in cache
            if (this._index[tag] && this._index[tag].list.indexOf(item) !== -1)
                return;

            // create index for tag
            if (!this._index[tag]) {
                this._index[tag] = {
                    list: []
                };
                // key indexing is available
                if (this._key)
                    this._index[tag].keys = { };
            }

            // add to index list
            this._index[tag].list.push(item);

            // add to index keys
            if (this._key)
                this._index[tag].keys[item[this._key]] = item;
        },

        remove: function (tag, item) {
            // no index created for that tag
            if (!this._index[tag])
                return;

            // check if item not in cache
            if (this._key) {
                // by key
                if (!this._index[tag].keys[item[this._key]])
                    return;
            }

            // by position in list
            var ind = this._index[tag].indexOf(item);
            if (ind === -1)
                return;

            // remove item from index list
            this._index[tag].list.splice(ind, 1);

            // rmeove item from index keys
            if (this._key)
                delete this._index[tag].keys[item[this._key]];

            // if index empty, remove it
            if (this._index[tag].list.length === 0)
                delete this._index[tag];
        },

        find: function (args) {
            var self = this;
            var index = { };
            var items = [];
            var i, n, t;
            var item, tag, tags, tagsRest, missingIndex;

            var sort = function (a, b) {
                return self._index[a].list.length - self._index[b].list.length;
            };

            for (i = 0; i < args.length; i++) {
                tag = args[i];

                if (tag instanceof Array) {
                    if (tag.length === 0)
                        continue;

                    if (tag.length === 1) {
                        tag = tag[0];
                    } else {
                        // check if all indexes are in present
                        missingIndex = false;
                        for (t = 0; t < tag.length; t++) {
                            if (!this._index[tag[t]]) {
                                missingIndex = true;
                                break;
                            }
                        }
                        if (missingIndex)
                            continue;

                        // sort tags by least number of matches first
                        tags = tag.slice(0).sort(sort);

                        // remainder of tags for `has` checks
                        tagsRest = tags.slice(1);
                        if (tagsRest.length === 1)
                            tagsRest = tagsRest[0];

                        for (n = 0; n < this._index[tags[0]].list.length; n++) {
                            item = this._index[tags[0]].list[n];
                            if ((this._key ? !index[item[this._key]] : (items.indexOf(item) === -1)) && item.tags.has(tagsRest)) {
                                if (this._key)
                                    index[item[this._key]] = true;
                                items.push(item);
                            }
                        }

                        continue;
                    }
                }

                if (tag && typeof tag === 'string' && this._index[tag]) {
                    for (n = 0; n < this._index[tag].list.length; n++) {
                        item = this._index[tag].list[n];

                        if (this._key) {
                            if (!index[item[this._key]]) {
                                index[item[this._key]] = true;
                                items.push(item);
                            }
                        } else if (items.indexOf(item) === -1) {
                            items.push(item);
                        }
                    }
                }
            }

            return items;
        }
    });


    /**
     * @name pc.Tags
     * @class Set of tag names
     * @description Create an instance of a Tags.
     * @param {Object} [parent] Parent object who tags belong to.
     * Note: Tags are used as addition of `pc.Entity` and `pc.Asset` as `tags` field.
     */

    /**
     * @event
     * @name pc.Tags#add
     * @param {String} tag Name of a tag added to a set.
     * @param {Object} parent Parent object who tags belong to.
     */

    /**
     * @event
     * @name pc.Tags#remove
     * @param {String} tag Name of a tag removed from a set.
     * @param {Object} parent Parent object who tags belong to.
     */

    /**
     * @event
     * @name pc.Tags#change
     * @param {Object} [parent] Parent object who tags belong to.
     * @description Fires when tags been added / removed.
     * It will fire once on bulk changes, while `add`/`remove` will fire on each tag operation
     */

    var Tags = function (parent) {
        this._index = { };
        this._list = [];
        this._parent = parent;

        pc.events.attach(this);
    };

    Object.assign(Tags.prototype, {
        /**
         * @function
         * @name pc.Tags#add
         * @description Add a tag, duplicates are ignored. Can be array or comma separated arguments for multiple tags.
         * @param {String} name Name of a tag, or array of tags
         * @returns {Boolean} true if any tag were added
         * @example
         * tags.add('level-1');
         * @example
         * tags.add('ui', 'settings');
         * @example
         * tags.add([ 'level-2', 'mob' ]);
         */
        add: function () {
            var changed = false;
            var tags = this._processArguments(arguments, true);

            if (!tags.length)
                return changed;

            for (var i = 0; i < tags.length; i++) {
                if (this._index[tags[i]])
                    continue;

                changed = true;

                this._index[tags[i]] = true;
                this._list.push(tags[i]);

                this.fire('add', tags[i], this._parent);
            }

            if (changed)
                this.fire('change', this._parent);

            return changed;
        },


        /**
         * @function
         * @name pc.Tags#remove
         * @description Remove tag.
         * @param {String} name Name of a tag or array of tags
         * @returns {Boolean} true if any tag were removed
         * @example
         * tags.remove('level-1');
         * @example
         * tags.remove('ui', 'settings');
         * @example
         * tags.remove([ 'level-2', 'mob' ]);
         */
        remove: function () {
            var changed = false;

            if (!this._list.length)
                return changed;

            var tags = this._processArguments(arguments, true);

            if (!tags.length)
                return changed;

            for (var i = 0; i < tags.length; i++) {
                if (!this._index[tags[i]])
                    continue;

                changed = true;

                delete this._index[tags[i]];
                this._list.splice(this._list.indexOf(tags[i]), 1);

                this.fire('remove', tags[i], this._parent);
            }

            if (changed)
                this.fire('change', this._parent);

            return changed;
        },


        /**
         * @function
         * @name pc.Tags#clear
         * @description Remove all tags.
         * @example
         * tags.clear();
         */
        clear: function () {
            if (!this._list.length)
                return;

            var tags = this._list.slice(0);
            this._list = [];
            this._index = { };

            for (var i = 0; i < tags.length; i++)
                this.fire('remove', tags[i], this._parent);

            this.fire('change', this._parent);
        },


        /**
         * @function
         * @name pc.Tags#has
         * @description Check if tags satisfy filters.
         * Filters can be provided by simple name of tag, as well as by array of tags.
         * When an array is provided it will check if tags contain each tag within the array.
         * If any of comma separated argument is satisfied, then it will return true.
         * Any number of combinations are valid, and order is irrelevant.
         * @param {String} name of tag, or array of names
         * @returns {Boolean} true if filters are satisfied
         * @example
         * tags.has('player'); // player
         * @example
         * tags.has('mob', 'player'); // player OR mob
         * @example
         * tags.has([ 'level-1', 'mob' ]); // monster AND level-1
         * @example
         * tags.has([ 'ui', 'settings' ], [ 'ui', 'levels' ]); // (ui AND settings) OR (ui AND levels)
         */
        has: function () {
            if (!this._list.length)
                return false;

            return this._has(this._processArguments(arguments));
        },


        _has: function (tags) {
            if (!this._list.length || !tags.length)
                return false;

            for (var i = 0; i < tags.length; i++) {
                if (tags[i].length === 1) {
                    // single occurance
                    if (this._index[tags[i][0]])
                        return true;
                } else {
                    // combined occurance
                    var multiple = true;

                    for (var t = 0; t < tags[i].length; t++) {
                        if (this._index[tags[i][t]])
                            continue;

                        multiple = false;
                        break;
                    }

                    if (multiple)
                        return true;
                }
            }

            return false;
        },


        /**
         * @function
         * @name pc.Tags#list
         * @description Returns immutable array of tags
         * @returns {String[]} copy of tags array
         */
        list: function () {
            return this._list.slice(0);
        },


        _processArguments: function (args, flat) {
            var tags = [];
            var tmp = [];

            if (!args || !args.length)
                return tags;

            for (var i = 0; i < args.length; i++) {
                if (args[i] instanceof Array) {
                    if (!flat)
                        tmp = [];

                    for (var t = 0; t < args[i].length; t++) {
                        if (typeof args[i][t] !== 'string')
                            continue;

                        if (flat) {
                            tags.push(args[i][t]);
                        } else {
                            tmp.push(args[i][t]);
                        }
                    }

                    if (!flat && tmp.length)
                        tags.push(tmp);
                } else if (typeof args[i] === 'string') {
                    if (flat) {
                        tags.push(args[i]);
                    } else {
                        tags.push([args[i]]);
                    }
                }
            }

            return tags;
        }
    });

    /**
     * @field
     * @readonly
     * @type Number
     * @name pc.Tags#size
     * @description Number of tags in set
     */
    Object.defineProperty(Tags.prototype, 'size', {
        get: function () {
            return this._list.length;
        }
    });


    return {
        TagsCache: TagsCache,
        Tags: Tags
    };
}()));