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.

302 lines
12 KiB

  1. import CONFIGURATION from "./adventure.mjs";
  2. /**
  3. * @typedef {Object} LocalizationData
  4. * @property {Set<string>} html HTML files which provide Journal Entry page translations
  5. * @property {object} i18n An object of localization keys and translation strings
  6. */
  7. /**
  8. * A subclass of the core AdventureImporter which performs some special functions for Pathfinder premium content.
  9. */
  10. export default class PF2EAdventureImporter extends AdventureImporter {
  11. constructor(adventure, options) {
  12. super(adventure, options);
  13. this.config = CONFIGURATION.adventure;
  14. this.options.classes.push(CONFIGURATION.cssClass);
  15. }
  16. /* -------------------------------------------- */
  17. /** @inheritDoc */
  18. async getData(options={}) {
  19. return foundry.utils.mergeObject(await super.getData(options), {
  20. importOptions: this.config.importOptions || {}
  21. });
  22. }
  23. /* -------------------------------------------- */
  24. /** @inheritDoc */
  25. async _renderInner(data) {
  26. const html = await super._renderInner(data);
  27. if ( !this.config.importOptions ) return html;
  28. // Insert import controls.
  29. const imported = game.settings.get(CONFIGURATION.moduleId, "imported");
  30. if ( imported ) this.#addImportControls(html.find(".adventure-contents")[0]);
  31. // Insert options and return
  32. html.find(".adventure-contents").append(this.#formatOptions());
  33. return html;
  34. }
  35. /* -------------------------------------------- */
  36. /**
  37. * Format adventure import options block.
  38. * @returns {string}
  39. */
  40. #formatOptions() {
  41. let options = `<section class="import-form"><h2>Importer Options</h2>`;
  42. for ( const [name, option] of Object.entries(this.config.importOptions) ) {
  43. options += `<div class="form-group">
  44. <label class="checkbox">
  45. <input type="checkbox" name="${name}" title="${option.label}" ${option.default ? "checked" : ""}/>
  46. ${option.label}
  47. </label>
  48. </div>`;
  49. }
  50. options += `</section>`;
  51. return options;
  52. }
  53. /* -------------------------------------------- */
  54. /**
  55. * Add controls for which content to import.
  56. * @param {HTMLElement} content The adventure content container.
  57. */
  58. #addImportControls(content) {
  59. const heading = content.querySelector("h2");
  60. const list = content.querySelector("ul");
  61. const section = document.createElement("section");
  62. section.classList.add("import-controls");
  63. let html = `
  64. <div class="form-group">
  65. <label class="checkbox">
  66. <input type="checkbox" name="importFields" value="all" title="Import All" checked>
  67. Import All
  68. </label>
  69. </div>
  70. `;
  71. for (const [field, cls] of Object.entries(Adventure.contentFields)) {
  72. const count = this.object[field].size;
  73. if ( !count ) continue;
  74. const label = game.i18n.localize(count > 1 ? cls.metadata.labelPlural : cls.metadata.label);
  75. html += `
  76. <div class="form-group">
  77. <label class="checkbox">
  78. <input type="checkbox" name="importFields" value="${field}" title="Import ${label}"
  79. checked disabled>
  80. <i class="${CONFIG[cls.documentName].sidebarIcon}"></i>
  81. ${count} ${label}
  82. </label>
  83. </div>
  84. `;
  85. }
  86. section.innerHTML = html;
  87. section.insertAdjacentElement("afterbegin", heading);
  88. list.before(section);
  89. list.remove();
  90. section.querySelector('[value="all"]').addEventListener("change", event => {
  91. this.#onToggleImportAll(event);
  92. });
  93. }
  94. /* -------------------------------------------- */
  95. /**
  96. * Handle toggling the import all checkbox.
  97. * @param {Event} event The change event.
  98. */
  99. #onToggleImportAll(event) {
  100. const target = event.currentTarget;
  101. const section = target.closest(".import-controls");
  102. const checked = target.checked;
  103. section.querySelectorAll("input").forEach(input => {
  104. if ( input.value !== "folders" ) input.disabled = checked;
  105. if ( checked ) input.checked = true;
  106. });
  107. target.disabled = false;
  108. }
  109. /* -------------------------------------------- */
  110. /** @inheritDoc */
  111. async _prepareImportData(formData) {
  112. this.submitOptions = formData;
  113. const {toCreate, toUpdate, documentCount} = await super._prepareImportData(formData);
  114. this.#applyImportControls(formData, toCreate, toUpdate);
  115. this.#applyEnhancedMapsPreference(formData.enhancedMaps, toCreate, toUpdate);
  116. // Prepare localization data
  117. const localization = await this.#prepareLocalizationData();
  118. // Merge Compendium Actor data
  119. if ( "Actor" in toCreate ) await this.#mergeCompendiumActors(toCreate.Actor, formData);
  120. if ( "Actor" in toUpdate ) await this.#mergeCompendiumActors(toUpdate.Actor, formData);
  121. // Merge Journal HTML data
  122. if ( "JournalEntry" in toCreate ) await this.#mergeJournalHTML(toCreate.JournalEntry, localization);
  123. if ( "JournalEntry" in toUpdate ) await this.#mergeJournalHTML(toUpdate.JournalEntry, localization);
  124. // Apply localized translations
  125. await this.#applyTranslations(toCreate, localization);
  126. await this.#applyTranslations(toUpdate, localization);
  127. return {toCreate, toUpdate, documentCount};
  128. }
  129. /* -------------------------------------------- */
  130. /** @inheritDoc */
  131. async _importContent(toCreate, toUpdate, documentCount) {
  132. const importResult = await super._importContent(toCreate, toUpdate, documentCount);
  133. for ( let [name, option] of Object.entries(this.config.importOptions || {}) ) {
  134. if ( !option.handler ) continue;
  135. await option.handler(this.document, option, this.submitOptions[name]);
  136. }
  137. return importResult;
  138. }
  139. /* -------------------------------------------- */
  140. /* Pre-Import Customizations */
  141. /* -------------------------------------------- */
  142. /**
  143. * Get available localization data which can be used during the import process
  144. * @returns {Promise<LocalizationData>}
  145. */
  146. async #prepareLocalizationData() {
  147. const path = `modules/${CONFIGURATION.moduleId}/lang/${game.i18n.lang}/${this.config.slug}`;
  148. if ( game.i18n.lang === "en" ) return {path, i18n: {}, html: new Set()};
  149. const json = `${path}/${this.config.slug}.json`;
  150. try {
  151. const files = (await FilePicker.browse("data", path)).files;
  152. const i18n = files.includes(json) ? await fetch(json).then(r => r.json()) : {};
  153. const html = new Set(files.filter(f => f.endsWith(".html")));
  154. return {path, i18n, html};
  155. } catch(err) {
  156. return {path, i18n: {}, html: new Set()};
  157. }
  158. }
  159. /* -------------------------------------------- */
  160. /**
  161. * Merge Actor data with authoritative source data from system compendium packs
  162. * @param {Actor[]} actors Actor documents intended to be imported
  163. * @param {object} importOptions Form submission import options
  164. * @returns {Promise<void>}
  165. */
  166. async #mergeCompendiumActors(actors, importOptions) {
  167. for ( const actor of actors ) {
  168. const sourceId = actor.flags?.core?.sourceId;
  169. if ( !sourceId ) {
  170. console.warn(`[${CONFIGURATION.moduleId}] Actor "${actor.name}" [${actor._id}] had no `
  171. + "sourceId to retrieve source data from.");
  172. continue;
  173. }
  174. const source = await fromUuid(sourceId);
  175. if ( source ) {
  176. const {system, items, effects} = source.toObject();
  177. const updateData = {
  178. system, items, effects,
  179. "flags.core.sourceId": source.uuid
  180. };
  181. const overrides = this.config.actorOverrides[actor._id] || [];
  182. for ( const field of overrides ) delete updateData[field];
  183. foundry.utils.mergeObject(actor, updateData);
  184. } else {
  185. const [, scope, packName] = sourceId?.split(".") ?? [];
  186. console.warn(`[${CONFIGURATION.moduleId}] Compendium source data for "${actor.name}" `
  187. + `[${actor._id}] not found in pack ${scope}.${packName}.`);
  188. }
  189. }
  190. }
  191. /* -------------------------------------------- */
  192. /**
  193. * Merge JournalEntry data with localized source HTML.
  194. * @param {JournalEntry[]} entries JournalEntry documents intended to be imported
  195. * @param {LocalizationData} localization Localization configuration data
  196. * @returns {Promise<void>}
  197. */
  198. async #mergeJournalHTML(entries, localization) {
  199. for ( const entry of entries ) {
  200. for ( const page of entry.pages ) {
  201. const htmlFile = `${localization.path}/${page._id}-${page.name.slugify({strict: true})}.html`;
  202. if ( localization.html.has(htmlFile) ) {
  203. const content = await fetch(htmlFile).then(r => r.text()).catch(err => null);
  204. if ( content ) foundry.utils.mergeObject(page, {"text.content": content});
  205. }
  206. }
  207. }
  208. }
  209. /* -------------------------------------------- */
  210. /**
  211. * Apply localization translations to documents prior to import.
  212. * @param {Object<string,Document[]>} group A group of documents to be created or updated
  213. * @param {LocalizationData} localization Localization configuration data
  214. * @returns {Promise<void>}
  215. */
  216. async #applyTranslations(group, localization) {
  217. for ( const [documentName, documents] of Object.entries(group) ) {
  218. const translations = localization.i18n[documentName] || [];
  219. for ( const document of documents ) {
  220. const translation = translations.find(d => d._id === document._id);
  221. if ( translation ) foundry.utils.mergeObject(document, translation);
  222. }
  223. }
  224. }
  225. /* -------------------------------------------- */
  226. /**
  227. * Remove adventure content that the user indicated they did not want to import.
  228. * @param {object} formData The submitted adventure form data.
  229. * @param {object} toCreate An object of document data to create.
  230. * @param {object} toUpdate An object of document data to update.
  231. */
  232. #applyImportControls(formData, toCreate, toUpdate) {
  233. if ( !game.settings.get(CONFIGURATION.moduleId, "imported") ) return;
  234. const fields = formData.importFields.filter(_ => _);
  235. fields.push("folders");
  236. if ( !fields || !Array.isArray(fields) || fields.some(field => field === "all") ) return;
  237. const keep = new Set(fields.map(field => Adventure.contentFields[field].documentName));
  238. [toCreate, toUpdate].forEach(docs => {
  239. for ( const type of Object.keys(docs) ) {
  240. if ( !keep.has(type) ) delete docs[type];
  241. }
  242. if ( docs.Folder ) docs.Folder = docs.Folder.filter(f => keep.has(f.type));
  243. });
  244. }
  245. /* -------------------------------------------- */
  246. /**
  247. * Remove scenes from the import depending on whether the user wants only the enhanced maps or
  248. * only the original ones.
  249. * @param {boolean} useEnhanced Whether to import enhanced or original maps.
  250. * @param {object} toCreate An object of document data to create.
  251. * @param {object} toUpdate An object of document data to update.
  252. */
  253. #applyEnhancedMapsPreference(useEnhanced, toCreate, toUpdate) {
  254. const sceneIds = this.config.importOptions.enhancedMaps.sceneIds;
  255. const affectedScenes = new Set(sceneIds.original.concat(sceneIds.enhanced));
  256. const original = new Set(sceneIds.original);
  257. const enhanced = new Set(sceneIds.enhanced);
  258. [toCreate, toUpdate].forEach(docs => {
  259. if ( docs.Scene ) docs.Scene = docs.Scene.filter(s => {
  260. if ( !affectedScenes.has(s._id) ) return true;
  261. return (useEnhanced && enhanced.has(s._id)) || (!useEnhanced && original.has(s._id));
  262. });
  263. });
  264. }
  265. }