/** * A mixin which extends each Document definition with specialized client-side behaviors. * This mixin defines the client-side interface for database operations and common document behaviors. * @param {typeof abstract.Document} Base The base Document class to be mixed * @returns {typeof ClientDocument} The mixed client-side document class definition * @category - Mixins * @mixin */ function ClientDocumentMixin(Base) { /** * The ClientDocument extends the base Document class by adding client-specific behaviors to all Document types. * @extends {abstract.Document} */ return class ClientDocument extends Base { constructor(data, context) { super(data, context); /** * A collection of Application instances which should be re-rendered whenever this document is updated. * The keys of this object are the application ids and the values are Application instances. Each * Application in this object will have its render method called by {@link Document#render}. * @type {Record} * @see {@link Document#render} * @memberof ClientDocumentMixin# */ Object.defineProperty(this, "apps", { value: {}, writable: false, enumerable: false }); /** * A cached reference to the FormApplication instance used to configure this Document. * @type {FormApplication|null} * @private */ Object.defineProperty(this, "_sheet", {value: null, writable: true, enumerable: false}); } /** @inheritdoc */ static name = "ClientDocumentMixin"; /* -------------------------------------------- */ /** * @inheritDoc * @this {ClientDocument} */ _initialize(options={}) { super._initialize(options); if ( !game._documentsReady ) return; return this._safePrepareData(); } /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * Return a reference to the parent Collection instance which contains this Document. * @memberof ClientDocumentMixin# * @this {ClientDocument} * @type {Collection} */ get collection() { if ( this.isEmbedded ) return this.parent[this.parentCollection]; else return CONFIG[this.documentName].collection.instance; } /* -------------------------------------------- */ /** * A reference to the Compendium Collection which contains this Document, if any, otherwise undefined. * @memberof ClientDocumentMixin# * @this {ClientDocument} * @type {CompendiumCollection} */ get compendium() { return game.packs.get(this.pack); } /* -------------------------------------------- */ /** * A boolean indicator for whether the current game User has ownership rights for this Document. * Different Document types may have more specialized rules for what constitutes ownership. * @type {boolean} * @memberof ClientDocumentMixin# */ get isOwner() { return this.testUserPermission(game.user, "OWNER"); } /* -------------------------------------------- */ /** * Test whether this Document is owned by any non-Gamemaster User. * @type {boolean} * @memberof ClientDocumentMixin# */ get hasPlayerOwner() { return game.users.some(u => !u.isGM && this.testUserPermission(u, "OWNER")); } /* ---------------------------------------- */ /** * A boolean indicator for whether the current game User has exactly LIMITED visibility (and no greater). * @type {boolean} * @memberof ClientDocumentMixin# */ get limited() { return this.testUserPermission(game.user, "LIMITED", {exact: true}); } /* -------------------------------------------- */ /** * Return a string which creates a dynamic link to this Document instance. * @returns {string} * @memberof ClientDocumentMixin# */ get link() { return `@UUID[${this.uuid}]{${this.name}}`; } /* ---------------------------------------- */ /** * Return the permission level that the current game User has over this Document. * See the CONST.DOCUMENT_OWNERSHIP_LEVELS object for an enumeration of these levels. * @type {number} * @memberof ClientDocumentMixin# * * @example Get the permission level the current user has for a document * ```js * game.user.id; // "dkasjkkj23kjf" * actor.data.permission; // {default: 1, "dkasjkkj23kjf": 2}; * actor.permission; // 2 * ``` */ get permission() { if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER; if ( this.isEmbedded ) return this.parent.permission; return this.getUserLevel(game.user); } /* -------------------------------------------- */ /** * Lazily obtain a FormApplication instance used to configure this Document, or null if no sheet is available. * @type {Application|ApplicationV2|null} * @memberof ClientDocumentMixin# */ get sheet() { if ( !this._sheet ) { const cls = this._getSheetClass(); // Application V1 Document Sheets if ( foundry.utils.isSubclass(cls, Application) ) { this._sheet = new cls(this, {editable: this.isOwner}); } // Application V2 Document Sheets else if ( foundry.utils.isSubclass(cls, foundry.applications.api.DocumentSheetV2) ) { this._sheet = new cls({document: this}); } // No valid sheet class else this._sheet = null; } return this._sheet; } /* -------------------------------------------- */ /** * A boolean indicator for whether the current game User has at least limited visibility for this Document. * Different Document types may have more specialized rules for what determines visibility. * @type {boolean} * @memberof ClientDocumentMixin# */ get visible() { if ( this.isEmbedded ) return this.parent.visible; return this.testUserPermission(game.user, "LIMITED"); } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Obtain the FormApplication class constructor which should be used to configure this Document. * @returns {Function|null} * @private */ _getSheetClass() { const cfg = CONFIG[this.documentName]; const type = this.type ?? CONST.BASE_DOCUMENT_TYPE; const sheets = cfg.sheetClasses[type] || {}; // Sheet selection overridden at the instance level const override = this.getFlag("core", "sheetClass") ?? null; if ( (override !== null) && (override in sheets) ) return sheets[override].cls; // Default sheet selection for the type const classes = Object.values(sheets); if ( !classes.length ) return BaseSheet; return (classes.find(s => s.default) ?? classes.pop()).cls; } /* -------------------------------------------- */ /** * Safely prepare data for a Document, catching any errors. * @internal */ _safePrepareData() { try { this.prepareData(); } catch(err) { Hooks.onError("ClientDocumentMixin#_initialize", err, { msg: `Failed data preparation for ${this.uuid}`, log: "error", uuid: this.uuid }); } } /* -------------------------------------------- */ /** * Prepare data for the Document. This method is called automatically by the DataModel#_initialize workflow. * This method provides an opportunity for Document classes to define special data preparation logic. * The work done by this method should be idempotent. There are situations in which prepareData may be called more * than once. * @memberof ClientDocumentMixin# */ prepareData() { const isTypeData = this.system instanceof foundry.abstract.TypeDataModel; if ( isTypeData ) this.system.prepareBaseData(); this.prepareBaseData(); this.prepareEmbeddedDocuments(); if ( isTypeData ) this.system.prepareDerivedData(); this.prepareDerivedData(); } /* -------------------------------------------- */ /** * Prepare data related to this Document itself, before any embedded Documents or derived data is computed. * @memberof ClientDocumentMixin# */ prepareBaseData() { } /* -------------------------------------------- */ /** * Prepare all embedded Document instances which exist within this primary Document. * @memberof ClientDocumentMixin# */ prepareEmbeddedDocuments() { for ( const collectionName of Object.keys(this.constructor.hierarchy || {}) ) { for ( let e of this.getEmbeddedCollection(collectionName) ) { e._safePrepareData(); } } } /* -------------------------------------------- */ /** * Apply transformations or derivations to the values of the source data object. * Compute data fields whose values are not stored to the database. * @memberof ClientDocumentMixin# */ prepareDerivedData() { } /* -------------------------------------------- */ /** * Render all Application instances which are connected to this document by calling their respective * @see Application#render * @param {boolean} [force=false] Force rendering * @param {object} [context={}] Optional context * @memberof ClientDocumentMixin# */ render(force=false, context={}) { for ( let app of Object.values(this.apps) ) { app.render(force, foundry.utils.deepClone(context)); } } /* -------------------------------------------- */ /** * Determine the sort order for this Document by positioning it relative a target sibling. * See SortingHelper.performIntegerSort for more details * @param {object} [options] Sorting options provided to SortingHelper.performIntegerSort * @param {object} [updateData] Additional data changes which are applied to each sorted document * @param {object} [sortOptions] Options which are passed to the SortingHelpers.performIntegerSort method * @returns {Promise} The Document after it has been re-sorted * @memberof ClientDocumentMixin# */ async sortRelative({updateData={}, ...sortOptions}={}) { const sorting = SortingHelpers.performIntegerSort(this, sortOptions); const updates = []; for ( let s of sorting ) { const doc = s.target; const update = foundry.utils.mergeObject(updateData, s.update, {inplace: false}); update._id = doc._id; if ( doc.sheet && doc.sheet.rendered ) await doc.sheet.submit({updateData: update}); else updates.push(update); } if ( updates.length ) await this.constructor.updateDocuments(updates, {parent: this.parent, pack: this.pack}); return this; } /* -------------------------------------------- */ /** * Construct a UUID relative to another document. * @param {ClientDocument} relative The document to compare against. */ getRelativeUUID(relative) { // The Documents are in two different compendia. if ( this.compendium && (this.compendium !== relative.compendium) ) return this.uuid; // This Document is a sibling of the relative Document. if ( this.isEmbedded && (this.collection === relative.collection) ) return `.${this.id}`; // This Document may be a descendant of the relative Document, so walk up the hierarchy to check. const parts = [this.documentName, this.id]; let parent = this.parent; while ( parent ) { if ( parent === relative ) break; parts.unshift(parent.documentName, parent.id); parent = parent.parent; } // The relative Document was a parent or grandparent of this one. if ( parent === relative ) return `.${parts.join(".")}`; // The relative Document was unrelated to this one. return this.uuid; } /* -------------------------------------------- */ /** * Create a content link for this document. * @param {object} eventData The parsed object of data provided by the drop transfer event. * @param {object} [options] Additional options to configure link generation. * @param {ClientDocument} [options.relativeTo] A document to generate a link relative to. * @param {string} [options.label] A custom label to use instead of the document's name. * @returns {string} * @internal */ _createDocumentLink(eventData, {relativeTo, label}={}) { if ( !relativeTo && !label ) return this.link; label ??= this.name; if ( relativeTo ) return `@UUID[${this.getRelativeUUID(relativeTo)}]{${label}}`; return `@UUID[${this.uuid}]{${label}}`; } /* -------------------------------------------- */ /** * Handle clicking on a content link for this document. * @param {MouseEvent} event The triggering click event. * @returns {any} * @protected */ _onClickDocumentLink(event) { return this.sheet.render(true); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ async _preCreate(data, options, user) { const allowed = await super._preCreate(data, options, user); if ( allowed === false ) return false; // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { return this.system._preCreate(data, options, user); } } /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); // Render the sheet for this application if ( options.renderSheet && (userId === game.user.id) && this.sheet ) { const options = { renderContext: `create${this.documentName}`, renderData: data }; /** @deprecated since v12 */ Object.defineProperties(options, { action: { get() { foundry.utils.logCompatibilityWarning("The render options 'action' property is deprecated. " + "Please use 'renderContext' instead.", { since: 12, until: 14 }); return "create"; } }, data: { get() { foundry.utils.logCompatibilityWarning("The render options 'data' property is deprecated. " + "Please use 'renderData' instead.", { since: 12, until: 14 }); return data; } } }); this.sheet.render(true, options); } // Update Compendium and global indices if ( this.pack && !this.isEmbedded ) { if ( this instanceof Folder ) this.compendium.folders.set(this.id, this); else this.compendium.indexDocument(this); } if ( this.constructor.metadata.indexed ) game.documentIndex.addDocument(this); // Update support metadata game.issues._countDocumentSubType(this.constructor, this._source); // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { this.system._onCreate(data, options, userId); } } /* -------------------------------------------- */ /** @inheritDoc */ async _preUpdate(changes, options, user) { const allowed = await super._preUpdate(changes, options, user); if ( allowed === false ) return false; // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { return this.system._preUpdate(changes, options, user); } } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); // Clear cached sheet if a new sheet is chosen, or the Document's sub-type changes. const sheetChanged = ("type" in changed) || ("sheetClass" in (changed.flags?.core || {})); if ( !options.preview && sheetChanged ) this._onSheetChange(); // Otherwise re-render associated applications. else if ( options.render !== false ) { const options = { renderContext: `update${this.documentName}`, renderData: changed }; /** @deprecated since v12 */ Object.defineProperties(options, { action: { get() { foundry.utils.logCompatibilityWarning("The render options 'action' property is deprecated. " + "Please use 'renderContext' instead.", { since: 12, until: 14 }); return "update"; } }, data: { get() { foundry.utils.logCompatibilityWarning("The render options 'data' property is deprecated. " + "Please use 'renderData' instead.", { since: 12, until: 14 }); return changed; } } }); this.render(false, options); } // Update Compendium and global indices if ( this.pack && !this.isEmbedded ) { if ( this instanceof Folder ) this.compendium.folders.set(this.id, this); else this.compendium.indexDocument(this); } if ( "name" in changed ) game.documentIndex.replaceDocument(this); // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { this.system._onUpdate(changed, options, userId); } } /* -------------------------------------------- */ /** @inheritDoc */ async _preDelete(options, user) { const allowed = await super._preDelete(options, user); if ( allowed === false ) return false; // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { return this.system._preDelete(options, user); } } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { super._onDelete(options, userId); // Close open Applications for this Document const renderOptions = { submit: false, renderContext: `delete${this.documentName}`, renderData: this }; /** @deprecated since v12 */ Object.defineProperties(renderOptions, { action: { get() { foundry.utils.logCompatibilityWarning("The render options 'action' property is deprecated. " + "Please use 'renderContext' instead.", {since: 12, until: 14}); return "delete"; } }, data: { get() { foundry.utils.logCompatibilityWarning("The render options 'data' property is deprecated. " + "Please use 'renderData' instead.", {since: 12, until: 14}); return this; } } }); Object.values(this.apps).forEach(a => a.close(renderOptions)); // Update Compendium and global indices if ( this.pack && !this.isEmbedded ) { if ( this instanceof Folder ) this.compendium.folders.delete(this.id); else this.compendium.index.delete(this.id); } game.documentIndex.removeDocument(this); // Update support metadata game.issues._countDocumentSubType(this.constructor, this._source, {decrement: true}); // Forward to type data model if ( this.system instanceof foundry.abstract.TypeDataModel ) { this.system._onDelete(options, userId); } } /* -------------------------------------------- */ /* Descendant Document Events */ /* -------------------------------------------- */ /** * Orchestrate dispatching descendant document events to parent documents when embedded children are modified. * @param {string} event The event name, preCreate, onCreate, etc... * @param {string} collection The collection name being modified within this parent document * @param {Array<*>} args Arguments passed to each dispatched function * @param {ClientDocument} [_parent] The document with directly modified embedded documents. * Either this document or a descendant of this one. * @internal */ _dispatchDescendantDocumentEvents(event, collection, args, _parent) { _parent ||= this; // Dispatch the event to this Document const fn = this[`_${event}DescendantDocuments`]; if ( !(fn instanceof Function) ) throw new Error(`Invalid descendant document event "${event}"`); fn.call(this, _parent, collection, ...args); // Dispatch the legacy "EmbeddedDocuments" event to the immediate parent only if ( _parent === this ) { /** @deprecated since v11 */ const legacyFn = `_${event}EmbeddedDocuments`; const isOverridden = foundry.utils.getDefiningClass(this, legacyFn)?.name !== "ClientDocumentMixin"; if ( isOverridden && (this[legacyFn] instanceof Function) ) { const documentName = this.constructor.hierarchy[collection].model.documentName; const warning = `The ${this.documentName} class defines the _${event}EmbeddedDocuments method which is ` + `deprecated in favor of a new _${event}DescendantDocuments method.`; foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13}); this[legacyFn](documentName, ...args); } } // Bubble the event to the parent Document /** @type ClientDocument */ const parent = this.parent; if ( !parent ) return; parent._dispatchDescendantDocumentEvents(event, collection, args, _parent); } /* -------------------------------------------- */ /** * Actions taken after descendant documents have been created, but before changes are applied to the client data. * @param {Document} parent The direct parent of the created Documents, may be this Document or a child * @param {string} collection The collection within which documents are being created * @param {object[]} data The source data for new documents that are being created * @param {object} options Options which modified the creation operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _preCreateDescendantDocuments(parent, collection, data, options, userId) {} /* -------------------------------------------- */ /** * Actions taken after descendant documents have been created and changes have been applied to client data. * @param {Document} parent The direct parent of the created Documents, may be this Document or a child * @param {string} collection The collection within which documents were created * @param {Document[]} documents The array of created Documents * @param {object[]} data The source data for new documents that were created * @param {object} options Options which modified the creation operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) { if ( options.render === false ) return; this.render(false, {renderContext: `create${collection}`, renderData: data}); } /* -------------------------------------------- */ /** * Actions taken after descendant documents have been updated, but before changes are applied to the client data. * @param {Document} parent The direct parent of the updated Documents, may be this Document or a child * @param {string} collection The collection within which documents are being updated * @param {object[]} changes The array of differential Document updates to be applied * @param {object} options Options which modified the update operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _preUpdateDescendantDocuments(parent, collection, changes, options, userId) {} /* -------------------------------------------- */ /** * Actions taken after descendant documents have been updated and changes have been applied to client data. * @param {Document} parent The direct parent of the updated Documents, may be this Document or a child * @param {string} collection The collection within which documents were updated * @param {Document[]} documents The array of updated Documents * @param {object[]} changes The array of differential Document updates which were applied * @param {object} options Options which modified the update operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) { if ( options.render === false ) return; this.render(false, {renderContext: `update${collection}`, renderData: changes}); } /* -------------------------------------------- */ /** * Actions taken after descendant documents have been deleted, but before deletions are applied to the client data. * @param {Document} parent The direct parent of the deleted Documents, may be this Document or a child * @param {string} collection The collection within which documents were deleted * @param {string[]} ids The array of document IDs which were deleted * @param {object} options Options which modified the deletion operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _preDeleteDescendantDocuments(parent, collection, ids, options, userId) {} /* -------------------------------------------- */ /** * Actions taken after descendant documents have been deleted and those deletions have been applied to client data. * @param {Document} parent The direct parent of the deleted Documents, may be this Document or a child * @param {string} collection The collection within which documents were deleted * @param {Document[]} documents The array of Documents which were deleted * @param {string[]} ids The array of document IDs which were deleted * @param {object} options Options which modified the deletion operation * @param {string} userId The ID of the User who triggered the operation * @protected */ _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) { if ( options.render === false ) return; this.render(false, {renderContext: `delete${collection}`, renderData: ids}); } /* -------------------------------------------- */ /** * Whenever the Document's sheet changes, close any existing applications for this Document, and re-render the new * sheet if one was already open. * @param {object} [options] * @param {boolean} [options.sheetOpen] Whether the sheet was originally open and needs to be re-opened. * @internal */ async _onSheetChange({ sheetOpen }={}) { sheetOpen ??= this.sheet.rendered; await Promise.all(Object.values(this.apps).map(app => app.close())); this._sheet = null; if ( sheetOpen ) this.sheet.render(true); // Re-draw the parent sheet in case of a dependency on the child sheet. this.parent?.sheet?.render(false); } /* -------------------------------------------- */ /** * Gets the default new name for a Document * @param {object} context The context for which to create the Document name. * @param {string} [context.type] The sub-type of the document * @param {Document|null} [context.parent] A parent document within which the created Document should belong * @param {string|null} [context.pack] A compendium pack within which the Document should be created * @returns {string} */ static defaultName({type, parent, pack}={}) { const documentName = this.metadata.name; let collection; if ( parent ) collection = parent.getEmbeddedCollection(documentName); else if ( pack ) collection = game.packs.get(pack); else collection = game.collections.get(documentName); const takenNames = new Set(); for ( const document of collection ) takenNames.add(document.name); let baseNameKey = this.metadata.label; if ( type && this.hasTypeData ) { const typeNameKey = CONFIG[documentName].typeLabels?.[type]; if ( typeNameKey && game.i18n.has(typeNameKey) ) baseNameKey = typeNameKey; } const baseName = game.i18n.localize(baseNameKey); let name = baseName; let index = 1; while ( takenNames.has(name) ) name = `${baseName} (${++index})`; return name; } /* -------------------------------------------- */ /* Importing and Exporting */ /* -------------------------------------------- */ /** * Present a Dialog form to create a new Document of this type. * Choose a name and a type from a select menu of types. * @param {object} data Initial data with which to populate the creation form * @param {object} [context={}] Additional context options or dialog positioning options * @param {Document|null} [context.parent] A parent document within which the created Document should belong * @param {string|null} [context.pack] A compendium pack within which the Document should be created * @param {string[]} [context.types] A restriction the selectable sub-types of the Dialog. * @returns {Promise} A Promise which resolves to the created Document, or null if the dialog was * closed. * @memberof ClientDocumentMixin */ static async createDialog(data={}, {parent=null, pack=null, types, ...options}={}) { const cls = this.implementation; // Identify allowed types let documentTypes = []; let defaultType = CONFIG[this.documentName]?.defaultType; let defaultTypeAllowed = false; let hasTypes = false; if ( this.TYPES.length > 1 ) { if ( types?.length === 0 ) throw new Error("The array of sub-types to restrict to must not be empty"); // Register supported types for ( const type of this.TYPES ) { if ( type === CONST.BASE_DOCUMENT_TYPE ) continue; if ( types && !types.includes(type) ) continue; let label = CONFIG[this.documentName]?.typeLabels?.[type]; label = label && game.i18n.has(label) ? game.i18n.localize(label) : type; documentTypes.push({value: type, label}); if ( type === defaultType ) defaultTypeAllowed = true; } if ( !documentTypes.length ) throw new Error("No document types were permitted to be created"); if ( !defaultTypeAllowed ) defaultType = documentTypes[0].value; // Sort alphabetically documentTypes.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang)); hasTypes = true; } // Identify destination collection let collection; if ( !parent ) { if ( pack ) collection = game.packs.get(pack); else collection = game.collections.get(this.documentName); } // Collect data const folders = collection?._formatFolderSelectOptions() ?? []; const label = game.i18n.localize(this.metadata.label); const title = game.i18n.format("DOCUMENT.Create", {type: label}); const type = data.type || defaultType; // Render the document creation form const html = await renderTemplate("templates/sidebar/document-create.html", { folders, name: data.name || "", defaultName: cls.defaultName({type, parent, pack}), folder: data.folder, hasFolders: folders.length >= 1, hasTypes, type, types: documentTypes }); // Render the confirmation dialog window return Dialog.prompt({ title, content: html, label: title, render: html => { if ( !hasTypes ) return; html[0].querySelector('[name="type"]').addEventListener("change", e => { const nameInput = html[0].querySelector('[name="name"]'); nameInput.placeholder = cls.defaultName({type: e.target.value, parent, pack}); }); }, callback: html => { const form = html[0].querySelector("form"); const fd = new FormDataExtended(form); foundry.utils.mergeObject(data, fd.object, {inplace: true}); if ( !data.folder ) delete data.folder; if ( !data.name?.trim() ) data.name = cls.defaultName({type: data.type, parent, pack}); return cls.create(data, {parent, pack, renderSheet: true}); }, rejectClose: false, options }); } /* -------------------------------------------- */ /** * Present a Dialog form to confirm deletion of this Document. * @param {object} [options] Positioning and sizing options for the resulting dialog * @returns {Promise} A Promise which resolves to the deleted Document */ async deleteDialog(options={}) { const type = game.i18n.localize(this.constructor.metadata.label); return Dialog.confirm({ title: `${game.i18n.format("DOCUMENT.Delete", {type})}: ${this.name}`, content: `

${game.i18n.localize("AreYouSure")}

${game.i18n.format("SIDEBAR.DeleteWarning", {type})}

`, yes: () => this.delete(), options: options }); } /* -------------------------------------------- */ /** * Export document data to a JSON file which can be saved by the client and later imported into a different session. * Only world Documents may be exported. * @param {object} [options] Additional options passed to the {@link ClientDocumentMixin#toCompendium} method * @memberof ClientDocumentMixin# */ exportToJSON(options) { if ( !CONST.WORLD_DOCUMENT_TYPES.includes(this.documentName) ) { throw new Error("Only world Documents may be exported"); } const data = this.toCompendium(null, options); data.flags.exportSource = { world: game.world.id, system: game.system.id, coreVersion: game.version, systemVersion: game.system.version }; const filename = ["fvtt", this.documentName, this.name?.slugify(), this.id].filterJoin("-"); saveDataToFile(JSON.stringify(data, null, 2), "text/json", `${filename}.json`); } /* -------------------------------------------- */ /** * Serialize salient information about this Document when dragging it. * @returns {object} An object of drag data. */ toDragData() { const dragData = {type: this.documentName}; if ( this.id ) dragData.uuid = this.uuid; else dragData.data = this.toObject(); return dragData; } /* -------------------------------------------- */ /** * A helper function to handle obtaining the relevant Document from dropped data provided via a DataTransfer event. * The dropped data could have: * 1. A data object explicitly provided * 2. A UUID * @memberof ClientDocumentMixin * * @param {object} data The data object extracted from a DataTransfer event * @param {object} options Additional options which affect drop data behavior * @returns {Promise} The resolved Document * @throws If a Document could not be retrieved from the provided data. */ static async fromDropData(data, options={}) { let document = null; // Case 1 - Data explicitly provided if ( data.data ) document = new this(data.data); // Case 2 - UUID provided else if ( data.uuid ) document = await fromUuid(data.uuid); // Ensure that we retrieved a valid document if ( !document ) { throw new Error("Failed to resolve Document from provided DragData. Either data or a UUID must be provided."); } if ( document.documentName !== this.documentName ) { throw new Error(`Invalid Document type '${document.type}' provided to ${this.name}.fromDropData.`); } // Flag the source UUID if ( document.id && !document._stats?.compendiumSource ) { document.updateSource({"_stats.compendiumSource": document.uuid}); } return document; } /* -------------------------------------------- */ /** * Create the Document from the given source with migration applied to it. * Only primary Documents may be imported. * * This function must be used to create a document from data that predates the current core version. * It must be given nonpartial data matching the schema it had in the core version it is coming from. * It applies legacy migrations to the source data before calling {@link Document.fromSource}. * If this function is not used to import old data, necessary migrations may not applied to the data * resulting in an incorrectly imported document. * * The core version is recorded in the `_stats` field, which all primary documents have. If the given source data * doesn't contain a `_stats` field, the data is assumed to be pre-V10, when the `_stats` field didn't exist yet. * The `_stats` field must not be stripped from the data before it is exported! * @param {object} source The document data that is imported. * @param {DocumentConstructionContext & DataValidationOptions} [context] * The model construction context passed to {@link Document.fromSource}. * @param {boolean} [context.strict=true] Strict validation is enabled by default. * @returns {Promise} */ static async fromImport(source, context) { if ( !CONST.PRIMARY_DOCUMENT_TYPES.includes(this.documentName) ) { throw new Error("Only primary Documents may be imported"); } const coreVersion = source._stats?.coreVersion; if ( coreVersion && foundry.utils.isNewerVersion(coreVersion, game.version) ) { throw new Error("Documents from a core version newer than the running version cannot be imported"); } if ( coreVersion !== game.version ) { const response = await new Promise(resolve => { game.socket.emit("migrateDocumentData", this.documentName, source, resolve); }); if ( response.error ) throw new Error(response.error); source = response.source; } return this.fromSource(source, {strict: true, ...context}); } /* -------------------------------------------- */ /** * Update this Document using a provided JSON string. * Only world Documents may be imported. * @this {ClientDocument} * @param {string} json Raw JSON data to import * @returns {Promise} The updated Document instance */ async importFromJSON(json) { if ( !CONST.WORLD_DOCUMENT_TYPES.includes(this.documentName) ) { throw new Error("Only world Documents may be imported"); } // Create a document from the JSON data const parsedJSON = JSON.parse(json); const doc = await this.constructor.fromImport(parsedJSON); // Treat JSON import using the same workflows that are used when importing from a compendium pack const data = this.collection.fromCompendium(doc); // Preserve certain fields from the destination document const preserve = Object.fromEntries(this.constructor.metadata.preserveOnImport.map(k => { return [k, foundry.utils.getProperty(this, k)]; })); preserve.folder = this.folder?.id; foundry.utils.mergeObject(data, preserve); // Commit the import as an update to this document await this.update(data, {diff: false, recursive: false, noHook: true}); ui.notifications.info(game.i18n.format("DOCUMENT.Imported", {document: this.documentName, name: this.name})); return this; } /* -------------------------------------------- */ /** * Render an import dialog for updating the data related to this Document through an exported JSON file * @returns {Promise} * @memberof ClientDocumentMixin# */ async importFromJSONDialog() { new Dialog({ title: `Import Data: ${this.name}`, content: await renderTemplate("templates/apps/import-data.html", { hint1: game.i18n.format("DOCUMENT.ImportDataHint1", {document: this.documentName}), hint2: game.i18n.format("DOCUMENT.ImportDataHint2", {name: this.name}) }), buttons: { import: { icon: '', label: "Import", callback: html => { const form = html.find("form")[0]; if ( !form.data.files.length ) return ui.notifications.error("You did not upload a data file!"); readTextFromFile(form.data.files[0]).then(json => this.importFromJSON(json)); } }, no: { icon: '', label: "Cancel" } }, default: "import" }, { width: 400 }).render(true); } /* -------------------------------------------- */ /** * Transform the Document data to be stored in a Compendium pack. * Remove any features of the data which are world-specific. * @param {CompendiumCollection} [pack] A specific pack being exported to * @param {object} [options] Additional options which modify how the document is converted * @param {boolean} [options.clearFlags=false] Clear the flags object * @param {boolean} [options.clearSource=true] Clear any prior source information * @param {boolean} [options.clearSort=true] Clear the currently assigned sort order * @param {boolean} [options.clearFolder=false] Clear the currently assigned folder * @param {boolean} [options.clearOwnership=true] Clear document ownership * @param {boolean} [options.clearState=true] Clear fields which store document state * @param {boolean} [options.keepId=false] Retain the current Document id * @returns {object} A data object of cleaned data suitable for compendium import * @memberof ClientDocumentMixin# */ toCompendium(pack, {clearSort=true, clearFolder=false, clearFlags=false, clearSource=true, clearOwnership=true, clearState=true, keepId=false} = {}) { const data = this.toObject(); if ( !keepId ) delete data._id; if ( clearSort ) delete data.sort; if ( clearFolder ) delete data.folder; if ( clearFlags ) delete data.flags; if ( clearSource ) { delete data._stats?.compendiumSource; delete data._stats?.duplicateSource; } if ( clearOwnership ) delete data.ownership; if ( clearState ) delete data.active; return data; } /* -------------------------------------------- */ /* Enrichment */ /* -------------------------------------------- */ /** * Create a content link for this Document. * @param {Partial} [options] Additional options to configure how the link is constructed. * @returns {HTMLAnchorElement} */ toAnchor({attrs={}, dataset={}, classes=[], name, icon}={}) { // Build dataset const documentConfig = CONFIG[this.documentName]; const documentName = game.i18n.localize(`DOCUMENT.${this.documentName}`); let anchorIcon = icon ?? documentConfig.sidebarIcon; if ( !classes.includes("content-link") ) classes.unshift("content-link"); attrs = foundry.utils.mergeObject({ draggable: "true" }, attrs); dataset = foundry.utils.mergeObject({ link: "", uuid: this.uuid, id: this.id, type: this.documentName, pack: this.pack, tooltip: documentName }, dataset); // If this is a typed document, add the type to the dataset if ( this.type ) { const typeLabel = documentConfig.typeLabels[this.type]; const typeName = game.i18n.has(typeLabel) ? `${game.i18n.localize(typeLabel)}` : ""; dataset.tooltip = typeName ? game.i18n.format("DOCUMENT.TypePageFormat", {type: typeName, page: documentName}) : documentName; anchorIcon = icon ?? documentConfig.typeIcons?.[this.type] ?? documentConfig.sidebarIcon; } name ??= this.name; return TextEditor.createAnchor({ attrs, dataset, name, classes, icon: anchorIcon }); } /* -------------------------------------------- */ /** * Convert a Document to some HTML display for embedding purposes. * @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior. * @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed * content also contains text that must be enriched. * @returns {Promise} A representation of the Document as HTML content, or null if such a * representation could not be generated. */ async toEmbed(config, options={}) { const content = await this._buildEmbedHTML(config, options); if ( !content ) return null; let embed; if ( config.inline ) embed = await this._createInlineEmbed(content, config, options); else embed = await this._createFigureEmbed(content, config, options); if ( embed ) { embed.classList.add("content-embed"); embed.dataset.uuid = this.uuid; embed.dataset.contentEmbed = ""; if ( config.classes ) embed.classList.add(...config.classes.split(" ")); } return embed; } /* -------------------------------------------- */ /** * A method that can be overridden by subclasses to customize embedded HTML generation. * @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior. * @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed * content also contains text that must be enriched. * @returns {Promise} Either a single root element to append, or a collection of * elements that comprise the embedded content. * @protected */ async _buildEmbedHTML(config, options={}) { return this.system instanceof foundry.abstract.TypeDataModel ? this.system.toEmbed(config, options) : null; } /* -------------------------------------------- */ /** * A method that can be overridden by subclasses to customize inline embedded HTML generation. * @param {HTMLElement|HTMLCollection} content The embedded content. * @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior. * @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed * content also contains text that must be enriched. * @returns {Promise} * @protected */ async _createInlineEmbed(content, config, options) { const section = document.createElement("section"); if ( content instanceof HTMLCollection ) section.append(...content); else section.append(content); return section; } /* -------------------------------------------- */ /** * A method that can be overridden by subclasses to customize the generation of the embed figure. * @param {HTMLElement|HTMLCollection} content The embedded content. * @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior. * @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed * content also contains text that must be enriched. * @returns {Promise} * @protected */ async _createFigureEmbed(content, { cite, caption, captionPosition="bottom", label }, options) { const figure = document.createElement("figure"); if ( content instanceof HTMLCollection ) figure.append(...content); else figure.append(content); if ( cite || caption ) { const figcaption = document.createElement("figcaption"); if ( caption ) figcaption.innerHTML += `${label || this.name}`; if ( cite ) figcaption.innerHTML += `${this.toAnchor().outerHTML}`; figure.insertAdjacentElement(captionPosition === "bottom" ? "beforeend" : "afterbegin", figcaption); if ( captionPosition === "top" ) figure.append(figcaption.querySelector(":scope > cite")); } return figure; } /* -------------------------------------------- */ /* Deprecations */ /* -------------------------------------------- */ /** * The following are stubs to prevent errors where existing classes may be attempting to call them via super. */ /** * @deprecated since v11 * @ignore */ _preCreateEmbeddedDocuments() {} /** * @deprecated since v11 * @ignore */ _preUpdateEmbeddedDocuments() {} /** * @deprecated since v11 * @ignore */ _preDeleteEmbeddedDocuments() {} /** * @deprecated since v11 * @ignore */ _onCreateEmbeddedDocuments() {} /** * @deprecated since v11 * @ignore */ _onUpdateEmbeddedDocuments() {} /** * @deprecated since v11 * @ignore */ _onDeleteEmbeddedDocuments() {} }; }