/**! * FlexSearch.js * Author and Copyright: Thomas Wilkerling * Licence: Apache-2.0 * Hosted by Nextapps GmbH * https://github.com/nextapps-de/flexsearch */ // COMPILER BLOCK --> import { SUPPORT_ASYNC, SUPPORT_CACHE, SUPPORT_SERIALIZE, SUPPORT_STORE, SUPPORT_TAGS, SUPPORT_WORKER } from "./config.js"; // <-- COMPILER BLOCK import Index from "./index.js"; import { DocumentInterface } from "./type.js"; import Cache, { searchCache } from "./cache.js"; import { create_object, is_array, is_string, is_object, parse_option, get_keys } from "./common.js"; import apply_async from "./async.js"; import { intersect, intersect_union } from "./intersect.js"; import { exportDocument, importDocument } from "./serialize.js"; import WorkerIndex from "./worker/index.js"; /** * @constructor * @implements {DocumentInterface} * @param {Object=} options * @return {Document} */ function Document(options){ if(!(this instanceof Document)) { return new Document(options); } const document = options["document"] || options["doc"] || options; let opt; this.tree = []; this.field = []; this.marker = []; this.register = create_object(); this.key = ((opt = document["key"] || document["id"]) && parse_tree(opt, this.marker)) || "id"; this.fastupdate = parse_option(options["fastupdate"], true); if(SUPPORT_STORE){ this.storetree = (opt = document["store"]) && (opt !== true) && []; this.store = opt && create_object(); } if(SUPPORT_TAGS){ // TODO case-insensitive tags this.tag = ((opt = document["tag"]) && parse_tree(opt, this.marker)); this.tagindex = opt && create_object(); } if(SUPPORT_CACHE){ this.cache = (opt = options["cache"]) && new Cache(opt); // do not apply cache again for the indexes options["cache"] = false; } if(SUPPORT_WORKER){ this.worker = options["worker"]; } if(SUPPORT_ASYNC){ // this switch is used by recall of promise callbacks this.async = false; } /** @export */ this.index = parse_descriptor.call(this, options, document); } export default Document; /** * @this Document */ function parse_descriptor(options, document){ const index = create_object(); let field = document["index"] || document["field"] || document; if(is_string(field)){ field = [field]; } for(let i = 0, key, opt; i < field.length; i++){ key = field[i]; if(!is_string(key)){ opt = key; key = key["field"]; } opt = is_object(opt) ? Object.assign({}, options, opt) : options; if(SUPPORT_WORKER && this.worker){ index[key] = new WorkerIndex(opt); if(!index[key].worker){ this.worker = false; } } if(!this.worker){ index[key] = new Index(opt, this.register); } this.tree[i] = parse_tree(key, this.marker); this.field[i] = key; } if(SUPPORT_STORE && this.storetree){ let store = document["store"]; if(is_string(store)){ store = [store]; } for(let i = 0; i < store.length; i++){ this.storetree[i] = parse_tree(store[i], this.marker); } } return index; } function parse_tree(key, marker){ const tree = key.split(":"); let count = 0; for(let i = 0; i < tree.length; i++){ key = tree[i]; if(key.indexOf("[]") >= 0){ key = key.substring(0, key.length - 2); if(key){ marker[count] = true; } } if(key){ tree[count++] = key; } } if(count < tree.length){ tree.length = count; } return count > 1 ? tree : tree[0]; } // TODO support generic function created from string when tree depth > 1 function parse_simple(obj, tree){ if(is_string(tree)){ obj = obj[tree]; } else{ for(let i = 0; obj && (i < tree.length); i++){ obj = obj[tree[i]]; } } return obj; } // TODO support generic function created from string when tree depth > 1 function store_value(obj, store, tree, pos, key){ obj = obj[key]; // reached target field if(pos === (tree.length - 1)){ // store target value store[key] = obj; } else if(obj){ if(is_array(obj)){ store = store[key] = new Array(obj.length); for(let i = 0; i < obj.length; i++){ // do not increase pos (an array is not a field) store_value(obj, store, tree, pos, i); } } else{ store = store[key] || (store[key] = create_object()); key = tree[++pos]; store_value(obj, store, tree, pos, key); } } } function add_index(obj, tree, marker, pos, index, id, key, _append){ obj = obj[key]; if(obj){ // reached target field if(pos === (tree.length - 1)){ // handle target value if(is_array(obj)){ // append array contents so each entry gets a new scoring context if(marker[pos]){ for(let i = 0; i < obj.length; i++){ index.add(id, obj[i], /* append: */ true, /* skip update: */ true); } return; } // or join array contents and use one scoring context obj = obj.join(" "); } index.add(id, obj, _append, /* skip_update: */ true); } else{ if(is_array(obj)){ for(let i = 0; i < obj.length; i++){ // do not increase index, an array is not a field add_index(obj, tree, marker, pos, index, id, i, _append); } } else{ key = tree[++pos]; add_index(obj, tree, marker, pos, index, id, key, _append); } } } } /** * * @param id * @param content * @param {boolean=} _append * @returns {Document|Promise} */ Document.prototype.add = function(id, content, _append){ if(is_object(id)){ content = id; id = parse_simple(content, this.key); } if(content && (id || (id === 0))){ if(!_append && this.register[id]){ return this.update(id, content); } for(let i = 0, tree, field; i < this.field.length; i++){ field = this.field[i]; tree = this.tree[i]; if(is_string(tree)){ tree = [tree]; } add_index(content, tree, this.marker, 0, this.index[field], id, tree[0], _append); } if(SUPPORT_TAGS && this.tag){ let tag = parse_simple(content, this.tag); let dupes = create_object(); if(is_string(tag)){ tag = [tag]; } for(let i = 0, key, arr; i < tag.length; i++){ key = tag[i]; if(!dupes[key]){ dupes[key] = 1; arr = this.tagindex[key] || (this.tagindex[key] = []); if(!_append || !arr.includes(id)){ arr[arr.length] = id; // add a reference to the register for fast updates if(this.fastupdate){ const tmp = this.register[id] || (this.register[id] = []); tmp[tmp.length] = arr; } } } } } // TODO: how to handle store when appending contents? if(SUPPORT_STORE && this.store && (!_append || !this.store[id])){ let store; if(this.storetree){ store = create_object(); for(let i = 0, tree; i < this.storetree.length; i++){ tree = this.storetree[i]; if(is_string(tree)){ store[tree] = content[tree]; } else{ store_value(content, store, tree, 0, tree[0]); } } } this.store[id] = store || content; } } return this; }; Document.prototype.append = function(id, content){ return this.add(id, content, true); }; Document.prototype.update = function(id, content){ return this.remove(id).add(id, content); }; Document.prototype.remove = function(id){ if(is_object(id)){ id = parse_simple(id, this.key); } if(this.register[id]){ for(let i = 0; i < this.field.length; i++){ // workers does not share the register this.index[this.field[i]].remove(id, !this.worker); if(this.fastupdate){ // when fastupdate was enabled all ids are removed break; } } if(SUPPORT_TAGS && this.tag){ // when fastupdate was enabled all ids are already removed if(!this.fastupdate){ for(let key in this.tagindex){ const tag = this.tagindex[key]; const pos = tag.indexOf(id); if(pos !== -1){ if(tag.length > 1){ tag.splice(pos, 1); } else{ delete this.tagindex[key]; } } } } } if(SUPPORT_STORE && this.store){ delete this.store[id]; } delete this.register[id]; } return this; }; /** * @param {!string|Object} query * @param {number|Object=} limit * @param {Object=} options * @param {Array=} _resolve For internal use only. * @returns {Promise|Array} */ Document.prototype.search = function(query, limit, options, _resolve){ if(!options){ if(!limit && is_object(query)){ options = /** @type {Object} */ (query); query = ""; } else if(is_object(limit)){ options = /** @type {Object} */ (limit); limit = 0; } } let result = [], result_field = []; let pluck, enrich; let field, tag, bool, offset, count = 0; if(options){ if(is_array(options)){ field = options; options = null; } else{ query = options["query"] || query; pluck = options["pluck"]; field = pluck || options["index"] || options["field"] /*|| (is_string(options) && [options])*/; tag = SUPPORT_TAGS && options["tag"]; enrich = SUPPORT_STORE && this.store && options["enrich"]; bool = options["bool"] === "and"; limit = options["limit"] || limit || 100; offset = options["offset"] || 0; if(tag){ if(is_string(tag)){ tag = [tag]; } // when tags is used and no query was set, // then just return the tag indexes if(!query){ for(let i = 0, res; i < tag.length; i++){ res = get_tag.call(this, tag[i], limit, offset, enrich); if(res){ result[result.length] = res; count++; } } return count ? result : []; } } if(is_string(field)){ field = [field]; } } } field || (field = this.field); bool = bool && ((field.length > 1) || (tag && (tag.length > 1))); const promises = !_resolve && (this.worker || this.async) && []; // TODO solve this in one loop below for(let i = 0, res, key, len; i < field.length; i++){ let field_options; key = field[i]; if(!is_string(key)){ field_options = key; key = field_options["field"]; query = field_options["query"] || query; limit = field_options["limit"] || limit; enrich = field_options["enrich"] || enrich; } if(promises){ promises[i] = this.index[key].searchAsync(query, limit, field_options || options); // just collect and continue continue; } else if(_resolve){ res = _resolve[i]; } else{ // inherit options also when search? it is just for laziness, Object.assign() has a cost res = this.index[key].search(query, limit, field_options || options); } len = res && res.length; if(tag && len){ const arr = []; let count = 0; if(bool){ // prepare for intersection arr[0] = [res]; } for(let y = 0, key, res; y < tag.length; y++){ key = tag[y]; res = this.tagindex[key]; len = res && res.length; if(len){ count++; arr[arr.length] = bool ? [res] : res; } } if(count){ if(bool){ res = intersect(arr, limit || 100, offset || 0); } else{ res = intersect_union(res, arr); } len = res.length; } } if(len){ result_field[count] = key; result[count++] = res; } else if(bool){ return []; } } if(promises){ const self = this; // anyone knows a better workaround of optionally having async promises? // the promise.all() needs to be wrapped into additional promise, // otherwise the recursive callback wouldn't run before return return new Promise(function(resolve){ Promise.all(promises).then(function(result){ resolve(self.search(query, limit, options, result)); }); }); } if(!count){ // fast path "not found" return []; } if(pluck && (!enrich || !this.store)){ // fast path optimization return result[0]; } for(let i = 0, res; i < result_field.length; i++){ res = result[i]; if(res.length){ if(enrich){ res = apply_enrich.call(this, res); } } if(pluck){ return res; } result[i] = { "field": result_field[i], "result": res }; } return result; }; /** * @this Document */ function get_tag(key, limit, offset, enrich){ let res = this.tagindex[key]; let len = res && (res.length - offset); if(len && (len > 0)){ if((len > limit) || offset){ res = res.slice(offset, offset + limit); } if(enrich){ res = apply_enrich.call(this, res); } return { "tag": key, "result": res }; } } /** * @this Document */ function apply_enrich(res){ const arr = new Array(res.length); for(let x = 0, id; x < res.length; x++){ id = res[x]; arr[x] = { "id": id, "doc": this.store[id] }; } return arr; } Document.prototype.contain = function(id){ return !!this.register[id]; }; if(SUPPORT_STORE){ Document.prototype.get = function(id){ return this.store[id]; }; Document.prototype.set = function(id, data){ this.store[id] = data; return this; }; } if(SUPPORT_CACHE){ Document.prototype.searchCache = searchCache; } if(SUPPORT_SERIALIZE){ Document.prototype.export = exportDocument; Document.prototype.import = importDocument; } if(SUPPORT_ASYNC){ apply_async(Document.prototype); }