525 lines
19 KiB
JavaScript
525 lines
19 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|