Initial
This commit is contained in:
101
resources/app/client/data/abstract/canvas-document.js
Normal file
101
resources/app/client/data/abstract/canvas-document.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* A specialized subclass of the ClientDocumentMixin which is used for document types that are intended to be
|
||||
* represented upon the game Canvas.
|
||||
* @category - Mixins
|
||||
* @param {typeof abstract.Document} Base The base document class mixed with client and canvas features
|
||||
* @returns {typeof CanvasDocument} The mixed CanvasDocument class definition
|
||||
*/
|
||||
function CanvasDocumentMixin(Base) {
|
||||
return class CanvasDocument extends ClientDocumentMixin(Base) {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A lazily constructed PlaceableObject instance which can represent this Document on the game canvas.
|
||||
* @type {PlaceableObject|null}
|
||||
*/
|
||||
get object() {
|
||||
if ( this._object || this._destroyed ) return this._object;
|
||||
if ( !this.parent?.isView || !this.layer ) return null;
|
||||
return this._object = this.layer.createObject(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {PlaceableObject|null}
|
||||
* @private
|
||||
*/
|
||||
_object = this._object ?? null;
|
||||
|
||||
/**
|
||||
* Has this object been deliberately destroyed as part of the deletion workflow?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
_destroyed = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the CanvasLayer which contains Document objects of this type.
|
||||
* @type {PlaceablesLayer}
|
||||
*/
|
||||
get layer() {
|
||||
return canvas.getLayerByEmbeddedName(this.documentName);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An indicator for whether this document is currently rendered on the game canvas.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get rendered() {
|
||||
return this._object && !this._object.destroyed;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
if ( !this.schema.has("sort") || ("sort" in data) ) return;
|
||||
let sort = 0;
|
||||
for ( const document of this.collection ) sort = Math.max(sort, document.sort + 1);
|
||||
this.updateSource({sort});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onCreate(data, options, userId) {
|
||||
super._onCreate(data, options, userId);
|
||||
const object = this.object;
|
||||
if ( !object ) return;
|
||||
this.layer.objects.addChild(object);
|
||||
object.draw().then(() => {
|
||||
object?._onCreate(data, options, userId);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onUpdate(changed, options, userId) {
|
||||
super._onUpdate(changed, options, userId);
|
||||
this._object?._onUpdate(changed, options, userId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onDelete(options, userId) {
|
||||
super._onDelete(options, userId);
|
||||
this._object?._onDelete(options, userId);
|
||||
}
|
||||
};
|
||||
}
|
||||
1235
resources/app/client/data/abstract/client-document.js
Normal file
1235
resources/app/client/data/abstract/client-document.js
Normal file
File diff suppressed because it is too large
Load Diff
285
resources/app/client/data/abstract/directory-collection-mixin.js
Normal file
285
resources/app/client/data/abstract/directory-collection-mixin.js
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* A mixin which adds directory functionality to a DocumentCollection, such as folders, tree structures, and sorting.
|
||||
* @param {typeof Collection} BaseCollection The base collection class to extend
|
||||
* @returns {typeof DirectoryCollection} A Collection mixed with DirectoryCollection functionality
|
||||
* @category - Mixins
|
||||
* @mixin
|
||||
*/
|
||||
function DirectoryCollectionMixin(BaseCollection) {
|
||||
|
||||
/**
|
||||
* An extension of the Collection class which adds behaviors specific to tree-based collections of entries and folders.
|
||||
* @extends {Collection}
|
||||
*/
|
||||
return class DirectoryCollection extends BaseCollection {
|
||||
|
||||
/**
|
||||
* Reference the set of Folders which contain documents in this collection
|
||||
* @type {Collection<string, Folder>}
|
||||
*/
|
||||
get folders() {
|
||||
throw new Error("You must implement the folders getter for this DirectoryCollection");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The built tree structure of the DocumentCollection
|
||||
* @type {object}
|
||||
*/
|
||||
get tree() {
|
||||
if ( !this.#tree ) this.initializeTree();
|
||||
return this.#tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* The built tree structure of the DocumentCollection. Lazy initialized.
|
||||
* @type {object}
|
||||
*/
|
||||
#tree;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The current search mode for this collection
|
||||
* @type {string}
|
||||
*/
|
||||
get searchMode() {
|
||||
const searchModes = game.settings.get("core", "collectionSearchModes");
|
||||
return searchModes[this.collection ?? this.name] || CONST.DIRECTORY_SEARCH_MODES.NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the search mode for this collection between "name" and "full" text search
|
||||
*/
|
||||
toggleSearchMode() {
|
||||
const name = this.collection ?? this.name;
|
||||
const searchModes = game.settings.get("core", "collectionSearchModes");
|
||||
searchModes[name] = searchModes[name] === CONST.DIRECTORY_SEARCH_MODES.FULL
|
||||
? CONST.DIRECTORY_SEARCH_MODES.NAME
|
||||
: CONST.DIRECTORY_SEARCH_MODES.FULL;
|
||||
game.settings.set("core", "collectionSearchModes", searchModes);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The current sort mode used to order the top level entries in this collection
|
||||
* @type {string}
|
||||
*/
|
||||
get sortingMode() {
|
||||
const sortingModes = game.settings.get("core", "collectionSortingModes");
|
||||
return sortingModes[this.collection ?? this.name] || "a";
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the sorting mode for this collection between "a" (Alphabetical) and "m" (Manual by sort property)
|
||||
*/
|
||||
toggleSortingMode() {
|
||||
const name = this.collection ?? this.name;
|
||||
const sortingModes = game.settings.get("core", "collectionSortingModes");
|
||||
const updatedSortingMode = sortingModes[name] === "a" ? "m" : "a";
|
||||
sortingModes[name] = updatedSortingMode;
|
||||
game.settings.set("core", "collectionSortingModes", sortingModes);
|
||||
this.initializeTree();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The maximum depth of folder nesting which is allowed in this collection
|
||||
* @returns {number}
|
||||
*/
|
||||
get maxFolderDepth() {
|
||||
return CONST.FOLDER_MAX_DEPTH;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to list of entries which are visible to the User in this tree
|
||||
* @returns {Array<*>}
|
||||
* @private
|
||||
*/
|
||||
_getVisibleTreeContents() {
|
||||
return this.contents;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the tree by categorizing folders and entries into a hierarchical tree structure.
|
||||
*/
|
||||
initializeTree() {
|
||||
const folders = this.folders.contents;
|
||||
const entries = this._getVisibleTreeContents();
|
||||
this.#tree = this.#buildTree(folders, entries);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a list of Folders and a list of Entries, set up the Folder tree
|
||||
* @param {Folder[]} folders The Array of Folder objects to organize
|
||||
* @param {Object[]} entries The Array of Entries objects to organize
|
||||
* @returns {object} A tree structure containing the folders and entries
|
||||
*/
|
||||
#buildTree(folders, entries) {
|
||||
const handled = new Set();
|
||||
const createNode = (root, folder, depth) => {
|
||||
return {root, folder, depth, visible: false, children: [], entries: []};
|
||||
};
|
||||
|
||||
// Create the tree structure
|
||||
const tree = createNode(true, null, 0);
|
||||
const depths = [[tree]];
|
||||
|
||||
// Iterate by folder depth, populating content
|
||||
for ( let depth = 1; depth <= this.maxFolderDepth + 1; depth++ ) {
|
||||
const allowChildren = depth <= this.maxFolderDepth;
|
||||
depths[depth] = [];
|
||||
const nodes = depths[depth - 1];
|
||||
if ( !nodes.length ) break;
|
||||
for ( const node of nodes ) {
|
||||
const folder = node.folder;
|
||||
if ( !node.root ) { // Ensure we don't encounter any infinite loop
|
||||
if ( handled.has(folder.id) ) continue;
|
||||
handled.add(folder.id);
|
||||
}
|
||||
|
||||
// Classify content for this folder
|
||||
const classified = this.#classifyFolderContent(folder, folders, entries, {allowChildren});
|
||||
node.entries = classified.entries;
|
||||
node.children = classified.folders.map(folder => createNode(false, folder, depth));
|
||||
depths[depth].push(...node.children);
|
||||
|
||||
// Update unassigned content
|
||||
folders = classified.unassignedFolders;
|
||||
entries = classified.unassignedEntries;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate left-over folders at the root level of the tree
|
||||
for ( const folder of folders ) {
|
||||
const node = createNode(false, folder, 1);
|
||||
const classified = this.#classifyFolderContent(folder, folders, entries, {allowChildren: false});
|
||||
node.entries = classified.entries;
|
||||
entries = classified.unassignedEntries;
|
||||
depths[1].push(node);
|
||||
}
|
||||
|
||||
// Populate left-over entries at the root level of the tree
|
||||
if ( entries.length ) {
|
||||
tree.entries.push(...entries);
|
||||
}
|
||||
|
||||
// Sort the top level entries and folders
|
||||
const sort = this.sortingMode === "a" ? this.constructor._sortAlphabetical : this.constructor._sortStandard;
|
||||
tree.entries.sort(sort);
|
||||
tree.children.sort((a, b) => sort(a.folder, b.folder));
|
||||
|
||||
// Recursively filter visibility of the tree
|
||||
const filterChildren = node => {
|
||||
node.children = node.children.filter(child => {
|
||||
filterChildren(child);
|
||||
return child.visible;
|
||||
});
|
||||
node.visible = node.root || game.user.isGM || ((node.children.length + node.entries.length) > 0);
|
||||
|
||||
// Populate some attributes of the Folder document
|
||||
if ( node.folder ) {
|
||||
node.folder.displayed = node.visible;
|
||||
node.folder.depth = node.depth;
|
||||
node.folder.children = node.children;
|
||||
}
|
||||
};
|
||||
filterChildren(tree);
|
||||
return tree;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates the list of Folder options in this Collection in hierarchical order
|
||||
* for populating the options of a select tag.
|
||||
* @returns {{id: string, name: string}[]}
|
||||
* @internal
|
||||
*/
|
||||
_formatFolderSelectOptions() {
|
||||
const options = [];
|
||||
const traverse = node => {
|
||||
if ( !node ) return;
|
||||
const folder = node.folder;
|
||||
if ( folder?.visible ) options.push({
|
||||
id: folder.id,
|
||||
name: `${"─".repeat(folder.depth - 1)} ${folder.name}`.trim()
|
||||
});
|
||||
node.children.forEach(traverse);
|
||||
};
|
||||
traverse(this.tree);
|
||||
return options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Populate a single folder with child folders and content
|
||||
* This method is called recursively when building the folder tree
|
||||
* @param {Folder|null} folder A parent folder being populated or null for the root node
|
||||
* @param {Folder[]} folders Remaining unassigned folders which may be children of this one
|
||||
* @param {Object[]} entries Remaining unassigned entries which may be children of this one
|
||||
* @param {object} [options={}] Options which configure population
|
||||
* @param {boolean} [options.allowChildren=true] Allow additional child folders
|
||||
*/
|
||||
#classifyFolderContent(folder, folders, entries, {allowChildren = true} = {}) {
|
||||
const sort = folder?.sorting === "a" ? this.constructor._sortAlphabetical : this.constructor._sortStandard;
|
||||
|
||||
// Determine whether an entry belongs to a folder, via folder ID or folder reference
|
||||
function folderMatches(entry) {
|
||||
if ( entry.folder?._id ) return entry.folder._id === folder?._id;
|
||||
return (entry.folder === folder) || (entry.folder === folder?._id);
|
||||
}
|
||||
|
||||
// Partition folders into children and unassigned folders
|
||||
const [unassignedFolders, subfolders] = folders.partition(f => allowChildren && folderMatches(f));
|
||||
subfolders.sort(sort);
|
||||
|
||||
// Partition entries into folder contents and unassigned entries
|
||||
const [unassignedEntries, contents] = entries.partition(e => folderMatches(e));
|
||||
contents.sort(sort);
|
||||
|
||||
// Return the classified content
|
||||
return {folders: subfolders, entries: contents, unassignedFolders, unassignedEntries};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Sort two Entries by name, alphabetically.
|
||||
* @param {Object} a Some Entry
|
||||
* @param {Object} b Some other Entry
|
||||
* @returns {number} The sort order between entries a and b
|
||||
* @protected
|
||||
*/
|
||||
static _sortAlphabetical(a, b) {
|
||||
if ( a.name === undefined ) throw new Error(`Missing name property for ${a.constructor.name} ${a.id}`);
|
||||
if ( b.name === undefined ) throw new Error(`Missing name property for ${b.constructor.name} ${b.id}`);
|
||||
return a.name.localeCompare(b.name, game.i18n.lang);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Sort two Entries using their numeric sort fields.
|
||||
* @param {Object} a Some Entry
|
||||
* @param {Object} b Some other Entry
|
||||
* @returns {number} The sort order between Entries a and b
|
||||
* @protected
|
||||
*/
|
||||
static _sortStandard(a, b) {
|
||||
if ( a.sort === undefined ) throw new Error(`Missing sort property for ${a.constructor.name} ${a.id}`);
|
||||
if ( b.sort === undefined ) throw new Error(`Missing sort property for ${b.constructor.name} ${b.id}`);
|
||||
return a.sort - b.sort;
|
||||
}
|
||||
}
|
||||
}
|
||||
334
resources/app/client/data/abstract/document-collection.js
Normal file
334
resources/app/client/data/abstract/document-collection.js
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* An abstract subclass of the Collection container which defines a collection of Document instances.
|
||||
* @extends {Collection}
|
||||
* @abstract
|
||||
*
|
||||
* @param {object[]} data An array of data objects from which to create document instances
|
||||
*/
|
||||
class DocumentCollection extends foundry.utils.Collection {
|
||||
constructor(data=[]) {
|
||||
super();
|
||||
|
||||
/**
|
||||
* The source data array from which the Documents in the WorldCollection are created
|
||||
* @type {object[]}
|
||||
* @private
|
||||
*/
|
||||
Object.defineProperty(this, "_source", {
|
||||
value: data,
|
||||
writable: false
|
||||
});
|
||||
|
||||
/**
|
||||
* An Array of application references which will be automatically updated when the collection content changes
|
||||
* @type {Application[]}
|
||||
*/
|
||||
this.apps = [];
|
||||
|
||||
// Initialize data
|
||||
this._initialize();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the DocumentCollection by constructing any initially provided Document instances
|
||||
* @private
|
||||
*/
|
||||
_initialize() {
|
||||
this.clear();
|
||||
for ( let d of this._source ) {
|
||||
let doc;
|
||||
if ( game.issues ) game.issues._countDocumentSubType(this.documentClass, d);
|
||||
try {
|
||||
doc = this.documentClass.fromSource(d, {strict: true, dropInvalidEmbedded: true});
|
||||
super.set(doc.id, doc);
|
||||
} catch(err) {
|
||||
this.invalidDocumentIds.add(d._id);
|
||||
if ( game.issues ) game.issues._trackValidationFailure(this, d, err);
|
||||
Hooks.onError(`${this.constructor.name}#_initialize`, err, {
|
||||
msg: `Failed to initialize ${this.documentName} [${d._id}]`,
|
||||
log: "error",
|
||||
id: d._id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Collection Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the Document class definition which is contained within this DocumentCollection.
|
||||
* @type {typeof foundry.abstract.Document}
|
||||
*/
|
||||
get documentClass() {
|
||||
return getDocumentClass(this.documentName);
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
get documentName() {
|
||||
const name = this.constructor.documentName;
|
||||
if ( !name ) throw new Error("A subclass of DocumentCollection must define its static documentName");
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base Document type which is contained within this DocumentCollection
|
||||
* @type {string}
|
||||
*/
|
||||
static documentName;
|
||||
|
||||
/**
|
||||
* Record the set of document ids where the Document was not initialized because of invalid source data
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
invalidDocumentIds = new Set();
|
||||
|
||||
/**
|
||||
* The Collection class name
|
||||
* @type {string}
|
||||
*/
|
||||
get name() {
|
||||
return this.constructor.name;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Collection Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Instantiate a Document for inclusion in the Collection.
|
||||
* @param {object} data The Document data.
|
||||
* @param {object} [context] Document creation context.
|
||||
* @returns {foundry.abstract.Document}
|
||||
*/
|
||||
createDocument(data, context={}) {
|
||||
return new this.documentClass(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Obtain a temporary Document instance for a document id which currently has invalid source data.
|
||||
* @param {string} id A document ID with invalid source data.
|
||||
* @param {object} [options] Additional options to configure retrieval.
|
||||
* @param {boolean} [options.strict=true] Throw an Error if the requested ID is not in the set of invalid IDs for
|
||||
* this collection.
|
||||
* @returns {Document} An in-memory instance for the invalid Document
|
||||
* @throws If strict is true and the requested ID is not in the set of invalid IDs for this collection.
|
||||
*/
|
||||
getInvalid(id, {strict=true}={}) {
|
||||
if ( !this.invalidDocumentIds.has(id) ) {
|
||||
if ( strict ) throw new Error(`${this.constructor.documentName} id [${id}] is not in the set of invalid ids`);
|
||||
return;
|
||||
}
|
||||
const data = this._source.find(d => d._id === id);
|
||||
return this.documentClass.fromSource(foundry.utils.deepClone(data));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an element from the DocumentCollection by its ID.
|
||||
* @param {string} id The ID of the Document to retrieve.
|
||||
* @param {object} [options] Additional options to configure retrieval.
|
||||
* @param {boolean} [options.strict=false] Throw an Error if the requested Document does not exist.
|
||||
* @param {boolean} [options.invalid=false] Allow retrieving an invalid Document.
|
||||
* @returns {foundry.abstract.Document}
|
||||
* @throws If strict is true and the Document cannot be found.
|
||||
*/
|
||||
get(id, {invalid=false, strict=false}={}) {
|
||||
let result = super.get(id);
|
||||
if ( !result && invalid ) result = this.getInvalid(id, { strict: false });
|
||||
if ( !result && strict ) throw new Error(`${this.constructor.documentName} id [${id}] does not exist in the `
|
||||
+ `${this.constructor.name} collection.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
set(id, document) {
|
||||
const cls = this.documentClass;
|
||||
if (!(document instanceof cls)) {
|
||||
throw new Error(`You may only push instances of ${cls.documentName} to the ${this.name} collection`);
|
||||
}
|
||||
const replacement = this.has(document.id);
|
||||
super.set(document.id, document);
|
||||
if ( replacement ) this._source.findSplice(e => e._id === id, document.toObject());
|
||||
else this._source.push(document.toObject());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
delete(id) {
|
||||
super.delete(id);
|
||||
const removed = this._source.findSplice(e => e._id === id);
|
||||
return !!removed;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render any Applications associated with this DocumentCollection.
|
||||
*/
|
||||
render(force, options) {
|
||||
for (let a of this.apps) a.render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The cache of search fields for each data model
|
||||
* @type {Map<string, Set<string>>}
|
||||
*/
|
||||
static #dataModelSearchFieldsCache = new Map();
|
||||
|
||||
/**
|
||||
* Get the searchable fields for a given document or index, based on its data model
|
||||
* @param {string} documentName The document type name
|
||||
* @param {string} [documentSubtype=""] The document subtype name
|
||||
* @param {boolean} [isEmbedded=false] Whether the document is an embedded object
|
||||
* @returns {Set<string>} The dot-delimited property paths of searchable fields
|
||||
*/
|
||||
static getSearchableFields(documentName, documentSubtype="", isEmbedded=false) {
|
||||
const isSubtype = !!documentSubtype;
|
||||
const cacheName = isSubtype ? `${documentName}.${documentSubtype}` : documentName;
|
||||
|
||||
// If this already exists in the cache, return it
|
||||
if ( DocumentCollection.#dataModelSearchFieldsCache.has(cacheName) ) {
|
||||
return DocumentCollection.#dataModelSearchFieldsCache.get(cacheName);
|
||||
}
|
||||
|
||||
// Load the Document DataModel
|
||||
const docConfig = CONFIG[documentName];
|
||||
if ( !docConfig ) throw new Error(`Could not find configuration for ${documentName}`);
|
||||
|
||||
// Read the fields that can be searched from the Data Model
|
||||
const textSearchFields = new Set(isSubtype ? this.getSearchableFields(documentName) : []);
|
||||
const dataModel = isSubtype ? docConfig.dataModels?.[documentSubtype] : docConfig.documentClass;
|
||||
dataModel?.schema.apply(function() {
|
||||
if ( (this instanceof foundry.data.fields.StringField) && this.textSearch ) {
|
||||
// Non-TypeDataModel sub-types may produce an incorrect field path, in which case we prepend "system."
|
||||
textSearchFields.add(isSubtype && !dataModel.schema.name ? `system.${this.fieldPath}` : this.fieldPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result
|
||||
DocumentCollection.#dataModelSearchFieldsCache.set(cacheName, textSearchFields);
|
||||
|
||||
return textSearchFields;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find all Documents which match a given search term using a full-text search against their indexed HTML fields and their name.
|
||||
* If filters are provided, results are filtered to only those that match the provided values.
|
||||
* @param {object} search An object configuring the search
|
||||
* @param {string} [search.query] A case-insensitive search string
|
||||
* @param {FieldFilter[]} [search.filters] An array of filters to apply
|
||||
* @param {string[]} [search.exclude] An array of document IDs to exclude from search results
|
||||
* @returns {string[]}
|
||||
*/
|
||||
search({query= "", filters=[], exclude=[]}) {
|
||||
query = SearchFilter.cleanQuery(query);
|
||||
const regex = new RegExp(RegExp.escape(query), "i");
|
||||
const results = [];
|
||||
const hasFilters = !foundry.utils.isEmpty(filters);
|
||||
let domParser;
|
||||
for ( const doc of this.index ?? this.contents ) {
|
||||
if ( exclude.includes(doc._id) ) continue;
|
||||
let isMatch = !query;
|
||||
|
||||
// Do a full-text search against any searchable fields based on metadata
|
||||
if ( query ) {
|
||||
const textSearchFields = DocumentCollection.getSearchableFields(
|
||||
doc.constructor.documentName ?? this.documentName, doc.type, !!doc.parentCollection);
|
||||
for ( const fieldName of textSearchFields ) {
|
||||
let value = foundry.utils.getProperty(doc, fieldName);
|
||||
// Search the text context of HTML instead of the HTML
|
||||
if ( value ) {
|
||||
let field;
|
||||
if ( fieldName.startsWith("system.") ) {
|
||||
if ( doc.system instanceof foundry.abstract.DataModel ) {
|
||||
field = doc.system.schema.getField(fieldName.slice(7));
|
||||
}
|
||||
} else field = doc.schema.getField(fieldName);
|
||||
if ( field instanceof foundry.data.fields.HTMLField ) {
|
||||
// TODO: Ideally we would search the text content of the enriched HTML: can we make that happen somehow?
|
||||
domParser ??= new DOMParser();
|
||||
value = domParser.parseFromString(value, "text/html").body.textContent;
|
||||
}
|
||||
}
|
||||
if ( value && regex.test(SearchFilter.cleanQuery(value)) ) {
|
||||
isMatch = true;
|
||||
break; // No need to evaluate other fields, we already know this is a match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if ( hasFilters ) {
|
||||
for ( const filter of filters ) {
|
||||
if ( !SearchFilter.evaluateFilter(doc, filter) ) {
|
||||
isMatch = false;
|
||||
break; // No need to evaluate other filters, we already know this is not a match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( isMatch ) results.push(doc);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Database Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update all objects in this DocumentCollection with a provided transformation.
|
||||
* Conditionally filter to only apply to Entities which match a certain condition.
|
||||
* @param {Function|object} transformation An object of data or function to apply to all matched objects
|
||||
* @param {Function|null} condition A function which tests whether to target each object
|
||||
* @param {object} [options] Additional options passed to Document.updateDocuments
|
||||
* @returns {Promise<Document[]>} An array of updated data once the operation is complete
|
||||
*/
|
||||
async updateAll(transformation, condition=null, options={}) {
|
||||
const hasTransformer = transformation instanceof Function;
|
||||
if ( !hasTransformer && (foundry.utils.getType(transformation) !== "Object") ) {
|
||||
throw new Error("You must provide a data object or transformation function");
|
||||
}
|
||||
const hasCondition = condition instanceof Function;
|
||||
const updates = [];
|
||||
for ( let doc of this ) {
|
||||
if ( hasCondition && !condition(doc) ) continue;
|
||||
const update = hasTransformer ? transformation(doc) : foundry.utils.deepClone(transformation);
|
||||
update._id = doc.id;
|
||||
updates.push(update);
|
||||
}
|
||||
return this.documentClass.updateDocuments(updates, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Follow-up actions to take when a database operation modifies Documents in this DocumentCollection.
|
||||
* @param {DatabaseAction} action The database action performed
|
||||
* @param {ClientDocument[]} documents The array of modified Documents
|
||||
* @param {any[]} result The result of the database operation
|
||||
* @param {DatabaseOperation} operation Database operation details
|
||||
* @param {User} user The User who performed the operation
|
||||
* @internal
|
||||
*/
|
||||
_onModifyContents(action, documents, result, operation, user) {
|
||||
if ( operation.render ) {
|
||||
this.render(false, {renderContext: `${action}${this.documentName}`, renderData: result});
|
||||
}
|
||||
}
|
||||
}
|
||||
180
resources/app/client/data/abstract/world-collection.js
Normal file
180
resources/app/client/data/abstract/world-collection.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* A collection of world-level Document objects with a singleton instance per primary Document type.
|
||||
* Each primary Document type has an associated subclass of WorldCollection which contains them.
|
||||
* @extends {DocumentCollection}
|
||||
* @abstract
|
||||
* @see {Game#collections}
|
||||
*
|
||||
* @param {object[]} data An array of data objects from which to create Document instances
|
||||
*/
|
||||
class WorldCollection extends DirectoryCollectionMixin(DocumentCollection) {
|
||||
/* -------------------------------------------- */
|
||||
/* Collection Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reference the set of Folders which contain documents in this collection
|
||||
* @type {Collection<string, Folder>}
|
||||
*/
|
||||
get folders() {
|
||||
return game.folders.reduce((collection, folder) => {
|
||||
if (folder.type === this.documentName) {
|
||||
collection.set(folder.id, folder);
|
||||
}
|
||||
return collection;
|
||||
}, new foundry.utils.Collection());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a reference to the SidebarDirectory application for this WorldCollection.
|
||||
* @type {DocumentDirectory}
|
||||
*/
|
||||
get directory() {
|
||||
const doc = getDocumentClass(this.constructor.documentName);
|
||||
return ui[doc.metadata.collection];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the singleton instance of this WorldCollection, or null if it has not yet been created.
|
||||
* @type {WorldCollection}
|
||||
*/
|
||||
static get instance() {
|
||||
return game.collections.get(this.documentName);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Collection Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getVisibleTreeContents(entry) {
|
||||
return this.contents.filter(c => c.visible);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Import a Document from a Compendium collection, adding it to the current World.
|
||||
* @param {CompendiumCollection} pack The CompendiumCollection instance from which to import
|
||||
* @param {string} id The ID of the compendium entry to import
|
||||
* @param {object} [updateData] Optional additional data used to modify the imported Document before it is created
|
||||
* @param {object} [options] Optional arguments passed to the {@link WorldCollection#fromCompendium} and
|
||||
* {@link Document.create} methods
|
||||
* @returns {Promise<Document>} The imported Document instance
|
||||
*/
|
||||
async importFromCompendium(pack, id, updateData={}, options={}) {
|
||||
const cls = this.documentClass;
|
||||
if (pack.documentName !== cls.documentName) {
|
||||
throw new Error(`The ${pack.documentName} Document type provided by Compendium ${pack.collection} is incorrect for this Collection`);
|
||||
}
|
||||
|
||||
// Prepare the source data from which to create the Document
|
||||
const document = await pack.getDocument(id);
|
||||
const sourceData = this.fromCompendium(document, options);
|
||||
const createData = foundry.utils.mergeObject(sourceData, updateData);
|
||||
|
||||
// Create the Document
|
||||
console.log(`${vtt} | Importing ${cls.documentName} ${document.name} from ${pack.collection}`);
|
||||
this.directory.activate();
|
||||
options.fromCompendium = true;
|
||||
return this.documentClass.create(createData, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {object} FromCompendiumOptions
|
||||
* @property {boolean} [options.clearFolder=false] Clear the currently assigned folder.
|
||||
* @property {boolean} [options.clearSort=true] Clear the current sort order.
|
||||
* @property {boolean} [options.clearOwnership=true] Clear Document ownership.
|
||||
* @property {boolean} [options.keepId=false] Retain the Document ID from the source Compendium.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Apply data transformations when importing a Document from a Compendium pack
|
||||
* @param {Document|object} document The source Document, or a plain data object
|
||||
* @param {FromCompendiumOptions} [options] Additional options which modify how the document is imported
|
||||
* @returns {object} The processed data ready for world Document creation
|
||||
*/
|
||||
fromCompendium(document, {clearFolder=false, clearSort=true, clearOwnership=true, keepId=false, ...rest}={}) {
|
||||
/** @deprecated since v12 */
|
||||
if ( "addFlags" in rest ) {
|
||||
foundry.utils.logCompatibilityWarning("The addFlags option for WorldCompendium#fromCompendium has been removed. ",
|
||||
{ since: 12, until: 14 });
|
||||
}
|
||||
|
||||
// Prepare the data structure
|
||||
let data = document;
|
||||
if (document instanceof foundry.abstract.Document) {
|
||||
data = document.toObject();
|
||||
if ( document.pack ) foundry.utils.setProperty(data, "_stats.compendiumSource", document.uuid);
|
||||
}
|
||||
|
||||
// Eliminate certain fields
|
||||
if ( !keepId ) delete data._id;
|
||||
if ( clearFolder ) delete data.folder;
|
||||
if ( clearSort ) delete data.sort;
|
||||
if ( clearOwnership && ("ownership" in data) ) {
|
||||
data.ownership = {
|
||||
default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
|
||||
[game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER
|
||||
};
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Sheet Registration Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register a Document sheet class as a candidate which can be used to display Documents of a given type.
|
||||
* See {@link DocumentSheetConfig.registerSheet} for details.
|
||||
* @static
|
||||
* @param {Array<*>} args Arguments forwarded to the DocumentSheetConfig.registerSheet method
|
||||
*
|
||||
* @example Register a new ActorSheet subclass for use with certain Actor types.
|
||||
* ```js
|
||||
* Actors.registerSheet("dnd5e", ActorSheet5eCharacter, { types: ["character], makeDefault: true });
|
||||
* ```
|
||||
*/
|
||||
static registerSheet(...args) {
|
||||
DocumentSheetConfig.registerSheet(getDocumentClass(this.documentName), ...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Unregister a Document sheet class, removing it from the list of available sheet Applications to use.
|
||||
* See {@link DocumentSheetConfig.unregisterSheet} for detauls.
|
||||
* @static
|
||||
* @param {Array<*>} args Arguments forwarded to the DocumentSheetConfig.unregisterSheet method
|
||||
*
|
||||
* @example Deregister the default ActorSheet subclass to replace it with others.
|
||||
* ```js
|
||||
* Actors.unregisterSheet("core", ActorSheet);
|
||||
* ```
|
||||
*/
|
||||
static unregisterSheet(...args) {
|
||||
DocumentSheetConfig.unregisterSheet(getDocumentClass(this.documentName), ...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return an array of currently registered sheet classes for this Document type.
|
||||
* @static
|
||||
* @type {DocumentSheet[]}
|
||||
*/
|
||||
static get registeredSheets() {
|
||||
const sheets = new Set();
|
||||
for ( let t of Object.values(CONFIG[this.documentName].sheetClasses) ) {
|
||||
for ( let s of Object.values(t) ) {
|
||||
sheets.add(s.cls);
|
||||
}
|
||||
}
|
||||
return Array.from(sheets);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user