import CONFIGURATION from "./adventure.mjs"; /** * @typedef {Object} LocalizationData * @property {Set} 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 = `

Importer Options

`; for ( const [name, option] of Object.entries(this.config.importOptions) ) { options += `
`; } options += `
`; 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 = `
`; 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 += `
`; } 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} */ 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} */ 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} */ 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} group A group of documents to be created or updated * @param {LocalizationData} localization Localization configuration data * @returns {Promise} */ 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)); }); }); } }