Initial
This commit is contained in:
524
resources/app/client/apps/forms/adventure-exporter.js
Normal file
524
resources/app/client/apps/forms/adventure-exporter.js
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* An interface for packaging Adventure content and loading it to a compendium pack.
|
||||
* // TODO - add a warning if you are building the adventure with any missing content
|
||||
* // TODO - add a warning if you are building an adventure that sources content from a different package' compendium
|
||||
*/
|
||||
class AdventureExporter extends DocumentSheet {
|
||||
constructor(document, options={}) {
|
||||
super(document, options);
|
||||
if ( !document.pack ) {
|
||||
throw new Error("You may not export an Adventure that does not belong to a Compendium pack");
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
template: "templates/adventure/exporter.html",
|
||||
id: "adventure-exporter",
|
||||
classes: ["sheet", "adventure", "adventure-exporter"],
|
||||
width: 560,
|
||||
height: "auto",
|
||||
tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "summary"}],
|
||||
dragDrop: [{ dropSelector: "form" }],
|
||||
scrollY: [".tab.contents"],
|
||||
submitOnClose: false,
|
||||
closeOnSubmit: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An alias for the Adventure document
|
||||
* @type {Adventure}
|
||||
*/
|
||||
adventure = this.object;
|
||||
|
||||
/**
|
||||
* @typedef {Object} AdventureContentTreeNode
|
||||
* @property {string} id An alias for folder.id
|
||||
* @property {string} name An alias for folder.name
|
||||
* @property {Folder} folder The Folder at this node level
|
||||
* @property {string} state The modification state of the Folder
|
||||
* @property {AdventureContentTreeNode[]} children An array of child nodes
|
||||
* @property {{id: string, name: string, document: ClientDocument, state: string}[]} documents An array of documents
|
||||
*/
|
||||
/**
|
||||
* @typedef {AdventureContentTreeNode} AdventureContentTreeRoot
|
||||
* @property {null} id The folder ID is null at the root level
|
||||
* @property {string} documentName The Document name contained in this tree
|
||||
* @property {string} collection The Document collection name of this tree
|
||||
* @property {string} name The name displayed at the root level of the tree
|
||||
* @property {string} icon The icon displayed at the root level of the tree
|
||||
* @property {string} collapseIcon The icon which represents the current collapsed state of the tree
|
||||
* @property {string} cssClass CSS classes which describe the display of the tree
|
||||
* @property {number} documentCount The number of documents which are present in the tree
|
||||
*/
|
||||
/**
|
||||
* The prepared document tree which is displayed in the form.
|
||||
* @type {Record<string, AdventureContentTreeRoot>}
|
||||
*/
|
||||
contentTree = {};
|
||||
|
||||
/**
|
||||
* A mapping which allows convenient access to content tree nodes by their folder ID
|
||||
* @type {Record<string, AdventureContentTreeNode>}
|
||||
*/
|
||||
#treeNodes = {};
|
||||
|
||||
/**
|
||||
* Track data for content which has been added to the adventure.
|
||||
* @type {Record<string, Set<ClientDocument>>}
|
||||
*/
|
||||
#addedContent = Object.keys(Adventure.contentFields).reduce((obj, f) => {
|
||||
obj[f] = new Set();
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Track the IDs of content which has been removed from the adventure.
|
||||
* @type {Record<string, Set<string>>}
|
||||
*/
|
||||
#removedContent = Object.keys(Adventure.contentFields).reduce((obj, f) => {
|
||||
obj[f] = new Set();
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Track which sections of the contents are collapsed.
|
||||
* @type {Set<string>}
|
||||
* @private
|
||||
*/
|
||||
#collapsedSections = new Set();
|
||||
|
||||
/** @override */
|
||||
get isEditable() {
|
||||
return game.user.isGM;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Application Rendering */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData(options={}) {
|
||||
this.contentTree = this.#organizeContentTree();
|
||||
return {
|
||||
adventure: this.adventure,
|
||||
contentTree: this.contentTree
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async activateEditor(name, options={}, initialContent="") {
|
||||
options.plugins = {
|
||||
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema),
|
||||
keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema)
|
||||
};
|
||||
return super.activateEditor(name, options, initialContent);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_getHeaderButtons() {
|
||||
return super._getHeaderButtons().filter(btn => btn.label !== "Import");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Organize content in the adventure into a tree structure which is displayed in the UI.
|
||||
* @returns {Record<string, AdventureContentTreeRoot>}
|
||||
*/
|
||||
#organizeContentTree() {
|
||||
const content = {};
|
||||
let remainingFolders = Array.from(this.adventure.folders).concat(Array.from(this.#addedContent.folders || []));
|
||||
|
||||
// Prepare each content section
|
||||
for ( const [name, cls] of Object.entries(Adventure.contentFields) ) {
|
||||
if ( name === "folders" ) continue;
|
||||
|
||||
// Partition content for the section
|
||||
let documents = Array.from(this.adventure[name]).concat(Array.from(this.#addedContent[name] || []));
|
||||
let folders;
|
||||
[remainingFolders, folders] = remainingFolders.partition(f => f.type === cls.documentName);
|
||||
if ( !(documents.length || folders.length) ) continue;
|
||||
|
||||
// Prepare the root node
|
||||
const collapsed = this.#collapsedSections.has(cls.documentName);
|
||||
const section = content[name] = {
|
||||
documentName: cls.documentName,
|
||||
collection: cls.collectionName,
|
||||
id: null,
|
||||
name: game.i18n.localize(cls.metadata.labelPlural),
|
||||
icon: CONFIG[cls.documentName].sidebarIcon,
|
||||
collapseIcon: collapsed ? "fa-solid fa-angle-up" : "fa-solid fa-angle-down",
|
||||
cssClass: [cls.collectionName, collapsed ? "collapsed" : ""].filterJoin(" "),
|
||||
documentCount: documents.length - this.#removedContent[name].size,
|
||||
folder: null,
|
||||
state: "root",
|
||||
children: [],
|
||||
documents: []
|
||||
};
|
||||
|
||||
// Recursively populate the tree
|
||||
[folders, documents] = this.#populateNode(section, folders, documents);
|
||||
|
||||
// Add leftover documents to the section root
|
||||
for ( const d of documents ) {
|
||||
const state = this.#getDocumentState(d);
|
||||
section.documents.push({document: d, id: d.id, name: d.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`});
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Populate one node of the content tree with folders and documents
|
||||
* @param {AdventureContentTreeNode }node The node being populated
|
||||
* @param {Folder[]} remainingFolders Folders which have yet to be populated to a node
|
||||
* @param {ClientDocument[]} remainingDocuments Documents which have yet to be populated to a node
|
||||
* @returns {Array<Folder[], ClientDocument[]>} Folders and Documents which still have yet to be populated
|
||||
*/
|
||||
#populateNode(node, remainingFolders, remainingDocuments) {
|
||||
|
||||
// Allocate Documents to this node
|
||||
let documents;
|
||||
[remainingDocuments, documents] = remainingDocuments.partition(d => d._source.folder === node.id );
|
||||
for ( const d of documents ) {
|
||||
const state = this.#getDocumentState(d);
|
||||
node.documents.push({document: d, id: d.id, name: d.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`});
|
||||
}
|
||||
|
||||
// Allocate Folders to this node
|
||||
let folders;
|
||||
[remainingFolders, folders] = remainingFolders.partition(f => f._source.folder === node.id);
|
||||
for ( const folder of folders ) {
|
||||
const state = this.#getDocumentState(folder);
|
||||
const child = {folder, id: folder.id, name: folder.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`,
|
||||
children: [], documents: []};
|
||||
[remainingFolders, remainingDocuments] = this.#populateNode(child, remainingFolders, remainingDocuments);
|
||||
node.children.push(child);
|
||||
this.#treeNodes[folder.id] = child;
|
||||
}
|
||||
return [remainingFolders, remainingDocuments];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Flag the current state of each document which is displayed
|
||||
* @param {ClientDocument} document The document being modified
|
||||
* @returns {string} The document state
|
||||
*/
|
||||
#getDocumentState(document) {
|
||||
const cn = document.collectionName;
|
||||
if ( this.#removedContent[cn].has(document.id) ) return "remove";
|
||||
if ( this.#addedContent[cn].has(document) ) return "add";
|
||||
const worldCollection = game.collections.get(document.documentName);
|
||||
if ( !worldCollection.has(document.id) ) return "missing";
|
||||
return "update";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async close(options = {}) {
|
||||
this.adventure.reset(); // Reset any pending changes
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
html.on("click", "a.control", this.#onClickControl.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _updateObject(event, adventureData) {
|
||||
|
||||
// Build the adventure data content
|
||||
for ( const [name, cls] of Object.entries(Adventure.contentFields) ) {
|
||||
const collection = game.collections.get(cls.documentName);
|
||||
adventureData[name] = [];
|
||||
const addDoc = id => {
|
||||
if ( this.#removedContent[name].has(id) ) return;
|
||||
const doc = collection.get(id);
|
||||
if ( !doc ) return;
|
||||
adventureData[name].push(doc.toObject());
|
||||
};
|
||||
for ( const d of this.adventure[name] ) addDoc(d.id);
|
||||
for ( const d of this.#addedContent[name] ) addDoc(d.id);
|
||||
}
|
||||
|
||||
const pack = game.packs.get(this.adventure.pack);
|
||||
const restrictedDocuments = adventureData.actors?.length || adventureData.items?.length
|
||||
|| adventureData.folders?.some(f => CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(f.type));
|
||||
if ( restrictedDocuments && !pack?.metadata.system ) {
|
||||
return ui.notifications.error("ADVENTURE.ExportPackNoSystem", {localize: true, permanent: true});
|
||||
}
|
||||
|
||||
// Create or update the document
|
||||
if ( this.adventure.id ) {
|
||||
const updated = await this.adventure.update(adventureData, {diff: false, recursive: false});
|
||||
pack.indexDocument(updated);
|
||||
ui.notifications.info(game.i18n.format("ADVENTURE.UpdateSuccess", {name: this.adventure.name}));
|
||||
} else {
|
||||
await this.adventure.constructor.createDocuments([adventureData], {
|
||||
pack: this.adventure.pack,
|
||||
keepId: true,
|
||||
keepEmbeddedIds: true
|
||||
});
|
||||
ui.notifications.info(game.i18n.format("ADVENTURE.CreateSuccess", {name: this.adventure.name}));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Save editing progress so that re-renders of the form do not wipe out un-saved changes.
|
||||
*/
|
||||
#saveProgress() {
|
||||
const formData = this._getSubmitData();
|
||||
this.adventure.updateSource(formData);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle pointer events on a control button
|
||||
* @param {PointerEvent} event The originating pointer event
|
||||
*/
|
||||
#onClickControl(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget;
|
||||
switch ( button.dataset.action ) {
|
||||
case "clear":
|
||||
return this.#onClearSection(button);
|
||||
case "collapse":
|
||||
return this.#onCollapseSection(button);
|
||||
case "remove":
|
||||
return this.#onRemoveContent(button);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clear all content from a particular document-type section.
|
||||
* @param {HTMLAnchorElement} button The clicked control button
|
||||
*/
|
||||
#onClearSection(button) {
|
||||
const section = button.closest(".document-type");
|
||||
const documentType = section.dataset.documentType;
|
||||
const cls = getDocumentClass(documentType);
|
||||
this.#removeNode(this.contentTree[cls.collectionName]);
|
||||
this.#saveProgress();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle the collapsed or expanded state of a document-type section
|
||||
* @param {HTMLAnchorElement} button The clicked control button
|
||||
*/
|
||||
#onCollapseSection(button) {
|
||||
const section = button.closest(".document-type");
|
||||
const icon = button.firstElementChild;
|
||||
const documentType = section.dataset.documentType;
|
||||
const isCollapsed = this.#collapsedSections.has(documentType);
|
||||
if ( isCollapsed ) {
|
||||
this.#collapsedSections.delete(documentType);
|
||||
section.classList.remove("collapsed");
|
||||
icon.classList.replace("fa-angle-up", "fa-angle-down");
|
||||
} else {
|
||||
this.#collapsedSections.add(documentType);
|
||||
section.classList.add("collapsed");
|
||||
icon.classList.replace("fa-angle-down", "fa-angle-up");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a single piece of content.
|
||||
* @param {HTMLAnchorElement} button The clicked control button
|
||||
*/
|
||||
#onRemoveContent(button) {
|
||||
const h4 = button.closest("h4");
|
||||
const isFolder = h4.classList.contains("folder");
|
||||
const documentName = isFolder ? "Folder" : button.closest(".document-type").dataset.documentType;
|
||||
const document = this.#getDocument(documentName, h4.dataset.documentId);
|
||||
if ( document ) {
|
||||
this.removeContent(document);
|
||||
this.#saveProgress();
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Document instance from the clicked content tag.
|
||||
* @param {string} documentName The document type
|
||||
* @param {string} documentId The document ID
|
||||
* @returns {ClientDocument|null} The Document instance, or null
|
||||
*/
|
||||
#getDocument(documentName, documentId) {
|
||||
const cls = getDocumentClass(documentName);
|
||||
const cn = cls.collectionName;
|
||||
const existing = this.adventure[cn].find(d => d.id === documentId);
|
||||
if ( existing ) return existing;
|
||||
const added = this.#addedContent[cn].find(d => d.id === documentId);
|
||||
return added || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Content Drop Handling */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onDrop(event) {
|
||||
const data = TextEditor.getDragEventData(event);
|
||||
const cls = getDocumentClass(data?.type);
|
||||
if ( !cls || !(cls.collectionName in Adventure.contentFields) ) return;
|
||||
const document = await cls.fromDropData(data);
|
||||
if ( document.pack || document.isEmbedded ) {
|
||||
return ui.notifications.error("ADVENTURE.ExportPrimaryDocumentsOnly", {localize: true});
|
||||
}
|
||||
const pack = game.packs.get(this.adventure.pack);
|
||||
const type = data?.type === "Folder" ? document.type : data?.type;
|
||||
if ( !pack?.metadata.system && CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(type) ) {
|
||||
return ui.notifications.error("ADVENTURE.ExportPackNoSystem", {localize: true});
|
||||
}
|
||||
this.addContent(document);
|
||||
this.#saveProgress();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Content Management Workflows */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Stage a document for addition to the Adventure.
|
||||
* This adds the document locally, the change is not yet submitted to the database.
|
||||
* @param {Folder|ClientDocument} document Some document to be added to the Adventure.
|
||||
*/
|
||||
addContent(document) {
|
||||
if ( document instanceof foundry.documents.BaseFolder ) this.#addFolder(document);
|
||||
if ( document.folder ) this.#addDocument(document.folder);
|
||||
this.#addDocument(document);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a single Document from the Adventure.
|
||||
* @param {ClientDocument} document The Document being removed from the Adventure.
|
||||
*/
|
||||
removeContent(document) {
|
||||
if ( document instanceof foundry.documents.BaseFolder ) {
|
||||
const node = this.#treeNodes[document.id];
|
||||
if ( !node ) return;
|
||||
if ( this.#removedContent.folders.has(node.id) ) return this.#restoreNode(node);
|
||||
return this.#removeNode(node);
|
||||
}
|
||||
else this.#removeDocument(document);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a single document from the content tree
|
||||
* @param {AdventureContentTreeNode} node The node to remove
|
||||
*/
|
||||
#removeNode(node) {
|
||||
for ( const child of node.children ) this.#removeNode(child);
|
||||
for ( const d of node.documents ) this.#removeDocument(d.document);
|
||||
if ( node.folder ) this.#removeDocument(node.folder);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Restore a removed node back to the content tree
|
||||
* @param {AdventureContentTreeNode} node The node to restore
|
||||
*/
|
||||
#restoreNode(node) {
|
||||
for ( const child of node.children ) this.#restoreNode(child);
|
||||
for ( const d of node.documents ) this.#removedContent[d.document.collectionName].delete(d.id);
|
||||
return this.#removedContent.folders.delete(node.id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a single document from the content tree
|
||||
* @param {ClientDocument} document The document to remove
|
||||
*/
|
||||
#removeDocument(document) {
|
||||
const cn = document.collectionName;
|
||||
|
||||
// If the Document was already removed, re-add it
|
||||
if ( this.#removedContent[cn].has(document.id) ) {
|
||||
this.#removedContent[cn].delete(document.id);
|
||||
}
|
||||
|
||||
// If the content was temporarily added, remove it
|
||||
else if ( this.#addedContent[cn].has(document) ) {
|
||||
this.#addedContent[cn].delete(document);
|
||||
}
|
||||
|
||||
// Otherwise, mark the content as removed
|
||||
else this.#removedContent[cn].add(document.id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add an entire folder tree including contained documents and subfolders to the Adventure.
|
||||
* @param {Folder} folder The folder to add
|
||||
* @private
|
||||
*/
|
||||
#addFolder(folder) {
|
||||
this.#addDocument(folder);
|
||||
for ( const doc of folder.contents ) {
|
||||
this.#addDocument(doc);
|
||||
}
|
||||
for ( const sub of folder.getSubfolders() ) {
|
||||
this.#addFolder(sub);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a single document to the Adventure.
|
||||
* @param {ClientDocument} document The Document to add
|
||||
* @private
|
||||
*/
|
||||
#addDocument(document) {
|
||||
const cn = document.collectionName;
|
||||
|
||||
// If the document was previously removed, restore it
|
||||
if ( this.#removedContent[cn].has(document.id) ) {
|
||||
return this.#removedContent[cn].delete(document.id);
|
||||
}
|
||||
|
||||
// Otherwise, add documents which don't yet exist
|
||||
const existing = this.adventure[cn].find(d => d.id === document.id);
|
||||
if ( !existing ) this.#addedContent[cn].add(document);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user