587 lines
18 KiB
JavaScript
587 lines
18 KiB
JavaScript
/*****************************************************************************
|
|
* Open MCT, Copyright (c) 2014-2024, United States Government
|
|
* as represented by the Administrator of the National Aeronautics and Space
|
|
* Administration. All rights reserved.
|
|
*
|
|
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
* http://www.apache.org/licenses/LICENSE-2.0.
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
* License for the specific language governing permissions and limitations
|
|
* under the License.
|
|
*
|
|
* Open MCT includes source code licensed under additional open source
|
|
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
|
* this source code distribution or the Licensing information page available
|
|
* at runtime from the About dialog for additional information.
|
|
*****************************************************************************/
|
|
|
|
import { v4 as uuid } from 'uuid';
|
|
|
|
class InMemorySearchProvider {
|
|
/**
|
|
* A search service which searches through domain objects in
|
|
* the filetree without using external search implementations.
|
|
*
|
|
* @constructor
|
|
* @param {Object} openmct
|
|
*/
|
|
constructor(openmct) {
|
|
/**
|
|
* Maximum number of concurrent index requests to allow.
|
|
*/
|
|
this.MAX_CONCURRENT_REQUESTS = 100;
|
|
/**
|
|
* If max results is not specified in query, use this as default.
|
|
*/
|
|
this.DEFAULT_MAX_RESULTS = 100;
|
|
this.openmct = openmct;
|
|
this.indexedIds = {};
|
|
this.indexedCompositions = {};
|
|
this.idsToIndex = [];
|
|
this.pendingIndex = {};
|
|
this.pendingRequests = 0;
|
|
this.worker = null;
|
|
|
|
/**
|
|
* If we don't have SharedWorkers available (e.g., iOS)
|
|
*/
|
|
this.localIndexedDomainObjects = {};
|
|
this.localIndexedAnnotationsByDomainObject = {};
|
|
this.localIndexedAnnotationsByTag = {};
|
|
|
|
this.pendingQueries = {};
|
|
this.onWorkerMessage = this.onWorkerMessage.bind(this);
|
|
this.onWorkerMessageError = this.onWorkerMessageError.bind(this);
|
|
this.localSearchForObjects = this.localSearchForObjects.bind(this);
|
|
this.localSearchForAnnotations = this.localSearchForAnnotations.bind(this);
|
|
this.localSearchForTags = this.localSearchForTags.bind(this);
|
|
this.onAnnotationCreation = this.onAnnotationCreation.bind(this);
|
|
this.onCompositionAdded = this.onCompositionAdded.bind(this);
|
|
this.onCompositionRemoved = this.onCompositionRemoved.bind(this);
|
|
this.onerror = this.onWorkerError.bind(this);
|
|
this.startIndexing = this.startIndexing.bind(this);
|
|
|
|
this.openmct.on('start', this.startIndexing);
|
|
this.openmct.on('destroy', () => {
|
|
if (this.worker && this.worker.port) {
|
|
this.worker.onerror = null;
|
|
this.worker.port.onmessage = null;
|
|
this.worker.port.onmessageerror = null;
|
|
this.worker.port.close();
|
|
}
|
|
|
|
Object.keys(this.indexedCompositions).forEach((keyString) => {
|
|
const composition = this.indexedCompositions[keyString];
|
|
composition.off('add', this.onCompositionAdded);
|
|
composition.off('remove', this.onCompositionRemoved);
|
|
});
|
|
|
|
this.destroyObservers(this.indexedIds);
|
|
this.destroyObservers(this.indexedCompositions);
|
|
});
|
|
}
|
|
|
|
startIndexing() {
|
|
const rootObject = this.openmct.objects.rootProvider.rootObject;
|
|
|
|
this.searchTypes = this.openmct.objects.SEARCH_TYPES;
|
|
|
|
this.supportedSearchTypes = [
|
|
this.searchTypes.OBJECTS,
|
|
this.searchTypes.ANNOTATIONS,
|
|
this.searchTypes.TAGS
|
|
];
|
|
|
|
this.scheduleForIndexing(rootObject.identifier);
|
|
|
|
this.indexAnnotations();
|
|
|
|
if (typeof SharedWorker !== 'undefined') {
|
|
this.worker = this.startSharedWorker();
|
|
} else {
|
|
// we must be on iOS
|
|
}
|
|
|
|
this.openmct.annotation.on('annotationCreated', this.onAnnotationCreation);
|
|
}
|
|
|
|
indexAnnotations() {
|
|
const theInMemorySearchProvider = this;
|
|
Object.values(this.openmct.objects.providers).forEach((objectProvider) => {
|
|
if (objectProvider.getAllObjects) {
|
|
const allObjects = objectProvider.getAllObjects();
|
|
if (allObjects) {
|
|
Object.values(allObjects).forEach((domainObject) => {
|
|
if (domainObject.type === 'annotation') {
|
|
theInMemorySearchProvider.scheduleForIndexing(domainObject.identifier);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
getIntermediateResponse() {
|
|
let intermediateResponse = {};
|
|
intermediateResponse.promise = new Promise(function (resolve, reject) {
|
|
intermediateResponse.resolve = resolve;
|
|
intermediateResponse.reject = reject;
|
|
});
|
|
|
|
return intermediateResponse;
|
|
}
|
|
|
|
search(query, searchType) {
|
|
const queryId = uuid();
|
|
const pendingQuery = this.getIntermediateResponse();
|
|
this.pendingQueries[queryId] = pendingQuery;
|
|
const searchOptions = {
|
|
queryId,
|
|
searchType,
|
|
query,
|
|
maxResults: this.DEFAULT_MAX_RESULTS
|
|
};
|
|
|
|
if (this.worker) {
|
|
this.#dispatchSearchToWorker(searchOptions);
|
|
} else {
|
|
this.#localQueryFallBack(searchOptions);
|
|
}
|
|
|
|
return pendingQuery.promise;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
#localQueryFallBack({ queryId, searchType, query, maxResults }) {
|
|
if (searchType === this.searchTypes.OBJECTS) {
|
|
return this.localSearchForObjects(queryId, query, maxResults);
|
|
} else if (searchType === this.searchTypes.ANNOTATIONS) {
|
|
return this.localSearchForAnnotations(queryId, query, maxResults);
|
|
} else if (searchType === this.searchTypes.TAGS) {
|
|
return this.localSearchForTags(queryId, query, maxResults);
|
|
} else {
|
|
throw new Error(`🤷♂️ Unknown search type passed: ${searchType}`);
|
|
}
|
|
}
|
|
|
|
supportsSearchType(searchType) {
|
|
return this.supportedSearchTypes.includes(searchType);
|
|
}
|
|
|
|
/**
|
|
* Handle messages from the worker.
|
|
* @private
|
|
*/
|
|
async onWorkerMessage(event) {
|
|
const pendingQuery = this.pendingQueries[event.data.queryId];
|
|
const modelResults = {
|
|
total: event.data.total
|
|
};
|
|
modelResults.hits = await Promise.all(
|
|
event.data.results.map(async (hit) => {
|
|
if (hit && hit.keyString) {
|
|
const identifier = this.openmct.objects.parseKeyString(hit.keyString);
|
|
const domainObject = await this.openmct.objects.get(identifier);
|
|
|
|
return domainObject;
|
|
}
|
|
})
|
|
);
|
|
|
|
pendingQuery.resolve(modelResults);
|
|
delete this.pendingQueries[event.data.queryId];
|
|
}
|
|
|
|
/**
|
|
* Handle error messages from the worker.
|
|
* @private
|
|
*/
|
|
onWorkerMessageError(event) {
|
|
console.error('⚙️ Error message from InMemorySearch worker ⚙️', event);
|
|
}
|
|
|
|
/**
|
|
* Handle errors from the worker.
|
|
* @private
|
|
*/
|
|
onWorkerError(event) {
|
|
console.error('⚙️ Error with InMemorySearch worker ⚙️', event);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
startSharedWorker() {
|
|
// eslint-disable-next-line no-undef
|
|
const sharedWorkerURL = `${this.openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}inMemorySearchWorker.js`;
|
|
|
|
const sharedWorker = new SharedWorker(sharedWorkerURL, 'InMemorySearch Shared Worker');
|
|
sharedWorker.onerror = this.onWorkerError;
|
|
sharedWorker.port.onmessage = this.onWorkerMessage;
|
|
sharedWorker.port.onmessageerror = this.onWorkerMessageError;
|
|
sharedWorker.port.start();
|
|
|
|
return sharedWorker;
|
|
}
|
|
|
|
/**
|
|
* Schedule an id to be indexed at a later date. If there are less
|
|
* pending requests than the maximum allowed, this will kick off an indexing request.
|
|
* This is done only when indexing first begins and we need to index a lot of objects.
|
|
*
|
|
* @private
|
|
* @param {identifier} id to be indexed.
|
|
*/
|
|
scheduleForIndexing(identifier) {
|
|
const keyString = this.openmct.objects.makeKeyString(identifier);
|
|
const objectProvider = this.openmct.objects.getProvider(identifier);
|
|
|
|
if (objectProvider === undefined || objectProvider.search === undefined) {
|
|
if (!this.indexedIds[keyString] && !this.pendingIndex[keyString]) {
|
|
this.pendingIndex[keyString] = true;
|
|
this.idsToIndex.push(keyString);
|
|
}
|
|
}
|
|
|
|
this.keepIndexing();
|
|
}
|
|
|
|
/**
|
|
* If there are less pending requests than concurrent requests, keep
|
|
* firing requests.
|
|
*
|
|
* @private
|
|
*/
|
|
keepIndexing() {
|
|
while (this.pendingRequests < this.MAX_CONCURRENT_REQUESTS && this.idsToIndex.length) {
|
|
this.beginIndexRequest();
|
|
}
|
|
}
|
|
|
|
onAnnotationCreation(annotationObject) {
|
|
const objectProvider = this.openmct.objects.getProvider(annotationObject.identifier);
|
|
if (objectProvider === undefined || objectProvider.search === undefined) {
|
|
const provider = this;
|
|
provider.index(annotationObject);
|
|
}
|
|
}
|
|
|
|
onNameMutation(domainObject, name) {
|
|
const provider = this;
|
|
|
|
domainObject.name = name;
|
|
provider.index(domainObject);
|
|
}
|
|
|
|
onCompositionAdded(newDomainObjectToIndex) {
|
|
const provider = this;
|
|
// The object comes in as a mutable domain object, which has functions,
|
|
// which the index function cannot handle as it will eventually be serialized
|
|
// using structuredClone. Thus we're using JSON.parse/JSON.stringify to discard
|
|
// those functions.
|
|
const nonMutableDomainObject = JSON.parse(JSON.stringify(newDomainObjectToIndex));
|
|
|
|
const objectProvider = this.openmct.objects.getProvider(nonMutableDomainObject.identifier);
|
|
if (objectProvider === undefined || objectProvider.search === undefined) {
|
|
provider.index(nonMutableDomainObject);
|
|
}
|
|
}
|
|
|
|
onCompositionRemoved(domainObjectToRemoveIdentifier) {
|
|
const keyString = this.openmct.objects.makeKeyString(domainObjectToRemoveIdentifier);
|
|
if (this.indexedIds[keyString]) {
|
|
// we store the unobserve function in the indexedId map
|
|
this.indexedIds[keyString]();
|
|
delete this.indexedIds[keyString];
|
|
}
|
|
|
|
const composition = this.indexedCompositions[keyString];
|
|
if (composition) {
|
|
composition.off('add', this.onCompositionAdded);
|
|
composition.off('remove', this.onCompositionRemoved);
|
|
delete this.indexedCompositions[keyString];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pass a domainObject to the worker to be indexed.
|
|
* If the object has composition, schedule those ids for later indexing.
|
|
* Watch for object changes and re-index object and children if so
|
|
*
|
|
* @private
|
|
* @param domainObject a domainObject
|
|
*/
|
|
async index(domainObject) {
|
|
const provider = this;
|
|
const keyString = this.openmct.objects.makeKeyString(domainObject.identifier);
|
|
const composition = this.openmct.composition.get(domainObject);
|
|
|
|
if (!this.indexedIds[keyString]) {
|
|
this.indexedIds[keyString] = this.openmct.objects.observe(
|
|
domainObject,
|
|
'name',
|
|
this.onNameMutation.bind(this, domainObject)
|
|
);
|
|
if (composition) {
|
|
composition.on('add', this.onCompositionAdded);
|
|
composition.on('remove', this.onCompositionRemoved);
|
|
this.indexedCompositions[keyString] = composition;
|
|
}
|
|
}
|
|
|
|
if (keyString !== 'ROOT') {
|
|
if (this.worker) {
|
|
this.worker.port.postMessage({
|
|
request: 'index',
|
|
model: domainObject,
|
|
keyString
|
|
});
|
|
} else {
|
|
this.localIndexItem(keyString, domainObject);
|
|
}
|
|
}
|
|
|
|
if (composition !== undefined) {
|
|
const children = await composition.load();
|
|
|
|
children.forEach((child) => provider.scheduleForIndexing(child.identifier));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pulls an id from the indexing queue, loads it from the model service,
|
|
* and indexes it. Upon completion, tells the provider to keep
|
|
* indexing.
|
|
*
|
|
* @private
|
|
*/
|
|
async beginIndexRequest() {
|
|
const keyString = this.idsToIndex.shift();
|
|
const provider = this;
|
|
|
|
this.pendingRequests += 1;
|
|
const domainObject = await this.openmct.objects.get(keyString);
|
|
delete provider.pendingIndex[keyString];
|
|
|
|
try {
|
|
if (domainObject && domainObject.identifier) {
|
|
await provider.index(domainObject);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to index domain object ' + keyString, error);
|
|
}
|
|
|
|
setTimeout(function () {
|
|
provider.pendingRequests -= 1;
|
|
provider.keepIndexing();
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Dispatch a search query to the worker and return a queryId.
|
|
*
|
|
* @private
|
|
* @returns {String} a unique query Id for the query.
|
|
*/
|
|
#dispatchSearchToWorker({ queryId, searchType, query, maxResults }) {
|
|
const message = {
|
|
request: searchType.toString(),
|
|
input: query,
|
|
maxResults,
|
|
queryId
|
|
};
|
|
this.worker.port.postMessage(message);
|
|
}
|
|
|
|
localIndexTags(keyString, objectToIndex, model) {
|
|
// add new tags
|
|
model.tags.forEach((tagID) => {
|
|
if (!this.localIndexedAnnotationsByTag[tagID]) {
|
|
this.localIndexedAnnotationsByTag[tagID] = [];
|
|
}
|
|
|
|
const existsInIndex = this.localIndexedAnnotationsByTag[tagID].some((indexedObject) => {
|
|
return indexedObject.keyString === objectToIndex.keyString;
|
|
});
|
|
|
|
if (!existsInIndex) {
|
|
this.localIndexedAnnotationsByTag[tagID].push(objectToIndex);
|
|
}
|
|
});
|
|
const tagsToRemoveFromIndex = Object.keys(this.localIndexedAnnotationsByTag).filter(
|
|
(indexedTag) => {
|
|
return !model.tags.includes(indexedTag);
|
|
}
|
|
);
|
|
tagsToRemoveFromIndex.forEach((tagToRemoveFromIndex) => {
|
|
this.localIndexedAnnotationsByTag[tagToRemoveFromIndex] = this.localIndexedAnnotationsByTag[
|
|
tagToRemoveFromIndex
|
|
].filter((indexedAnnotation) => {
|
|
const shouldKeep = indexedAnnotation.keyString !== keyString;
|
|
|
|
return shouldKeep;
|
|
});
|
|
});
|
|
}
|
|
|
|
localIndexAnnotation(objectToIndex, model) {
|
|
model.targets.forEach((target) => {
|
|
const targetID = target.keyString;
|
|
if (!this.localIndexedAnnotationsByDomainObject[targetID]) {
|
|
this.localIndexedAnnotationsByDomainObject[targetID] = [];
|
|
}
|
|
|
|
objectToIndex.targets = model.targets;
|
|
objectToIndex.tags = model.tags;
|
|
const existsInIndex = this.localIndexedAnnotationsByDomainObject[targetID].some(
|
|
(indexedObject) => {
|
|
return indexedObject.keyString === objectToIndex.keyString;
|
|
}
|
|
);
|
|
|
|
if (!existsInIndex) {
|
|
this.localIndexedAnnotationsByDomainObject[targetID].push(objectToIndex);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A local version of the same SharedWorker function
|
|
* if we don't have SharedWorkers available (e.g., iOS)
|
|
*/
|
|
localIndexItem(keyString, model) {
|
|
const objectToIndex = {
|
|
type: model.type,
|
|
name: model.name,
|
|
keyString
|
|
};
|
|
if (model && model.type === 'annotation') {
|
|
if (model.targets) {
|
|
this.localIndexAnnotation(objectToIndex, model);
|
|
}
|
|
|
|
if (model.tags) {
|
|
this.localIndexTags(keyString, objectToIndex, model);
|
|
}
|
|
} else {
|
|
this.localIndexedDomainObjects[keyString] = objectToIndex;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A local version of the same SharedWorker function
|
|
* if we don't have SharedWorkers available (e.g., iOS)
|
|
*
|
|
* Gets search results from the indexedItems based on provided search
|
|
* input. Returns matching results from indexedItems
|
|
*/
|
|
localSearchForObjects(queryId, searchInput, maxResults) {
|
|
// This results dictionary will have domain object ID keys which
|
|
// point to the value the domain object's score.
|
|
let results = [];
|
|
const input = searchInput.trim().toLowerCase();
|
|
const message = {
|
|
request: 'searchForObjects',
|
|
results: [],
|
|
total: 0,
|
|
queryId
|
|
};
|
|
|
|
results =
|
|
Object.values(this.localIndexedDomainObjects).filter((indexedItem) => {
|
|
return indexedItem.name.toLowerCase().includes(input);
|
|
}) || [];
|
|
|
|
message.total = results.length;
|
|
message.results = results.slice(0, maxResults);
|
|
const eventToReturn = {
|
|
data: message
|
|
};
|
|
this.onWorkerMessage(eventToReturn);
|
|
}
|
|
|
|
/**
|
|
* A local version of the same SharedWorker function
|
|
* if we don't have SharedWorkers available (e.g., iOS)
|
|
*/
|
|
localSearchForAnnotations(queryId, searchInput, maxResults) {
|
|
// This results dictionary will have domain object ID keys which
|
|
// point to the value the domain object's score.
|
|
let results = [];
|
|
const message = {
|
|
request: 'searchForAnnotations',
|
|
results: [],
|
|
total: 0,
|
|
queryId
|
|
};
|
|
|
|
results = this.localIndexedAnnotationsByDomainObject[searchInput] || [];
|
|
|
|
message.total = results.length;
|
|
message.results = results.slice(0, maxResults);
|
|
const eventToReturn = {
|
|
data: message
|
|
};
|
|
this.onWorkerMessage(eventToReturn);
|
|
}
|
|
|
|
/**
|
|
* A local version of the same SharedWorker function
|
|
* if we don't have SharedWorkers available (e.g., iOS)
|
|
*/
|
|
localSearchForTags(queryId, matchingTagKeys, maxResults) {
|
|
let results = [];
|
|
const message = {
|
|
request: 'searchForTags',
|
|
results: [],
|
|
total: 0,
|
|
queryId
|
|
};
|
|
|
|
if (matchingTagKeys) {
|
|
matchingTagKeys.forEach((matchingTag) => {
|
|
const matchingAnnotations = this.localIndexedAnnotationsByTag[matchingTag];
|
|
if (matchingAnnotations) {
|
|
matchingAnnotations.forEach((matchingAnnotation) => {
|
|
const existsInResults = results.some((indexedObject) => {
|
|
return matchingAnnotation.keyString === indexedObject.keyString;
|
|
});
|
|
if (!existsInResults) {
|
|
results.push(matchingAnnotation);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
message.total = results.length;
|
|
message.results = results.slice(0, maxResults);
|
|
const eventToReturn = {
|
|
data: message
|
|
};
|
|
this.onWorkerMessage(eventToReturn);
|
|
}
|
|
|
|
destroyObservers(observers) {
|
|
Object.entries(observers).forEach(([keyString, unobserve]) => {
|
|
if (typeof unobserve === 'function') {
|
|
unobserve();
|
|
}
|
|
|
|
delete observers[keyString];
|
|
});
|
|
}
|
|
}
|
|
|
|
export default InMemorySearchProvider;
|