All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

299 lines
11 KiB

import CONFIGURATION from "./adventure.mjs";
/**
* @typedef {Object} LocalizationData
* @property {Set<string>} html HTML files which provide Journal Entry page translations
* @property {object} i18n An object of localization keys and translation strings
*/
/**
* A subclass of the core AdventureImporter which performs some special functions for Pathfinder premium content.
*/
export default class PF2EAdventureImporter extends AdventureImporter {
constructor(adventure, options) {
super(adventure, options);
this.config = CONFIGURATION.adventure;
this.options.classes.push(CONFIGURATION.cssClass);
}
/* -------------------------------------------- */
/** @inheritDoc */
async getData(options={}) {
return foundry.utils.mergeObject(await super.getData(), {
importOptions: this.config.importOptions || {}
});
}
/* -------------------------------------------- */
/** @inheritDoc */
async _renderInner(data) {
const html = await super._renderInner(data);
if ( !this.config.importOptions ) return html;
// Insert import controls.
const imported = game.settings.get(CONFIGURATION.moduleId, "imported");
if ( imported ) this.#addImportControls(html.find(".adventure-contents")[0]);
// Insert options and return
html.find(".adventure-contents").append(this.#formatOptions());
return html;
}
/* -------------------------------------------- */
/**
* Format adventure import options block.
* @returns {string}
*/
#formatOptions() {
let options = `<section class="import-form"><h2>Importer Options</h2>`;
for ( const [name, option] of Object.entries(this.config.importOptions) ) {
options += `<div class="form-group">
<label class="checkbox">
<input type="checkbox" name="${name}" title="${option.label}" ${option.default ? "checked" : ""}/>
${option.label}
</label>
</div>`;
}
options += `</section>`;
return options;
}
/* -------------------------------------------- */
/**
* Add controls for which content to import.
* @param {HTMLElement} content The adventure content container.
*/
#addImportControls(content) {
const heading = content.querySelector("h2");
const list = content.querySelector("ul");
const section = document.createElement("section");
section.classList.add("import-controls");
let html = `
<div class="form-group">
<label class="checkbox">
<input type="checkbox" name="importFields" value="all" title="Import All" checked>
Import All
</label>
</div>
`;
for (const [field, cls] of Object.entries(Adventure.contentFields)) {
const count = this.object[field].size;
if ( !count ) continue;
const label = game.i18n.localize(count > 1 ? cls.metadata.labelPlural : cls.metadata.label);
html += `
<div class="form-group">
<label class="checkbox">
<input type="checkbox" name="importFields" value="${field}" title="Import ${label}"
checked disabled>
<i class="${CONFIG[cls.documentName].sidebarIcon}"></i>
${count} ${label}
</label>
</div>
`;
}
section.innerHTML = html;
section.insertAdjacentElement("afterbegin", heading);
list.before(section);
list.remove();
section.querySelector('[value="all"]').addEventListener("change", event => {
this.#onToggleImportAll(event);
});
}
/* -------------------------------------------- */
/**
* Handle toggling the import all checkbox.
* @param {Event} event The change event.
*/
#onToggleImportAll(event) {
const target = event.currentTarget;
const section = target.closest(".import-controls");
const checked = target.checked;
section.querySelectorAll("input").forEach(input => {
if ( input.value !== "folders" ) input.disabled = checked;
if ( checked ) input.checked = true;
});
target.disabled = false;
}
/* -------------------------------------------- */
/** @inheritDoc */
async _prepareImportData(formData) {
this.submitOptions = formData;
const {toCreate, toUpdate, documentCount} = await super._prepareImportData(formData);
this.#applyImportControls(formData, toCreate, toUpdate);
this.#applyEnhancedMapsPreference(formData.enhancedMaps, toCreate, toUpdate);
// Prepare localization data
const localization = await this.#prepareLocalizationData();
// Merge Compendium Actor data
if ( "Actor" in toCreate ) await this.#mergeCompendiumActors(toCreate.Actor, formData);
if ( "Actor" in toUpdate ) await this.#mergeCompendiumActors(toUpdate.Actor, formData);
// Merge Journal HTML data
if ( "JournalEntry" in toCreate ) await this.#mergeJournalHTML(toCreate.JournalEntry, localization);
if ( "JournalEntry" in toUpdate ) await this.#mergeJournalHTML(toUpdate.JournalEntry, localization);
// Apply localized translations
await this.#applyTranslations(toCreate, localization);
await this.#applyTranslations(toUpdate, localization);
return {toCreate, toUpdate, documentCount};
}
/* -------------------------------------------- */
/** @inheritDoc */
async _importContent(toCreate, toUpdate, documentCount) {
const importResult = await super._importContent(toCreate, toUpdate, documentCount);
for ( let [name, option] of Object.entries(this.config.importOptions || {}) ) {
if ( !option.handler ) continue;
await option.handler(this.document, option, this.submitOptions[name]);
}
return importResult;
}
/* -------------------------------------------- */
/* Pre-Import Customizations */
/* -------------------------------------------- */
/**
* Get available localization data which can be used during the import process
* @returns {Promise<LocalizationData>}
*/
async #prepareLocalizationData() {
const path = `modules/${CONFIGURATION.moduleId}/lang/${game.i18n.lang}/${this.config.slug}`;
if ( game.i18n.lang === "en" ) return {path, i18n: {}, html: new Set()};
const json = `${path}/${this.config.slug}.json`;
try {
const files = (await FilePicker.browse("data", path)).files;
const i18n = files.includes(json) ? await fetch(json).then(r => r.json()) : {};
const html = new Set(files.filter(f => f.endsWith(".html")));
return {path, i18n, html};
} catch(err) {
return {path, i18n: {}, html: new Set()};
}
}
/* -------------------------------------------- */
/**
* Merge Actor data with authoritative source data from system compendium packs
* @param {Actor[]} actors Actor documents intended to be imported
* @param {object} importOptions Form submission import options
* @returns {Promise<void>}
*/
async #mergeCompendiumActors(actors, importOptions) {
for ( const actor of actors ) {
const sourceId = actor.flags?.core?.sourceId;
if ( !sourceId ) {
console.warn(`[${CONFIGURATION.moduleId}] Actor "${actor.name}" [${actor._id}] had no `
+ "sourceId to retrieve source data from.");
continue;
}
const source = await fromUuid(sourceId);
if ( source ) {
const {system, items, effects} = source.toObject();
const updateData = {
system, items, effects,
"flags.core.sourceId": source.uuid
};
const overrides = this.config.actorOverrides[actor._id] || [];
for ( const field of overrides ) delete updateData[field];
foundry.utils.mergeObject(actor, updateData);
} else {
const [, scope, packName] = sourceId?.split(".") ?? [];
console.warn(`[${CONFIGURATION.moduleId}] Compendium source data for "${actor.name}" `
+ `[${actor._id}] not found in pack ${scope}.${packName}.`);
}
}
}
/* -------------------------------------------- */
/**
* Merge JournalEntry data with localized source HTML.
* @param {JournalEntry[]} entries JournalEntry documents intended to be imported
* @param {LocalizationData} localization Localization configuration data
* @returns {Promise<void>}
*/
async #mergeJournalHTML(entries, localization) {
for ( const entry of entries ) {
for ( const page of entry.pages ) {
const htmlFile = `${localization.path}/${page._id}-${page.name.slugify({strict: true})}.html`;
if ( localization.html.has(htmlFile) ) {
const content = await fetch(htmlFile).then(r => r.text()).catch(err => null);
if ( content ) foundry.utils.mergeObject(page, {"text.content": content});
}
}
}
}
/* -------------------------------------------- */
/**
* Apply localization translations to documents prior to import.
* @param {Object<string,Document[]>} group A group of documents to be created or updated
* @param {LocalizationData} localization Localization configuration data
* @returns {Promise<void>}
*/
async #applyTranslations(group, localization) {
for ( const [documentName, documents] of Object.entries(group) ) {
const translations = localization.i18n[documentName] || [];
for ( const document of documents ) {
const translation = translations.find(d => d._id === document._id);
if ( translation ) foundry.utils.mergeObject(document, translation);
}
}
}
/* -------------------------------------------- */
/**
* Remove adventure content that the user indicated they did not want to import.
* @param {object} formData The submitted adventure form data.
* @param {object} toCreate An object of document data to create.
* @param {object} toUpdate An object of document data to update.
*/
#applyImportControls(formData, toCreate, toUpdate) {
if ( !game.settings.get(CONFIGURATION.moduleId, "imported") ) return;
const fields = formData.importFields.filter(_ => _);
fields.push("folders");
if ( !fields || !Array.isArray(fields) || fields.some(field => field === "all") ) return;
const keep = new Set(fields.map(field => Adventure.contentFields[field].documentName));
[toCreate, toUpdate].forEach(docs => {
for ( const type of Object.keys(docs) ) {
if ( !keep.has(type) ) delete docs[type];
}
if ( docs.Folder ) docs.Folder = docs.Folder.filter(f => keep.has(f.type));
});
}
/* -------------------------------------------- */
/**
* Remove scenes from the import depending on whether the user wants only the enhanced maps or
* only the original ones.
* @param {boolean} useEnhanced Whether to import enhanced or original maps.
* @param {object} toCreate An object of document data to create.
* @param {object} toUpdate An object of document data to update.
*/
#applyEnhancedMapsPreference(useEnhanced, toCreate, toUpdate) {
const sceneIds = this.config.importOptions.enhancedMaps.sceneIds;
const affectedScenes = new Set(sceneIds.original.concat(sceneIds.enhanced));
const original = new Set(sceneIds.original);
const enhanced = new Set(sceneIds.enhanced);
[toCreate, toUpdate].forEach(docs => {
if ( docs.Scene ) docs.Scene = docs.Scene.filter(s => {
if ( !affectedScenes.has(s._id) ) return true;
return (useEnhanced && enhanced.has(s._id)) || (!useEnhanced && original.has(s._id));
});
});
}
}