Files
Foundry-VTT-Docker/resources/app/client/core/document-index.js
2025-01-04 00:34:03 +01:00

223 lines
7.7 KiB
JavaScript

/**
* This class is responsible for indexing all documents available in the world and storing them in a word tree structure
* that allows for fast searching.
*/
class DocumentIndex {
constructor() {
/**
* A collection of WordTree structures for each document type.
* @type {Record<string, WordTree>}
*/
Object.defineProperty(this, "trees", {value: {}});
/**
* A reverse-lookup of a document's UUID to its parent node in the word tree.
* @type {Record<string, StringTreeNode>}
*/
Object.defineProperty(this, "uuids", {value: {}});
}
/**
* While we are indexing, we store a Promise that resolves when the indexing is complete.
* @type {Promise<void>|null}
* @private
*/
#ready = null;
/* -------------------------------------------- */
/**
* Returns a Promise that resolves when the indexing process is complete.
* @returns {Promise<void>|null}
*/
get ready() {
return this.#ready;
}
/* -------------------------------------------- */
/**
* Index all available documents in the world and store them in a word tree.
* @returns {Promise<void>}
*/
async index() {
// Conclude any existing indexing.
await this.#ready;
const indexedCollections = CONST.WORLD_DOCUMENT_TYPES.filter(c => {
const documentClass = getDocumentClass(c);
return documentClass.metadata.indexed && documentClass.schema.has("name");
});
// TODO: Consider running this process in a web worker.
const start = performance.now();
return this.#ready = new Promise(resolve => {
for ( const documentName of indexedCollections ) {
this._indexWorldCollection(documentName);
}
for ( const pack of game.packs ) {
if ( !indexedCollections.includes(pack.documentName) ) continue;
this._indexCompendium(pack);
}
resolve();
console.debug(`${vtt} | Document indexing complete in ${performance.now() - start}ms.`);
});
}
/* -------------------------------------------- */
/**
* Return entries that match the given string prefix.
* @param {string} prefix The prefix.
* @param {object} [options] Additional options to configure behaviour.
* @param {string[]} [options.documentTypes] Optionally provide an array of document types. Only entries of that type
* will be searched for.
* @param {number} [options.limit=10] The maximum number of items per document type to retrieve. It is
* important to set this value as very short prefixes will naturally match
* large numbers of entries.
* @param {StringTreeEntryFilter} [options.filterEntries] A filter function to apply to each candidate entry.
* @param {DOCUMENT_OWNERSHIP_LEVELS|string} [options.ownership] Only return entries that the user meets this
* ownership level for.
* @returns {Record<string, WordTreeEntry[]>} A number of entries that have the given prefix, grouped by document
* type.
*/
lookup(prefix, {limit=10, documentTypes=[], ownership, filterEntries}={}) {
const types = documentTypes.length ? documentTypes : Object.keys(this.trees);
if ( ownership !== undefined ) {
const originalFilterEntries = filterEntries ?? (() => true);
filterEntries = entry => {
return originalFilterEntries(entry) && DocumentIndex.#filterEntryForOwnership(entry, ownership);
}
}
const results = {};
for ( const type of types ) {
results[type] = [];
const tree = this.trees[type];
if ( !tree ) continue;
results[type].push(...tree.lookup(prefix, { limit, filterEntries }));
}
return results;
}
/* -------------------------------------------- */
/**
* Add an entry to the index.
* @param {Document} doc The document entry.
*/
addDocument(doc) {
if ( doc.pack ) {
if ( doc.isEmbedded ) return; // Only index primary documents inside compendium packs
const pack = game.packs.get(doc.pack);
const index = pack.index.get(doc.id);
if ( index ) this._addLeaf(index, {pack});
}
else this._addLeaf(doc);
}
/* -------------------------------------------- */
/**
* Remove an entry from the index.
* @param {Document} doc The document entry.
*/
removeDocument(doc) {
const node = this.uuids[doc.uuid];
if ( !node ) return;
node[foundry.utils.StringTree.leaves].findSplice(e => e.uuid === doc.uuid);
delete this.uuids[doc.uuid];
}
/* -------------------------------------------- */
/**
* Replace an entry in the index with an updated one.
* @param {Document} doc The document entry.
*/
replaceDocument(doc) {
this.removeDocument(doc);
this.addDocument(doc);
}
/* -------------------------------------------- */
/**
* Add a leaf node to the word tree index.
* @param {Document|object} doc The document or compendium index entry to add.
* @param {object} [options] Additional information for indexing.
* @param {CompendiumCollection} [options.pack] The compendium that the index belongs to.
* @protected
*/
_addLeaf(doc, {pack}={}) {
const entry = {entry: doc, documentName: doc.documentName, uuid: doc.uuid};
if ( pack ) foundry.utils.mergeObject(entry, {
documentName: pack.documentName,
uuid: `Compendium.${pack.collection}.${doc._id}`,
pack: pack.collection
});
const tree = this.trees[entry.documentName] ??= new foundry.utils.WordTree();
this.uuids[entry.uuid] = tree.addLeaf(doc.name, entry);
}
/* -------------------------------------------- */
/**
* Aggregate the compendium index and add it to the word tree index.
* @param {CompendiumCollection} pack The compendium pack.
* @protected
*/
_indexCompendium(pack) {
for ( const entry of pack.index ) {
this._addLeaf(entry, {pack});
}
}
/* -------------------------------------------- */
/**
* Add all of a parent document's embedded documents to the index.
* @param {Document} parent The parent document.
* @protected
*/
_indexEmbeddedDocuments(parent) {
const embedded = parent.constructor.metadata.embedded;
for ( const embeddedName of Object.keys(embedded) ) {
if ( !CONFIG[embeddedName].documentClass.metadata.indexed ) continue;
for ( const doc of parent[embedded[embeddedName]] ) {
this._addLeaf(doc);
}
}
}
/* -------------------------------------------- */
/**
* Aggregate all documents and embedded documents in a world collection and add them to the index.
* @param {string} documentName The name of the documents to index.
* @protected
*/
_indexWorldCollection(documentName) {
const cls = CONFIG[documentName].documentClass;
const collection = cls.metadata.collection;
for ( const doc of game[collection] ) {
this._addLeaf(doc);
this._indexEmbeddedDocuments(doc);
}
}
/* -------------------------------------------- */
/* Helpers */
/* -------------------------------------------- */
/**
* Check if the given entry meets the given ownership requirements.
* @param {WordTreeEntry} entry The candidate entry.
* @param {DOCUMENT_OWNERSHIP_LEVELS|string} ownership The ownership.
* @returns {boolean}
*/
static #filterEntryForOwnership({ uuid, pack }, ownership) {
if ( pack ) return game.packs.get(pack)?.testUserPermission(game.user, ownership);
return fromUuidSync(uuid)?.testUserPermission(game.user, ownership);
}
}