(function () { 'use strict'; const _app$6 = foundry.applications.api; /** * Display the End User License Agreement and prompt the user to agree before moving forwards. * @extends ApplicationV2 * @mixes HandlebarsApplication */ class EULA extends _app$6.HandlebarsApplicationMixin(_app$6.ApplicationV2) { /** @inheritDoc */ static DEFAULT_OPTIONS = foundry.utils.mergeObject(super.DEFAULT_OPTIONS, { id: "eula", tag: "aside", window: { title: "End User License Agreement", icon: "fa-solid fa-file-contract", minimizable: false }, position: { width: 800, height: "auto" }, actions: { accept: EULA.#onClickAccept, decline: EULA.#onClickDecline } }, {inplace: false}); /** @override */ static PARTS = { content: { id: "content", template: "templates/setup/parts/eula-content.hbs", scrollable: [""] }, form: { id: "form", template: "templates/setup/parts/eula-form.hbs" } }; /* -------------------------------------------- */ /** @override */ async _prepareContext(options) { const html = await foundry.utils.fetchWithTimeout("license.html").then(r => r.text()); return {html}; } /* -------------------------------------------- */ /** @inheritDoc */ async _renderFrame(options) { const frame = await super._renderFrame(options); this.window.controlsDropdown.remove(); this.window.close.remove(); return frame; } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Require the user to have checked the agreement box in order to proceed. * @this {EULA} * @param {PointerEvent} event * @param {HTMLButtonElement} target */ static #onClickAccept(event, target) { if ( !this.parts.form.agree.checked ) { event.preventDefault(); event.stopPropagation(); ui.notifications.error("EULA.ErrorAgreeRequired", {localize: true}); } } /* -------------------------------------------- */ /** * Require the user to have checked the agreement box in order to proceed. * @this {EULA} * @param {PointerEvent} event * @param {HTMLButtonElement} target */ static #onClickDecline(event, target) { this.parts.form.agree.checked = false; ui.notifications.warn("EULA.Declined", {localize: true}); setTimeout(() => window.location.href = CONST.WEBSITE_URL, 1000); } } const _app$5 = foundry.applications.api; /** * The Join Game setup application. * @extends ApplicationV2 * @mixes HandlebarsApplication */ class JoinGameForm extends _app$5.HandlebarsApplicationMixin(_app$5.ApplicationV2) { constructor(options) { super(options); game.users.apps.push(this); } /** @inheritDoc */ static DEFAULT_OPTIONS = foundry.utils.mergeObject(super.DEFAULT_OPTIONS, { id: "join-game", window: { frame: false, positioned: false } }, {inplace: false}); /** @override */ static PARTS = { form: { id: "form", template: "templates/setup/parts/join-form.hbs", forms: { "#join-game-form": { handler: JoinGameForm.#onSubmitLoginForm } } }, details: { id: "details", template: "templates/setup/parts/join-details.hbs", }, setup: { id: "setup", template: "templates/setup/parts/join-setup.hbs", forms: { "#join-game-setup": { handler: JoinGameForm.#onSubmitSetupForm } } }, world: { id: "world", template: "templates/setup/parts/join-world.hbs", scrollable: ["#world-description"] } }; /* -------------------------------------------- */ /** @inheritDoc */ _configureRenderOptions(options) { super._configureRenderOptions(options); if ( game.world.joinTheme === "minimal" ) { const minimalParts = ["form", "setup"]; options.parts = options.parts.filter(p => minimalParts.includes(p)); } } /* -------------------------------------------- */ /** @override */ async _prepareContext(options) { const context = { isAdmin: game.data.isAdmin, users: game.users, world: game.world, passwordString: game.data.passwordString, usersCurrent: game.users.filter(u => u.active).length, usersMax: game.users.contents.length }; // Next session time const nextDate = new Date(game.world.nextSession || undefined); if ( nextDate.isValid() ) { context.nextTime = nextDate.toLocaleTimeString(game.i18n.lang, { weekday: "long", month: "short", day: "numeric", hour: "numeric", minute: "numeric", timeZoneName: "short" }); } return context; } /* -------------------------------------------- */ /** @inheritDoc */ _syncPartState(partId, newElement, priorElement, state) { super._syncPartState(partId, newElement, priorElement, state); // Retain login form state if ( partId === "form" ) { newElement.userid.value = priorElement.userid.value; if ( newElement.userid.selectedOptions[0]?.disabled ) newElement.userid.value = ""; newElement.password.value = priorElement.password.value; } } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * @this {JoinGameForm} * @param {SubmitEvent} event * @param {HTMLFormElement} form * @param {FormDataExtended} formData */ static async #onSubmitSetupForm(event, form, formData) { event.preventDefault(); form.disabled = true; // Display a warning if other players are connected const othersActive = game.users.filter(u => u.active).length; if ( othersActive ) { const warning = othersActive > 1 ? "GAME.ReturnSetupActiveUsers" : "GAME.ReturnSetupActiveUser"; const confirm = await Dialog.confirm({ title: game.i18n.localize("GAME.ReturnSetup"), content: `

${game.i18n.format(warning, {number: othersActive})}

` }); if ( !confirm ) { form.disabled = false; return; } } // Submit the request const postData = Object.assign(formData.object, {action: "shutdown"}); return this.#post(form, postData); } /* -------------------------------------------- */ /** * @this {JoinGameForm} * @param {SubmitEvent} event * @param {HTMLFormElement} form * @param {FormDataExtended} formData */ static async #onSubmitLoginForm(event, form, formData) { event.preventDefault(); if ( !formData.get("userid") ) return ui.notifications.error("JOIN.ErrorMustSelectUser", {localize: true}); const postData = Object.assign(formData.object, {action: "join"}); return this.#post(form, postData); } /* -------------------------------------------- */ /** * Submit join view POST requests to the server for handling. * @param {HTMLFormElement} form The form being submitted * @param {object} postData The processed form data * @returns {Promise} */ async #post(form, postData) { form.disabled = true; const joinURL = foundry.utils.getRoute("join"); const user = game.users.get(postData.userid)?.name || postData.userid; let response; // Submit the request try { response = await foundry.utils.fetchJsonWithTimeout(joinURL, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(postData) }); } catch(e) { if (e instanceof foundry.utils.HttpError) { const error = game.i18n.format(e.displayMessage, {user}); ui.notifications.error(error); } else { ui.notifications.error(e); } form.disabled = false; return; } // Redirect on success ui.notifications.info(game.i18n.format(response.message, {user})); setTimeout(() => window.location.href = response.redirect, 500 ); } } /** * A form application for managing core server configuration options. * @see config.ApplicationConfiguration */ class SetupApplicationConfiguration extends FormApplication { /** * An ApplicationConfiguration instance which is used for validation and processing of form changes. * @type {config.ApplicationConfiguration} */ config = new foundry.config.ApplicationConfiguration(this.object); /** @inheritdoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "setup-configuration", template: "templates/setup/app-configuration.hbs", title: "SETUP.ConfigTitle", popOut: true, width: 720 }); } /** * Which CSS theme is currently being previewed * @type {string} */ #previewTheme = this.config.cssTheme; /* -------------------------------------------- */ /** @override */ getData(options={}) { const worlds = Array.from(game.worlds.values()); worlds.sort((a, b) => a.title.localeCompare(b.title, game.i18n.lang)); return { noAdminPW: !game.data.options.adminPassword, config: this.config.toObject(), cssThemes: CONST.CSS_THEMES, languages: this.#getLanguages(), fields: this.config.schema.fields, worlds: worlds, }; } /* -------------------------------------------- */ /** * Get the set of languages which are choices for selection. * @returns {FormSelectOption[]} */ #getLanguages() { const options = []; for ( const l of game.data.languages ) { for ( const m of l.modules ) { options.push({group: l.label, value: `${l.id}.${m.id}`, label: `${l.label} - ${m.label}`}); } } return options; } /* -------------------------------------------- */ /** @inheritDoc */ async close(options) { this.#applyThemeChange(this.config.cssTheme); return super.close(options); } /* -------------------------------------------- */ /** @override */ async _onChangeInput(event) { this.#applyThemeChange(this.form.cssTheme.value); } /* -------------------------------------------- */ /** @override */ async _onSubmit(event, options={}) { event.preventDefault(); const original = this.config.toObject(); // Validate the proposed changes const formData = this._getSubmitData(); let changes; try { changes = this.config.updateSource(formData); } catch(err) { return ui.notifications.error(err.message); } if ( foundry.utils.isEmpty(changes) ) return this.close(); // Confirm that a server restart is okay const confirm = await Dialog.confirm({ title: game.i18n.localize("SETUP.ConfigSave"), content: `

${game.i18n.localize("SETUP.ConfigSaveWarning")}

`, defaultYes: false, options: {width: 480} }); // Submit the form if ( confirm ) { const response = await Setup.post({action: "adminConfigure", config: changes}); if ( response.restart ) ui.notifications.info("SETUP.ConfigSaveRestart", {localize: true, permanent: true}); return this.close(); } // Reset the form this.config.updateSource(original); return this.render(); } /* -------------------------------------------- */ /** @override */ async _updateObject(event, formData) {} /* -------------------------------------------- */ /** * Update the body class with the previewed CSS theme. * @param {string} themeId The theme ID to preview */ #applyThemeChange(themeId) { document.body.classList.replace(`theme-${this.#previewTheme}`, `theme-${themeId}`); this.#previewTheme = themeId; } /* -------------------------------------------- */ /** * Prompt the user with a request to share telemetry data if they have not yet chosen an option. * @returns {Promise} */ static async telemetryRequestDialog() { if ( game.data.options.telemetry !== undefined ) return; const response = await Dialog.wait({ title: game.i18n.localize("SETUP.TelemetryRequestTitle"), content: `

${game.i18n.localize("SETUP.TelemetryRequest1")}

` + `
${game.i18n.localize("SETUP.TelemetryHint")}
` + `

${game.i18n.localize("SETUP.TelemetryRequest2")}

`, focus: true, close: () => null, buttons: { yes: { icon: '', label: game.i18n.localize("SETUP.TelemetryAllow"), callback: () => true }, no: { icon: '', label: game.i18n.localize("SETUP.TelemetryDecline"), callback: () => false } } }, {width: 480}); if ( response !== null ) { const { changes } = await Setup.post({action: "adminConfigure", config: {telemetry: response}}); foundry.utils.mergeObject(game.data.options, changes); } } } const _app$4 = foundry.applications.api; /** * The Setup Authentication Form. * Prompt the user to provide a server administrator password if one has been configured. * @extends ApplicationV2 * @mixes HandlebarsApplication */ class SetupAuthenticationForm extends _app$4.HandlebarsApplicationMixin(_app$4.ApplicationV2) { /** @inheritDoc */ static DEFAULT_OPTIONS = foundry.utils.mergeObject(super.DEFAULT_OPTIONS, { id: "setup-authentication", classes: ["application", "framed"], window: { frame: false, positioned: false } }, {inplace: false}); /** @override */ static PARTS = { form: { template: "templates/setup/setup-authentication.hbs" } }; } const _app$3 = foundry.applications.api; /** * An application that renders the floating setup menu buttons. * @extends ApplicationV2 * @mixes HandlebarsApplication */ class SetupWarnings extends _app$3.HandlebarsApplicationMixin(_app$3.ApplicationV2) { /** @inheritDoc */ static DEFAULT_OPTIONS = foundry.utils.mergeObject(super.DEFAULT_OPTIONS, { id: "setup-warnings", window: { title: "SETUP.WarningsTitle", icon: "fa-solid fa-triangle-exclamation" }, position: { width: 680 }, actions: { reinstallPackage: SetupWarnings.#reinstallPackage, uninstallPackage: SetupWarnings.#uninstallPackage, managePackage: SetupWarnings.#managePackage } }, {inplace: false}); /** @override */ static PARTS = { packages: { id: "packages", template: "templates/setup/setup-warnings.hbs", scrollable: [""] } } /* -------------------------------------------- */ /** @inheritDoc */ get title() { return `${super.title} (${game.issueCount.total})`; } /* -------------------------------------------- */ /** @override */ async _prepareContext(options) { const categories = { world: {label: "SETUP.Worlds", packages: {}}, system: {label: "SETUP.Systems", packages: {}}, module: {label: "SETUP.Modules", packages: {}} }; // Organize warnings for ( const pkg of Object.values(game.data.packageWarnings) ) { const cls = PACKAGE_TYPES[pkg.type]; const p = game[cls.collection].get(pkg.id); categories[pkg.type].packages[pkg.id] = { id: pkg.id, type: pkg.type, name: p ? p.title : "", errors: pkg.error.map(e => e.trim()).join("\n"), warnings: pkg.warning.map(e => e.trim()).join("\n"), reinstallable: pkg.reinstallable, installed: p !== undefined }; } // Filter categories to ones which have issues for ( const [k, v] of Object.entries(categories) ) { if ( foundry.utils.isEmpty(v.packages) ) delete categories[k]; } return {categories}; } /* -------------------------------------------- */ /** * Handle button clicks to manage the package in the main setup interface. * @param {PointerEvent} event The initiating click event * @param {HTMLButtonElement} target The clicked button */ static #managePackage(event, target) { event.preventDefault(); const li = target.closest(".package"); const packageType = li.closest("section[data-package-type]").dataset.packageType; ui.setupPackages.activateTab(`${packageType}s`); // Filter to the target package const packageId = li.dataset.packageId; const filter = ui.setupPackages._searchFilters.find(f => f._inputSelector === `#${packageType}-filter`)._input; filter.value = packageId; filter.dispatchEvent(new Event("input", {bubbles: true})); } /* -------------------------------------------- */ /** * Handle button clicks to reinstall a package. * @param {PointerEvent} event The initiating click event * @param {HTMLButtonElement} target The clicked button */ static async #reinstallPackage(event, target) { event.preventDefault(); const pkg = target.closest("[data-package-id]"); const id = pkg.dataset.packageId; const type = pkg.dataset.packageType; target.querySelector("i").classList.add("fa-spin"); // Uninstall current await Setup.uninstallPackage({id, type}); delete game.data.packageWarnings[id]; // Install package await Setup.warmPackages({ type }); const warnInfo = game.data.packageWarnings[id]; if ( !pkg && !warnInfo?.manifest ) { return ui.notifications.error("SETUP.ReinstallPackageNotFound", { localize: true, permanent: true }); } await Setup.installPackage({ type, id, manifest: warnInfo?.manifest ?? pkg.manifest }); } /* -------------------------------------------- */ /** * Handle button clicks to uninstall a package * @param {PointerEvent} event The initiating click event * @param {HTMLButtonElement} target The clicked button */ static async #uninstallPackage(event, target) { event.preventDefault(); const pkg = target.closest("[data-package-id]"); const id = pkg.dataset.packageId; const type = pkg.dataset.packageType; await Setup.uninstallPackage({id, type}); delete game.data.packageWarnings[id]; } } /** * @typedef {FormApplicationOptions} CategoryFilterApplicationOptions * @property {string} initialCategory The category that is initially selected when the Application first renders. * @property {string[]} inputs A list of selectors for form inputs that should have their values preserved on * re-render. */ /** * @typedef {object} CategoryFilterCategoryContext * @property {string} id The category identifier. * @property {boolean} active Whether the category is currently selected. * @property {string} label The localized category label. * @property {number} count The number of entries in this category. */ /** * An abstract class responsible for displaying a 2-pane Application that allows for entries to be grouped and filtered * by category. */ class CategoryFilterApplication extends FormApplication { /** * The currently selected category. * @type {string} */ #category = this.options.initialCategory; /** * The currently selected category. * @type {string} */ get category() { return this.#category; } /** * Record the state of user inputs. * @type {string[]} * @protected */ _inputs = []; /* -------------------------------------------- */ /** @returns {CategoryFilterApplicationOptions} */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["category-filter"], width: 920, height: 780, scrollY: [".categories", ".entry-list"], filters: [{ inputSelector: 'input[name="filter"]', contentSelector: ".entries" }] }); } /* -------------------------------------------- */ /** @override */ async _updateObject(event, formData) {} /* -------------------------------------------- */ /** @inheritdoc */ async _render(force=false, options={}) { this._saveInputs(); await super._render(force, options); this._restoreInputs(); } /* -------------------------------------------- */ /** @override */ getData(options={}) { const { categories, entries } = this._prepareCategoryData(); categories.sort(this._sortCategories.bind(this)); entries.sort(this._sortEntries.bind(this)); return { categories, entries }; } /* -------------------------------------------- */ /** @inheritdoc */ activateListeners(html) { super.activateListeners(html); html[0].children[0].onsubmit = ev => ev.preventDefault(); html.find(".entry-title h3").on("click", this._onClickEntryTitle.bind(this)); html.find(".categories .category").on("click", this._onClickCategoryFilter.bind(this)); } /* -------------------------------------------- */ /** * Category comparator. * @param {CategoryFilterCategoryContext} a * @param {CategoryFilterCategoryContext} b * @returns {number} * @protected */ _sortCategories(a, b) { return 0; } /* -------------------------------------------- */ /** * Entries comparator. * @param {object} a * @param {object} b * @return {number} * @protected */ _sortEntries(a, b) { return 0; } /* -------------------------------------------- */ /** * Handle click events to filter by a certain category. * @param {PointerEvent} event The triggering event. * @protected */ _onClickCategoryFilter(event) { event.preventDefault(); this.#category = event.currentTarget.dataset.category; this.render(); } /* -------------------------------------------- */ /** @override */ _onSearchFilter(event, query, rgx, html) { if ( html.classList.contains("loading") ) return; for ( const entry of html.querySelectorAll(".entry") ) { if ( !query ) { entry.classList.remove("hidden"); continue; } let match = false; this._getSearchFields(entry).forEach(field => match ||= rgx.test(SearchFilter.cleanQuery(field))); entry.classList.toggle("hidden", !match); } } /* -------------------------------------------- */ /** * Retrieve any additional fields that the entries should be filtered on. * @param {HTMLElement} entry The entry element. * @returns {string[]} * @protected */ _getSearchFields(entry) { return []; } /* -------------------------------------------- */ /** * Record the state of user inputs. * @protected */ _saveInputs() { if ( !this.element.length || !this.options.inputs?.length ) return; this._inputs = this.options.inputs.map(selector => { const input = this.element[0].querySelector(selector); return input?.value ?? ""; }); } /* -------------------------------------------- */ /** * Restore the state of user inputs. * @protected */ _restoreInputs() { if ( !this.options.inputs?.length || !this.element.length ) return; this.options.inputs.forEach((selector, i) => { const value = this._inputs[i] ?? ""; const input = this.element[0].querySelector(selector); if ( input ) input.value = value; }); } /* -------------------------------------------- */ /* Abstract Methods */ /* -------------------------------------------- */ /** * Get category context data. * @returns {{categories: CategoryFilterCategoryContext[], entries: object[]}} * @abstract */ _prepareCategoryData() { return { categories: [], entries: [] }; } /* -------------------------------------------- */ /** * Handle clicking on the entry title. * @param {PointerEvent} event The triggering event. * @abstract */ _onClickEntryTitle(event) {} } /** * An application that manages backups for a single package. */ class BackupList extends FormApplication { /** * The list of available backups for this package. * @type {BackupData[]} */ #backups = []; /** * The backup date formatter. * @type {Intl.DateTimeFormat} */ #dateFormatter = new Intl.DateTimeFormat(game.i18n.lang, { dateStyle: "full", timeStyle: "short" }); /* -------------------------------------------- */ /** @inheritdoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["backup-list", "category-filter"], template: "templates/setup/backup-list.hbs", width: 640, height: 780 }); } /* -------------------------------------------- */ /** @override */ get id() { return `backup-list-${this.object.type}-${this.object.id}`; } /** @override */ get title() { return game.i18n.format("SETUP.BACKUPS.ManagePackage", { package: this.object.title }); } /* -------------------------------------------- */ /** @inheritdoc */ async _render(force=false, options={}) { await super._render(force, options); if ( !Setup.backups && force ) Setup.listBackups().then(() => this.render()); } /* -------------------------------------------- */ /** @override */ getData(options={}) { const context = {}; if ( Setup.backups ) this.#backups = Setup.backups[this.object.type]?.[this.object.id] ?? []; else context.progress = { label: "SETUP.BACKUPS.Loading", icon: "fas fa-spinner fa-spin" }; context.entries = this.#prepareEntries(); return context; } /* -------------------------------------------- */ /** @inheritdoc */ activateListeners(html) { super.activateListeners(html); html.find("[data-action]").on("click", this.#onAction.bind(this)); html.find(".entry-title").on("click", this.#onClickEntryTitle.bind(this)); } /* -------------------------------------------- */ /** @inheritDoc */ _getHeaderButtons() { const buttons = super._getHeaderButtons(); buttons.unshift({ label: "SETUP.BACKUPS.TakeBackup", class: "create-backup", icon: "fas fa-floppy-disk", onclick: this.#onCreateBackup.bind(this) }); return buttons; } /* -------------------------------------------- */ /** * Delete any selected backups. */ async #deleteSelected() { const toDelete = []; for ( const el of this.form.elements ) { if ( el.checked && (el.name !== "select-all") ) toDelete.push(el.name); } await Setup.deleteBackups(this.object, toDelete, { dialog: true }); this.render(); } /* -------------------------------------------- */ /** * Prepare template context data for backup entries. * @returns {BackupEntryUIDescriptor[]} */ #prepareEntries() { return this.#backups.map(backupData => { const { id, size, note, createdAt, snapshotId } = backupData; const tags = [ { label: foundry.utils.formatFileSize(size, { decimalPlaces: 0 }) }, this.constructor.getVersionTag(backupData) ]; if ( snapshotId ) tags.unshift({ label: game.i18n.localize("SETUP.BACKUPS.Snapshot") }); return { id, tags, description: note, inSnapshot: !!snapshotId, noRestore: !this.constructor.canRestoreBackup(backupData), title: this.#dateFormatter.format(createdAt), }; }); } /* -------------------------------------------- */ /** * Determine the version tag for a given backup. * @param {BackupData} backupData The backup. * @returns {BackupEntryTagDescriptor} */ static getVersionTag(backupData) { const cls = PACKAGE_TYPES[backupData.type]; const availability = cls.testAvailability(backupData); return cls.getVersionBadge(availability, backupData); } /* -------------------------------------------- */ /** * Determine if a given backup is allowed to be restored. * @param {BackupData} backupData The backup. * @returns {boolean} */ static canRestoreBackup(backupData) { const { packageId, type } = backupData; const cls = PACKAGE_TYPES[type]; const pkg = game[cls.collection].get(packageId); // If there is no currently-installed version of the package, it can always be restored. if ( !pkg ) return true; const codes = CONST.PACKAGE_AVAILABILITY_CODES; const usable = code => (code >= codes.VERIFIED) && (code <= codes.UNVERIFIED_GENERATION); // If the installed package is already unusable, there is no harm in restoring a backup, it can't make things worse. if ( !usable(pkg.availability) ) return true; // Otherwise check if restoring the backup would make the package unusable. return usable(cls.testAvailability(backupData)); } /* -------------------------------------------- */ /** * Handle clicking on an action button. * @param {PointerEvent} event The triggering event. */ #onAction(event) { const { action } = event.currentTarget.dataset; switch ( action ) { case "delete": this.#deleteSelected(); break; case "restore": this.#onRestore(event); break; case "select-all": this.#toggleSelectAll(event.currentTarget.checked); break; } } /* -------------------------------------------- */ /** * Handle clicking the backup title in order to toggle its checkbox. * @param {PointerEvent} event The triggering event. */ #onClickEntryTitle(event) { const row = event.currentTarget.closest(".checkbox-row"); const checkbox = row.querySelector("input"); if ( !checkbox.disabled ) checkbox.checked = !checkbox.checked; } /* -------------------------------------------- */ /** * Handle creating a new backup. */ async #onCreateBackup() { await Setup.createBackup(this.object, { dialog: true }); this.render(); } /* -------------------------------------------- */ /** * Handle restoring a specific backup. * @param {PointerEvent} event The triggering event. */ async #onRestore(event) { const { backupId } = event.currentTarget.closest("[data-backup-id]").dataset; const backupData = this.#backups.find(entry => entry.id === backupId); const pkg = game[`${this.object.type}s`].get(this.object.id); await Setup.restoreBackup(backupData, { dialog: !!pkg }); this.render(); } /* -------------------------------------------- */ /** * Handle selecting or deselecting all backups. * @param {boolean} select Whether to select or deselect. */ #toggleSelectAll(select) { for ( const el of this.form.elements ) { if ( !el.disabled && (el.type === "checkbox") && (el.name !== "select-all") ) el.checked = select; } } /* -------------------------------------------- */ /** * Toggle the locked state of the interface. * @param {boolean} locked Is the interface locked? */ toggleLock(locked) { const element = this.element[0]; if ( !element ) return; element.querySelectorAll("a.button, .create-backup").forEach(el => el.classList.toggle("disabled", locked)); element.querySelectorAll("button").forEach(el => el.disabled = locked); } } /** * @typedef {object} BackupEntryTagDescriptor * @property {"unsafe"|"warning"|"neutral"|"safe"} [type] The tag type. * @property {string} [icon] An icon class. * @property {string} label The tag text. * @property {string} [tooltip] Tooltip text. */ /** * @typedef {object} BackupEntryUIDescriptor * @property {string} [packageId] The ID of the package this backup represents, if applicable. * @property {string} [backupId] The ID of the package backup, if applicable. * @property {string} [snapshotId] The ID of the snapshot, if applicable. * @property {number} [createdAt] The snapshot's creation timestamp. * @property {string} title The title of the entry. Either a formatted date for snapshots, or the title of the * package for package backups. * @property {string} [restoreLabel] The label for the restore button. * @property {string} description The description for the entry. Either the user's note for snapshots, or the * package description for package backups. * @property {boolean} [inSnapshot] For package backups, this indicates that it is part of a snapshot. * @property {boolean} [noRestore] Is the backup allowed to be restored. * @property {BackupEntryTagDescriptor[]} tags Tag descriptors for the backup or snapshot. */ /** * An Application that manages user backups and snapshots. */ class BackupManager extends CategoryFilterApplication { /** * The snapshot date formatter. * @type {Intl.DateTimeFormat} */ #dateFormatter = new Intl.DateTimeFormat(game.i18n.lang, { dateStyle: "full", timeStyle: "short" }); /* -------------------------------------------- */ /** @inheritdoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "backup-manager", template: "templates/setup/backup-manager.hbs", title: "SETUP.BACKUPS.ManageBackups", inputs: ['[name="filter"]'], initialCategory: "world" }); } /* -------------------------------------------- */ /** @inheritdoc */ async _render(force=false, options={}) { await super._render(force, options); if ( !Setup.backups && force ) Setup.listBackups().then(() => this.render(false)); } /* -------------------------------------------- */ /** @inheritdoc */ getData(options={}) { const context = super.getData(options); // Loading progress. if ( Setup.backups ) { const totalSize = Object.entries(Setup.backups).reduce((acc, [k, v]) => { if ( k === "snapshots" ) return acc; return acc + Object.values(v).reduce((acc, arr) => acc + arr.reduce((acc, d) => acc + d.size, 0), 0); }, 0); context.totalSize = foundry.utils.formatFileSize(totalSize, { decimalPlaces: 0 }); } else context.progress = { label: "SETUP.BACKUPS.Loading", icon: "fas fa-spinner fa-spin" }; context.hasBulkActions = this.category === "snapshots"; return context; } /* -------------------------------------------- */ /** @inheritdoc */ activateListeners(html) { super.activateListeners(html); html.find("[data-action]").on("click", this.#onAction.bind(this)); } /* -------------------------------------------- */ /** @override */ _prepareCategoryData() { const categories = ["snapshots", "world", "module", "system"].map(id => { let count; if ( id === "snapshots" ) count = Object.keys(Setup.backups?.[id] ?? {}).length; else count = Object.values(Setup.backups?.[id] ?? {}).filter(backups => backups.length).length; return { id, count, active: this.category === id, label: game.i18n.localize(`SETUP.BACKUPS.TYPE.${id}`) }; }); let entries; if ( this.category === "snapshots" ) entries = this.#getSnapshotsContext(); else entries = this.#getPackagesContext(this.category); return { categories, entries }; } /* -------------------------------------------- */ /** @override */ _sortEntries(a, b) { if ( this.category === "snapshots" ) return b.createdAt - a.createdAt; return a.title.localeCompare(b.title, game.i18n.lang); } /* -------------------------------------------- */ /** @override */ _sortCategories(a, b) { const order = ["snapshots", "world", "module", "system"]; return order.indexOf(a.id) - order.indexOf(b.id); } /* -------------------------------------------- */ /** * Get snapshot context data. * @returns {BackupEntryUIDescriptor[]} */ #getSnapshotsContext() { return Object.values(Setup.backups?.snapshots ?? {}).map(snapshotData => { const { createdAt } = snapshotData; const versionTag = this.#getSnapshotVersionTag(snapshotData); return { createdAt, snapshotId: snapshotData.id, title: this.#dateFormatter.format(createdAt), restoreLabel: "SETUP.BACKUPS.Restore", description: snapshotData.note, noRestore: versionTag.noRestore, tags: [ versionTag, { label: foundry.utils.formatFileSize(snapshotData.size, { decimalPlaces: 0 }) } ] }; }); } /* -------------------------------------------- */ /** * Determine the version tag for a given snapshot. * @param {SnapshotData} snapshotData The snapshot. * @returns {BackupEntryTagDescriptor} */ #getSnapshotVersionTag({ generation, build }) { const label = game.i18n.format("SETUP.BACKUPS.VersionFormat", { version: `${generation}.${build}` }); // Safe to restore a snapshot taken in the current generation. if ( generation === game.release.generation ) return { label, type: "safe", icon: "fas fa-code-branch" }; // Potentially safe to restore a snapshot from an older generation into a newer generation software version. if ( generation < game.release.generation ) return { label, type: "warning", icon: "fas fa-exclamation-triangle" }; // Impossible to restore a snapshot from a newer generation than the current software version. if ( generation > game.release.generation ) return { label, type: "error", icon: "fa fa-file-slash", noRestore: true }; } /* -------------------------------------------- */ /** * Get package backup context data. * @param {"module"|"system"|"world"} type The package type. * @returns {BackupEntryUIDescriptor[]} */ #getPackagesContext(type) { const entries = []; for ( const backups of Object.values(Setup.backups?.[type] ?? {}) ) { if ( !backups.length ) continue; const newest = backups[0]; const size = backups.reduce((acc, backupData) => acc + backupData.size, 0); const { packageId, title, description } = newest; const pkg = game[PACKAGE_TYPES[type].collection].get(packageId); const tags = [ { label: game.i18n.format(`SETUP.BACKUPS.Num${backups.length === 1 ? "" : "Pl"}`, { number: backups.length }) }, { label: foundry.utils.formatFileSize(size, { decimalPlaces: 0 }) }, BackupList.getVersionTag(newest) ]; entries.push({ packageId, title, tags, packageType: type, backupId: newest.id, restoreLabel: "SETUP.BACKUPS.RestoreLatest", noRestore: !BackupList.canRestoreBackup(newest), packageExists: !!pkg, description: TextEditor.previewHTML(description, 150) }); } return entries; } /* -------------------------------------------- */ /** @override */ _onClickEntryTitle(event) { const { packageId, packageType, packageTitle } = event.currentTarget.closest(".entry").dataset; return new BackupList({ id: packageId, type: packageType, title: packageTitle }).render(true); } /* -------------------------------------------- */ /** * Handle clicking on an action button. * @param {PointerEvent} event The triggering event. */ #onAction(event) { const { action } = event.currentTarget.dataset; switch ( action ) { case "create": this.#onCreateBackup(event); break; case "delete": this.#deleteSelected(); break; case "manage": this._onClickEntryTitle(event); break; case "restore": this.#onRestore(event); break; case "select-all": this.#toggleSelectAll(event.currentTarget.checked); break; } } /* -------------------------------------------- */ /** * Handle selecting or deleting all snapshots. * @param {boolean} select Whether to select or deselect. */ #toggleSelectAll(select) { for ( const el of this.form.elements ) { if ( !el.disabled && (el.type === "checkbox") && (el.name !== "select-all") ) el.checked = select; } } /* -------------------------------------------- */ /** * Handle creating a new package backup. * @param {PointerEvent} event The triggering event. * @returns {Promise} */ async #onCreateBackup(event) { const { packageId, packageType } = event.currentTarget.closest(".entry").dataset; const pkg = game[PACKAGE_TYPES[packageType].collection].get(packageId); if ( !pkg ) return; await Setup.createBackup(pkg, { dialog: true }); this.render(); } /* -------------------------------------------- */ /** * Handle restoring a snapshot or the latest backup. * @param {PointerEvent} event The triggering event. */ async #onRestore(event) { const { packageId, packageType, snapshotId } = event.currentTarget.closest(".entry").dataset; if ( snapshotId ) return Setup.restoreSnapshot(Setup.backups.snapshots[snapshotId], { dialog: true }); const pkg = game[PACKAGE_TYPES[packageType].collection].get(packageId); await Setup.restoreLatestBackup({ id: packageId, type: packageType }, { dialog: !!pkg }); this.render(); } /* -------------------------------------------- */ /** * Handle creating a snapshot. */ async #onCreateSnapshot() { await Setup.createSnapshot({ dialog: true }); this.render(true); } /* -------------------------------------------- */ /** * Delete any selected snapshots. */ async #deleteSelected() { const toDelete = []; for ( const el of this.form.elements ) { if ( el.checked && (el.name !== "select-all") ) toDelete.push(el.name); } await Setup.deleteSnapshots(toDelete, { dialog: true }); this.render(true); } /* -------------------------------------------- */ /** @override */ _getSearchFields(entry) { return [entry.dataset.packageId ?? "", entry.querySelector(".entry-title h3")?.textContent ?? ""]; } /* -------------------------------------------- */ /** @inheritdoc */ _getHeaderButtons() { const buttons = super._getHeaderButtons(); const packages = game.worlds.size + game.systems.size + game.modules.size; if ( packages ) { buttons.unshift({ label: "SETUP.BACKUPS.CreateSnapshot", class: "create-snapshot", icon: "fas fa-camera-retro", onclick: this.#onCreateSnapshot.bind(this) }); } return buttons; } /* -------------------------------------------- */ /** * Toggle the locked state of the interface. * @param {boolean} locked Is the interface locked? */ toggleLock(locked) { const element = this.element[0]; if ( !element ) return; element.querySelectorAll("a.control.category, .create-snapshot, a.button, .entry-title h3").forEach(el => { el.classList.toggle("disabled", locked); }); element.querySelectorAll("button").forEach(el => el.disabled = locked); } } const _app$2 = foundry.applications.api; /** * An application that renders the floating setup menu buttons. * @extends ApplicationV2 * @mixes HandlebarsApplication */ class SetupMenu extends _app$2.HandlebarsApplicationMixin(_app$2.ApplicationV2) { /** @inheritDoc */ static DEFAULT_OPTIONS = foundry.utils.mergeObject(super.DEFAULT_OPTIONS, { id: "setup-menu", tag: "nav", window: { frame: false, positioned: false }, actions: { adminLogout: SetupMenu.#onClickAdminLogout, backups: SetupMenu.#onClickBackupManager, configure: SetupMenu.#onClickConfiguration, update: SetupMenu.#onClickUpdate, viewWarnings: SetupMenu.#onClickViewWarnings } }, {inplace: false}); /** @override */ static PARTS = { buttons: { id: "buttons", template: "templates/setup/parts/setup-menu.hbs", } } /* -------------------------------------------- */ /** @override */ async _prepareContext(options) { let { hasUpdate, channel } = game.data.coreUpdate; hasUpdate &&= ["testing", "stable"].includes(channel); const buttons = [ { action: "viewWarnings", tooltip: game.i18n.localize("Warnings"), icon: "fa-solid fa-triangle-exclamation", pip: game.issueCount.total ? { type: game.issueCount.error > 0 ? "error" : "warning", label: game.issueCount.total } : null }, { action: "configure", tooltip: "Configure", icon: "fa-solid fa-cogs", pip: !game.data.options.adminPassword ? { type: "warning", label: "!" } : null }, { action: "update", tooltip: "Update", icon: "fa-solid fa-download", pip: hasUpdate ? { type: "warning", label: "!" } : null } ]; // Backup const canBackup = !game.data.options.noBackups; if ( canBackup ) buttons.push({ action: "backups", tooltip: game.i18n.localize("SETUP.BACKUPS.ManageBackups"), icon: "fa-solid fa-floppy-disks" }); // Log Out const canLogOut = !!game.data.options.adminPassword; if ( canLogOut ) buttons.push({ action: "adminLogout", tooltip: "Log Out", icon: "fa-solid fa-door-open" }); return {buttons}; } /* -------------------------------------------- */ /** * Toggle the locked state of the interface. * @param {boolean} locked Is the interface locked? */ toggleLock(locked) { if ( !this.rendered ) return; this.element.querySelectorAll("button").forEach(el => el.disabled = locked); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ static #onClickAdminLogout() { Setup.post({action: "adminLogout"}); } static #onClickBackupManager() { new BackupManager().render(true); } static #onClickConfiguration() { new SetupApplicationConfiguration(game.data.options).render(true); } static #onClickUpdate() { window.location.href = foundry.utils.getRoute("update"); } static #onClickViewWarnings(event, target) { const warnings = new SetupWarnings(); const {bottom, right} = target.parentElement.getBoundingClientRect(); warnings.render(true, {left: right - warnings.options.width, top: bottom + 20}); } } /** * A FormApplication which facilitates the creation of a new Module. */ class ModuleConfigurationForm extends FormApplication { constructor(moduleData, options) { super(undefined, options); this.#module = new Module(moduleData || { id: "my-new-module", title: "My New Module", version: "1.0.0", compatibility: { minimum: game.release.generation, verified: game.release.generation } }); this.#source = moduleData ? game.modules.get(this.#module.id) : undefined; } /** @override */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "module-create", template: "templates/setup/module-configuration.hbs", width: 760, height: "auto", tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "basics"}] }); } /** @override */ get title() { if ( !this.#source ) return game.i18n.localize("PACKAGE.ModuleCreate"); return `${game.i18n.localize("PACKAGE.ModuleEdit")}: ${this.#module.title}`; } /** * A Module instance used as the source data for the form and to validate changes. * @type {Module} */ #module; /** * If editing an existing package, track a reference to its persisted data * @type {Module} */ #source; /** * Display a pending relationship which has not yet been confirmed to appear at the bottom of the list? * @type {boolean} */ #pendingRelationship = false; /* -------------------------------------------- */ /** @inheritDoc */ async getData(options={}) { const compendiumTypes = CONST.COMPENDIUM_DOCUMENT_TYPES.map(documentName => { return { value: documentName, label: game.i18n.localize(getDocumentClass(documentName).metadata.label) }; }); game.i18n.sortObjects(compendiumTypes, "label"); return { compendiumTypes, isCreation: !this.#source, module: this.#module, moduleId: this.#source?.id || "", packs: this.#getPacks(), relatedPackages: { systems: Object.fromEntries(Array.from(game.systems.values()).map(s => [s.id, s.title])), modules: Object.fromEntries(Array.from(game.modules.values()).map(m => [m.id, m.title])) }, relationships: this.#getFlattenedRelationships(), relationshipCategories: { requires: "PACKAGE.Relationships.Requires", recommends: "PACKAGE.Relationships.Recommends", conflicts: "PACKAGE.Relationships.Conflicts" }, submitLabel: this.#source ? "PACKAGE.ModuleEdit" : "PACKAGE.ModuleCreate" } } /* -------------------------------------------- */ #getPacks() { return this.#module.packs.map(pack => { return { name: pack.name, label: pack.label, type: pack.type, system: pack.system, creating: pack.flags?._placeholder, existing: this.#source?.packs.find(p => p.name === pack.name) } }); } /* -------------------------------------------- */ /** * Flatten the relationships object into an array which is more convenient for rendering. * @returns {Array<{id: string, type: string, category: string}>} */ #getFlattenedRelationships() { const relationships = []; for ( const [category, rs] of Object.entries(this.#module.relationships) ) { if ( !["systems", "requires", "recommends", "conflicts"].includes(category) ) continue; for ( let [i, r] of Object.entries(Array.from(rs)) ) { r = foundry.utils.deepClone(r); r.category = category; r.index = i; relationships.push(r); } } if ( this.#pendingRelationship ) relationships.push({id: "", category: "", index: -1}); return relationships; } /* -------------------------------------------- */ /** @inheritDoc */ activateListeners(html) { super.activateListeners(html); html.on("click", "[data-action]", this.#onAction.bind(this)); html.on("input", "input[data-slugify]", this.#onSlugify.bind(this)); } /* -------------------------------------------- */ /** * Handle click events on action buttons within the form. * @param {Event} event The originating click event */ #onAction(event) { event.preventDefault(); const button = event.currentTarget; switch ( button.dataset.action ) { case "authorAdd": return this.#authorAdd(); case "authorDelete": return this.#authorDelete(Number(button.dataset.index)); case "packAdd": return this.#packAdd(); case "packDelete": return this.#packDelete(Number(button.dataset.index)); case "relationshipAdd": return this.#relationshipAdd(); case "relationshipDelete": return this.#relationshipDelete(button.dataset.category, Number(button.dataset.index)); } } /* -------------------------------------------- */ /** * Add a new entry to the authors array. */ #authorAdd() { const data = this._getSubmitData(); data.authors.push({name: `Author ${data.authors.length + 1}`}); this.#tryUpdate(data); } /* -------------------------------------------- */ /** * Delete an entry from the authors array. * @param {number} index The array index to delete */ #authorDelete(index) { const data = this._getSubmitData(); data.authors.splice(index, 1); this.#tryUpdate(data); } /* -------------------------------------------- */ /** * Add a new entry to the packs array. */ #packAdd() { const data = this._getSubmitData(); let i = data.packs.length; let nextName; while ( true ) { i++; nextName = `pack-${i}`; if ( !data.packs.find(p => p.name === nextName ) && !this.#source?.packs.find(p => p.name === nextName) ) break; } data.packs.push({ name: nextName, label: `Pack ${i}`, path: `packs/${nextName}`, type: "JournalEntry", ownership: {PLAYER: "OBSERVER", ASSISTANT: "OWNER"}, flags: { _placeholder: true } }); this.#tryUpdate(data); } /* -------------------------------------------- */ /** * Delete an entry from the packs array. * @param {number} index The array index to delete */ #packDelete(index) { const data = this._getSubmitData(); data.packs.splice(index, 1); this.#tryUpdate(data); } /* -------------------------------------------- */ /** * Add a pending relationship entry to the relationships object. */ #relationshipAdd() { this.#pendingRelationship = true; const data = this._getSubmitData(); this.#tryUpdate(data); } /* -------------------------------------------- */ /** * Remove a relationship, or remove the pending relationship from the relationships object. * @param {string} category The relationship category being removed * @param {number} index The array index to delete */ #relationshipDelete(category, index) { const data = this._getSubmitData(); for ( const c of ["systems", "requires", "recommends", "conflicts"] ) { if ( !data.relationships[c] ) continue; for ( const [i, r] of Object.entries(data.relationships[c]) ) { if ( (r._category === category) && (r._index === index) ) { data.relationships[c].splice(i, 1); break; } } } this.#pendingRelationship = false; this.#tryUpdate(data); } /* -------------------------------------------- */ /** @override */ async _onChangeInput(event) { await super._onChangeInput(event); // If the .relationship select changes, update the category select if ( event.target.classList.contains("relationship") ) { this.#updateRelationshipOptions(event.currentTarget); } } /* -------------------------------------------- */ /** @override */ async _render(force, options) { await super._render(force, options); this.element[0].querySelectorAll("select.relationship") .forEach(select => this.#updateRelationshipOptions(select)); } /* -------------------------------------------- */ /** * Swaps what options are available based on Package type * @param {HTMLSelectElement} select The select element */ #updateRelationshipOptions(select) { // If this is a system relationship, the only valid category is "system" const selectedOption = select.options[select.selectedIndex]; const isSystem = selectedOption.parentNode.dataset.category === "system"; const categorySelect = select.closest("fieldset").querySelector("select[name$='.category']"); // Remove the system option, if it exists categorySelect.querySelector("option[value='systems']")?.remove(); categorySelect.disabled = isSystem; if ( isSystem ) { // Create a selected option const option = document.createElement("option"); option.value = "systems"; option.text = game.i18n.localize("PACKAGE.Relationships.Systems"); option.selected = true; // Prepend the selected option categorySelect.prepend(option); } } /* -------------------------------------------- */ /** * Automatically slugify a related input field as text is typed. * @param {Event} event The field input event */ #onSlugify(event) { const input = event.currentTarget; const target = this.form[input.dataset.slugify]; if ( target.disabled ) return; target.placeholder = input.value.slugify({strict: true}); } /* -------------------------------------------- */ /** @override */ _getSubmitData(updateData = {}) { const fd = new FormDataExtended(this.form, {disabled: true}); const formData = foundry.utils.expandObject(fd.object); const moduleData = this.#module.toObject(); // Module ID if ( this.#source ) formData.id = this.#source.id; else if ( !formData.id ) formData.id = formData.title.slugify({strict: true}); // Authors formData.authors = Object.values(formData.authors || {}).map((author, i) => { const moduleAuthor = moduleData.authors[i]; author = foundry.utils.mergeObject(moduleAuthor, author, {inplace: false}); if ( foundry.utils.isEmpty(author.flags) ) delete author.flags; return author; }); // Packs formData.packs = Object.values(formData.packs || {}).map((pack, i) => { const modulePack = moduleData.packs[i]; if ( !pack.name ) pack.name = pack.label.slugify({strict: true}); const sourcePath = this.#source?.packs.find(p => p.name === pack.name)?.path; pack.path = sourcePath?.replace(`modules/${this.#source.id}/`, "") ?? `packs/${pack.name}`; pack = foundry.utils.mergeObject(modulePack, pack, {inplace: false}); if ( pack.flags?._placeholder ) delete pack.flags._placeholder; if ( foundry.utils.isEmpty(pack.flags) ) delete pack.flags; return pack; }); // Relationships const relationships = {}; for ( let r of Object.values(formData.relationships || {}) ) { if ( !(r.category && r.id) ) continue; const c = r.category; delete r.category; if ( r._category ) { const moduleRelationship = moduleData.relationships[r._category][r._index]; r = foundry.utils.mergeObject(moduleRelationship, r, {inplace: false}); } if ( foundry.utils.isEmpty(r.compatibility) ) delete r.compatibility; relationships[c] ||= []; r.type = game.systems.has(r.id) ? "system" : "module"; relationships[c].push(r); } formData.relationships = relationships; return formData; } /* -------------------------------------------- */ /** @override */ async _updateObject(event, formData) { // Assert that the final data is valid this.form.disabled = true; this.#tryUpdate(formData, {render: false}); // Prepare request data let requestData; if ( this.#source ) { requestData = this.#source.updateSource(formData, {dryRun: true}); requestData.id = this.#source.id; } else { requestData = this.#module.toObject(); if ( game.modules.has(requestData.id) ) { const msg = game.i18n.format("PACKAGE.ModuleCreateErrorAlreadyExists", {id: this.#module.id}); ui.notifications.error(msg, {console: false}); throw new Error(msg); } } requestData.action = "manageModule"; // Submit the module management request await Setup.post(requestData); const msg = this.#source ? "PACKAGE.ModuleEditSuccess" : "PACKAGE.ModuleCreateSuccess"; ui.notifications.info(game.i18n.format(msg, {id: this.#module.id})); return Setup.reload(); } /* -------------------------------------------- */ /** * Attempt to update the working Module instance, displaying error messages for any validation failures. * @param {object} changes Proposed changes to the Module source * @param {object} [options] Additional options * @param {boolean} [options.render] Re-render the app? */ #tryUpdate(changes, {render=true}={}) { try { this.#module.updateSource(changes); } catch(err) { ui.notifications.error(err.message); this.form.disabled = false; throw err; } if ( render ) this.render(); } } /** * A class responsible for managing a server-side operation's progress lifecycle. */ class ProgressReceiver extends foundry.utils.EventEmitterMixin(Object) { /** * @typedef {object} ProgressReceiverPacket * @property {string} action The progress action. * @property {string} id The operation identifier. * @property {number} pct The progress percentage. * @property {string} step The individual step in the action. * @property {string} [message] A text status message. * @property {string} [title] The title of the entry. If not provided, the ID is used instead. */ /** * @typedef {object} ProgressReceiverOptions * @property {boolean} [notify=true] Spawn UI notifications during the lifecycle events. * @property {string} [title] A human-readable title for the operation. * @property {string} [successMessage] A message to display on operation success. * @property {string} [failureMessage] A message to display on operation failure. */ /** * @callback ProgressReceiverProgress * @param {ProgressReceiverPacket} data The progress packet. */ /** * @callback ProgressReceiverComplete * @param {ProgressReceiverPacket} data Completion event data. * @returns {void} */ /** * @param {string} operationId A unique identifier for the operation. * @param {string} action The operation action. * @param {object} [context] Additional context to send with the request. * @param {ProgressReceiverOptions} [options] */ constructor(operationId, action, context={}, options={}) { super(); this.#operationId = operationId; this.#action = action; this.#context = context; this.#options = { notify: true, ...options }; } static emittedEvents = ["progress", "error", "complete"]; /** * The operation action. * @type {string} */ #action; /** * Additional context to send with the request. * @type {object} */ #context; /** * Additional options to configure behavior. * @type {ProgressReceiverOptions} */ #options; /** * A unique identifier for the operation. * @type {string} */ get operationId() { return this.#operationId; } #operationId; /** * The progress listener. * @type {function} */ #progressListener = this._onProgress.bind(this); /** * A callback to invoke on operation success. * @type {function} */ #resolve; /* -------------------------------------------- */ /** * Handle operation completion. * @param {ProgressReceiverPacket} data Completion event data. * @protected */ _onComplete(data) { const { notify, successMessage } = this.#options; if ( notify && successMessage ) ui.notifications.info(successMessage); Setup._removeProgressListener(this.#progressListener); const event = new Event("complete"); event.data = data; this.dispatchEvent(event); this.#resolve(data); } /* -------------------------------------------- */ /** * Handle an error during the operation. * @param {object} data Error event data. * @param {string} data.error The error message. * @param {string} data.stack The error stack. * @protected */ _onError(data) { const { error, stack, ...context } = data; const { notify, failureMessage } = this.#options; const err = new Error(error); err.stack = stack; err.context = context; if ( notify && failureMessage ) ui.notifications.error(failureMessage, { console: false, permanent: true }); console.error(err); ui.setupPackages?.removeProgressBar(this.#operationId); Setup._removeProgressListener(this.#progressListener); const event = new Event("error"); event.data = data; this.dispatchEvent(event); this.#resolve(err); } /* -------------------------------------------- */ /** * Handle progress ticks. * @param {ProgressReceiverPacket} data Progress event data. * @protected */ _onProgress(data) { const { STEPS } = CONST.SETUP_PACKAGE_PROGRESS; const { action, step, id } = data; if ( (action !== this.#action) || (id !== this.operationId) ) return; if ( (this.#options.title !== undefined) && !("title" in data) ) data.title = this.#options.title; ui.setupPackages?.onProgress(data); const event = new Event("progress"); event.data = data; this.dispatchEvent(event); if ( step === STEPS.ERROR ) return this._onError(data); if ( step === STEPS.COMPLETE ) return this._onComplete(data); } /* -------------------------------------------- */ /** * Handle a warning during the operation. * @param {object} data Warning event data. * @param {string} data.warning The warning message. * @protected */ _onWarning({ warning }) { if ( this.#options.notify ) ui.notifications.warn(warning); } /* -------------------------------------------- */ /** * Fire the request and begin listening for progress events. * @returns {Promise} */ listen() { return new Promise(async (resolve, reject) => { this.#resolve = resolve; Setup._addProgressListener(this.#progressListener); let response; try { response = await Setup.post({ ...this.#context, action: this.#action }); } catch(err) { Setup._removeProgressListener(this.#progressListener); return reject(err); } if ( response.error ) this._onError(response); if ( response.warning ) this._onWarning(response); }); } } /** * @typedef {import("../types.mjs").Constructor} Constructor */ /** * @callback EmittedEventListener * @param {Event} event The emitted event * @returns {any} */ /** * Augment a base class with EventEmitter behavior. * @template {Constructor} BaseClass * @param {BaseClass} BaseClass Some base class augmented with event emitter functionality */ function EventEmitterMixin(BaseClass) { /** * A mixin class which implements the behavior of EventTarget. * This is useful in cases where a class wants EventTarget-like behavior but needs to extend some other class. * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget */ class EventEmitter extends BaseClass { /** * An array of event types which are valid for this class. * @type {string[]} */ static emittedEvents = []; /** * A mapping of registered events. * @type {Record>} */ #events = {}; /* -------------------------------------------- */ /** * Add a new event listener for a certain type of event. * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener * @param {string} type The type of event being registered for * @param {EmittedEventListener} listener The listener function called when the event occurs * @param {object} [options={}] Options which configure the event listener * @param {boolean} [options.once=false] Should the event only be responded to once and then removed */ addEventListener(type, listener, {once = false} = {}) { if ( !this.constructor.emittedEvents.includes(type) ) { throw new Error(`"${type}" is not a supported event of the ${this.constructor.name} class`); } this.#events[type] ||= new Map(); this.#events[type].set(listener, {fn: listener, once}); } /* -------------------------------------------- */ /** * Remove an event listener for a certain type of event. * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener * @param {string} type The type of event being removed * @param {EmittedEventListener} listener The listener function being removed */ removeEventListener(type, listener) { this.#events[type]?.delete(listener); } /* -------------------------------------------- */ /** * Dispatch an event on this target. * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent * @param {Event} event The Event to dispatch * @returns {boolean} Was default behavior for the event prevented? */ dispatchEvent(event) { if ( !(event instanceof Event) ) { throw new Error("EventEmitter#dispatchEvent must be provided an Event instance"); } if ( !this.constructor.emittedEvents.includes(event?.type) ) { throw new Error(`"${event.type}" is not a supported event of the ${this.constructor.name} class`); } const listeners = this.#events[event.type]; if ( !listeners ) return true; // Extend and configure the Event Object.defineProperties(event, { target: {value: this}, stopPropagation: {value: function() { event.propagationStopped = true; Event.prototype.stopPropagation.call(this); }}, stopImmediatePropagation: {value: function() { event.propagationStopped = true; Event.prototype.stopImmediatePropagation.call(this); }} }); // Call registered listeners for ( const listener of listeners.values() ) { listener.fn(event); if ( listener.once ) this.removeEventListener(event.type, listener.fn); if ( event.propagationStopped ) break; } return event.defaultPrevented; } } return EventEmitter; } /** * A simple Semaphore implementation which provides a limited queue for ensuring proper concurrency. * @param {number} [max=1] The maximum number of tasks which are allowed concurrently. * * @example Using a Semaphore * ```js * // Some async function that takes time to execute * function fn(x) { * return new Promise(resolve => { * setTimeout(() => { * console.log(x); * resolve(x); * }, 1000)); * } * }; * * // Create a Semaphore and add many concurrent tasks * const semaphore = new Semaphore(1); * for ( let i of Array.fromRange(100) ) { * semaphore.add(fn, i); * } * ``` */ class Semaphore { constructor(max=1) { /** * The maximum number of tasks which can be simultaneously attempted. * @type {number} */ this.max = max; /** * A queue of pending function signatures * @type {Array>} * @private */ this._queue = []; /** * The number of tasks which are currently underway * @type {number} * @private */ this._active = 0; } /** * The number of pending tasks remaining in the queue * @type {number} */ get remaining() { return this._queue.length; } /** * The number of actively executing tasks * @type {number} */ get active() { return this._active; } /** * Add a new tasks to the managed queue * @param {Function} fn A callable function * @param {...*} [args] Function arguments * @returns {Promise} A promise that resolves once the added function is executed */ add(fn, ...args) { return new Promise((resolve, reject) => { this._queue.push([fn, args, resolve, reject]); return this._try(); }); } /** * Abandon any tasks which have not yet concluded */ clear() { this._queue = []; } /** * Attempt to perform a task from the queue. * If all workers are busy, do nothing. * If successful, try again. * @private */ async _try() { if ( (this.active === this.max) || !this.remaining ) return false; // Obtain the next task from the queue const next = this._queue.shift(); if ( !next ) return; this._active += 1; // Try and execute it, resolving its promise const [fn, args, resolve, reject] = next; try { const r = await fn(...args); resolve(r); } catch(err) { reject(err); } // Try the next function in the queue this._active -= 1; return this._try(); } } /** * @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration * @typedef {import("../_types.mjs").ApplicationRenderOptions} ApplicationRenderOptions * @typedef {import("../_types.mjs").ApplicationRenderContext} ApplicationRenderContext * @typedef {import("../_types.mjs").ApplicationClosingOptions} ApplicationClosingOptions * @typedef {import("../_types.mjs").ApplicationPosition} ApplicationPosition * @typedef {import("../_types.mjs").ApplicationHeaderControlsEntry} ApplicationHeaderControlsEntry */ /** * The Application class is responsible for rendering an HTMLElement into the Foundry Virtual Tabletop user interface. * @template {ApplicationConfiguration} Configuration * @template {ApplicationRenderOptions} RenderOptions * @alias ApplicationV2 */ class ApplicationV2 extends EventEmitterMixin(Object) { /** * Applications are constructed by providing an object of configuration options. * @param {Partial} [options] Options used to configure the Application instance */ constructor(options={}) { super(); // Configure Application Options this.options = Object.freeze(this._initializeApplicationOptions(options)); this.#id = this.options.id.replace("{id}", this.options.uniqueId); Object.assign(this.#position, this.options.position); // Verify the Application class is renderable this.#renderable = (this._renderHTML !== ApplicationV2.prototype._renderHTML) && (this._replaceHTML !== ApplicationV2.prototype._replaceHTML); } /** * Designates which upstream Application class in this class' inheritance chain is the base application. * Any DEFAULT_OPTIONS of super-classes further upstream of the BASE_APPLICATION are ignored. * Hook events for super-classes further upstream of the BASE_APPLICATION are not dispatched. * @type {typeof ApplicationV2} */ static BASE_APPLICATION = ApplicationV2; /** * The default configuration options which are assigned to every instance of this Application class. * @type {Partial} */ static DEFAULT_OPTIONS = { id: "app-{id}", classes: [], tag: "div", window: { frame: true, positioned: true, title: "", icon: "", controls: [], minimizable: true, resizable: false, contentTag: "section", contentClasses: [] }, actions: {}, form: { handler: undefined, submitOnChange: false, closeOnSubmit: false }, position: {} } /** * The sequence of rendering states that describe the Application life-cycle. * @enum {number} */ static RENDER_STATES = Object.freeze({ ERROR: -3, CLOSING: -2, CLOSED: -1, NONE: 0, RENDERING: 1, RENDERED: 2 }); /** * Which application is currently "in front" with the maximum z-index * @type {ApplicationV2} */ static #frontApp; /** @override */ static emittedEvents = Object.freeze(["render", "close", "position"]); /** * Application instance configuration options. * @type {Configuration} */ options; /** * @type {string} */ #id; /** * Flag that this Application instance is renderable. * Applications are not renderable unless a subclass defines the _renderHTML and _replaceHTML methods. */ #renderable = true; /** * The outermost HTMLElement of this rendered Application. * For window applications this is ApplicationV2##frame. * For non-window applications this ApplicationV2##content. * @type {HTMLDivElement} */ #element; /** * The HTMLElement within which inner HTML is rendered. * For non-window applications this is the same as ApplicationV2##element. * @type {HTMLElement} */ #content; /** * Data pertaining to the minimization status of the Application. * @type {{ * active: boolean, * [priorWidth]: number, * [priorHeight]: number, * [priorBoundingWidth]: number, * [priorBoundingHeight]: number * }} */ #minimization = Object.seal({ active: false, priorWidth: undefined, priorHeight: undefined, priorBoundingWidth: undefined, priorBoundingHeight: undefined }); /** * The rendered position of the Application. * @type {ApplicationPosition} */ #position = Object.seal({ top: undefined, left: undefined, width: undefined, height: "auto", scale: 1, zIndex: _maxZ }); /** * @type {ApplicationV2.RENDER_STATES} */ #state = ApplicationV2.RENDER_STATES.NONE; /** * A Semaphore used to enqueue asynchronous operations. * @type {Semaphore} */ #semaphore = new Semaphore(1); /** * Convenience references to window header elements. * @type {{ * header: HTMLElement, * resize: HTMLElement, * title: HTMLHeadingElement, * icon: HTMLElement, * close: HTMLButtonElement, * controls: HTMLButtonElement, * controlsDropdown: HTMLDivElement, * onDrag: Function, * onResize: Function, * pointerStartPosition: ApplicationPosition, * pointerMoveThrottle: boolean * }} */ get window() { return this.#window; } #window = { title: undefined, icon: undefined, close: undefined, controls: undefined, controlsDropdown: undefined, onDrag: this.#onWindowDragMove.bind(this), onResize: this.#onWindowResizeMove.bind(this), pointerStartPosition: undefined, pointerMoveThrottle: false }; /** * If this Application uses tabbed navigation groups, this mapping is updated whenever the changeTab method is called. * Reports the active tab for each group. * Subclasses may override this property to define default tabs for each group. * @type {Record} */ tabGroups = {}; /* -------------------------------------------- */ /* Application Properties */ /* -------------------------------------------- */ /** * The CSS class list of this Application instance * @type {DOMTokenList} */ get classList() { return this.#element?.classList; } /** * The HTML element ID of this Application instance. * @type {string} */ get id() { return this.#id; } /** * A convenience reference to the title of the Application window. * @type {string} */ get title() { return game.i18n.localize(this.options.window.title); } /** * The HTMLElement which renders this Application into the DOM. * @type {HTMLElement} */ get element() { return this.#element; } /** * Is this Application instance currently minimized? * @type {boolean} */ get minimized() { return this.#minimization.active; } /** * The current position of the application with respect to the window.document.body. * @type {ApplicationPosition} */ position = new Proxy(this.#position, { set: (obj, prop, value) => { if ( prop in obj ) { obj[prop] = value; this._updatePosition(this.#position); return value; } } }); /** * Is this Application instance currently rendered? * @type {boolean} */ get rendered() { return this.#state === ApplicationV2.RENDER_STATES.RENDERED; } /** * The current render state of the Application. * @type {ApplicationV2.RENDER_STATES} */ get state() { return this.#state; } /** * Does this Application instance render within an outer window frame? * @type {boolean} */ get hasFrame() { return this.options.window.frame; } /* -------------------------------------------- */ /* Initialization */ /* -------------------------------------------- */ /** * Iterate over the inheritance chain of this Application. * The chain includes this Application itself and all parents until the base application is encountered. * @see ApplicationV2.BASE_APPLICATION * @generator * @yields {typeof ApplicationV2} */ static *inheritanceChain() { let cls = this; while ( cls ) { yield cls; if ( cls === this.BASE_APPLICATION ) return; cls = Object.getPrototypeOf(cls); } } /* -------------------------------------------- */ /** * Initialize configuration options for the Application instance. * The default behavior of this method is to intelligently merge options for each class with those of their parents. * - Array-based options are concatenated * - Inner objects are merged * - Otherwise, properties in the subclass replace those defined by a parent * @param {Partial} options Options provided directly to the constructor * @returns {ApplicationConfiguration} Configured options for the application instance * @protected */ _initializeApplicationOptions(options) { // Options initialization order const order = [options]; for ( const cls of this.constructor.inheritanceChain() ) { order.unshift(cls.DEFAULT_OPTIONS); } // Intelligently merge with parent class options const applicationOptions = {}; for ( const opts of order ) { for ( const [k, v] of Object.entries(opts) ) { if ( (k in applicationOptions) ) { const v0 = applicationOptions[k]; if ( Array.isArray(v0) ) applicationOptions[k].push(...v); // Concatenate arrays else if ( foundry.utils.getType(v0) === "Object") Object.assign(v0, v); // Merge objects else applicationOptions[k] = foundry.utils.deepClone(v); // Override option } else applicationOptions[k] = foundry.utils.deepClone(v); } } // Unique application ID applicationOptions.uniqueId = String(++globalThis._appId); // Special handling for classes if ( applicationOptions.window.frame ) applicationOptions.classes.unshift("application"); applicationOptions.classes = Array.from(new Set(applicationOptions.classes)); return applicationOptions; } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** * Render the Application, creating its HTMLElement and replacing its innerHTML. * Add it to the DOM if it is not currently rendered and rendering is forced. Otherwise, re-render its contents. * @param {boolean|RenderOptions} [options] Options which configure application rendering behavior. * A boolean is interpreted as the "force" option. * @param {RenderOptions} [_options] Legacy options for backwards-compatibility with the original * ApplicationV1#render signature. * @returns {Promise} A Promise which resolves to the rendered Application instance */ async render(options={}, _options={}) { if ( typeof options === "boolean" ) options = Object.assign(_options, {force: options}); return this.#semaphore.add(this.#render.bind(this), options); } /* -------------------------------------------- */ /** * Manage the rendering step of the Application life-cycle. * This private method delegates out to several protected methods which can be defined by the subclass. * @param {RenderOptions} [options] Options which configure application rendering behavior * @returns {Promise} A Promise which resolves to the rendered Application instance */ async #render(options) { const states = ApplicationV2.RENDER_STATES; if ( !this.#renderable ) throw new Error(`The ${this.constructor.name} Application class is not renderable because` + " it does not define the _renderHTML and _replaceHTML methods which are required."); // Verify that the Application is allowed to be rendered try { const canRender = this._canRender(options); if ( canRender === false ) return this; } catch(err) { ui.notifications.warn(err.message); return this; } options.isFirstRender = this.#state <= states.NONE; // Prepare rendering context data this._configureRenderOptions(options); const context = await this._prepareContext(options); // Pre-render life-cycle events (awaited) if ( options.isFirstRender ) { if ( !options.force ) return this; await this.#doEvent(this._preFirstRender, {async: true, handlerArgs: [context, options], debugText: "Before first render"}); } await this.#doEvent(this._preRender, {async: true, handlerArgs: [context, options], debugText: "Before render"}); // Render the Application frame this.#state = states.RENDERING; if ( options.isFirstRender ) { this.#element = await this._renderFrame(options); this.#content = this.hasFrame ? this.#element.querySelector(".window-content") : this.#element; this._attachFrameListeners(); } // Render Application content try { const result = await this._renderHTML(context, options); this._replaceHTML(result, this.#content, options); } catch(err) { if ( this.#element ) { this.#element.remove(); this.#element = null; } this.#state = states.ERROR; throw new Error(`Failed to render Application "${this.id}":\n${err.message}`, { cause: err }); } // Register the rendered Application if ( options.isFirstRender ) { foundry.applications.instances.set(this.#id, this); this._insertElement(this.#element); } if ( this.hasFrame ) this._updateFrame(options); this.#state = states.RENDERED; // Post-render life-cycle events (not awaited) if ( options.isFirstRender ) { // noinspection ES6MissingAwait this.#doEvent(this._onFirstRender, {handlerArgs: [context, options], debugText: "After first render"}); } // noinspection ES6MissingAwait this.#doEvent(this._onRender, {handlerArgs: [context, options], debugText: "After render", eventName: "render", hookName: "render", hookArgs: [this.#element]}); // Update application position if ( "position" in options ) this.setPosition(options.position); if ( options.force && this.minimized ) this.maximize(); return this; } /* -------------------------------------------- */ /** * Modify the provided options passed to a render request. * @param {RenderOptions} options Options which configure application rendering behavior * @protected */ _configureRenderOptions(options) { const isFirstRender = this.#state <= ApplicationV2.RENDER_STATES.NONE; const {window, position} = this.options; // Initial frame options if ( isFirstRender ) { if ( this.hasFrame ) { options.window ||= {}; options.window.title ||= this.title; options.window.icon ||= window.icon; options.window.controls = true; options.window.resizable = window.resizable; } } // Automatic repositioning if ( isFirstRender ) options.position = Object.assign(this.#position, options.position); else { if ( position.width === "auto" ) options.position = Object.assign({width: "auto"}, options.position); if ( position.height === "auto" ) options.position = Object.assign({height: "auto"}, options.position); } } /* -------------------------------------------- */ /** * Prepare application rendering context data for a given render request. * @param {RenderOptions} options Options which configure application rendering behavior * @returns {Promise} Context data for the render operation * @protected */ async _prepareContext(options) { return {}; } /* -------------------------------------------- */ /** * Configure the array of header control menu options * @returns {ApplicationHeaderControlsEntry[]} * @protected */ _getHeaderControls() { return this.options.window.controls || []; } /* -------------------------------------------- */ /** * Iterate over header control buttons, filtering for controls which are visible for the current client. * @returns {Generator} * @yields {ApplicationHeaderControlsEntry} * @protected */ *_headerControlButtons() { for ( const control of this._getHeaderControls() ) { if ( control.visible === false ) continue; yield control; } } /* -------------------------------------------- */ /** * Render an HTMLElement for the Application. * An Application subclass must implement this method in order for the Application to be renderable. * @param {ApplicationRenderContext} context Context data for the render operation * @param {RenderOptions} options Options which configure application rendering behavior * @returns {Promise} The result of HTML rendering may be implementation specific. * Whatever value is returned here is passed to _replaceHTML * @abstract */ async _renderHTML(context, options) {} /* -------------------------------------------- */ /** * Replace the HTML of the application with the result provided by the rendering backend. * An Application subclass should implement this method in order for the Application to be renderable. * @param {any} result The result returned by the application rendering backend * @param {HTMLElement} content The content element into which the rendered result must be inserted * @param {RenderOptions} options Options which configure application rendering behavior * @protected */ _replaceHTML(result, content, options) {} /* -------------------------------------------- */ /** * Render the outer framing HTMLElement which wraps the inner HTML of the Application. * @param {RenderOptions} options Options which configure application rendering behavior * @returns {Promise} * @protected */ async _renderFrame(options) { const frame = document.createElement(this.options.tag); frame.id = this.#id; if ( this.options.classes.length ) frame.className = this.options.classes.join(" "); if ( !this.hasFrame ) return frame; // Window applications const labels = { controls: game.i18n.localize("APPLICATION.TOOLS.ControlsMenu"), toggleControls: game.i18n.localize("APPLICATION.TOOLS.ToggleControls"), close: game.i18n.localize("APPLICATION.TOOLS.Close") }; const contentClasses = ["window-content", ...this.options.window.contentClasses].join(" "); frame.innerHTML = `

<${this.options.window.contentTag} class="${contentClasses}"> ${this.options.window.resizable ? `
` : ""}`; // Reference elements this.#window.header = frame.querySelector(".window-header"); this.#window.title = frame.querySelector(".window-title"); this.#window.icon = frame.querySelector(".window-icon"); this.#window.resize = frame.querySelector(".window-resize-handle"); this.#window.close = frame.querySelector("button[data-action=close]"); this.#window.controls = frame.querySelector("button[data-action=toggleControls]"); this.#window.controlsDropdown = frame.querySelector(".controls-dropdown"); return frame; } /* -------------------------------------------- */ /** * Render a header control button. * @param {ApplicationHeaderControlsEntry} control * @returns {HTMLLIElement} * @protected */ _renderHeaderControl(control) { const li = document.createElement("li"); li.className = "header-control"; li.dataset.action = control.action; const label = game.i18n.localize(control.label); li.innerHTML = ``; return li; } /* -------------------------------------------- */ /** * When the Application is rendered, optionally update aspects of the window frame. * @param {RenderOptions} options Options provided at render-time * @protected */ _updateFrame(options) { const window = options.window; if ( !window ) return; if ( "title" in window ) this.#window.title.innerText = window.title; if ( "icon" in window ) this.#window.icon.className = `window-icon fa-fw ${window.icon || "hidden"}`; // Window header controls if ( "controls" in window ) { const controls = []; for ( const c of this._headerControlButtons() ) { controls.push(this._renderHeaderControl(c)); } this.#window.controlsDropdown.replaceChildren(...controls); this.#window.controls.classList.toggle("hidden", !controls.length); } } /* -------------------------------------------- */ /** * Insert the application HTML element into the DOM. * Subclasses may override this method to customize how the application is inserted. * @param {HTMLElement} element The element to insert * @protected */ _insertElement(element) { const existing = document.getElementById(element.id); if ( existing ) existing.replaceWith(element); else document.body.append(element); element.querySelector("[autofocus]")?.focus(); } /* -------------------------------------------- */ /* Closing */ /* -------------------------------------------- */ /** * Close the Application, removing it from the DOM. * @param {ApplicationClosingOptions} [options] Options which modify how the application is closed. * @returns {Promise} A Promise which resolves to the closed Application instance */ async close(options={}) { return this.#semaphore.add(this.#close.bind(this), options); } /* -------------------------------------------- */ /** * Manage the closing step of the Application life-cycle. * This private method delegates out to several protected methods which can be defined by the subclass. * @param {ApplicationClosingOptions} [options] Options which modify how the application is closed * @returns {Promise} A Promise which resolves to the rendered Application instance */ async #close(options) { const states = ApplicationV2.RENDER_STATES; if ( !this.#element ) { this.#state = states.CLOSED; return this; } // Pre-close life-cycle events (awaited) await this.#doEvent(this._preClose, {async: true, handlerArgs: [options], debugText: "Before close"}); // Set explicit dimensions for the transition. if ( options.animate !== false ) { const { width, height } = this.#element.getBoundingClientRect(); this.#applyPosition({ ...this.#position, width, height }); } // Remove the application element this.#element.classList.add("minimizing"); this.#element.style.maxHeight = "0px"; this.#state = states.CLOSING; if ( options.animate !== false ) await this._awaitTransition(this.#element, 1000); // Remove the closed element this._removeElement(this.#element); this.#element = null; this.#state = states.CLOSED; foundry.applications.instances.delete(this.#id); // Reset minimization state this.#minimization.active = false; // Post-close life-cycle events (not awaited) // noinspection ES6MissingAwait this.#doEvent(this._onClose, {handlerArgs: [options], debugText: "After close", eventName: "close", hookName: "close"}); return this; } /* -------------------------------------------- */ /** * Remove the application HTML element from the DOM. * Subclasses may override this method to customize how the application element is removed. * @param {HTMLElement} element The element to be removed * @protected */ _removeElement(element) { element.remove(); } /* -------------------------------------------- */ /* Positioning */ /* -------------------------------------------- */ /** * Update the Application element position using provided data which is merged with the prior position. * @param {Partial} [position] New Application positioning data * @returns {ApplicationPosition} The updated application position */ setPosition(position) { if ( !this.options.window.positioned ) return; position = Object.assign(this.#position, position); this.#doEvent(this._prePosition, {handlerArgs: [position], debugText: "Before reposition"}); // Update resolved position const updated = this._updatePosition(position); Object.assign(this.#position, updated); // Assign CSS styles this.#applyPosition(updated); this.#doEvent(this._onPosition, {handlerArgs: [position], debugText: "After reposition", eventName: "position"}); return position; } /* -------------------------------------------- */ /** * Translate a requested application position updated into a resolved allowed position for the Application. * Subclasses may override this method to implement more advanced positioning behavior. * @param {ApplicationPosition} position Requested Application positioning data * @returns {ApplicationPosition} Resolved Application positioning data * @protected */ _updatePosition(position) { if ( !this.#element ) return position; const el = this.#element; let {width, height, left, top, scale} = position; scale ??= 1.0; const computedStyle = getComputedStyle(el); let minWidth = ApplicationV2.parseCSSDimension(computedStyle.minWidth, el.parentElement.offsetWidth) || 0; let maxWidth = ApplicationV2.parseCSSDimension(computedStyle.maxWidth, el.parentElement.offsetWidth) || Infinity; let minHeight = ApplicationV2.parseCSSDimension(computedStyle.minHeight, el.parentElement.offsetHeight) || 0; let maxHeight = ApplicationV2.parseCSSDimension(computedStyle.maxHeight, el.parentElement.offsetHeight) || Infinity; let bounds = el.getBoundingClientRect(); const {clientWidth, clientHeight} = document.documentElement; // Explicit width const autoWidth = width === "auto"; if ( !autoWidth ) { const targetWidth = Number(width || bounds.width); minWidth = parseInt(minWidth) || 0; maxWidth = parseInt(maxWidth) || (clientWidth / scale); width = Math.clamp(targetWidth, minWidth, maxWidth); } // Explicit height const autoHeight = height === "auto"; if ( !autoHeight ) { const targetHeight = Number(height || bounds.height); minHeight = parseInt(minHeight) || 0; maxHeight = parseInt(maxHeight) || (clientHeight / scale); height = Math.clamp(targetHeight, minHeight, maxHeight); } // Implicit height if ( autoHeight ) { Object.assign(el.style, {width: `${width}px`, height: ""}); bounds = el.getBoundingClientRect(); height = bounds.height; } // Implicit width if ( autoWidth ) { Object.assign(el.style, {height: `${height}px`, width: ""}); bounds = el.getBoundingClientRect(); width = bounds.width; } // Left Offset const scaledWidth = width * scale; const targetLeft = left ?? ((clientWidth - scaledWidth) / 2); const maxLeft = Math.max(clientWidth - scaledWidth, 0); left = Math.clamp(targetLeft, 0, maxLeft); // Top Offset const scaledHeight = height * scale; const targetTop = top ?? ((clientHeight - scaledHeight) / 2); const maxTop = Math.max(clientHeight - scaledHeight, 0); top = Math.clamp(targetTop, 0, maxTop); // Scale scale ??= 1.0; return {width: autoWidth ? "auto" : width, height: autoHeight ? "auto" : height, left, top, scale}; } /* -------------------------------------------- */ /** * Apply validated position changes to the element. * @param {ApplicationPosition} position The new position data to apply. */ #applyPosition(position) { Object.assign(this.#element.style, { width: position.width === "auto" ? "" : `${position.width}px`, height: position.height === "auto" ? "" : `${position.height}px`, left: `${position.left}px`, top: `${position.top}px`, transform: position.scale === 1 ? "" : `scale(${position.scale})` }); } /* -------------------------------------------- */ /* Other Public Methods */ /* -------------------------------------------- */ /** * Is the window control buttons menu currently expanded? * @type {boolean} */ #controlsExpanded = false; /** * Toggle display of the Application controls menu. * Only applicable to window Applications. * @param {boolean} [expanded] Set the controls visibility to a specific state. * Otherwise, the visible state is toggled from its current value */ toggleControls(expanded) { expanded ??= !this.#controlsExpanded; if ( expanded === this.#controlsExpanded ) return; const dropdown = this.#element.querySelector(".controls-dropdown"); dropdown.classList.toggle("expanded", expanded); this.#controlsExpanded = expanded; game.tooltip.deactivate(); } /* -------------------------------------------- */ /** * Minimize the Application, collapsing it to a minimal header. * @returns {Promise} */ async minimize() { if ( this.minimized || !this.hasFrame || !this.options.window.minimizable ) return; this.#minimization.active = true; // Set explicit dimensions for the transition. const { width, height } = this.#element.getBoundingClientRect(); this.#applyPosition({ ...this.#position, width, height }); // Record pre-minimization data this.#minimization.priorWidth = this.#position.width; this.#minimization.priorHeight = this.#position.height; this.#minimization.priorBoundingWidth = width; this.#minimization.priorBoundingHeight = height; // Animate to collapsed size this.#element.classList.add("minimizing"); this.#element.style.maxWidth = "var(--minimized-width)"; this.#element.style.maxHeight = "var(--header-height)"; await this._awaitTransition(this.#element, 1000); this.#element.classList.add("minimized"); this.#element.classList.remove("minimizing"); } /* -------------------------------------------- */ /** * Restore the Application to its original dimensions. * @returns {Promise} */ async maximize() { if ( !this.minimized ) return; this.#minimization.active = false; // Animate back to full size const { priorBoundingWidth: width, priorBoundingHeight: height } = this.#minimization; this.#element.classList.remove("minimized"); this.#element.classList.add("maximizing"); this.#element.style.maxWidth = ""; this.#element.style.maxHeight = ""; this.#applyPosition({ ...this.#position, width, height }); await this._awaitTransition(this.#element, 1000); this.#element.classList.remove("maximizing"); // Restore the application position this._updatePosition(Object.assign(this.#position, { width: this.#minimization.priorWidth, height: this.#minimization.priorHeight })); } /* -------------------------------------------- */ /** * Bring this Application window to the front of the rendering stack by increasing its z-index. * Once ApplicationV1 is deprecated we should switch from _maxZ to ApplicationV2#maxZ * We should also eliminate ui.activeWindow in favor of only ApplicationV2#frontApp */ bringToFront() { if ( !((ApplicationV2.#frontApp === this) && (ui.activeWindow === this)) ) this.#position.zIndex = ++_maxZ; this.#element.style.zIndex = String(this.#position.zIndex); ApplicationV2.#frontApp = this; ui.activeWindow = this; // ApplicationV1 compatibility } /* -------------------------------------------- */ /** * Change the active tab within a tab group in this Application instance. * @param {string} tab The name of the tab which should become active * @param {string} group The name of the tab group which defines the set of tabs * @param {object} [options] Additional options which affect tab navigation * @param {Event} [options.event] An interaction event which caused the tab change, if any * @param {HTMLElement} [options.navElement] An explicit navigation element being modified * @param {boolean} [options.force=false] Force changing the tab even if the new tab is already active * @param {boolean} [options.updatePosition=true] Update application position after changing the tab? */ changeTab(tab, group, {event, navElement, force=false, updatePosition=true}={}) { if ( !tab || !group ) throw new Error("You must pass both the tab and tab group identifier"); if ( (this.tabGroups[group] === tab) && !force ) return; // No change necessary const tabElement = this.#content.querySelector(`.tabs > [data-group="${group}"][data-tab="${tab}"]`); if ( !tabElement ) throw new Error(`No matching tab element found for group "${group}" and tab "${tab}"`); // Update tab navigation for ( const t of this.#content.querySelectorAll(`.tabs > [data-group="${group}"]`) ) { t.classList.toggle("active", t.dataset.tab === tab); } // Update tab contents for ( const section of this.#content.querySelectorAll(`.tab[data-group="${group}"]`) ) { section.classList.toggle("active", section.dataset.tab === tab); } this.tabGroups[group] = tab; // Update automatic width or height if ( !updatePosition ) return; const positionUpdate = {}; if ( this.options.position.width === "auto" ) positionUpdate.width = "auto"; if ( this.options.position.height === "auto" ) positionUpdate.height = "auto"; if ( !foundry.utils.isEmpty(positionUpdate) ) this.setPosition(positionUpdate); } /* -------------------------------------------- */ /* Life-Cycle Handlers */ /* -------------------------------------------- */ /** * Perform an event in the application life-cycle. * Await an internal life-cycle method defined by the class. * Optionally dispatch an event for any registered listeners. * @param {Function} handler A handler function to call * @param {object} options Options which configure event handling * @param {boolean} [options.async] Await the result of the handler function? * @param {any[]} [options.handlerArgs] Arguments passed to the handler function * @param {string} [options.debugText] Debugging text to log for the event * @param {string} [options.eventName] An event name to dispatch for registered listeners * @param {string} [options.hookName] A hook name to dispatch for this and all parent classes * @param {any[]} [options.hookArgs] Arguments passed to the requested hook function * @returns {Promise} A promise which resoles once the handler is complete */ async #doEvent(handler, {async=false, handlerArgs, debugText, eventName, hookName, hookArgs=[]}={}) { // Debug logging if ( debugText && CONFIG.debug.applications ) { console.debug(`${this.constructor.name} | ${debugText}`); } // Call handler function const response = handler.call(this, ...handlerArgs); if ( async ) await response; // Dispatch event for this Application instance if ( eventName ) this.dispatchEvent(new Event(eventName, { bubbles: true, cancelable: true })); // Call hooks for this Application class if ( hookName ) { for ( const cls of this.constructor.inheritanceChain() ) { if ( !cls.name ) continue; Hooks.callAll(`${hookName}${cls.name}`, this, ...hookArgs); } } return response; } /* -------------------------------------------- */ /* Rendering Life-Cycle Methods */ /* -------------------------------------------- */ /** * Test whether this Application is allowed to be rendered. * @param {RenderOptions} options Provided render options * @returns {false|void} Return false to prevent rendering * @throws {Error} An Error to display a warning message * @protected */ _canRender(options) {} /** * Actions performed before a first render of the Application. * @param {ApplicationRenderContext} context Prepared context data * @param {RenderOptions} options Provided render options * @returns {Promise} * @protected */ async _preFirstRender(context, options) {} /** * Actions performed after a first render of the Application. * Post-render steps are not awaited by the render process. * @param {ApplicationRenderContext} context Prepared context data * @param {RenderOptions} options Provided render options * @protected */ _onFirstRender(context, options) {} /** * Actions performed before any render of the Application. * Pre-render steps are awaited by the render process. * @param {ApplicationRenderContext} context Prepared context data * @param {RenderOptions} options Provided render options * @returns {Promise} * @protected */ async _preRender(context, options) {} /** * Actions performed after any render of the Application. * Post-render steps are not awaited by the render process. * @param {ApplicationRenderContext} context Prepared context data * @param {RenderOptions} options Provided render options * @protected */ _onRender(context, options) {} /** * Actions performed before closing the Application. * Pre-close steps are awaited by the close process. * @param {RenderOptions} options Provided render options * @returns {Promise} * @protected */ async _preClose(options) {} /** * Actions performed after closing the Application. * Post-close steps are not awaited by the close process. * @param {RenderOptions} options Provided render options * @protected */ _onClose(options) {} /** * Actions performed before the Application is re-positioned. * Pre-position steps are not awaited because setPosition is synchronous. * @param {ApplicationPosition} position The requested application position * @protected */ _prePosition(position) {} /** * Actions performed after the Application is re-positioned. * @param {ApplicationPosition} position The requested application position * @protected */ _onPosition(position) {} /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Attach event listeners to the Application frame. * @protected */ _attachFrameListeners() { // Application Click Events this.#element.addEventListener("pointerdown", this.#onPointerDown.bind(this), {capture: true}); const click = this.#onClick.bind(this); this.#element.addEventListener("click", click); this.#element.addEventListener("contextmenu", click); if ( this.hasFrame ) { this.bringToFront(); this.#window.header.addEventListener("pointerdown", this.#onWindowDragStart.bind(this)); this.#window.header.addEventListener("dblclick", this.#onWindowDoubleClick.bind(this)); this.#window.resize?.addEventListener("pointerdown", this.#onWindowResizeStart.bind(this)); } // Form handlers if ( this.options.tag === "form" ) { this.#element.addEventListener("submit", this._onSubmitForm.bind(this, this.options.form)); this.#element.addEventListener("change", this._onChangeForm.bind(this, this.options.form)); } } /* -------------------------------------------- */ /** * Handle initial pointerdown events inside a rendered Application. * @param {PointerEvent} event */ async #onPointerDown(event) { if ( this.hasFrame ) this.bringToFront(); } /* -------------------------------------------- */ /** * Centralized handling of click events which occur on or within the Application frame. * @param {PointerEvent} event */ async #onClick(event) { const target = event.target; const actionButton = target.closest("[data-action]"); if ( actionButton ) return this.#onClickAction(event, actionButton); } /* -------------------------------------------- */ /** * Handle a click event on an element which defines a [data-action] handler. * @param {PointerEvent} event The originating click event * @param {HTMLElement} target The capturing HTML element which defined a [data-action] */ #onClickAction(event, target) { const action = target.dataset.action; switch ( action ) { case "close": event.stopPropagation(); if ( event.button === 0 ) this.close(); break; case "tab": if ( event.button === 0 ) this.#onClickTab(event); break; case "toggleControls": event.stopPropagation(); if ( event.button === 0 ) this.toggleControls(); break; default: let handler = this.options.actions[action]; // No defined handler if ( !handler ) { this._onClickAction(event, target); break; } // Defined handler let buttons = [0]; if ( typeof handler === "object" ) { buttons = handler.buttons; handler = handler.handler; } if ( buttons.includes(event.button) ) handler?.call(this, event, target); break; } } /* -------------------------------------------- */ /** * Handle click events on a tab within the Application. * @param {PointerEvent} event */ #onClickTab(event) { const button = event.target; const tab = button.dataset.tab; if ( !tab || button.classList.contains("active") ) return; const group = button.dataset.group; const navElement = button.closest(".tabs"); this.changeTab(tab, group, {event, navElement}); } /* -------------------------------------------- */ /** * A generic event handler for action clicks which can be extended by subclasses. * Action handlers defined in DEFAULT_OPTIONS are called first. This method is only called for actions which have * no defined handler. * @param {PointerEvent} event The originating click event * @param {HTMLElement} target The capturing HTML element which defined a [data-action] * @protected */ _onClickAction(event, target) {} /* -------------------------------------------- */ /** * Begin capturing pointer events on the application frame. * @param {PointerEvent} event The triggering event. * @param {function} callback The callback to attach to pointer move events. */ #startPointerCapture(event, callback) { this.#window.pointerStartPosition = Object.assign(foundry.utils.deepClone(this.#position), { clientX: event.clientX, clientY: event.clientY }); this.#element.addEventListener("pointermove", callback, { passive: true }); this.#element.addEventListener("pointerup", event => this.#endPointerCapture(event, callback), { capture: true, once: true }); } /* -------------------------------------------- */ /** * End capturing pointer events on the application frame. * @param {PointerEvent} event The triggering event. * @param {function} callback The callback to remove from pointer move events. */ #endPointerCapture(event, callback) { this.#element.releasePointerCapture(event.pointerId); this.#element.removeEventListener("pointermove", callback); delete this.#window.pointerStartPosition; this.#window.pointerMoveThrottle = false; } /* -------------------------------------------- */ /** * Handle a pointer move event while dragging or resizing the window frame. * @param {PointerEvent} event * @returns {{dx: number, dy: number}|void} The amount the cursor has moved since the last frame, or undefined if * the movement occurred between frames. */ #onPointerMove(event) { if ( this.#window.pointerMoveThrottle ) return; this.#window.pointerMoveThrottle = true; const dx = event.clientX - this.#window.pointerStartPosition.clientX; const dy = event.clientY - this.#window.pointerStartPosition.clientY; requestAnimationFrame(() => this.#window.pointerMoveThrottle = false); return { dx, dy }; } /* -------------------------------------------- */ /** * Begin dragging the Application position. * @param {PointerEvent} event */ #onWindowDragStart(event) { if ( event.target.closest(".header-control") ) return; this.#endPointerCapture(event, this.#window.onDrag); this.#startPointerCapture(event, this.#window.onDrag); } /* -------------------------------------------- */ /** * Begin resizing the Application. * @param {PointerEvent} event */ #onWindowResizeStart(event) { this.#endPointerCapture(event, this.#window.onResize); this.#startPointerCapture(event, this.#window.onResize); } /* -------------------------------------------- */ /** * Drag the Application position during mouse movement. * @param {PointerEvent} event */ #onWindowDragMove(event) { if ( !this.#window.header.hasPointerCapture(event.pointerId) ) { this.#window.header.setPointerCapture(event.pointerId); } const delta = this.#onPointerMove(event); if ( !delta ) return; const { pointerStartPosition } = this.#window; let { top, left, height, width } = pointerStartPosition; left += delta.dx; top += delta.dy; this.setPosition({ top, left, height, width }); } /* -------------------------------------------- */ /** * Resize the Application during mouse movement. * @param {PointerEvent} event */ #onWindowResizeMove(event) { if ( !this.#window.resize.hasPointerCapture(event.pointerId) ) { this.#window.resize.setPointerCapture(event.pointerId); } const delta = this.#onPointerMove(event); if ( !delta ) return; const { scale } = this.#position; const { pointerStartPosition } = this.#window; let { top, left, height, width } = pointerStartPosition; if ( width !== "auto" ) width += delta.dx / scale; if ( height !== "auto" ) height += delta.dy / scale; this.setPosition({ top, left, width, height }); } /* -------------------------------------------- */ /** * Double-click events on the window title are used to minimize or maximize the application. * @param {PointerEvent} event */ #onWindowDoubleClick(event) { event.preventDefault(); if ( event.target.dataset.action ) return; // Ignore double clicks on buttons which perform an action if ( !this.options.window.minimizable ) return; if ( this.minimized ) this.maximize(); else this.minimize(); } /* -------------------------------------------- */ /** * Handle submission for an Application which uses the form element. * @param {ApplicationFormConfiguration} formConfig The form configuration for which this handler is bound * @param {Event|SubmitEvent} event The form submission event * @returns {Promise} * @protected */ async _onSubmitForm(formConfig, event) { event.preventDefault(); const form = event.currentTarget; const {handler, closeOnSubmit} = formConfig; const formData = new FormDataExtended(form); if ( handler instanceof Function ) { try { await handler.call(this, event, form, formData); } catch(err){ ui.notifications.error(err, {console: true}); return; // Do not close } } if ( closeOnSubmit ) await this.close(); } /* -------------------------------------------- */ /** * Handle changes to an input element within the form. * @param {ApplicationFormConfiguration} formConfig The form configuration for which this handler is bound * @param {Event} event An input change event within the form */ _onChangeForm(formConfig, event) { if ( formConfig.submitOnChange ) this._onSubmitForm(formConfig, event); } /* -------------------------------------------- */ /* Helper Methods */ /* -------------------------------------------- */ /** * Parse a CSS style rule into a number of pixels which apply to that dimension. * @param {string} style The CSS style rule * @param {number} parentDimension The relevant dimension of the parent element * @returns {number} The parsed style dimension in pixels */ static parseCSSDimension(style, parentDimension) { if ( style.includes("px") ) return parseInt(style.replace("px", "")); if ( style.includes("%") ) { const p = parseInt(style.replace("%", "")) / 100; return parentDimension * p; } } /* -------------------------------------------- */ /** * Wait for a CSS transition to complete for an element. * @param {HTMLElement} element The element which is transitioning * @param {number} timeout A timeout in milliseconds in case the transitionend event does not occur * @returns {Promise} * @internal */ async _awaitTransition(element, timeout) { return Promise.race([ new Promise(resolve => element.addEventListener("transitionend", resolve, {once: true})), new Promise(resolve => window.setTimeout(resolve, timeout)) ]); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ bringToTop() { foundry.utils.logCompatibilityWarning(`ApplicationV2#bringToTop is not a valid function and redirects to ApplicationV2#bringToFront. This shim will be removed in v14.`, {since: 12, until: 14}); return this.bringToFront(); } } /** * @typedef {import("../types.mjs").ColorSource} ColorSource */ /** * A representation of a color in hexadecimal format. * This class provides methods for transformations and manipulations of colors. */ class Color extends Number { /** * Is this a valid color? * @type {boolean} */ get valid() { const v = this.valueOf(); return Number.isInteger(v) && v >= 0 && v <= 0xFFFFFF; } /* ------------------------------------------ */ /** * A CSS-compatible color string. * If this color is not valid, the empty string is returned. * An alias for Color#toString. * @type {string} */ get css() { return this.toString(16); } /* ------------------------------------------ */ /** * The color represented as an RGB array. * @type {[number, number, number]} */ get rgb() { return [((this >> 16) & 0xFF) / 255, ((this >> 8) & 0xFF) / 255, (this & 0xFF) / 255]; } /* ------------------------------------------ */ /** * The numeric value of the red channel between [0, 1]. * @type {number} */ get r() { return ((this >> 16) & 0xFF) / 255; } /* ------------------------------------------ */ /** * The numeric value of the green channel between [0, 1]. * @type {number} */ get g() { return ((this >> 8) & 0xFF) / 255; } /* ------------------------------------------ */ /** * The numeric value of the blue channel between [0, 1]. * @type {number} */ get b() { return (this & 0xFF) / 255; } /* ------------------------------------------ */ /** * The maximum value of all channels. * @type {number} */ get maximum() { return Math.max(...this); } /* ------------------------------------------ */ /** * The minimum value of all channels. * @type {number} */ get minimum() { return Math.min(...this); } /* ------------------------------------------ */ /** * Get the value of this color in little endian format. * @type {number} */ get littleEndian() { return ((this >> 16) & 0xFF) + (this & 0x00FF00) + ((this & 0xFF) << 16); } /* ------------------------------------------ */ /** * The color represented as an HSV array. * Conversion formula adapted from http://en.wikipedia.org/wiki/HSV_color_space. * Assumes r, g, and b are contained in the set [0, 1] and returns h, s, and v in the set [0, 1]. * @type {[number, number, number]} */ get hsv() { const [r, g, b] = this.rgb; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const d = max - min; let h; const s = max === 0 ? 0 : d / max; const v = max; // Achromatic colors if (max === min) return [0, s, v]; // Normal colors switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; return [h, s, v]; } /* ------------------------------------------ */ /** * The color represented as an HSL array. * Assumes r, g, and b are contained in the set [0, 1] and returns h, s, and l in the set [0, 1]. * @type {[number, number, number]} */ get hsl() { const [r, g, b] = this.rgb; // Compute luminosity, saturation and hue const l = Math.max(r, g, b); const s = l - Math.min(r, g, b); let h = 0; if ( s > 0 ) { if ( l === r ) { h = (g - b) / s; } else if ( l === g ) { h = 2 + (b - r) / s; } else { h = 4 + (r - g) / s; } } const finalHue = (60 * h < 0 ? 60 * h + 360 : 60 * h) / 360; const finalSaturation = s ? (l <= 0.5 ? s / (2 * l - s) : s / (2 - (2 * l - s))) : 0; const finalLuminance = (2 * l - s) / 2; return [finalHue, finalSaturation, finalLuminance]; } /* ------------------------------------------ */ /** * The color represented as a linear RGB array. * Assumes r, g, and b are contained in the set [0, 1] and returns linear r, g, and b in the set [0, 1]. * @link https://en.wikipedia.org/wiki/SRGB#Transformation * @type {Color} */ get linear() { const toLinear = c => (c > 0.04045) ? Math.pow((c + 0.055) / 1.055, 2.4) : (c / 12.92); return this.constructor.fromRGB([toLinear(this.r), toLinear(this.g), toLinear(this.b)]); } /* ------------------------------------------ */ /* Color Manipulation Methods */ /* ------------------------------------------ */ /** @override */ toString(radix) { if ( !this.valid ) return ""; return `#${super.toString(16).padStart(6, "0")}`; } /* ------------------------------------------ */ /** * Serialize the Color. * @returns {string} The color as a CSS string */ toJSON() { return this.css; } /* ------------------------------------------ */ /** * Returns the color as a CSS string. * @returns {string} The color as a CSS string */ toHTML() { return this.css; } /* ------------------------------------------ */ /** * Test whether this color equals some other color * @param {Color|number} other Some other color or hex number * @returns {boolean} Are the colors equal? */ equals(other) { return this.valueOf() === other.valueOf(); } /* ------------------------------------------ */ /** * Get a CSS-compatible RGBA color string. * @param {number} alpha The desired alpha in the range [0, 1] * @returns {string} A CSS-compatible RGBA string */ toRGBA(alpha) { const rgba = [(this >> 16) & 0xFF, (this >> 8) & 0xFF, this & 0xFF, alpha]; return `rgba(${rgba.join(", ")})`; } /* ------------------------------------------ */ /** * Mix this Color with some other Color using a provided interpolation weight. * @param {Color} other Some other Color to mix with * @param {number} weight The mixing weight placed on this color where weight is placed on the other color * @returns {Color} The resulting mixed Color */ mix(other, weight) { return new Color(Color.mix(this, other, weight)); } /* ------------------------------------------ */ /** * Multiply this Color by another Color or a static scalar. * @param {Color|number} other Some other Color or a static scalar. * @returns {Color} The resulting Color. */ multiply(other) { if ( other instanceof Color ) return new Color(Color.multiply(this, other)); return new Color(Color.multiplyScalar(this, other)); } /* ------------------------------------------ */ /** * Add this Color by another Color or a static scalar. * @param {Color|number} other Some other Color or a static scalar. * @returns {Color} The resulting Color. */ add(other) { if ( other instanceof Color ) return new Color(Color.add(this, other)); return new Color(Color.addScalar(this, other)); } /* ------------------------------------------ */ /** * Subtract this Color by another Color or a static scalar. * @param {Color|number} other Some other Color or a static scalar. * @returns {Color} The resulting Color. */ subtract(other) { if ( other instanceof Color ) return new Color(Color.subtract(this, other)); return new Color(Color.subtractScalar(this, other)); } /* ------------------------------------------ */ /** * Max this color by another Color or a static scalar. * @param {Color|number} other Some other Color or a static scalar. * @returns {Color} The resulting Color. */ maximize(other) { if ( other instanceof Color ) return new Color(Color.maximize(this, other)); return new Color(Color.maximizeScalar(this, other)); } /* ------------------------------------------ */ /** * Min this color by another Color or a static scalar. * @param {Color|number} other Some other Color or a static scalar. * @returns {Color} The resulting Color. */ minimize(other) { if ( other instanceof Color ) return new Color(Color.minimize(this, other)); return new Color(Color.minimizeScalar(this, other)); } /* ------------------------------------------ */ /* Iterator */ /* ------------------------------------------ */ /** * Iterating over a Color is equivalent to iterating over its [r,g,b] color channels. * @returns {Generator} */ *[Symbol.iterator]() { yield this.r; yield this.g; yield this.b; } /* ------------------------------------------------------------------------------------------- */ /* Real-time performance Methods and Properties */ /* Important Note: */ /* These methods are not a replacement, but a tool when real-time performance is needed. */ /* They do not have the flexibility of the "classic" methods and come with some limitations. */ /* Unless you have to deal with real-time performance, you should use the "classic" methods. */ /* ------------------------------------------------------------------------------------------- */ /** * Set an rgb array with the rgb values contained in this Color class. * @param {number[]} vec3 Receive the result. Must be an array with at least a length of 3. */ applyRGB(vec3) { vec3[0] = ((this >> 16) & 0xFF) / 255; vec3[1] = ((this >> 8) & 0xFF) / 255; vec3[2] = (this & 0xFF) / 255; } /* ------------------------------------------ */ /** * Apply a linear interpolation between two colors, according to the weight. * @param {number} color1 The first color to mix. * @param {number} color2 The second color to mix. * @param {number} weight Weight of the linear interpolation. * @returns {number} The resulting mixed color */ static mix(color1, color2, weight) { return (((((color1 >> 16) & 0xFF) * (1 - weight) + ((color2 >> 16) & 0xFF) * weight) << 16) & 0xFF0000) | (((((color1 >> 8) & 0xFF) * (1 - weight) + ((color2 >> 8) & 0xFF) * weight) << 8) & 0x00FF00) | (((color1 & 0xFF) * (1 - weight) + (color2 & 0xFF) * weight) & 0x0000FF); } /* ------------------------------------------ */ /** * Multiply two colors. * @param {number} color1 The first color to multiply. * @param {number} color2 The second color to multiply. * @returns {number} The result. */ static multiply(color1, color2) { return ((((color1 >> 16) & 0xFF) / 255 * ((color2 >> 16) & 0xFF) / 255) * 255 << 16) | ((((color1 >> 8) & 0xFF) / 255 * ((color2 >> 8) & 0xFF) / 255) * 255 << 8) | (((color1 & 0xFF) / 255 * ((color2 & 0xFF) / 255)) * 255); } /* ------------------------------------------ */ /** * Multiply a color by a scalar * @param {number} color The color to multiply. * @param {number} scalar A static scalar to multiply with. * @returns {number} The resulting color as a number. */ static multiplyScalar(color, scalar) { return (Math.clamp(((color >> 16) & 0xFF) / 255 * scalar, 0, 1) * 255 << 16) | (Math.clamp(((color >> 8) & 0xFF) / 255 * scalar, 0, 1) * 255 << 8) | (Math.clamp((color & 0xFF) / 255 * scalar, 0, 1) * 255); } /* ------------------------------------------ */ /** * Maximize two colors. * @param {number} color1 The first color. * @param {number} color2 The second color. * @returns {number} The result. */ static maximize(color1, color2) { return (Math.clamp(Math.max((color1 >> 16) & 0xFF, (color2 >> 16) & 0xFF), 0, 0xFF) << 16) | (Math.clamp(Math.max((color1 >> 8) & 0xFF, (color2 >> 8) & 0xFF), 0, 0xFF) << 8) | Math.clamp(Math.max(color1 & 0xFF, color2 & 0xFF), 0, 0xFF); } /* ------------------------------------------ */ /** * Maximize a color by a static scalar. * @param {number} color The color to maximize. * @param {number} scalar Scalar to maximize with (normalized). * @returns {number} The resulting color as a number. */ static maximizeScalar(color, scalar) { return (Math.clamp(Math.max((color >> 16) & 0xFF, scalar * 255), 0, 0xFF) << 16) | (Math.clamp(Math.max((color >> 8) & 0xFF, scalar * 255), 0, 0xFF) << 8) | Math.clamp(Math.max(color & 0xFF, scalar * 255), 0, 0xFF); } /* ------------------------------------------ */ /** * Add two colors. * @param {number} color1 The first color. * @param {number} color2 The second color. * @returns {number} The resulting color as a number. */ static add(color1, color2) { return (Math.clamp((((color1 >> 16) & 0xFF) + ((color2 >> 16) & 0xFF)), 0, 0xFF) << 16) | (Math.clamp((((color1 >> 8) & 0xFF) + ((color2 >> 8) & 0xFF)), 0, 0xFF) << 8) | Math.clamp(((color1 & 0xFF) + (color2 & 0xFF)), 0, 0xFF); } /* ------------------------------------------ */ /** * Add a static scalar to a color. * @param {number} color The color. * @param {number} scalar Scalar to add with (normalized). * @returns {number} The resulting color as a number. */ static addScalar(color, scalar) { return (Math.clamp((((color >> 16) & 0xFF) + scalar * 255), 0, 0xFF) << 16) | (Math.clamp((((color >> 8) & 0xFF) + scalar * 255), 0, 0xFF) << 8) | Math.clamp(((color & 0xFF) + scalar * 255), 0, 0xFF); } /* ------------------------------------------ */ /** * Subtract two colors. * @param {number} color1 The first color. * @param {number} color2 The second color. */ static subtract(color1, color2) { return (Math.clamp((((color1 >> 16) & 0xFF) - ((color2 >> 16) & 0xFF)), 0, 0xFF) << 16) | (Math.clamp((((color1 >> 8) & 0xFF) - ((color2 >> 8) & 0xFF)), 0, 0xFF) << 8) | Math.clamp(((color1 & 0xFF) - (color2 & 0xFF)), 0, 0xFF); } /* ------------------------------------------ */ /** * Subtract a color by a static scalar. * @param {number} color The color. * @param {number} scalar Scalar to subtract with (normalized). * @returns {number} The resulting color as a number. */ static subtractScalar(color, scalar) { return (Math.clamp((((color >> 16) & 0xFF) - scalar * 255), 0, 0xFF) << 16) | (Math.clamp((((color >> 8) & 0xFF) - scalar * 255), 0, 0xFF) << 8) | Math.clamp(((color & 0xFF) - scalar * 255), 0, 0xFF); } /* ------------------------------------------ */ /** * Minimize two colors. * @param {number} color1 The first color. * @param {number} color2 The second color. */ static minimize(color1, color2) { return (Math.clamp(Math.min((color1 >> 16) & 0xFF, (color2 >> 16) & 0xFF), 0, 0xFF) << 16) | (Math.clamp(Math.min((color1 >> 8) & 0xFF, (color2 >> 8) & 0xFF), 0, 0xFF) << 8) | Math.clamp(Math.min(color1 & 0xFF, color2 & 0xFF), 0, 0xFF); } /* ------------------------------------------ */ /** * Minimize a color by a static scalar. * @param {number} color The color. * @param {number} scalar Scalar to minimize with (normalized). */ static minimizeScalar(color, scalar) { return (Math.clamp(Math.min((color >> 16) & 0xFF, scalar * 255), 0, 0xFF) << 16) | (Math.clamp(Math.min((color >> 8) & 0xFF, scalar * 255), 0, 0xFF) << 8) | Math.clamp(Math.min(color & 0xFF, scalar * 255), 0, 0xFF); } /* ------------------------------------------ */ /** * Convert a color to RGB and assign values to a passed array. * @param {number} color The color to convert to RGB values. * @param {number[]} vec3 Receive the result. Must be an array with at least a length of 3. */ static applyRGB(color, vec3) { vec3[0] = ((color >> 16) & 0xFF) / 255; vec3[1] = ((color >> 8) & 0xFF) / 255; vec3[2] = (color & 0xFF) / 255; } /* ------------------------------------------ */ /* Factory Methods */ /* ------------------------------------------ */ /** * Create a Color instance from an RGB array. * @param {ColorSource} color A color input * @returns {Color} The hex color instance or NaN */ static from(color) { if ( (color === null) || (color === undefined) ) return new this(NaN); if ( typeof color === "string" ) return this.fromString(color); if ( typeof color === "number" ) return new this(color); if ( (color instanceof Array) && (color.length === 3) ) return this.fromRGB(color); if ( color instanceof Color ) return color; return new this(color); } /* ------------------------------------------ */ /** * Create a Color instance from a color string which either includes or does not include a leading #. * @param {string} color A color string * @returns {Color} The hex color instance */ static fromString(color) { return new this(parseInt(color.startsWith("#") ? color.substring(1) : color, 16)); } /* ------------------------------------------ */ /** * Create a Color instance from an RGB array. * @param {[number, number, number]} rgb An RGB tuple * @returns {Color} The hex color instance */ static fromRGB(rgb) { return new this(((rgb[0] * 255) << 16) + ((rgb[1] * 255) << 8) + (rgb[2] * 255 | 0)); } /* ------------------------------------------ */ /** * Create a Color instance from an RGB normalized values. * @param {number} r The red value * @param {number} g The green value * @param {number} b The blue value * @returns {Color} The hex color instance */ static fromRGBvalues(r, g, b) { return new this(((r * 255) << 16) + ((g * 255) << 8) + (b * 255 | 0)); } /* ------------------------------------------ */ /** * Create a Color instance from an HSV array. * Conversion formula adapted from http://en.wikipedia.org/wiki/HSV_color_space. * Assumes h, s, and v are contained in the set [0, 1]. * @param {[number, number, number]} hsv An HSV tuple * @returns {Color} The hex color instance */ static fromHSV(hsv) { const [h, s, v] = hsv; const i = Math.floor(h * 6); const f = (h * 6) - i; const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s); let rgb; switch (i % 6) { case 0: rgb = [v, t, p]; break; case 1: rgb = [q, v, p]; break; case 2: rgb = [p, v, t]; break; case 3: rgb = [p, q, v]; break; case 4: rgb = [t, p, v]; break; case 5: rgb = [v, p, q]; break; } return this.fromRGB(rgb); } /* ------------------------------------------ */ /** * Create a Color instance from an HSL array. * Assumes h, s, and l are contained in the set [0, 1]. * @param {[number, number, number]} hsl An HSL tuple * @returns {Color} The hex color instance */ static fromHSL(hsl) { const [h, s, l] = hsl; // Calculate intermediate values for the RGB components const chroma = (1 - Math.abs(2 * l - 1)) * s; const hue = h * 6; const x = chroma * (1 - Math.abs(hue % 2 - 1)); const m = l - chroma / 2; let r, g, b; switch (Math.floor(hue)) { case 0: [r, g, b] = [chroma, x, 0]; break; case 1: [r, g, b] = [x, chroma, 0]; break; case 2: [r, g, b] = [0, chroma, x]; break; case 3: [r, g, b] = [0, x, chroma]; break; case 4: [r, g, b] = [x, 0, chroma]; break; case 5: case 6:[r, g, b] = [chroma, 0, x]; break; default: [r, g, b] = [0, 0, 0]; break; } // Adjust for luminance r += m; g += m; b += m; return this.fromRGB([r, g, b]); } /* ------------------------------------------ */ /** * Create a Color instance (sRGB) from a linear rgb array. * Assumes r, g, and b are contained in the set [0, 1]. * @link https://en.wikipedia.org/wiki/SRGB#Transformation * @param {[number, number, number]} linear The linear rgb array * @returns {Color} The hex color instance */ static fromLinearRGB(linear) { const [r, g, b] = linear; const tosrgb = c => (c <= 0.0031308) ? (12.92 * c) : (1.055 * Math.pow(c, 1 / 2.4) - 0.055); return this.fromRGB([tosrgb(r), tosrgb(g), tosrgb(b)]); } } /** @module constants */ /** * Valid Chat Message styles which affect how the message is presented in the chat log. * @enum {number} */ const CHAT_MESSAGE_STYLES = { /** * An uncategorized chat message */ OTHER: 0, /** * The message is spoken out of character (OOC). * OOC messages will be outlined by the player's color to make them more easily recognizable. */ OOC: 1, /** * The message is spoken by an associated character. */ IC: 2, /** * The message is an emote performed by the selected character. * Entering "/emote waves his hand." while controlling a character named Simon will send the message, "Simon waves his hand." */ EMOTE: 3, }; /** * The primary Document types. * @type {string[]} */ const PRIMARY_DOCUMENT_TYPES = [ "Actor", "Adventure", "Cards", "ChatMessage", "Combat", "FogExploration", "Folder", "Item", "JournalEntry", "Macro", "Playlist", "RollTable", "Scene", "Setting", "User" ]; /** * The embedded Document types. * @type {Readonly} */ const EMBEDDED_DOCUMENT_TYPES = [ "ActiveEffect", "ActorDelta", "AmbientLight", "AmbientSound", "Card", "Combatant", "Drawing", "Item", "JournalEntryPage", "MeasuredTemplate", "Note", "PlaylistSound", "Region", "RegionBehavior", "TableResult", "Tile", "Token", "Wall" ]; /** * A listing of all valid Document types, both primary and embedded. * @type {Readonly} */ Array.from(new Set([ ...PRIMARY_DOCUMENT_TYPES, ...EMBEDDED_DOCUMENT_TYPES ])).sort(); /** * The allowed primary Document types which may exist within a World. * @type {string[]} */ const WORLD_DOCUMENT_TYPES = [ "Actor", "Cards", "ChatMessage", "Combat", "FogExploration", "Folder", "Item", "JournalEntry", "Macro", "Playlist", "RollTable", "Scene", "Setting", "User" ]; /** * Define the allowed User permission levels. * Each level is assigned a value in ascending order. Higher levels grant more permissions. * @enum {number} * @see https://foundryvtt.com/article/users/ */ const USER_ROLES = { /** * The User is blocked from taking actions in Foundry Virtual Tabletop. * You can use this role to temporarily or permanently ban a user from joining the game. */ NONE: 0, /** * The User is able to join the game with permissions available to a standard player. * They cannot take some more advanced actions which require Trusted permissions, but they have the basic functionalities needed to operate in the virtual tabletop. */ PLAYER: 1, /** * Similar to the Player role, except a Trusted User has the ability to perform some more advanced actions like create drawings, measured templates, or even to (optionally) upload media files to the server. */ TRUSTED: 2, /** * A special User who has many of the same in-game controls as a Game Master User, but does not have the ability to perform administrative actions like changing User roles or modifying World-level settings. */ ASSISTANT: 3, /** * A special User who has administrative control over this specific World. * Game Masters behave quite differently than Players in that they have the ability to see all Documents and Objects within the world as well as the capability to configure World settings. */ GAMEMASTER: 4 }; /** * Invert the User Role mapping to recover role names from a role integer * @enum {string} * @see USER_ROLES */ Object.entries(USER_ROLES).reduce((obj, r) => { obj[r[1]] = r[0]; return obj; }, {}); /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ new Proxy(CHAT_MESSAGE_STYLES, { get(target, prop, receiver) { const msg = "CONST.CHAT_MESSAGE_TYPES is deprecated in favor of CONST.CHAT_MESSAGE_STYLES because the " + "ChatMessage#type field has been renamed to ChatMessage#style"; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return Reflect.get(...arguments); } }); // Deprecated chat message styles Object.defineProperties(CHAT_MESSAGE_STYLES, { /** * @deprecated since v12 * @ignore */ ROLL: { get() { foundry.utils.logCompatibilityWarning("CONST.CHAT_MESSAGE_STYLES.ROLL is deprecated in favor of defining " + "rolls directly in ChatMessage#rolls", {since: 12, until: 14, once: true}); return 0; } }, /** * @deprecated since v12 * @ignore */ WHISPER: { get() { foundry.utils.logCompatibilityWarning("CONST.CHAT_MESSAGE_STYLES.WHISPER is deprecated in favor of defining " + "whisper recipients directly in ChatMessage#whisper", {since: 12, until: 14, once: true}); return 0; } } }); /** * @deprecated since v12 * @ignore */ const _DOCUMENT_TYPES = Object.freeze(WORLD_DOCUMENT_TYPES.filter(t => { const excluded = ["FogExploration", "Setting"]; return !excluded.includes(t); })); /** * @deprecated since v12 * @ignore */ new Proxy(_DOCUMENT_TYPES, { get(target, prop, receiver) { const msg = "CONST.DOCUMENT_TYPES is deprecated in favor of either CONST.WORLD_DOCUMENT_TYPES or " + "CONST.COMPENDIUM_DOCUMENT_TYPES."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return Reflect.get(...arguments); } }); /* -------------------------------------------- */ /** * Quickly clone a simple piece of data, returning a copy which can be mutated safely. * This method DOES support recursive data structures containing inner objects or arrays. * This method DOES NOT support advanced object types like Set, Map, or other specialized classes. * @param {*} original Some sort of data * @param {object} [options] Options to configure the behaviour of deepClone * @param {boolean} [options.strict=false] Throw an Error if deepClone is unable to clone something instead of * returning the original * @param {number} [options._d] An internal depth tracker * @return {*} The clone of that data */ function deepClone(original, {strict=false, _d=0}={}) { if ( _d > 100 ) { throw new Error("Maximum depth exceeded. Be sure your object does not contain cyclical data structures."); } _d++; // Simple types if ( (typeof original !== "object") || (original === null) ) return original; // Arrays if ( original instanceof Array ) return original.map(o => deepClone(o, {strict, _d})); // Dates if ( original instanceof Date ) return new Date(original); // Unsupported advanced objects if ( original.constructor && (original.constructor !== Object) ) { if ( strict ) throw new Error("deepClone cannot clone advanced objects"); return original; } // Other objects const clone = {}; for ( let k of Object.keys(original) ) { clone[k] = deepClone(original[k], {strict, _d}); } return clone; } /* -------------------------------------------- */ /** * Expand a flattened object to be a standard nested Object by converting all dot-notation keys to inner objects. * Only simple objects will be expanded. Other Object types like class instances will be retained as-is. * @param {object} obj The object to expand * @return {object} An expanded object */ function expandObject(obj) { function _expand(value, depth) { if ( depth > 32 ) throw new Error("Maximum object expansion depth exceeded"); if ( !value ) return value; if ( Array.isArray(value) ) return value.map(v => _expand(v, depth+1)); // Map arrays if ( value.constructor?.name !== "Object" ) return value; // Return advanced objects directly const expanded = {}; // Expand simple objects for ( let [k, v] of Object.entries(value) ) { setProperty(expanded, k, _expand(v, depth+1)); } return expanded; } return _expand(obj, 0); } /* -------------------------------------------- */ /** * Learn the underlying data type of some variable. Supported identifiable types include: * undefined, null, number, string, boolean, function, Array, Set, Map, Promise, Error, * HTMLElement (client side only), Object (catchall for other object types) * @param {*} variable A provided variable * @return {string} The named type of the token */ function getType(variable) { // Primitive types, handled with simple typeof check const typeOf = typeof variable; if ( typeOf !== "object" ) return typeOf; // Special cases of object if ( variable === null ) return "null"; if ( !variable.constructor ) return "Object"; // Object with the null prototype. if ( variable.constructor.name === "Object" ) return "Object"; // simple objects // Match prototype instances const prototypes = [ [Array, "Array"], [Set, "Set"], [Map, "Map"], [Promise, "Promise"], [Error, "Error"], [Color, "number"] ]; if ( "HTMLElement" in globalThis ) prototypes.push([globalThis.HTMLElement, "HTMLElement"]); for ( const [cls, type] of prototypes ) { if ( variable instanceof cls ) return type; } // Unknown Object type return "Object"; } /* -------------------------------------------- */ /** * A helper function which searches through an object to assign a value using a string key * This string key supports the notation a.b.c which would target object[a][b][c] * @param {object} object The object to update * @param {string} key The string key * @param {*} value The value to be assigned * @return {boolean} Whether the value was changed from its previous value */ function setProperty(object, key, value) { if ( !key ) return false; // Convert the key to an object reference if it contains dot notation let target = object; if ( key.indexOf('.') !== -1 ) { let parts = key.split('.'); key = parts.pop(); target = parts.reduce((o, i) => { if ( !o.hasOwnProperty(i) ) o[i] = {}; return o[i]; }, object); } // Update the target if ( !(key in target) || (target[key] !== value) ) { target[key] = value; return true; } return false; } /* -------------------------------------------- */ /** * Test whether a value is empty-like; either undefined or a content-less object. * @param {*} value The value to test * @returns {boolean} Is the value empty-like? */ function isEmpty(value) { const t = getType(value); switch ( t ) { case "undefined": return true; case "null": return true; case "Array": return !value.length; case "Object": return !Object.keys(value).length; case "Set": case "Map": return !value.size; default: return false; } } /* -------------------------------------------- */ /** * Update a source object by replacing its keys and values with those from a target object. * * @param {object} original The initial object which should be updated with values from the * target * @param {object} [other={}] A new object whose values should replace those in the source * @param {object} [options={}] Additional options which configure the merge * @param {boolean} [options.insertKeys=true] Control whether to insert new top-level objects into the resulting * structure which do not previously exist in the original object. * @param {boolean} [options.insertValues=true] Control whether to insert new nested values into child objects in * the resulting structure which did not previously exist in the * original object. * @param {boolean} [options.overwrite=true] Control whether to replace existing values in the source, or only * merge values which do not already exist in the original object. * @param {boolean} [options.recursive=true] Control whether to merge inner-objects recursively (if true), or * whether to simply replace inner objects with a provided new value. * @param {boolean} [options.inplace=true] Control whether to apply updates to the original object in-place * (if true), otherwise the original object is duplicated and the * copy is merged. * @param {boolean} [options.enforceTypes=false] Control whether strict type checking requires that the value of a * key in the other object must match the data type in the original * data to be merged. * @param {boolean} [options.performDeletions=false] Control whether to perform deletions on the original object if * deletion keys are present in the other object. * @param {number} [_d=0] A privately used parameter to track recursion depth. * @returns {object} The original source object including updated, inserted, or * overwritten records. * * @example Control how new keys and values are added * ```js * mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: false}); // {k1: "v1"} * mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: true}); // {k1: "v1", k2: "v2"} * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: false}); // {k1: {i1: "v1"}} * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: true}); // {k1: {i1: "v1", i2: "v2"}} * ``` * * @example Control how existing data is overwritten * ```js * mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: true}); // {k1: "v2"} * mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: false}); // {k1: "v1"} * ``` * * @example Control whether merges are performed recursively * ```js * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: false}); // {k1: {i2: "v2"}} * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: true}); // {k1: {i1: "v1", i2: "v2"}} * ``` * * @example Deleting an existing object key * ```js * mergeObject({k1: "v1", k2: "v2"}, {"-=k1": null}, {performDeletions: true}); // {k2: "v2"} * ``` */ function mergeObject(original, other={}, { insertKeys=true, insertValues=true, overwrite=true, recursive=true, inplace=true, enforceTypes=false, performDeletions=false }={}, _d=0) { other = other || {}; if (!(original instanceof Object) || !(other instanceof Object)) { throw new Error("One of original or other are not Objects!"); } const options = {insertKeys, insertValues, overwrite, recursive, inplace, enforceTypes, performDeletions}; // Special handling at depth 0 if ( _d === 0 ) { if ( Object.keys(other).some(k => /\./.test(k)) ) other = expandObject(other); if ( Object.keys(original).some(k => /\./.test(k)) ) { const expanded = expandObject(original); if ( inplace ) { Object.keys(original).forEach(k => delete original[k]); Object.assign(original, expanded); } else original = expanded; } else if ( !inplace ) original = deepClone(original); } // Iterate over the other object for ( let k of Object.keys(other) ) { const v = other[k]; if ( original.hasOwnProperty(k) ) _mergeUpdate(original, k, v, options, _d+1); else _mergeInsert(original, k, v, options, _d+1); } return original; } /** * A helper function for merging objects when the target key does not exist in the original * @private */ function _mergeInsert(original, k, v, {insertKeys, insertValues, performDeletions}={}, _d) { // Delete a key if ( k.startsWith("-=") && performDeletions ) { delete original[k.slice(2)]; return; } const canInsert = ((_d <= 1) && insertKeys) || ((_d > 1) && insertValues); if ( !canInsert ) return; // Recursively create simple objects if ( v?.constructor === Object ) { original[k] = mergeObject({}, v, {insertKeys: true, inplace: true, performDeletions}); return; } // Insert a key original[k] = v; } /** * A helper function for merging objects when the target key exists in the original * @private */ function _mergeUpdate(original, k, v, { insertKeys, insertValues, enforceTypes, overwrite, recursive, performDeletions }={}, _d) { const x = original[k]; const tv = getType(v); const tx = getType(x); // Recursively merge an inner object if ( (tv === "Object") && (tx === "Object") && recursive) { return mergeObject(x, v, { insertKeys, insertValues, overwrite, enforceTypes, performDeletions, inplace: true }, _d); } // Overwrite an existing value if ( overwrite ) { if ( (tx !== "undefined") && (tv !== tx) && enforceTypes ) { throw new Error(`Mismatched data types encountered during object merge.`); } original[k] = v; } } /** * @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration */ /** * @typedef {Object} DialogV2Button * @property {string} action The button action identifier. * @property {string} label The button label. Will be localized. * @property {string} [icon] FontAwesome icon classes. * @property {string} [class] CSS classes to apply to the button. * @property {boolean} [default] Whether this button represents the default action to take if the user * submits the form without pressing a button, i.e. with an Enter * keypress. * @property {DialogV2ButtonCallback} [callback] A function to invoke when the button is clicked. The value returned * from this function will be used as the dialog's submitted value. * Otherwise, the button's identifier is used. */ /** * @callback DialogV2ButtonCallback * @param {PointerEvent|SubmitEvent} event The button click event, or a form submission event if the dialog was * submitted via keyboard. * @param {HTMLButtonElement} button If the form was submitted via keyboard, this will be the default * button, otherwise the button that was clicked. * @param {HTMLDialogElement} dialog The dialog element. * @returns {Promise} */ /** * @typedef {Object} DialogV2Configuration * @property {boolean} [modal] Modal dialogs prevent interaction with the rest of the UI until they * are dismissed or submitted. * @property {DialogV2Button[]} buttons Button configuration. * @property {string} [content] The dialog content. * @property {DialogV2SubmitCallback} [submit] A function to invoke when the dialog is submitted. This will not be * called if the dialog is dismissed. */ /** * @callback DialogV2RenderCallback * @param {Event} event The render event. * @param {HTMLDialogElement} dialog The dialog element. */ /** * @callback DialogV2CloseCallback * @param {Event} event The close event. * @param {DialogV2} dialog The dialog instance. */ /** * @callback DialogV2SubmitCallback * @param {any} result Either the identifier of the button that was clicked to submit the * dialog, or the result returned by that button's callback. * @returns {Promise} */ /** * @typedef {object} DialogV2WaitOptions * @property {DialogV2RenderCallback} [render] A synchronous function to invoke whenever the dialog is rendered. * @property {DialogV2CloseCallback} [close] A synchronous function to invoke when the dialog is closed under any * circumstances. * @property {boolean} [rejectClose=true] Throw a Promise rejection if the dialog is dismissed. */ /** * A lightweight Application that renders a dialog containing a form with arbitrary content, and some buttons. * @extends {ApplicationV2} * * @example Prompt the user to confirm an action. * ```js * const proceed = await foundry.applications.api.DialogV2.confirm({ * content: "Are you sure?", * rejectClose: false, * modal: true * }); * if ( proceed ) console.log("Proceed."); * else console.log("Do not proceed."); * ``` * * @example Prompt the user for some input. * ```js * let guess; * try { * guess = await foundry.applications.api.DialogV2.prompt({ * window: { title: "Guess a number between 1 and 10" }, * content: '', * ok: { * label: "Submit Guess", * callback: (event, button, dialog) => button.form.elements.guess.valueAsNumber * } * }); * } catch { * console.log("User did not make a guess."); * return; * } * const n = Math.ceil(CONFIG.Dice.randomUniform() * 10); * if ( n === guess ) console.log("User guessed correctly."); * else console.log("User guessed incorrectly."); * ``` * * @example A custom dialog. * ```js * new foundry.applications.api.DialogV2({ * window: { title: "Choose an option" }, * content: ` * * * * `, * buttons: [{ * action: "choice", * label: "Make Choice", * default: true, * callback: (event, button, dialog) => button.form.elements.choice.value * }, { * action: "all", * label: "Take All" * }], * submit: result => { * if ( result === "all" ) console.log("User picked all options."); * else console.log(`User picked option: ${result}`); * } * }).render({ force: true }); * ``` */ class DialogV2 extends ApplicationV2 { /** @inheritDoc */ static DEFAULT_OPTIONS = { id: "dialog-{id}", classes: ["dialog"], tag: "dialog", form: { closeOnSubmit: true }, window: { frame: true, positioned: true, minimizable: false } }; /* -------------------------------------------- */ /** @inheritDoc */ _initializeApplicationOptions(options) { options = super._initializeApplicationOptions(options); if ( !options.buttons?.length ) throw new Error("You must define at least one entry in options.buttons"); options.buttons = options.buttons.reduce((obj, button) => { options.actions[button.action] = this.constructor._onClickButton; obj[button.action] = button; return obj; }, {}); return options; } /* -------------------------------------------- */ /** @override */ async _renderHTML(_context, _options) { const form = document.createElement("form"); form.className = "dialog-form standard-form"; form.autocomplete = "off"; form.innerHTML = ` ${this.options.content ? `
${this.options.content}
` : ""}
${this._renderButtons()}
`; form.addEventListener("submit", event => this._onSubmit(event.submitter, event)); return form; } /* -------------------------------------------- */ /** * Render configured buttons. * @returns {string} * @protected */ _renderButtons() { return Object.values(this.options.buttons).map(button => { const { action, label, icon, default: isDefault, class: cls="" } = button; return ` `; }).join(""); } /* -------------------------------------------- */ /** * Handle submitting the dialog. * @param {HTMLButtonElement} target The button that was clicked or the default button. * @param {PointerEvent|SubmitEvent} event The triggering event. * @returns {Promise} * @protected */ async _onSubmit(target, event) { event.preventDefault(); const button = this.options.buttons[target?.dataset.action]; const result = (await button?.callback?.(event, target, this.element)) ?? button?.action; await this.options.submit?.(result); return this.options.form.closeOnSubmit ? this.close() : this; } /* -------------------------------------------- */ /** @override */ _onFirstRender(_context, _options) { if ( this.options.modal ) this.element.showModal(); else this.element.show(); } /* -------------------------------------------- */ /** @inheritDoc */ _attachFrameListeners() { super._attachFrameListeners(); this.element.addEventListener("keydown", this._onKeyDown.bind(this)); } /* -------------------------------------------- */ /** @override */ _replaceHTML(result, content, _options) { content.replaceChildren(result); } /* -------------------------------------------- */ /** * Handle keypresses within the dialog. * @param {KeyboardEvent} event The triggering event. * @protected */ _onKeyDown(event) { // Capture Escape keypresses for dialogs to ensure that close is called properly. if ( event.key === "Escape" ) { event.preventDefault(); // Prevent default browser dialog dismiss behavior. event.stopPropagation(); this.close(); } } /* -------------------------------------------- */ /** * @this {DialogV2} * @param {PointerEvent} event The originating click event. * @param {HTMLButtonElement} target The button element that was clicked. * @protected */ static _onClickButton(event, target) { this._onSubmit(target, event); } /* -------------------------------------------- */ /* Factory Methods */ /* -------------------------------------------- */ /** * A utility helper to generate a dialog with yes and no buttons. * @param {Partial} [options] * @param {DialogV2Button} [options.yes] Options to overwrite the default yes button configuration. * @param {DialogV2Button} [options.no] Options to overwrite the default no button configuration. * @returns {Promise} Resolves to true if the yes button was pressed, or false if the no button * was pressed. If additional buttons were provided, the Promise resolves to * the identifier of the one that was pressed, or the value returned by its * callback. If the dialog was dismissed, and rejectClose is false, the * Promise resolves to null. */ static async confirm({ yes={}, no={}, ...options }={}) { options.buttons ??= []; options.buttons.unshift(mergeObject({ action: "yes", label: "Yes", icon: "fas fa-check", callback: () => true }, yes), mergeObject({ action: "no", label: "No", icon: "fas fa-xmark", default: true, callback: () => false }, no)); return this.wait(options); } /* -------------------------------------------- */ /** * A utility helper to generate a dialog with a single confirmation button. * @param {Partial} [options] * @param {Partial} [options.ok] Options to overwrite the default confirmation button configuration. * @returns {Promise} Resolves to the identifier of the button used to submit the dialog, * or the value returned by that button's callback. If the dialog was * dismissed, and rejectClose is false, the Promise resolves to null. */ static async prompt({ ok={}, ...options }={}) { options.buttons ??= []; options.buttons.unshift(mergeObject({ action: "ok", label: "Confirm", icon: "fas fa-check", default: true }, ok)); return this.wait(options); } /* -------------------------------------------- */ /** * Spawn a dialog and wait for it to be dismissed or submitted. * @param {Partial} [options] * @param {DialogV2RenderCallback} [options.render] A function to invoke whenever the dialog is rendered. * @param {DialogV2CloseCallback} [options.close] A function to invoke when the dialog is closed under any * circumstances. * @param {boolean} [options.rejectClose=true] Throw a Promise rejection if the dialog is dismissed. * @returns {Promise} Resolves to the identifier of the button used to submit the * dialog, or the value returned by that button's callback. If the * dialog was dismissed, and rejectClose is false, the Promise * resolves to null. */ static async wait({ rejectClose=true, close, render, ...options }={}) { return new Promise((resolve, reject) => { // Wrap submission handler with Promise resolution. const originalSubmit = options.submit; options.submit = async result => { await originalSubmit?.(result); resolve(result); }; const dialog = new this(options); dialog.addEventListener("close", event => { if ( close instanceof Function ) close(event, dialog); if ( rejectClose ) reject(new Error("Dialog was dismissed without pressing a button.")); else resolve(null); }, { once: true }); if ( render instanceof Function ) { dialog.addEventListener("render", event => render(event, dialog.element)); } dialog.render({ force: true }); }); } } /** * The primary application which renders packages on the Setup view. */ class SetupPackages extends Application { constructor(...args) { super(...args); this.#viewModes = this.#initializeViewModes(); } /** * Initialize user-designated favorite packages. */ #initializePackageFavorites() { const packageFavorites = game.settings.get("core", Setup.FAVORITE_PACKAGES_SETTING); for ( const [collectionName, ids] of Object.entries(packageFavorites) ) { const c = game[collectionName]; for ( const id of ids ) { const pkg = c.get(id); if ( pkg ) pkg.favorite = true; } } } /** * Retrieve selected view modes from client storage. * @returns {{worlds: string, systems: string, modules: string}} */ #initializeViewModes() { const vm = game.settings.get("core", "setupViewModes"); if ( !(vm.worlds in SetupPackages.VIEW_MODES) ) vm.worlds = "GALLERY"; if ( !(vm.systems in SetupPackages.VIEW_MODES) ) vm.systems = "GALLERY"; if ( !(vm.modules in SetupPackages.VIEW_MODES) ) vm.modules = "TILES"; return vm; } /* -------------------------------------------- */ /** @override */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "setup-packages", template: "templates/setup/setup-packages.hbs", popOut: false, scrollY: ["#worlds-list", "#systems-list", "#modules-list"], tabs: [{navSelector: ".tabs", contentSelector: "#setup-packages", initial: "worlds"}], filters: [ {inputSelector: "#world-filter", contentSelector: "#worlds-list"}, {inputSelector: "#system-filter", contentSelector: "#systems-list"}, {inputSelector: "#module-filter", contentSelector: "#modules-list"} ] }); } /** * The set of progress actions eligible for display in the package progress bar. * @type {Set} */ static progressActions = new Set([ CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.INSTALL_PKG, CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.LAUNCH_WORLD, CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.CREATE_BACKUP, CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.RESTORE_BACKUP, CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.DELETE_BACKUP, CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.CREATE_SNAPSHOT, CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.RESTORE_SNAPSHOT ]); /** * A mapping of package IDs to progress bar elements * @type {Map} */ progress = new Map(); /** * The view modes applied to each package tab. * @type {{worlds: string, systems: string, modules: string}} */ #viewModes; /** * Track whether an "Update All" workflow is currently in progress. * @type {"world"|"system"|"module"|null} */ #updatingAll = null; /** * The allowed view modes which can be used for each package-type tab. * @enum {Readonly<{id: string, label: string, template: string}>} */ static VIEW_MODES = Object.freeze({ GALLERY: { id: "GALLERY", icon: "fa-solid fa-image-landscape", label: "PACKAGE.VIEW_MODES.GALLERY", template: "templates/setup/parts/package-gallery.hbs" }, TILES: { id: "TILES", icon: "fa-solid fa-grid-horizontal", label: "PACKAGE.VIEW_MODES.TILES", template: "templates/setup/parts/package-tiles.hbs" }, DETAILS: { id: "DETAILS", icon: "fa-solid fa-list", label: "PACKAGE.VIEW_MODES.DETAILS", template: "templates/setup/parts/package-details.hbs" } }); /** * The maximum number of progress bars that will be displayed simultaneously. * @type {number} */ static MAX_PROGRESS_BARS = 5; /* -------------------------------------------- */ /* Tabs and Filters */ /* -------------------------------------------- */ /** * The name of the currently active packages tab. * @type {string} */ get activeTab() { return this._tabs[0].active; } /* -------------------------------------------- */ /** @inheritdoc */ _onChangeTab(event, tabs, active) { super._onChangeTab(event, tabs, active); this._searchFilters.forEach(f => { if ( f._input ) f._input.value = ""; f.filter(null, ""); }); this.element.find(".tab.active .filter > input").trigger("focus"); document.querySelector(".tab.active > header").insertAdjacentElement("afterend", document.getElementById("progress")); } /* -------------------------------------------- */ /** @override */ _onSearchFilter(event, query, rgx, html) { if ( !html ) return; let anyMatch = !query; const noResults = html.closest("section").querySelector(".no-results"); for ( const li of html.children ) { if ( !query ) { li.classList.remove("hidden"); continue; } const id = li.dataset.packageId; const title = li.querySelector(".package-title")?.textContent; let match = rgx.test(id) || rgx.test(SearchFilter.cleanQuery(title)); li.classList.toggle("hidden", !match); if ( match ) anyMatch = true; } const empty = !anyMatch || !html.children.length; html.classList.toggle("empty", empty); if ( !anyMatch ) { const label = game.i18n.localize(`SETUP.${html.closest(".tab").id.titleCase()}`); const search = game.i18n.localize("SETUP.PackagesNoResultsSearch", { name: query}); noResults.innerHTML = `

${game.i18n.format("SETUP.PackagesNoResults", {type: label, name: query})} ${search}

`; } noResults.classList.toggle("hidden", anyMatch); } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** @inheritdoc */ async _render(force, options) { await loadTemplates([ "templates/setup/parts/package-tags.hbs", ...Object.values(SetupPackages.VIEW_MODES).map(m => m.template) ]); await super._render(force, options); const progressBars = document.getElementById("progress"); progressBars.append(...this.progress.values()); document.querySelector(".tab.active > header").insertAdjacentElement("afterend", progressBars); } /* -------------------------------------------- */ /** @override */ async getData(options={}) { this.#initializePackageFavorites(); return { worlds: { packages: this.#prepareWorlds(), count: game.worlds.size, viewMode: this.#viewModes.worlds, template: SetupPackages.VIEW_MODES[this.#viewModes.worlds].template, icon: World.icon, updatingAll: this.#updatingAll === "world" }, systems: { packages: this.#prepareSystems(), count: game.systems.size, viewMode: this.#viewModes.systems, template: SetupPackages.VIEW_MODES[this.#viewModes.systems].template, icon: System.icon, updatingAll: this.#updatingAll === "system" }, modules: { packages: this.#prepareModules(), count: game.modules.size, viewMode: this.#viewModes.modules, template: SetupPackages.VIEW_MODES[this.#viewModes.modules].template, icon: Module.icon, updatingAll: this.#updatingAll === "module" }, viewModes: Object.values(SetupPackages.VIEW_MODES) }; } /* -------------------------------------------- */ /** * Prepare data for rendering the Worlds tab. * @returns {object[]} */ #prepareWorlds() { const codes = CONST.PACKAGE_AVAILABILITY_CODES; const worlds = game.worlds.map(world => { const w = world.toObject(); w.authors = this.#formatAuthors(w.authors); w.system = game.systems.get(w.system); w.thumb = this.#getCover(world) || this.#getCover(w.system) || "ui/anvil-bg.png"; w.badge = world.getVersionBadge(); w.systemBadge = world.getSystemBadge(); w.available = (world.availability <= codes.REQUIRES_UPDATE) || (world.availability === codes.VERIFIED); w.lastPlayedDate = new Date(w.lastPlayed); w.lastPlayedLabel = this.#formatDate(w.lastPlayedDate); w.canPlay = !(world.locked || world.unavailable); w.favorite = world.favorite; w.locked = world.locked; w.shortDesc = TextEditor.previewHTML(w.description); return w; }); worlds.sort(this.#sortWorlds); return worlds; } /* -------------------------------------------- */ #prepareSystems() { const systems = game.systems.map(system => { const s = system.toObject(); s.authors = this.#formatAuthors(s.authors); s.shortDesc = TextEditor.previewHTML(s.description); s.badge = system.getVersionBadge(); s.favorite = system.favorite; s.locked = system.locked; s.thumb = this.#getCover(system) || "ui/anvil-bg.png"; return s; }); systems.sort(this.#sortPackages); return systems; } /* -------------------------------------------- */ #prepareModules() { const modules = game.modules.map(module => { const m = module.toObject(); m.authors = this.#formatAuthors(m.authors); m.shortDesc = TextEditor.previewHTML(m.description); m.badge = module.getVersionBadge(); m.favorite = module.favorite; m.locked = module.locked; m.thumb = this.#getCover(module) || "ui/anvil-bg.png"; return m; }); modules.sort(this.#sortPackages); return modules; } /* -------------------------------------------- */ /** * Obtain a cover image used to represent the package. * Prefer the "setup" media type, and prefer a thumbnail to the full image. * Otherwise, use a background image if the package has one. * @param {BasePackage} pkg The package which requires a cover image * @returns {string} A cover image URL or undefined */ #getCover(pkg) { if ( !pkg ) return undefined; if ( pkg.media.size ) { const setup = pkg.media.find(m => m.type === "setup"); if ( setup?.thumbnail ) return setup.thumbnail; else if ( setup?.url ) return setup.url; } if ( pkg.background ) return pkg.background; } /* -------------------------------------------- */ #formatAuthors(authors=[]) { return authors.map(a => { if ( a.url ) return `${a.name}`; return a.name; }).join(", "); } /* -------------------------------------------- */ /** * Format dates displayed in the app. * @param {Date} date The Date instance to format * @returns {string} The formatted date string */ #formatDate(date) { return date.isValid() ? date.toLocaleDateString(game.i18n.lang, { weekday: "long", month: "short", day: "numeric" }) : ""; } /* -------------------------------------------- */ /** * A sorting function used to order worlds. * @returns {number} */ #sortWorlds(a, b) { // Favorites const fd = b.favorite - a.favorite; if ( fd !== 0 ) return fd; // Sort date const ad = a.lastPlayedDate.isValid() ? a.lastPlayedDate : 0; const bd = b.lastPlayedDate.isValid() ? b.lastPlayedDate : 0; if ( ad && !bd ) return -1; if ( bd && !ad ) return 1; if ( ad && bd ) return bd - ad; // Sort title return a.title.localeCompare(b.title, game.i18n.lang); } /* -------------------------------------------- */ /** * A sorting function used to order systems and modules. * @param {ClientPackage} a A system or module * @param {ClientPackage} b Another system or module * @returns {number} The relative sort order between the two */ #sortPackages(a, b) { return (b.favorite - a.favorite) || a.title.localeCompare(b.title, game.i18n.lang); } /* -------------------------------------------- */ /* Interactivity */ /* -------------------------------------------- */ /** @inheritDoc */ activateListeners(html) { super.activateListeners(html); html.on("click", "[data-action]", this.#onClickAction.bind(this)); html.on("click", "[data-tour]", this.#onClickTour.bind(this)); // Context Menu for package management new ContextMenu(html, ".package", [], {onOpen: this.#setContextMenuItems.bind(this)}); // Intersection observer for world background images const observer = new IntersectionObserver(this.#onLazyLoadImages.bind(this), { root: html[0] }); const systems = html.find("#systems-list")[0].children; for ( const li of html.find("#worlds-list")[0].children ) observer.observe(li); for ( const li of systems ) observer.observe(li); for ( const li of html.find("#modules-list")[0].children ) observer.observe(li); // If there are no systems, disable the world tab and swap to the systems tab if ( systems.length === 0 ) { const worldsTab = html.find("[data-tab=worlds]"); worldsTab.addClass("disabled"); worldsTab.removeClass("active"); // Only activate systems if modules is not the active tab if ( this.activeTab !== "modules" ) { html.find("[data-tab=systems").addClass("active"); } } } /* -------------------------------------------- */ /** * Dynamically assign context menu options depending on the package that is interacted with. * @param {HTMLLIElement} li The HTML
  • element to which the context menu is attached */ #setContextMenuItems(li) { const packageType = li.closest("[data-package-type]").dataset.packageType; const typeLabel = game.i18n.localize(`PACKAGE.Type.${packageType}`); const collection = PACKAGE_TYPES[packageType].collection; const pkg = game[collection].get(li.dataset.packageId); const menuItems = []; // Launch World if ( (packageType === "world") && !pkg.locked && !pkg.unavailable ) menuItems.push({ name: "SETUP.WorldLaunch", icon: '', callback: () => this.#launchWorld(pkg), group: "primary" }); // Edit World if ( (packageType === "world") && !pkg.locked ) menuItems.push({ name: "SETUP.WorldEdit", icon: '', callback: () => new WorldConfig(pkg).render(true), group: "primary" }); // Edit Module if ( (packageType === "module") && !pkg.locked ) menuItems.push({ name: "PACKAGE.ModuleEdit", icon: '', callback: () => new ModuleConfigurationForm(pkg.toObject()).render(true), group: "primary" }); // Mark or Unmark Favorite menuItems.push({ name: game.i18n.format(pkg.favorite ? "PACKAGE.Unfavorite" : "PACKAGE.Favorite"), icon: ``, callback: () => this.#toggleFavorite(pkg), group: "primary" }); // Lock or Unlock Package menuItems.push({ name: game.i18n.format(pkg.locked ? "PACKAGE.Unlock" : "PACKAGE.Lock", {type: typeLabel}), icon: ``, callback: () => this.#toggleLock(pkg), group: "primary" }); // Delete Package menuItems.push({ name: packageType === "world" ? "SETUP.WorldDelete" : "SETUP.Uninstall", icon: '', callback: () => Setup.uninstallPackage(pkg), group: "primary" }); if ( !game.data.options.noBackups ) { // Taking backups menuItems.push({ name: "SETUP.BACKUPS.TakeBackup", icon: '', callback: () => Setup.createBackup(pkg, { dialog: true }), group: "backups" }); if ( Setup.backups?.[pkg.type]?.[pkg.id]?.length ) { menuItems.push({ name: "SETUP.BACKUPS.RestoreLatestBackup", icon: '', callback: () => Setup.restoreLatestBackup(pkg, { dialog: true }), group: "backups" }); } // Managing backups menuItems.push({ name: "SETUP.BACKUPS.ManageBackups", icon: '', callback: () => new BackupList(pkg).render(true), group: "backups" }); } ui.context.menuItems = menuItems; } /* -------------------------------------------- */ /** * Handle click events on an action button. * @param {PointerEvent} event The initiating click event */ async #onClickTour(event) { event.preventDefault(); // Gather data const link = event.currentTarget; // Delegate tour switch ( link.dataset.tour ) { case "creatingAWorld": return game.tours.get("core.creatingAWorld").start(); case "installingASystem": return game.tours.get("core.installingASystem").start(); } } /* -------------------------------------------- */ /** * Handle click events on an action button. * @param {PointerEvent} event The initiating click event */ async #onClickAction(event) { event.preventDefault(); // Gather data const button = event.currentTarget; const packageType = button.closest("[data-package-type]").dataset.packageType; const packageId = button.closest(".package")?.dataset.packageId; const pkg = packageId ? game[PACKAGE_TYPES[packageType].collection].get(packageId) : undefined; // Delegate action switch ( button.dataset.action ) { case "installPackage": await Setup.browsePackages(packageType, {search: button.dataset.query}); break; case "moduleCreate": new ModuleConfigurationForm().render(true); break; case "updateAll": await this.#updateAll(packageType); break; case "updatePackage": await this.#updatePackage(pkg); break; case "viewMode": this.#onChangeViewMode(button); break; case "worldCreate": this.#createWorld(); break; case "worldInstall": await Setup.browsePackages(packageType); break; case "worldLaunch": await this.#launchWorld(pkg); break; } } /* -------------------------------------------- */ /** * Handle toggling the view mode for a certain package type. * @param {HTMLElement} button The clicked button element */ #onChangeViewMode(button) { const tab = button.closest(".tab").dataset.tab; this.#viewModes[tab] = button.dataset.viewMode; game.settings.set("core", "setupViewModes", this.#viewModes); this.render(); } /* -------------------------------------------- */ /** * Handle lazy loading for world background images to only load them once they become observed. * @param {IntersectionObserverEntry[]} entries The entries which are now observed * @param {IntersectionObserver} observer The intersection observer instance */ #onLazyLoadImages(entries, observer) { for ( const e of entries ) { if ( !e.isIntersecting ) continue; const li = e.target; const img = li.querySelector(".thumbnail"); if ( img?.dataset.src ) { img.src = img.dataset.src; delete img.dataset.src; } observer.unobserve(li); } } /* -------------------------------------------- */ /** * Display a confirmation dialog which warns the user that launching the world will trigger irreversible migration. * @param {World} world The World being launched * @returns {Promise<{confirm: boolean, [backup]: boolean}>} Did the user agree to proceed? */ async #displayWorldMigrationInfo(world) { if ( !world ) return { confirm: false }; const system = game.systems.get(world.system); const needsCoreMigration = foundry.utils.isNewerVersion(game.release.version, world.coreVersion); const needsSystemMigration = world.systemVersion && foundry.utils.isNewerVersion(system.version, world.systemVersion); if ( !needsCoreMigration && !needsSystemMigration ) return { confirm: true }; if ( !needsCoreMigration && needsSystemMigration && game.data.options.noBackups ) return { confirm: true }; // Prompt that world migration will be required const title = game.i18n.localize("SETUP.WorldMigrationRequiredTitle"); const disableModules = game.release.isGenerationalChange(world.compatibility.verified); let content = [ needsCoreMigration ? game.i18n.format("SETUP.WorldCoreMigrationRequired", { world: world.title, oldVersion: world.coreVersion, newVersion: game.release }) : game.i18n.format("SETUP.WorldSystemMigrationRequired", { oldVersion: world.systemVersion, newVersion: system.version }), system.availability !== CONST.PACKAGE_AVAILABILITY_CODES.VERIFIED ? game.i18n.format("SETUP.WorldMigrationSystemUnavailable", { system: system.title, systemVersion: system.version }) : "", disableModules ? game.i18n.localize("SETUP.WorldMigrationDisableModules") : "", game.i18n.localize("SETUP.WorldMigrationBackupPrompt") ].filterJoin(""); if ( !game.data.options.noBackups ) { content += ` `; } // Present the confirmation dialog return Dialog.wait({ title, content, default: "no", buttons: { yes: { icon: '', label: game.i18n.localize("SETUP.WorldMigrationBegin"), callback: html => ({ confirm: true, backup: html.querySelector("#create-backup input")?.checked }) }, no: { icon: '', label: game.i18n.localize("Cancel"), callback: () => ({ confirm: false }) } }, close: () => ({ confirm: false }) }, { jQuery: false }); } /* -------------------------------------------- */ /** * Toggle the locked state of the interface. * @param {boolean} locked Is the interface locked? * @param {object} [options] * @param {string} [options.message] The message to display. */ toggleLock(locked, { message }={}) { const element = this.element[0]; if ( !element ) return; element.querySelectorAll(".tabs .item").forEach(el => el.classList.toggle("disabled", locked)); element.querySelectorAll(".package-list").forEach(el => el.classList.toggle("hidden", locked)); element.querySelectorAll(".controls :is(input, button)").forEach(el => el.disabled = locked); const status = element.querySelector(".tab.active .locked"); status.classList.toggle("hidden", !locked); if ( message ) status.querySelector("h3").innerText = game.i18n.localize(message); } /* -------------------------------------------- */ /* Package Management Operations */ /* -------------------------------------------- */ /** * Create a new World. */ #createWorld() { if ( !game.systems.size ) return ui.notifications.warn(game.i18n.localize("SETUP.YouMustInstallASystem")); const world = new World({name: "1", title: "1", system: "1", coreVersion: game.release.version}); world.id = world.title = world.system = ""; new WorldConfig(world, {create: true}).render(true); } /* -------------------------------------------- */ /** * Request to launch a World. * @param {World} world The requested World to launch * @returns {Promise} A Promise that resolves when the World is launched or an error occurs. */ async #launchWorld(world) { if ( world.locked ) return ui.notifications.error(game.i18n.format("PACKAGE.LaunchLocked", {id: world.id})); const { confirm, backup } = await this.#displayWorldMigrationInfo(world); if ( !confirm ) return; if ( backup ) await Setup.createBackup(world, { dialog: true }); // Notify migration in progress. if ( foundry.utils.isNewerVersion(game.release.version, world.coreVersion) ) { const msg = game.i18n.format("SETUP.WorldMigrationInProcess", {version: game.release}); ui.notifications.info(msg, {permanent: true}); } // Show progress spinner and disable interaction with worlds. const worlds = document.getElementById("worlds-list"); worlds.classList.add("disabled"); const tile = worlds.querySelector(`.world[data-package-id="${world.id}"]`); tile.classList.add("loading"); const icon = tile.querySelector(`.control.play > i`); icon.setAttribute("class", "fas fa-spinner fa-spin-pulse"); const { ACTIONS } = CONST.SETUP_PACKAGE_PROGRESS; const response = await new ProgressReceiver(world.id, ACTIONS.LAUNCH_WORLD, { world: world.id }).listen(); if ( response instanceof Error ) { ui.notifications.error(game.i18n.format("SETUP.WorldLaunchFailure", { message: response.message }), { console: false, permanent: true }); } else location.href = foundry.utils.getRoute("/game"); } /* -------------------------------------------- */ /** * Toggle marking a package as a favorite. * @param {BasePackage} pkg The requested Package to mark or unmark as a favorite */ async #toggleFavorite(pkg) { const favorites = game.settings.get("core", Setup.FAVORITE_PACKAGES_SETTING); const collectionName = PACKAGE_TYPES[pkg.type].collection; if ( pkg.favorite ) favorites[collectionName].findSplice(f => f === pkg.id); else favorites[collectionName].push(pkg.id); game.settings.set("core", Setup.FAVORITE_PACKAGES_SETTING, favorites); pkg.favorite = !pkg.favorite; this.render(); } /* -------------------------------------------- */ /** * Toggle locking or unlocking a package. * @param {BasePackage} pkg The requested Package to lock or unlock * @returns {Promise} Returned response from the server */ async #toggleLock(pkg) { const shouldLock = !pkg.locked; await Setup.post({action: "lockPackage", type: pkg.type, id: pkg.id, shouldLock}); pkg.locked = shouldLock; this.render(); } /* -------------------------------------------- */ /** * Handle update button press for a single Package. * @param {BasePackage} pkg The requested Package to update * @param {object} [options] Options which configure installation * @param {boolean} [options.notify=true] Display a notification toast. Suppressed for "updateAll" * @returns {Promise} */ async #installPackageUpdate(pkg, {notify=true}={}) { return Setup.installPackage({type: pkg.type, id: pkg.id, manifest: pkg.manifest, notify}); } /* -------------------------------------------- */ /** * Update all package for a certain package type. * @param {string} packageType The package type to update * @param {object} [options] * @param {Set} [options.ids] A fixed set of package IDs to update, rather than all of the given type. * @param {boolean} [options.force] Update the packages without prompting the user to confirm in cases of * incompatible systems. * @returns {Promise} */ async #updateAll(packageType, { ids, force }={}) { if ( this.#updatingAll ) return ui.notifications.warn("PACKAGE.UpdateAllInProgress", {localize: true}); this.#updatingAll = packageType; // Disable the "Update All" button let button = this.element[0].querySelector(`[data-package-type="${packageType}"] [data-action="updateAll"]`); button.disabled = true; button.firstElementChild.className = "fas fa-spinner fa-spin"; // Create two queues const max = SetupPackages.MAX_PROGRESS_BARS; const pending = game[PACKAGE_TYPES[packageType].collection].filter(p => { return p.manifest && !p.locked && (!ids || ids.has(p.id)); }); const active = new Set(); const results = []; let requireReload = false; // Populate the package cache console.group(`${vtt} | Updating ${packageType.titleCase()}s`); await Setup.warmPackages({type: packageType}); console.debug(`Warmed ${packageType} package cache`); // Store information on incompatible module updates. const incompatible = {}; // A semaphore which updates a certain number of packages concurrently let complete; const next = () => { while ( (active.size < max) && pending.length ) { const pkg = pending.shift(); active.add(pkg); update(pkg); } if ( !pending.length && !active.size ) complete(); }; // TODO #8732 // Update function const update = async pkg => { console.debug(`Checking ${packageType} ${pkg.id} for updates`); const check = await this.#updateCheck(pkg); switch ( check.state ) { // Error case "error": results.push({ package: pkg, action: game.i18n.localize("Error"), actionClass: "error", description: check.error }); console.debug(`Checked ${packageType} ${pkg.id}: error`); break; // Warning case "warning": results.push({ package: pkg, action: game.i18n.localize("Warning"), actionClass: "warning", description: check.warning }); console.debug(`Checked ${packageType} ${pkg.id}: warning`); break; // Sidegrade case "sidegrade": requireReload = true; console.debug(`Checked ${packageType} ${pkg.id}: sidegrade`); break; // Track Change case "trackChange": const confirm = await this.#promptTrackChange(pkg, check.trackChange); if ( confirm ) { pkg.updateSource({manifest: check.trackChange.manifest}); try { const trackChangeUpdate = await this.#installPackageUpdate(pkg, {notify: false}); results.push({ package: trackChangeUpdate, action: game.i18n.localize("Update"), actionClass: "success", description: `${pkg.version} âžž ${trackChangeUpdate.version}` }); console.debug(`${vtt} | Checked ${packageType} ${pkg.id}: track change success`); } catch(err) { results.push({ package: pkg, action: game.i18n.localize("Error"), actionClass: "error", description: err.message }); console.debug(`Checked ${packageType} ${pkg.id}: track change failed`); } } else console.debug(`Checked ${packageType} ${pkg.id}: track change declined`); break; // Standard Update case "update": if ( !force && Object.values(check.systemCompatibility ?? {}).some(({ compatible }) => !compatible) ) { incompatible[pkg.id] = { pkg, systemCompatibility: check.systemCompatibility }; break; } try { const updated = await this.#installPackageUpdate(pkg, {notify: false}); results.push({ package: updated, action: game.i18n.localize("Update"), actionClass: "success", description: `${pkg.version} âžž ${updated.version}` }); console.debug(`Checked ${packageType} ${pkg.id}: update success`); } catch(err) { results.push({ package: pkg, action: game.i18n.localize("Error"), actionClass: "error", description: err.message }); console.debug(`Checked ${packageType} ${pkg.id}: update failed`); } break; case "current": console.debug(`Checked ${packageType} ${pkg.id}: current`); break; // Unknown default: console.warn(`Checked ${packageType} ${pkg.id}: unknown state`); break; } active.delete(pkg); next(); }; // Wait for completion await new Promise(resolve => { complete = resolve; next(); }); console.debug("Update check complete"); // Display Update Log let response; if ( results.length || !isEmpty(incompatible) ) { const dialog = isEmpty(incompatible) ? Dialog.prompt.bind(Dialog) : Dialog.confirm.bind(Dialog); const incompatibleList = Object.values(incompatible ?? {}).map(({ pkg, systemCompatibility }) => { return game.i18n.format("SETUP.PackageIncompatibleWithSystems", { module: `${pkg.title}`, systems: Object.values(systemCompatibility).map(({ id, title, compatible }) => { return compatible ? null : `${title ?? id}`; }).filterJoin(", ") }); }); const content = await renderTemplate("templates/setup/updated-packages.html", { changed: results, incompatible: incompatibleList }); response = await dialog({ title: game.i18n.localize("SETUP.UpdatedPackages"), content: content, options: {width: 700}, rejectClose: false }); } // No results else ui.notifications.info(game.i18n.format("PACKAGE.AllUpdated", { type: game.i18n.localize(`PACKAGE.Type.${packageType}Pl`) })); console.groupEnd(); // Reload package data if ( requireReload ) await Setup.reload(); // Re-enable the "Update All" button button = this.element[0].querySelector(`[data-package-type="${packageType}"] [data-action="updateAll"]`); button.disabled = false; button.firstElementChild.className = "fas fa-cloud-download"; this.#updatingAll = null; if ( response === true ) { return this.#updateAll(packageType, { ids: new Set(Object.keys(incompatible)), force: true }); } } /* -------------------------------------------- */ /** * Check for an available update for a specific package * @param {Package} pkg The package to check */ async #updatePackage(pkg) { // Disable the "Update" button let button = this.element[0].querySelector(`[data-package-id="${pkg.id}"] [data-action="updatePackage"]`); button.disabled = true; button.firstElementChild.className = "fas fa-spinner fa-spin"; // TODO #8732 const check = await this.#updateCheck(pkg); switch ( check.state ) { case "error": ui.notifications.error(check.error, {permanent: true}); break; case "warning": ui.notifications.warn(check.warning); break; case "sidegrade": await Setup.reload(); break; case "trackChange": const accepted = await this.#promptTrackChange(pkg, check.trackChange); if ( accepted ) { pkg.updateSource({manifest: check.trackChange.manifest}); await this.#installPackageUpdate(pkg); } break; case "current": await ui.notifications.info(game.i18n.format("PACKAGE.AlreadyUpdated", {name: pkg.title})); break; case "update": if ( Object.values(check.systemCompatibility ?? {}).some(({ compatible }) => !compatible) ) { const proceed = await this.#promptIncompatibleSystems(check.systemCompatibility); if ( !proceed ) break; } await this.#installPackageUpdate(pkg); break; } // Re-enable the "Update" button button = this.element[0].querySelector(`[data-package-id="${pkg.id}"] [data-action="updatePackage"]`); button.disabled = false; button.firstElementChild.className = "fas fa-sync-alt"; } /* -------------------------------------------- */ /** * @typedef {object} PackageCheckResult * @property {BasePackage} package The checked package * @property {string} state The State of the check, from [ "error", "sidegrade", "trackChange", "warning", "update", "current", "unknown" ] * @property {string} [error] An error to display, if any * @property {string} [warning] A warning to display, if any * @property {manifest: string, version: string} [trackChange] The suggested track change, if any * @property {string} [manifest] The manifest of the Update, if any * @property {Record} [systemCompatibility] Compatibility information for this package's * related systems. */ /** * Execute upon an update check for a single Package * @param {BasePackage} pkg The Package to check * @returns {Promise} The status of the update check */ async #updateCheck(pkg) { const checkData = {package: pkg, state: "unknown"}; let responseData; let manifestData; // Check whether an update is available try { responseData = await Setup.checkPackage({type: pkg.type, id: pkg.id}); manifestData = responseData.remote; } catch(err) { checkData.state = "error"; checkData.error = err.toString(); return checkData; } // Metadata sidegrade performed if ( responseData.hasSidegraded ) { checkData.state = "sidegrade"; return checkData; } // Track change suggested if ( responseData.trackChange ) { checkData.state = "trackChange"; checkData.trackChange = responseData.trackChange; checkData.manifest = responseData.trackChange.manifest; return checkData; } // Verify remote manifest compatibility with current software const availability = responseData.availability; const codes = CONST.PACKAGE_AVAILABILITY_CODES; // Unsupported updates const wrongCore = [ codes.REQUIRES_CORE_UPGRADE_STABLE, codes.REQUIRES_CORE_UPGRADE_UNSTABLE, codes.REQUIRES_CORE_DOWNGRADE ]; if ( responseData.isUpgrade && wrongCore.includes(availability) ) { checkData.state = "warning"; const message = { 7: "Insufficient", 8: "UpdateNeeded", 9: "Unstable" }[availability]; checkData.warning = game.i18n.format(`SETUP.PackageUpdateCore${message}`, { id: manifestData.id, vmin: manifestData.compatibility.minimum, vmax: manifestData.compatibility.maximum, vcur: game.version }); return checkData; } // TODO #8732 // Available updates if ( responseData.isUpgrade && (availability <= codes.UNVERIFIED_GENERATION) ) { checkData.systemCompatibility = responseData.systemCompatibility; checkData.state = "update"; checkData.manifest = manifestData.manifest; return checkData; } // Packages which are already current checkData.state = "current"; return checkData; } /* -------------------------------------------- */ /** * Prompt the user to use a new Package track it if they haven't previously declined. * @param {BasePackage} pkg The Package being updated * @param {{manifest: string, version: string}} trackChange A recommended track change provided by the server * @returns {Promise} Whether the recommended track change was accepted */ async #promptTrackChange(pkg, trackChange) { // Verify that the user has not already declined a suggested track change const declinedManifestUpgrades = game.settings.get("core", "declinedManifestUpgrades"); if ( declinedManifestUpgrades[pkg.id] === pkg.version ) return false; // Generate dialog HTML const content = await renderTemplate("templates/setup/manifest-update.html", { localManifest: pkg.manifest, localTitle: game.i18n.format("SETUP.PriorManifestUrl", {version: pkg.version}), remoteManifest: trackChange.manifest, remoteTitle: game.i18n.format("SETUP.UpdatedManifestUrl", {version: trackChange.version}), package: pkg.title }); // Prompt for confirmation const accepted = await Dialog.confirm({ title: `${pkg.title} ${game.i18n.localize("SETUP.ManifestUpdate")}`, content, yes: () => { delete declinedManifestUpgrades[pkg.id]; return true; }, no: () => { declinedManifestUpgrades[pkg.id] = pkg.version; return false; }, defaultYes: true }); await game.settings.set("core", "declinedManifestUpgrades", declinedManifestUpgrades); return accepted; } /* -------------------------------------------- */ /** * Confirm that the user wishes to proceed with the update in cases where it would make the module incompatible with * one of its listed system relationships. * @param {Record} compatibility System compatibility information. * @returns {Promise} */ #promptIncompatibleSystems(compatibility) { return DialogV2.confirm({ modal: true, rejectClose: false, window: { title: "AreYouSure" }, position: { width: 450 }, content: `

    ${game.i18n.localize("SETUP.PackageIncompatibleSystemsPrompt")}

      ${Object.values(compatibility).map(({ id, title, compatible }) => { return compatible ? null : `
    • ${title ?? id}
    • `; }).filterJoin("")}
    ` }); } /* -------------------------------------------- */ /* Installation Progress Bar */ /* -------------------------------------------- */ /** * Update the UI progress bar in response to server progress ticks. * @param {ProgressReceiverPacket} [progress] The incremental progress information. */ onProgress({action, id, title, pct, step, message}={}) { const { STEPS } = CONST.SETUP_PACKAGE_PROGRESS; if ( !this.constructor.progressActions.has(action) ) return; if ( [STEPS.VEND, STEPS.COMPLETE].includes(step) ) return this.removeProgressBar(id); const bar = this.#getProgressBar(id); if ( bar && Number.isNumeric(pct) ) { const status = [message ? game.i18n.localize(message) : null, title ?? id, `${pct}%`].filterJoin(" "); bar.firstElementChild.style.maxWidth = `${pct}%`; bar.firstElementChild.firstElementChild.innerText = status; } } /* -------------------------------------------- */ /** * Get the progress bar element used to track installation for a certain package ID. * @param {string} packageId The package being installed * @returns {HTMLDivElement|null} The progress bar element to use */ #getProgressBar(packageId) { // Existing bar let bar = this.progress.get(packageId); if ( bar ) return bar; // Too many bars if ( this.progress.size >= SetupPackages.MAX_PROGRESS_BARS ) return null; // New Bar const d = document.createElement("div"); d.innerHTML = `
    `; bar = d.firstElementChild; this.progress.set(packageId, bar); // Add to DOM document.getElementById("progress").appendChild(bar); return bar; } /* -------------------------------------------- */ /** * Remove a Progress Bar from the DOM and from the progress mapping. * @param {string} id The operation ID that is no longer being tracked. */ removeProgressBar(id) { const bar = this.progress.get(id); if ( bar ) { bar.remove(); this.progress.delete(id); } } } /** * @typedef {Object} NewsItem * @property {string} title The title of the featured item * @property {string} image The background image URL * @property {string} url The website URL where clicking on the link should lead * @property {string} [caption] A caption used for featured content */ const _app$1 = foundry.applications.api; /** * An application that renders the Setup sidebar containing News and Featured Content widgets * @extends ApplicationV2 */ class SetupSidebar extends _app$1.HandlebarsApplicationMixin(_app$1.ApplicationV2) { /** @inheritDoc */ static DEFAULT_OPTIONS = foundry.utils.mergeObject(super.DEFAULT_OPTIONS, { id: "setup-sidebar", tag: "aside", window: { frame: false, positioned: false } }, {inplace: false}); /** @override */ static PARTS = { news: { id: "news", template: "templates/setup/parts/setup-news.hbs", scrollable: ["#news-articles"] }, featured: { id: "featured", template: "templates/setup/parts/setup-featured.hbs" } }; /* -------------------------------------------- */ /** @override */ async _prepareContext(options) { return { featured: game.data.featuredContent, news: game.data.news }; } } /** * @typedef {object} PreviewCompatibilitySummary * @property {string} icon The icon. * @property {"success"|"neutral"|"warning"|"error"} status The compatibility status. * @property {string} label The compatibility label. * @property {number} count The number of packages. */ /** * An Application that allows for browsing the previewed compatibility state of packages in the next version of the core * software. */ class CompatibilityChecker extends CategoryFilterApplication { /** * @param {ReleaseData} release The release to preview. * @param {CategoryFilterApplicationOptions} [options] Options to configure this Application. */ constructor(release, options={}) { super({}, options); this.#release = release; } /** * Options for filtering on compatibility. * @enum {number} */ static #COMPATIBILITY_FILTERS = { NONE: 0, COMPATIBLE: 1, UNVERIFIED: 2, INCOMPATIBLE: 3 }; /** * The currently active filters. * @type {{types: Set, compatibility: number}} */ #filters = { types: new Set(["module", "system"]), compatibility: CompatibilityChecker.#COMPATIBILITY_FILTERS.NONE }; /** * The release to preview. * @type {ReleaseData} */ #release; /** * The previewed package compatibilities. * @type {PreviewCompatibilityDescriptor} */ #preview; /* -------------------------------------------- */ /** @inheritDoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "compatibility-checker", template: "templates/setup/compatibility-checker.hbs", inputs: ['[name="filter"]'], initialCategory: "all" }); } /* -------------------------------------------- */ /** @override */ get title() { return game.i18n.format("SETUP.PreviewCompatibilityVersion", { version: this.#release.version }); } /* -------------------------------------------- */ /** @inheritDoc */ async _render(force=false, options={}) { await super._render(force, options); if ( !this.#preview ) this.#previewCompatibility(); const tour = game.tours.get("core.compatOverview"); if ( tour?.status === Tour.STATUS.UNSTARTED ) tour.start(); } /* -------------------------------------------- */ /** @inheritDoc */ getData(options={}) { const context = super.getData(options); if ( !this.#preview ) context.progress = { label: "SETUP.PreviewingCompatibility", icon: "fas fa-spinner fa-spin" }; const compat = CompatibilityChecker.#COMPATIBILITY_FILTERS; const codes = CONST.PACKAGE_AVAILABILITY_CODES; context.version = this.#release.version; context.summary = this.#prepareCompatibilitySummary(); context.filters = { types:[], compatibility: ["compatible", "unverified", "incompatible"].map(id => ({ id, active: this.#filters.compatibility === compat[id.toUpperCase()], label: `SETUP.PackageVis${id.capitalize()}` })) }; if ( this.category === "all" ) context.filters.types = ["world", "system", "module"].map(id => ({ id, active: this.#filters.types.has(id), label: `PACKAGE.Type.${id}Pl` })); context.entries = context.entries.filter(p => { if ( (this.category === "all") && this.#filters.types.size && !this.#filters.types.has(p.type) ) return false; if ( this.#filters.compatibility === compat.NONE ) return true; switch ( p.availability ) { case codes.VERIFIED: return this.#filters.compatibility === compat.COMPATIBLE; case codes.UNVERIFIED_BUILD: case codes.UNVERIFIED_GENERATION: return this.#filters.compatibility === compat.UNVERIFIED; default: return this.#filters.compatibility === compat.INCOMPATIBLE; } }); return context; } /* -------------------------------------------- */ /** @inheritDoc */ activateListeners(html) { super.activateListeners(html); html.find("[data-visibility]").on("click", this.#onToggleVisibility.bind(this)); html.find("[data-compatibility]").on("click", this.#onToggleCompatibility.bind(this)); } /* -------------------------------------------- */ /** @override */ _prepareCategoryData() { const total = this.#preview ? this.#preview.world.size + this.#preview.system.size + this.#preview.module.size : 0; const entries = []; ["world", "module", "system"].forEach(type => { if ( (this.category !== "all") && (this.category !== type) ) return; for ( const pkg of this.#preview?.[type].values() ?? [] ) { const { id, title, description, url, changelog, availability } = pkg; const tags = [ this.#getVersionBadge(availability, pkg, { modules: this.#preview.module, systems: this.#preview.system }) ]; if ( type === "world" ) tags.unshift(this.#getSystemBadge(pkg, this.#preview.system.get(pkg.system))); entries.push({ id, type, title, url, tags, changelog, availability, hasLink: type !== "world", description: TextEditor.previewHTML(description, 150) }); } }); const categories = ["all", "world", "module", "system"].map(id => ({ id, count: id === "all" ? total : this.#preview?.[id]?.size ?? 0, active: this.category === id, label: game.i18n.localize(`PACKAGE.Type.${id}Pl`) })); return { categories, entries }; } /* -------------------------------------------- */ /** * Determine a version badge for the provided package. * @param {number} availability The availability level. * @param {ClientPackage} pkg The package. * @param {object} context * @param {Collection} context.modules The collection of modules to test availability against. * @param {Collection} context.systems The collection of systems to test availability against. * @returns {PackageCompatibilityBadge|null} */ #getVersionBadge(availability, pkg, { modules, systems }) { const codes = CONST.PACKAGE_AVAILABILITY_CODES; const badge = pkg.constructor.getVersionBadge(availability, pkg, { modules, systems }); if ( !badge ) return badge; let level; switch ( availability ) { case codes.REQUIRES_CORE_DOWNGRADE: level = "INCOMPATIBLE"; break; case codes.UNVERIFIED_GENERATION: case codes.UNVERIFIED_BUILD: level = "RISK"; break; case codes.VERIFIED: level = "COMPATIBLE"; break; } if ( level ) { const isWorld = pkg.type === "world"; const system = this.#preview.system.get(pkg.system); const i18n = `SETUP.COMPAT.${level}.${isWorld ? "World" : "Latest"}`; const verified = isWorld ? system?.compatibility.verified : pkg.compatibility.verified; badge.tooltip = game.i18n.format(i18n, { version: this.#release.version, verified }); } return badge; } /* -------------------------------------------- */ /** * Determine a version badge for a World's System. * @param {World} world The world. * @param {System} system The system. * @returns {PackageCompatibilityBadge|null} */ #getSystemBadge(world, system) { if ( !system ) return { type: "error", tooltip: game.i18n.format("SETUP.COMPAT.INCOMPATIBLE.World", { version: this.#release.version }), label: world.system, icon: "fa fa-file-slash" }; const badge = this.#getVersionBadge(system.availability, system, { modules: this.#preview.module, systems: this.#preview.system }); if ( !badge ) return badge; badge.tooltip = `

    ${system.title}

    ${badge.tooltip}

    `; badge.label = system.id; return badge; } /* -------------------------------------------- */ /** @override */ _sortEntries(a, b) { return a.title.localeCompare(b.title, game.i18n.lang); } /* -------------------------------------------- */ /** * Summarize the results of the compatibility check. * @returns {PreviewCompatibilitySummary[]} */ #prepareCompatibilitySummary() { if ( !this.#preview ) return []; const codes = CONST.PACKAGE_AVAILABILITY_CODES; const { compatible, incompatible, warning, unverified } = ["world", "system", "module"].reduce((obj, type) => { for ( const pkg of this.#preview[type]?.values() ) { if ( pkg.availability === codes.VERIFIED ) obj.compatible++; else if ( pkg.availability === codes.UNVERIFIED_BUILD ) obj.unverified++; else if ( pkg.availability === codes.UNVERIFIED_GENERATION ) obj.warning++; else obj.incompatible++; } return obj; }, { compatible: 0, incompatible: 0, warning: 0, unverified: 0 }); return [ { icon: "fas fa-circle-check", status: "success", count: compatible, label: "SETUP.COMPAT.Compatible", tooltip: "SETUP.COMPAT.CompatibleTooltip" }, { icon: "fas fa-circle-question", status: "neutral", count: unverified, label: "SETUP.COMPAT.Unverified", tooltip: "SETUP.COMPAT.UnverifiedTooltip" }, { icon: "fas fa-triangle-exclamation", status: "warning", count: warning, label: "SETUP.COMPAT.Warning", tooltip: "SETUP.COMPAT.WarningTooltip" }, { icon: "fas fa-circle-xmark", status: "error", count: incompatible, label: "SETUP.COMPAT.Incompatible", tooltip: "SETUP.COMPAT.IncompatibleTooltip" } ]; } /* -------------------------------------------- */ /** @override */ _getSearchFields(entry) { return [ entry.dataset.packageId ?? "", entry.querySelector(".entry-title h3")?.textContent ?? "" ]; } /* -------------------------------------------- */ /** @inheritDoc */ _getHeaderButtons() { const buttons = super._getHeaderButtons(); buttons.unshift({ label: "", class: "info", icon: "fas fa-circle-question", tooltip: "SETUP.COMPAT.LearnMore", onclick: () => { const tour = game.tours.get("core.compatOverview"); tour.reset(); tour.start(); } }); return buttons; } /* -------------------------------------------- */ /** * Handle toggling package compatibility filtering. * @param {PointerEvent} event The triggering event. */ #onToggleCompatibility(event) { const compat = CompatibilityChecker.#COMPATIBILITY_FILTERS; const value = compat[event.currentTarget.dataset.compatibility.toUpperCase()]; if ( this.#filters.compatibility === value ) this.#filters.compatibility = compat.NONE; else this.#filters.compatibility = value; this.render(); } /* -------------------------------------------- */ /** * Handle toggling package type filtering. * @param {PointerEvent} event The triggering event. */ #onToggleVisibility(event) { const { visibility } = event.currentTarget.dataset; if ( this.#filters.types.has(visibility) ) this.#filters.types.delete(visibility); else this.#filters.types.add(visibility); this.render(); } /* -------------------------------------------- */ /** * Retrieve compatibility data for installed packages in the next version of the core software. */ async #previewCompatibility() { const preview = await Setup.previewCompatibility(this.#release); if ( !preview ) return; this.#preview = { world: new Map(preview.world.map(w => [w.id, new World(foundry.utils.deepClone(w))])), system: new Map(preview.system.map(s => [s.id, new System(foundry.utils.deepClone(s))])), module: new Map(preview.module.map(m => [m.id, new Module(foundry.utils.deepClone(m))])) }; this.render(); } } /** * An application which displays Foundry Virtual Tabletop release notes to the user during the update progress. */ class UpdateNotes extends Application { constructor(target, options) { super(options); this.target = target; this.candidateReleaseData = new foundry.config.ReleaseData(this.target); ui.updateNotes = this; } /* ----------------------------------------- */ /** @override */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "update-notes", template: "templates/setup/update-notes.hbs", width: 720 }); } /* ----------------------------------------- */ /** @override */ get title() { return `Update Notes - ${this.candidateReleaseData.display}`; } /* ----------------------------------------- */ /** @override */ async getData(options={}) { return { notes: this.target.notes, requiresManualInstall: this.candidateReleaseData.isGenerationalChange(game.release), canCheckCompatibility: game.version !== this.candidateReleaseData.version, version: this.candidateReleaseData.version } } /* ----------------------------------------- */ /** @override */ activateListeners(html) { super.activateListeners(html); html.find("[data-action]").on("click", this.#onAction.bind(this)); } /* -------------------------------------------- */ /** * Handle clicking an action button. * @param {PointerEvent} event The triggering event. */ async #onAction(event) { const action = event.currentTarget.dataset.action; switch ( action ) { case "checkCompatibility": new CompatibilityChecker(this.candidateReleaseData).render(true); break; case "createSnapshot": this.toggleLock(true); await ui.setupUpdate._onCreateSnapshot(); this.toggleLock(false); break; case "update": event.preventDefault(); this.toggleLock(true); document.getElementById("update-core").click(); break; } } /* -------------------------------------------- */ /** * Toggle the locked state of the interface. * @param {boolean} locked Is the interface locked? */ toggleLock(locked) { const element = this.element[0]; if ( !element ) return; element.querySelectorAll("[data-action]").forEach(el => el.disabled = locked); } /* ----------------------------------------- */ /** * Update the button at the footer of the Update Notes application to reflect the current status of the workflow. * @param {object} progressData Data supplied by SetupConfig#_onCoreUpdate */ static updateButton(progressData) { const notes = ui.updateNotes; if ( !notes?.rendered ) return; const button = notes.element.find('[data-action="update"]')[0]; if ( !button ) return; const icon = button.querySelector("i"); icon.className = progressData.pct < 100 ? "fas fa-spinner fa-pulse" : "fas fa-check"; const label = button.querySelector("label"); label.textContent = game.i18n.localize(progressData.step); } } /** * The software update application. */ class SetupUpdate extends Application { /** @override */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "setup-update", template: "templates/setup/setup-update.hbs", popOut: false, }); } /** * The current update step. Either "updateCheck" or "updateDownload" * @type {string} */ #action = "updateCheck"; /** * The currently bound update progress listener * @type {function} */ #onProgress; /* -------------------------------------------- */ /** @override */ getData(options={}) { const canReachInternet = game.data.addresses.remote; const couldReachWebsite = game.data.coreUpdate.couldReachWebsite; const updateChannels = { ...CONST.SOFTWARE_UPDATE_CHANNELS }; delete updateChannels.prototype; return { updateChannels, coreVersion: game.version, release: game.release, coreVersionHint: game.i18n.format("SETUP.CoreVersionHint", {versionDisplay: game.release.display}), updateChannel: game.data.options.updateChannel, updateChannelHints: Object.entries(updateChannels).reduce((obj, [chan, label]) => { obj[chan] = game.i18n.localize(`${label}Hint`); return obj; }, {}), coreUpdate: game.data.coreUpdate.hasUpdate ? game.i18n.format("SETUP.UpdateAvailable", game.data.coreUpdate) : false, canReachInternet: canReachInternet, couldReachWebsite: couldReachWebsite, slowResponse: game.data.coreUpdate.slowResponse, updateButtonEnabled: canReachInternet && couldReachWebsite }; } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @override */ activateListeners(html) { super.activateListeners(html); html.find("select[name='updateChannel']").on("change", this.#onChangeChannel.bind(this)); html.find("button[data-action]").on("click", this.#onClickButton.bind(this)); html.submit(this.#onSubmit.bind(this)); } /* -------------------------------------------- */ /** * Handle update application button clicks. * @param {PointerEvent} event The triggering click event. */ #onClickButton(event) { event.preventDefault(); const button = event.currentTarget; switch ( button.dataset.action ) { case "setup": window.location.href = foundry.utils.getRoute("setup"); break; } } /* -------------------------------------------- */ /** * When changing the software update channel, reset the state of the update button and "Force Update" checkbox. * Clear results from a prior check to ensure that users don't accidentally perform an update for some other channel. * @param {Event} event The select change event */ async #onChangeChannel(event) { this.#action = "updateCheck"; // reset the action const button = document.getElementById("update-core"); button.children[1].textContent = game.i18n.localize("SETUP.UpdateCheckFor"); const check = document.querySelector("input[name='forceUpdate']"); check.checked = false; } /* -------------------------------------------- */ /** * Handle button clicks to update the core VTT software * @param {Event} event */ async #onSubmit(event) { event.preventDefault(); const form = event.currentTarget; const button = form.querySelector("#update-core"); const label = button.querySelector("label"); // Disable the form button.disabled = true; form.disabled = true; // Prepare request data const requestData = { action: this.#action, updateChannel: form.updateChannel.value, forceUpdate: form.forceUpdate.checked }; if ( this.#action === "updateDownload" ) { const { ACTIONS, STEPS } = CONST.SETUP_PACKAGE_PROGRESS; const progress = new ProgressReceiver(ACTIONS.UPDATE_CORE, ACTIONS.UPDATE_DOWNLOAD, requestData); progress.addEventListener("progress", ({ data }) => { if ( (data.step !== STEPS.ERROR) && (data.step !== STEPS.COMPLETE) ) UpdateNotes.updateButton(data); this.#updateProgressBar(data); this.#updateProgressButton(data); }); progress.addEventListener("error", ({ data }) => { form.disabled = false; ui.updateNotes?.close(); ui.notifications.error(data.error, { localize: true, permanent: true }); }); progress.addEventListener("complete", ({ data }) => { form.disabled = false; ui.updateNotes?.close(); ui.notifications.info(data.message, { localize: true, permanent: true }); }); return progress.listen(); } // Submit request let response; try { response = await Setup.post(requestData); } catch(err) { button.disabled = false; form.disabled = false; throw err; } // Display response info if ( response.info || response.warn ) { button.disabled = false; form.disabled = false; return response.info ? ui.notifications.info(response.info, {localize: true}) : ui.notifications.warn(response.warn, {localize: true}); } // Proceed to download step if ( this.#action === "updateCheck" ) { // Construct the release data const releaseData = new foundry.config.ReleaseData(response); ui.notifications.info(game.i18n.format("SETUP.UpdateInfoAvailable", {display: releaseData.display})); // Update the button if ( releaseData.isGenerationalChange(game.version) ) { label.textContent = game.i18n.localize("SETUP.UpdateNewGeneration"); } else { this.#action = "updateDownload"; label.textContent = game.i18n.format("SETUP.UpdateButtonDownload", {display: releaseData.display}); button.disabled = false; } // Render release notes if ( response.notes ) new UpdateNotes(response).render(true); // Warn about module disabling if ( response.willDisableModules ) { ui.notifications.warn(game.i18n.format("SETUP.UpdateWarningWillDisable", { nIncompatible: game.modules.filter(m => m.incompatible).length, nModules: game.modules.size }), {permanent: true}); } } } /* -------------------------------------------- */ /** * Handle creating a snapshot. * @internal */ async _onCreateSnapshot() { const progress = this._updateProgressBar.bind(this); Setup._addProgressListener(progress); this.toggleLock(true); await Setup.createSnapshot({ dialog: true }, { packageList: false }); this.toggleLock(false); Setup._removeProgressListener(progress); } /* -------------------------------------------- */ /** * Toggle the locked state of the update interface. * @param {boolean} locked Is the interface locked? */ toggleLock(locked) { const element = this.element[0]; if ( !element ) return; element.querySelectorAll("button").forEach(el => el.disabled = locked); } /* -------------------------------------------- */ /** * Update the display of an installation progress bar for a particular progress packet * @param {object} data The progress update data */ #updateProgressBar(data) { const progress = document.getElementById("update-progress"); // Update Bar const bar = progress.firstElementChild; bar.style.maxWidth = `${data.pct}%`; // Update Label const label = bar.firstElementChild; label.innerText = [game.i18n.localize(data.message), data.title, `${data.pct}%`].filterJoin(" "); const steps = CONST.SETUP_PACKAGE_PROGRESS.STEPS; progress.style.display = [steps.COMPLETE, steps.ERROR].includes(data.step) ? "" : "initial"; } /* -------------------------------------------- */ /** * Update installation progress for a particular button which triggered the action * @param {object} data The progress update data */ #updateProgressButton({ step, message }) { const { STEPS } = CONST.SETUP_PACKAGE_PROGRESS; const button = document.getElementById("update-core"); button.disabled = (step !== STEPS.ERROR) && (step !== STEPS.COMPLETE); // Update Icon const icon = button.firstElementChild; if ( step === STEPS.ERROR ) icon.className = "fas fa-times"; else if ( step === STEPS.COMPLETE ) icon.className = "fas fa-check"; else icon.className = "fas fa-spinner fa-pulse"; // Update label const label = icon.nextElementSibling; label.textContent = game.i18n.localize(message); } } const _app = foundry.applications.api; /** * The User Management setup application. * @extends ApplicationV2 * @mixes HandlebarsApplication */ class UserManagement extends _app.HandlebarsApplicationMixin(_app.ApplicationV2) { /** @inheritDoc */ static DEFAULT_OPTIONS = foundry.utils.mergeObject(super.DEFAULT_OPTIONS, { id: "manage-players", classes: ["application", "framed"], tag: "form", window: { frame: false, positioned: false }, actions: { createUser: UserManagement.#onUserCreate, configurePermissions: UserManagement.#onConfigurePermission, deleteUser: UserManagement.#onUserDelete, showPassword: UserManagement.#onShowPassword, }, form: { handler: UserManagement.#onSubmitForm } }, {inplace: false}); /** * The template path used to render a single user. * @type {string} */ static USER_TEMPLATE = "templates/setup/parts/user-management-user.hbs"; /** @override */ static PARTS = { form: { id: "form", template: "templates/setup/parts/user-management-form.hbs", templates: [this.USER_TEMPLATE], scrollable: ["#player-list"], } }; /* -------------------------------------------- */ /** @override */ async _prepareContext(options) { return { users: game.users, roles: UserManagement.#getRoleLabels(), canConfigurePermissions: game.user.hasRole("GAMEMASTER"), userTemplate: this.constructor.USER_TEMPLATE, passwordString: game.data.passwordString }; } /* -------------------------------------------- */ /** * Get a mapping of role IDs to labels that should be displayed */ static #getRoleLabels() { return Object.entries(CONST.USER_ROLES).reduce((obj, e) => { obj[e[1]] = game.i18n.localize(`USER.Role${e[0].titleCase()}`); return obj; }, {}); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _attachPartListeners(partId, htmlElement, options) { super._attachPartListeners(partId, htmlElement, options); for ( const passwordInput of htmlElement.querySelectorAll("input[type=password]") ) { passwordInput.addEventListener("focus", UserManagement.#onPasswordFocus); passwordInput.addEventListener("keydown", UserManagement.#onPasswordKeydown); } } /* -------------------------------------------- */ /** * Handle focus in and out of the password field. * @param {PointerEvent} event The initiating pointer event */ static #onPasswordFocus(event) { event.currentTarget.select(); } /* -------------------------------------------- */ /** * Toggle visibility of the "Show Password" control. * @param {KeyboardEvent} event The initiating keydown event */ static #onPasswordKeydown(event) { if ( ["Shift", "Ctrl", "Alt", "Tab"].includes(event.key) ) return; const input = event.currentTarget; const show = input.parentElement.nextElementSibling; show.hidden = false; } /* -------------------------------------------- */ /** * Reveal the password that is being configured so the user can verify they have typed it correctly. * @this {UserManagement} * @param {PointerEvent} event * @param {HTMLAnchorElement} button */ static #onShowPassword(event, button) { const li = button.closest(".player"); const label = li.querySelector(".password"); const input = label.firstElementChild; input.type = input.type === "password" ? "text" : "password"; } /* -------------------------------------------- */ /** * Handle click events to display the permission configuration app. * @this {UserManagement} * @param {PointerEvent} event * @param {HTMLButtonElement} button */ static #onConfigurePermission(event, button) { const config = new foundry.applications.apps.PermissionConfig(); config.render({force: true}); } /* -------------------------------------------- */ /** * Handle creating a new User record in the form. * @this {UserManagement} * @param {PointerEvent} event * @param {HTMLButtonElement} button */ static async #onUserCreate(event, button) { // Create the new User let newPlayerIndex = game.users.size + 1; while ( game.users.getName(`Player${newPlayerIndex}` )) { newPlayerIndex++; } const user = await User.create({ name: `Player${newPlayerIndex}`, role: CONST.USER_ROLES.PLAYER }); // Render the User's HTML const roles = UserManagement.#getRoleLabels(); const html = await renderTemplate(UserManagement.USER_TEMPLATE, {user, roles}); const playerList = this.element.querySelector("#player-list"); playerList.insertAdjacentHTML("beforeend", html); // Attach listeners to new password input const newInput = playerList.lastElementChild.querySelector("input[type=password]"); newInput.addEventListener("focus", UserManagement.#onPasswordFocus); newInput.addEventListener("keydown", UserManagement.#onPasswordKeydown); } /* -------------------------------------------- */ /** * Handle user deletion event. * @param {PointerEvent} event * @param {HTMLAnchorElement} button */ static #onUserDelete(event, button) { const li = button.closest(".player"); const user = game.users.get(li.dataset.userId); // Craft a message let message = `

    ${game.i18n.localize("AreYouSure")}

    ${game.i18n.localize("USERS.DeleteWarning")}

    `; if (user.isGM) message += `

    ${game.i18n.localize("USERS.DeleteGMWarning")}

    `; // Render a confirmation dialog new Dialog({ title: `${game.i18n.localize("USERS.Delete")} ${user.name}?`, content: message, buttons: { yes: { icon: '', label: game.i18n.localize("Delete"), callback: async () => { await user.delete(); li.remove(); } }, no: { icon: '', label: game.i18n.localize("Cancel") } }, default: "yes" }).render(true); } /* -------------------------------------------- */ /** * @this {UserManagement} * @param {SubmitEvent} event * @param {HTMLFormElement} form * @param {FormDataExtended} formData */ static async #onSubmitForm(event, form, formData) { const userData = foundry.utils.expandObject(formData.object).users; const updates = Object.entries(userData).reduce((arr, e) => { const [id, data] = e; // Identify changes const user = game.users.get(id); const diff = foundry.utils.diffObject(user.toObject(), data); if ( data.password === game.data.passwordString ) delete diff.password; else diff.password = data.password; // Register changes for update if ( !foundry.utils.isEmpty(diff) ) { diff._id = id; arr.push(diff); } return arr; }, []); // The World must have at least one Gamemaster if ( !Object.values(userData).some(u => u.role === CONST.USER_ROLES.GAMEMASTER) ) { return ui.notifications.error("USERS.NoGMError", {localize: true}); } // Update all users and redirect try { if ( updates.length ) await User.updateDocuments(updates, {diff: false}); ui.notifications.info("USERS.UpdateSuccess", {localize: true}); return setTimeout(() => window.location.href = foundry.utils.getRoute("game"), 1000); } catch(err) { await this.render(); } } } /** * An Application that manages the browsing and installation of Packages. */ class InstallPackage extends CategoryFilterApplication { constructor({packageType, search}={}, options) { super({}, options); this.#packageType = packageType; this.#initialSearch = search; ui.installPackages = this; } /** * The list of installable packages * @type {ClientPackage[]} */ packages; /** * The list of Tags available * @type {object} */ tags; /** * The type of package being installed, a value in PACKAGE_TYPES * @type {string} */ #packageType; /** * The current package visibility filter that is applied * @type {string} */ #visibility = "all"; /** * An initial provided search filter value. * @type {string} */ #initialSearch; /* -------------------------------------------- */ /** @inheritdoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "install-package", template: "templates/setup/install-package.hbs", inputs: ['[name="filter"]', '[name="manifestURL"]'], initialCategory: "all" }); } /* -------------------------------------------- */ /** @override */ get title() { return game.i18n.localize(`SETUP.Install${this.#packageType.titleCase()}`); } /* -------------------------------------------- */ /** @inheritdoc */ async _render(force=false, options={}) { await super._render(force, options); const type = this.#packageType; if ( Setup.cache[type].state === Setup.CACHE_STATES.COLD ) { Setup.warmPackages({type}).then(() => this.render(false)); } } /* -------------------------------------------- */ /** @inheritDoc */ getData(options={}) { const data = super.getData(options); const type = data.packageType = this.#packageType; // Loading Progress if ( Setup.cache[type].state < Setup.CACHE_STATES.WARMED ) { data.progress = {label: "SETUP.PackagesLoading", icon: "fas fa-spinner fa-spin"}; } else if ( !this.packages.length && Setup.cache[type].state === Setup.CACHE_STATES.WARMED ) { data.progress = {label: "SETUP.CouldntLoadPackages", icon: "fas fa-exclamation-triangle"}; } // Visibility filters data.visibility = [ { id: "inst", css: this.#visibility === "inst" ? " active" : "", label: "SETUP.PackageVisInst" }, { id: "unin", css: this.#visibility === "unin" ? " active" : "", label: "SETUP.PackageVisUnin" }, { id: "all", css: this.#visibility === "all" ? " active" : "", label: "SETUP.PackageVisAll" } ]; // Filter packages const installed = new Set(game.data[`${type}s`].map(s => s.id)); data.entries = this.packages.filter(p => { p.installed = installed.has(p.id); if ( (this.#visibility === "unin") && p.installed ) return false; if ( (this.#visibility === "inst") && !p.installed ) return false; p.cssClass = [p.installed ? "installed" : null, p.installable ? null: "locked"].filterJoin(" "); if ( this.category === "all" ) return true; if ( this.category === "premium" ) return p.protected; if ( this.category === "exclusive" ) return p.exclusive; return p.tags.includes(this.category); }); return data; } /* -------------------------------------------- */ /** @override */ activateListeners(html) { super.activateListeners(html); html[0].children[0].onsubmit = ev => ev.preventDefault(); html.find(".entry-title a.website-link").click(this.#onClickPackageLink.bind(this)); html.find("button.install").click(this.#onClickPackageInstall.bind(this)); html.find("button[type='submit']").click(this.#onClickManifestInstall.bind(this)); html.find(".visibilities .visibility").click(this.#onClickVisibilityFilter.bind(this)); // Assign an initial search value const loading = Setup.cache[this.#packageType].state < Setup.CACHE_STATES.WARMED; if ( this.#initialSearch && !loading ) { this._inputs[0] = this.#initialSearch; this._searchFilters[0].filter(null, this.#initialSearch); this.#initialSearch = undefined; } } /* -------------------------------------------- */ /** * Handle a left-click event on the package website link. * @param {PointerEvent} event The originating click event */ #onClickPackageLink(event) { event.preventDefault(); const li = event.currentTarget.closest(".package"); const href = `https://foundryvtt.com/packages/${li.dataset.packageId}/`; return window.open(href, "_blank"); } /* -------------------------------------------- */ /** @inheritdoc */ _onClickEntryTitle(event) { event.preventDefault(); const li = event.currentTarget.closest(".package"); if ( li.classList.contains("installed") || li.classList.contains("locked") ) return; const manifestURL = li.querySelector("button.install").dataset.manifest; const input = this.element.find("input[name='manifestURL']")[0]; input.value = manifestURL; } /* -------------------------------------------- */ /** * Handle left-click events to filter to a certain visibility state. * @param {PointerEvent} event The originating click event */ #onClickVisibilityFilter(event) { event.preventDefault(); this.#visibility = event.target.dataset.visibility || "all"; this.render(); } /* -------------------------------------------- */ /** * Handle a left-click event on the package "Install" button. * @param {PointerEvent} event The originating click event */ async #onClickPackageInstall(event) { event.preventDefault(); const button = event.currentTarget; button.disabled = true; let manifest = button.dataset.manifest; if ( !manifest ) return; await Setup.installPackage({type: this.#packageType, manifest}); button.disabled = false; } /* -------------------------------------------- */ /** * Handle a left-click event on the button to install by manifest URL. * @param {PointerEvent} event The originating click event */ async #onClickManifestInstall(event) { event.preventDefault(); const button = event.currentTarget; button.disabled = true; const input = button.previousElementSibling; if ( !input.value ) { button.disabled = false; return; } // noinspection ES6MissingAwait Setup.installPackage({type: this.#packageType, manifest: input.value.trim()}); input.value = ""; button.disabled = false; } /* -------------------------------------------- */ /** @override */ _getSearchFields(entry) { return [ entry.dataset.packageId ?? "", entry.querySelector(".entry-title h3")?.textContent ?? "", entry.querySelector(".tag.author")?.textContent ?? "" ]; } /* -------------------------------------------- */ /** @override */ _prepareCategoryData() { if ( !this.packages?.length || !this.tags?.length ) { const {packages, tags} = InstallPackage.getTaggedPackages(this.#packageType); this.packages = packages; this.tags = tags; } const categories = Object.entries(this.tags).reduce((acc, [k, v]) => { v.id = k; v.active = this.category === k; v.css = v.active ? " active" : ""; acc.push(v); return acc; }, []); return { categories, entries: this.packages ?? [] }; } /* -------------------------------------------- */ /** * Organize package data and cache it to the application * @param {string} type The type of packages being retrieved * @returns {object} The retrieved or cached packages */ static getTaggedPackages(type) { // Identify package tags and counts const packages = []; const counts = {premium: 0, exclusive: 0}; const unorderedTags = {}; const codes = CONST.PACKAGE_AVAILABILITY_CODES; // Prepare package data for ( const pack of Setup.cache[type].packages.values() ) { const p = pack.toObject(); const availability = pack.availability; // Skip packages which require downgrading or upgrading to an unstable version if ( [codes.REQUIRES_CORE_DOWNGRADE, codes.REQUIRES_CORE_UPGRADE_UNSTABLE].includes(availability) ) continue; // Create the array of package tags const tags = pack.tags.map(t => { const [k, v] = t; if ( !unorderedTags[k] ) unorderedTags[k] = {label: v, count: 0, [type]: true}; unorderedTags[k].count++; return k; }); // Structure package data foundry.utils.mergeObject(p, { cssClass: "", author: Array.from(pack.authors).map(a => a.name).join(", "), tags: tags, installable: availability !== codes.REQUIRES_CORE_UPGRADE_STABLE }); if ( pack.protected ) { if ( !pack.owned ) p.installable = false; counts.premium++; } if ( pack.exclusive ) counts.exclusive++; packages.push(p); } // Organize category tags const orderedTags = Array.from(Object.keys(unorderedTags)).sort(); const tags = orderedTags.reduce((obj, k) => { obj[k] = unorderedTags[k]; return obj; }, { all: { label: game.i18n.localize("SETUP.PackageVisAll"), count: packages.length, [type]: true}, premium: { label: game.i18n.localize("SETUP.PremiumContent"), count: counts.premium, [type]: true}, exclusive: { label: game.i18n.localize("SETUP.ExclusiveContent"), count: counts.exclusive, [type]: true } }); return { packages: packages, tags: tags }; } /* -------------------------------------------- */ /** @inheritdoc */ _restoreInputs() { super._restoreInputs(); if ( this.element.length ) { this.element[0].querySelector('[name="filter"]')?.focus(); } } } /** * A class responsible for managing snapshot progress events that include a side-track for individual backup progress * events. */ class SnapshotProgressReceiver extends ProgressReceiver { /** * @param {string} operationId A unique identifier for the operation. * @param {string} action The operation action. * @param {string} backupAction The individual backup operation action. * @param {object} [context] Additional context to send with the request. * @param {ProgressReceiverOptions} [options] */ constructor(operationId, action, backupAction, context={}, options={}) { super(operationId, action, context, options); this.#backupAction = backupAction; } /** * The individual backup operation action to listen to. * @type {string} */ #backupAction; /** * The passive backup progress listener. * @type {function} */ #backupProgressListener = this.#onBackupProgress.bind(this); /* -------------------------------------------- */ /** * Handle progress ticks on individual backup operations. * @param {ProgressReceiverPacket} data Progress event data. */ #onBackupProgress(data) { if ( data.action === this.#backupAction ) ui.setupPackages?.onProgress(data); } /* -------------------------------------------- */ /** @inheritDoc */ _onComplete(data) { Setup._removeProgressListener(this.#backupProgressListener); super._onComplete(data); } /* -------------------------------------------- */ /** @inheritDoc */ _onError(data) { for ( const id of ui.setupPackages?.progress.keys() ?? [] ) ui.setupPackages.removeProgressBar(id); Setup._removeProgressListener(this.#backupProgressListener); super._onError(data); } /* -------------------------------------------- */ /** @inheritDoc */ listen() { Setup._addProgressListener(this.#backupProgressListener); return super.listen(); } } /** * @typedef {DialogOptions} SnapshotOperationDialogOptions * @property {boolean} [warning] Whether the dialog contains a warning. * @property {boolean} [note] Whether the dialog should prompt the user for a note. * @property {boolean} [confirmCode] Whether the dialog should prompt the user for a confirmation code. * @property {boolean} [packageList] Whether the dialog should include a list of currently-installed packages. * @property {SnapshotData} [snapshotData] A snapshot associated with this operation. * @property {string} diskSpaceAction The action value to send to /setup to request disk space information for * this operation. * @property {string} message The dialog message. * @property {string} [confirm] An additional confirmation message. */ /** * An application that prompts the user to confirm a snapshot operation. */ class SnapshotOperationDialog extends Dialog { /** * @param {function} resolve The function to invoke when the dialog is closed. * @param {DialogData} data * @param {SnapshotOperationDialogOptions} [options] */ constructor(resolve, data, options={}) { const buttons = { confirm: { id: "confirm" }, cancel: { id: "cancel" } }; super({ ...data, buttons, default: "confirm" }, options); this.#resolve = resolve; if ( options.confirmCode ) this.#confirmCode = (Math.random() + 1).toString(36).substring(7, 11); } /** * The code the user must enter to confirm the operation. * @type {string} */ #confirmCode; /** * The disk space requirements for the operation. * @type {{required: string, available: string, enough: boolean}} */ #diskSpace; /** * The function to invoke when the dialog is closed. * @type {function} */ #resolve; /* -------------------------------------------- */ /** @override */ static wait(data={}, options={}, renderOptions={}) { return new Promise(resolve => new this(resolve, data, options).render(true, renderOptions)); } /* -------------------------------------------- */ /** @inheritDoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["dialog", "snapshot-dialog"], width: 480, jQuery: false }); } /* -------------------------------------------- */ /** @inheritDoc */ async _render(force=false, options={}) { let input; if ( this.element ) input = this.element.find("input").val(); await super._render(force, options); if ( input ) this.element.find("input").val(input).focus(); if ( !this.#diskSpace ) this.#checkDiskSpace().then(() => this.render()); } /* -------------------------------------------- */ /** @inheritDoc */ getData(options={}) { const context = super.getData(options); context.content = this.#buildContent(); context.buttons = this.#buildButtons(); return context; } /* -------------------------------------------- */ /** * Build the dialog button descriptors. * @returns {Record>} */ #buildButtons() { let yesLabel = "SETUP.BACKUPS.DiskSpaceChecking"; if ( this.#diskSpace ) yesLabel = this.#diskSpace.enough ? "Yes" : "SETUP.BACKUPS.DiskSpaceInsufficient"; return { confirm: { icon: '', label: game.i18n.localize(yesLabel), cssClass: "yes default bright", disabled: !this.#diskSpace?.enough }, cancel: { icon: '', label: game.i18n.localize("No"), cssClass: "no" } }; } /* -------------------------------------------- */ /** * Build the content for this dialog based on the passed options. * @returns {string} */ #buildContent() { const unchanged = this.#getUnchangedPackageCount(); return `

    ${game.i18n.localize(this.options.message)}

    ${this.options.packageList ? `

    ${game.i18n.localize("SETUP.BACKUPS.CreateSnapshotPackageList")}

    • ${game.i18n.format("SETUP.BACKUPS.WorldCount", { count: game.worlds.size })}
    • ${game.i18n.format("SETUP.BACKUPS.ModuleCount", { count: game.modules.size })}
    • ${game.i18n.format("SETUP.BACKUPS.SystemCount", { count: game.systems.size })}
    ` : ""} ${unchanged ? `

    ${game.i18n.format(`SETUP.BACKUPS.RestoreSnapshotUnchangedPackages${unchanged === 1 ? "" : "Pl"}`, { count: unchanged })}

    ` : ""}
    ${game.i18n.localize("SETUP.BACKUPS.DiskSpace")}: ${this.#diskSpace ? ` ${game.i18n.format("SETUP.BACKUPS.DiskSpaceRequired", { required: this.#diskSpace.required })} / ${game.i18n.format("SETUP.BACKUPS.DiskSpaceAvailable", { available: this.#diskSpace.available })} ` : ''}
    ${this.options.note ? `

    ${game.i18n.localize("SETUP.BACKUPS.NoteHint")}

    ` : ""} ${this.#confirmCode ? `

    ${game.i18n.localize("SETUP.WorldDeleteConfirmCode")}

    ${this.#confirmCode}

    ` : ""} ${this.options.confirm ? `

    ${game.i18n.localize(this.options.confirm)}

    ` : ""} `; } /* -------------------------------------------- */ /** * Determine the number of installed packages that are not included in the snapshot and will not be affected by the * snapshot restoration. * @returns {number} */ #getUnchangedPackageCount() { if ( !this.options.snapshotData ) return 0; const packages = { world: new Set(), module: new Set(), system: new Set() }; for ( const backupId of this.options.snapshotData.backups ) { const [type, id] = backupId.split("."); packages[type].add(id); } let count = 0; for ( const [type, cls] of Object.entries(PACKAGE_TYPES) ) { for ( const pkg of game[cls.collection] ) { if ( !packages[type].has(pkg.id) ) count++; } } return count; } /* -------------------------------------------- */ /** @inheritDoc */ async close(options = {}) { this.#resolve({ confirm: false }); return super.close(options); } /* -------------------------------------------- */ /** * Request disk space information for the given operation from the /setup endpoint. */ async #checkDiskSpace() { const data = { action: this.options.diskSpaceAction }; if ( this.options.snapshotData ) data.snapshotData = this.options.snapshotData; const { required, available } = await Setup.post(data, { timeoutMs: null }); this.#diskSpace = { required: foundry.utils.formatFileSize(required, { decimalPlaces: 0 }), available: foundry.utils.formatFileSize(available, { decimalPlaces: 0 }), enough: available > required }; } /* -------------------------------------------- */ /** @override */ submit(button, event) { const el = this.element[0].querySelector(`[data-button="${button.id}"]`); if ( el.disabled ) return; switch ( button.id ) { case "confirm": this.#onConfirm(); break; case "cancel": this.close(); break; } } /* -------------------------------------------- */ /** * Handle confirming the action. */ #onConfirm() { const element = this.element[0]; if ( this.options.confirmCode ) { const code = element.querySelector('input[name="code"]').value; if ( code !== this.#confirmCode ) { ui.notifications.error("SETUP.PackageDeleteWorldConfirm", { localize: true }); this.#resolve({ confirm: false }); return this.close(); } } if ( this.options.note ) this.#resolve({ note: element.querySelector('input[name="note"]').value, confirm: true }); this.#resolve({ confirm: true }); return this.close(); } } /** * A library of package management commands which are used by various interfaces around the software. */ let Setup$1 = class Setup extends Game { /** * An enum that indicates a state the Cache is in * @enum {number} */ static CACHE_STATES = { COLD: 0, WARMING: 1, WARMED: 2 }; /** * The name of the setting used to persist package favorites. * @type {string} */ static FAVORITE_PACKAGES_SETTING = "setupPackageFavorites"; /** * A cached object of retrieved packages from the web server * @type {{ * world: {packages: Map, state: Setup.CACHE_STATES}, * system: {packages: Map, state: Setup.CACHE_STATES}, * module: {packages: Map, state: Setup.CACHE_STATES} * }} */ static cache = { world: { packages: new Map(), state: Setup.CACHE_STATES.COLD }, module: { packages: new Map(), state: Setup.CACHE_STATES.COLD }, system: { packages: new Map(), state: Setup.CACHE_STATES.COLD } }; /** * A cached list of the user's backups. * @type {BackupsListing|null} */ static backups = null; /** * Store a reference to any in-flight request to list backups. * @type {Promise|null} */ static #listingBackups = null; /** * Cached compatibility preview data. * @type {PreviewCompatibilityDescriptor|null} */ static #compatibilityPreview = null; /** * Store a reference to any in-flight request to check package compatibility. * @type {Promise|null} */ static #checkingCompatibility = null; /** * A reference to the setup URL used under the current route prefix, if any * @type {string} */ static get setupURL() { return foundry.utils.getRoute("setup"); } /* -------------------------------------------- */ /** * Register core game settings * @override */ registerSettings() { super.registerSettings(); game.settings.register("core", "declinedManifestUpgrades", { scope: "client", config: false, type: Object, default: {} }); game.settings.register("core", Setup.FAVORITE_PACKAGES_SETTING, { scope: "client", config: false, type: Object, default: {worlds: [], systems: [], modules: []} }); game.settings.register("core", "setupViewModes", { scope: "client", config: false, type: Object, default: {worlds: "GALLERY", systems: "GALLERY", modules: "TILES"} }); } /* -------------------------------------------- */ /** @override */ setupPackages(data) { super.setupPackages(data); const Collection = foundry.utils.Collection; if ( data.worlds ) { this.worlds = new Collection(data.worlds.map(m => [m.id, new World(m)])); } if ( data.systems ) { this.systems = new Collection(data.systems.map(m => [m.id, new System(m)])); } } /* -------------------------------------------- */ /** @override */ static async getData(socket, view) { let req; switch (view) { case "auth": case "license": req = "getAuthData"; break; case "join": req = "getJoinData"; break; case "players": req = "getPlayersData"; break; case "setup": req = "getSetupData"; break; case "update": req = "getUpdateData"; break; } return new Promise(resolve => { socket.emit(req, resolve); }); } /* -------------------------------------------- */ /* View Handlers */ /* -------------------------------------------- */ /** @override */ async _initializeView() { switch (this.view) { case "auth": return this.#authView(); case "license": return this.#licenseView(); case "setup": return this.#setupView(); case "players": return this.#playersView(); case "join": return this.#joinView(); case "update": return this.#updateView(); default: throw new Error(`Unknown view URL ${this.view} provided`); } } /* -------------------------------------------- */ /** * The application view which displays the End User License Agreement (EULA). */ #licenseView() { ui.notifications = new Notifications().render(true); // Render EULA const form = document.getElementById("license-key"); if ( !form ) { new EULA().render(true); return; } // Allow right-clicks specifically in the key field const input = document.getElementById("key"); input?.addEventListener("contextmenu", ev => ev.stopPropagation()); } /* -------------------------------------------- */ /** * The application view which displays the admin authentication application. */ #authView() { if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license"); ui.notifications = new Notifications().render(true); new SetupAuthenticationForm().render(true); } /* -------------------------------------------- */ /** * The application view which displays the application Setup and Configuration. */ async #setupView() { if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license"); this.issueCount = Setup.#logPackageWarnings(this.data.packageWarnings, {notify: false}); // Data Architecture Setup._activateSocketListeners(); // Interface Elements // noinspection ES6MissingAwait FontConfig._loadFonts(); ui.notifications = (new Notifications()).render(true); ui.setupMenu = await (new SetupMenu()).render(true); ui.setupPackages = (new SetupPackages()).render(true); ui.setupSidebar = await (new SetupSidebar()).render(true); // User Interaction this.initializeKeyboard(); ContextMenu.eventListeners(); ProseMirror.ProseMirrorMenu.eventListeners(); // Initial prompts await SetupApplicationConfiguration.telemetryRequestDialog(); if ( !game.data.options.noBackups ) { const tour = game.tours.get("core.backupsOverview"); if ( tour?.status === Tour.STATUS.UNSTARTED ) tour.start(); // noinspection ES6MissingAwait Setup.listBackups(); } } /* -------------------------------------------- */ /** * Log server-provided package warnings so that they are discoverable on the client-side. * @param {object} packageWarnings An object of package warnings and errors by package ID. * @param {object} [options] Additional options to configure logging behaviour. * @param {boolean} [options.notify=true] Whether to create UI notifications in addition to logging. * @returns {{error: number, warning: number, total: number}} A count of the number of warnings and errors */ static #logPackageWarnings(packageWarnings, {notify=true}={}) { const counts = { error: 0, warning: 0 }; for ( const pkg of Object.values(packageWarnings) ) { for ( const error of pkg.error ) { counts.error++; console.error(`[${pkg.id}] ${error}`); } for ( const warning of pkg.warning ) { counts.warning++; console.warn(`[${pkg.id}] ${warning}`); } } // Notify if ( notify && counts.errors ) { const err = game.i18n.format("PACKAGE.SetupErrors", {number: counts.errors}); ui.notifications.error(err, {permanent: true, console: false}); } if ( notify && counts.warnings ) { const warn = game.i18n.format("PACKAGE.SetupWarnings", {number: counts.warnings}); ui.notifications.warn(warn, {permanent: true, console: false}); } // Return total count counts.total = counts.error + counts.warning; return counts; } /* -------------------------------------------- */ /** * The application view which displays the User Configuration. */ #playersView() { if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license"); this.users = new Users(this.data.users); this.collections.set("User", this.users); this.collections.set("Setting", this.settings.storage.get("world")); // Render applications ui.notifications = new Notifications().render(true); ui.players = new UserManagement(); ui.players.render(true); // Game is ready for use this.ready = true; } /* -------------------------------------------- */ /** * The application view which displays the Game join and authentication screen. */ #joinView() { if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license"); // Configure Join view data this.users = new Users(this.data.users); this.collections.set("User", this.users); // Activate Join view socket listeners Users._activateSocketListeners(this.socket); // Render Join view applications ui.notifications = new Notifications(); ui.notifications.render(true); ui.join = new JoinGameForm(); ui.join.render(true); } /* -------------------------------------------- */ /** * The application update view which allows for updating the Foundry Virtual Tabletop software. */ #updateView() { ui.notifications = new Notifications().render(true); ui.setupUpdate = new SetupUpdate().render(true); Setup._activateSocketListeners(); } /* -------------------------------------------- */ /* Package Management */ /* -------------------------------------------- */ /** * Check with the server whether a package of a certain type may be installed or updated. * @param {object} options Options which affect how the check is performed * @param {string} options.type The package type to check * @param {string} options.id The package id to check * @param {string} [options.manifest] The manifest URL to check * @param {number} [options.timeout] A timeout in milliseconds after which the check will fail * @returns {Promise} The resulting manifest if an update is available */ static async checkPackage({type="module", id, manifest, timeout=20000}={}) { return this.post({action: "checkPackage", strict: false, type, id, manifest}, timeout); } /* -------------------------------------------- */ /** * Prepares the cache of available and owned packages * @param {object} options Options which configure how the cache is warmed * @param {string} options.type The type of package being cached * @returns {Promise} */ static async warmPackages({type="system"}={}) { if ( Setup.cache[type].state > Setup.CACHE_STATES.COLD ) return; Setup.cache[type].state = Setup.CACHE_STATES.WARMING; await this.getPackages({type}); Setup.cache[type].state = Setup.CACHE_STATES.WARMED; } /* -------------------------------------------- */ /** * Get a Map of available packages of a given type which may be installed * @param {string} type * @returns {Promise>} */ static async getPackages({type="system"}={}) { // Return from cache if ( this.cache[type].packages?.size > 0 ) return this.cache[type].packages; // Request from server const packages = new Map(); let response; try { response = await this.post({action: "getPackages", type: type}); } catch(err) { ui.notifications.error(err.message, {localize: true}); return packages; } // Populate the cache response.packages.forEach(p => { const pkg = new PACKAGE_TYPES[type](p); packages.set(p.id, pkg); }); this.cache[type].packages = packages; this.cache[type].owned = response.owned; return packages; } /* -------------------------------------------- */ /** * List the user's current backups. * @returns {Promise} */ static async listBackups() { let backups = null; try { if ( !Setup.#listingBackups ) Setup.#listingBackups = this.post({ action: "listBackups" }); backups = await Setup.#listingBackups; } catch ( err ) { ui.notifications.error(err.message, { localize: true }); } this.backups = backups; Setup.#listingBackups = null; return backups; } /* -------------------------------------------- */ /** * Open the Package Browser application * @param {string} packageType The type of package being installed, in ["module", "system", "world"] * @param {string} [search] An optional search string to filter packages * @returns {Promise} */ static async browsePackages(packageType, options={}) { return new InstallPackage({packageType, ...options})._render(true); } /* -------------------------------------------- */ /** * Install a Package * @param {object} options Options which affect how the package is installed * @param {string} options.type The type of package being installed, in ["module", "system", "world"] * @param {string} options.id The package id * @param {string} options.manifest The package manifest URL * @param {boolean} [options.notify=true] Display a notification toast? * @returns {Promise} A Promise which resolves to the installed package */ static async installPackage({type="module", id, manifest, notify=true}={}) { const { ACTIONS } = CONST.SETUP_PACKAGE_PROGRESS; const response = await new ProgressReceiver(manifest, ACTIONS.INSTALL_PKG, { type, id, manifest }, { notify: false }).listen(); if ( response instanceof Error ) { if ( response.context.packageWarnings ) { ui.notifications.error(game.i18n.localize(response.message)); Setup.#logPackageWarnings(response.context.packageWarnings, { notify: false }); } else if ( notify ) { const [message] = response.error.split("\n"); ui.notifications.error(game.i18n.format("SETUP.InstallFailure", { message }), { console: false }); } ui.setupPackages?.render(); throw response; } const pkg = new PACKAGE_TYPES[type](response.pkg); if ( notify ) { ui.notifications.info(game.i18n.format("SETUP.InstallSuccess", { type: game.i18n.localize(`PACKAGE.Type.${type}`), id: pkg.id })); } // Trigger dependency installation (asynchronously) if ( pkg.relationships ) { // noinspection ES6MissingAwait this.installDependencies(pkg, { notify }); } // Add the created package to game data pkg.install(); await this.reload(); return pkg; } /* -------------------------------------------- */ /** * Install a set of dependency modules which are required by an installed package * @param {ClientPackage} pkg The package which was installed that requested dependencies * @param {object} options Options which modify dependency installation, forwarded to installPackage * @returns {Promise} */ static async installDependencies(pkg, options={}) { const dependencyChecks = new Map(); // Check required Relationships for ( let d of pkg.relationships?.requires ?? [] ) { await this.#checkDependency(d, dependencyChecks); } // Check recommended Relationships for ( let d of pkg.relationships?.recommends ?? [] ) { await this.#checkDependency(d, dependencyChecks, false); } const uninstalled = Array.from(dependencyChecks.values()).filter(d => d.installNeeded); if ( !uninstalled.length ) return; // Prepare data for rendering const categories = uninstalled.reduce((obj, dep) => { if ( dep.canInstall && dep.required ) obj.canInstallRequired.push(dep); if ( dep.canInstall && !dep.required ) obj.canInstallOptional.push(dep); if ( !dep.canInstall && dep.required ) obj.cantInstallRequired.push(dep); if ( !dep.canInstall && !dep.required ) obj.cantInstallOptional.push(dep); return obj; }, { canInstallRequired: [], canInstallOptional: [], cantInstallRequired: [], cantInstallOptional: [] }); const { canInstallRequired, canInstallOptional, cantInstallRequired, cantInstallOptional } = categories; const data = { title: pkg.title, totalDependencies: uninstalled.length, canInstallRequired, canInstallOptional, cantInstallRequired, cantInstallOptional }; // Handle pluralization const singleDependency = data.totalDependencies === 1; const canInstall = data.canInstallRequired.length + data.canInstallOptional.length; const cantInstall = data.cantInstallRequired.length + data.cantInstallOptional.length; data.hasDependenciesLabel = singleDependency ? game.i18n.format("SETUP.PackageHasDependenciesSingular", {title: pkg.title}) : game.i18n.format("SETUP.PackageHasDependenciesPlural", {title: pkg.title, number: data.totalDependencies}); data.autoInstallLabel = canInstall === 1 ? game.i18n.localize("SETUP.PackageDependenciesCouldInstallSingular") : game.i18n.format("SETUP.PackageDependenciesCouldInstallPlural", {number: canInstall}); data.manualInstallLabel = cantInstall === 1 ? game.i18n.localize("SETUP.PackageDependenciesCouldNotInstallSingular") : game.i18n.format("SETUP.PackageDependenciesCouldNotInstallPlural", {number: cantInstall}); // Prompt the user to confirm installation of dependency packages const html = await renderTemplate("templates/setup/install-dependencies.html", data); new Dialog( { title: game.i18n.localize("SETUP.PackageDependenciesTitle"), content: html, buttons: { automatic: { icon: '', label: canInstall === 1 ? game.i18n.localize("SETUP.PackageDependenciesAutomaticSingular") : game.i18n.format("SETUP.PackageDependenciesAutomaticPlural"), disabled: canInstall === 0, callback: async (event) => { // Install selected dependency packages const inputs = Array.from(event[0].querySelectorAll("input")); let installed = 0; for ( let d of dependencyChecks.values() ) { if ( !d.installNeeded ) continue; // Only install the package if the input is checked if ( !inputs.find(i => i.name === d.id)?.checked ) continue; await this.installPackage({type: d.type, id: d.id, manifest: d.manifest, ...options}); installed++; } return ui.notifications.info(game.i18n.format("SETUP.PackageDependenciesSuccess", { title: pkg.title, number: installed })); } }, manual: { icon: '', label: game.i18n.localize(`SETUP.PackageDependenciesManual${singleDependency ? "Singular" : "Plural"}`), callback: () => { if ( canInstallRequired.length ) { ui.notifications.warn(game.i18n.format("SETUP.PackageDependenciesDecline", { title: pkg.title })); } } } }, default: "automatic" }, { id: "setup-install-dependencies", width: 600 }).render(true); } /* -------------------------------------------- */ /** * @typedef {Object} PackageDependencyCheck * @property {string} id The package id * @property {string} type The package type * @property {string} manifest The package manifest URL * @property {boolean} installNeeded Whether the package is already installed * @property {boolean} canInstall Whether the package can be installed * @property {string} message An error message to display to the user * @property {string} url The URL to the package * @property {string} version The package version */ /** * Checks a dependency to see if it needs to be installed * @param {RelatedPackage} relatedPackage The dependency * @param {Map} dependencyChecks The current map of dependencies to install * @returns {Promise} * @private */ static async #checkDependency(relatedPackage, dependencyChecks, required = true) { if ( !relatedPackage.id || dependencyChecks.has(relatedPackage.id) ) return; relatedPackage.type = relatedPackage.type || "module"; let dependencyCheck = { id: relatedPackage.id, type: relatedPackage.type, manifest: "", installNeeded: true, canInstall: false, message: "", url: "", version: "", required: required, note: required ? game.i18n.localize("SETUP.RequiredPackageNote") : game.i18n.localize("SETUP.RecommendedPackageNote"), reason: relatedPackage.reason }; const installed = game.data[`${relatedPackage.type}s`].find(p => p.id === relatedPackage.id); if ( installed ) { const msg = `Dependency ${relatedPackage.type} ${relatedPackage.id} is already installed.`; console.debug(msg); dependencyCheck.installNeeded = false; dependencyCheck.message = msg; dependencyChecks.set(dependencyCheck.id, dependencyCheck); return; } // Manifest URL provided let dependency; if ( relatedPackage.manifest ) { dependencyCheck.manifest = relatedPackage.manifest; dependencyCheck.url = relatedPackage.manifest; dependency = await PACKAGE_TYPES[relatedPackage.type].fromRemoteManifest(relatedPackage.manifest); if ( !dependency ) { const msg = `Requested dependency "${relatedPackage.id}" not found at ${relatedPackage.manifest}.`; console.warn(msg); dependencyCheck.message = msg; dependencyChecks.set(dependencyCheck.id, dependencyCheck); return; } } else { // Discover from package listing const packages = await Setup.getPackages({type: relatedPackage.type}); dependency = packages.get(relatedPackage.id); if ( !dependency ) { const msg = `Requested dependency "${relatedPackage.id}" not found in ${relatedPackage.type} directory.`; console.warn(msg); dependencyCheck.message = msg; dependencyChecks.set(dependencyCheck.id, dependencyCheck); return; } // Prefer linking to Readme over Project URL over Manifest if ( dependency.readme ) dependencyCheck.url = dependency.readme; else if ( dependency.url ) dependencyCheck.url = dependency.url; else dependencyCheck.url = dependency.manifest; dependencyCheck.manifest = dependency.manifest; } dependencyCheck.version = dependency.version; /** * Test whether a package dependency version matches the defined compatibility criteria of its dependant package. * @param {string} dependencyVersion The version string of the dependency package * @param {PackageCompatibility} compatibility Compatibility criteria defined by the dependant package * @param {string} [compatibility.minimum] A minimum version of the dependency which is required * @param {string} [compatibility.maximum] A maximum version of the dependency which is allowed * @returns {boolean} */ function isDependencyCompatible(dependencyVersion, {minimum, maximum}={}) { if ( minimum && foundry.utils.isNewerVersion(minimum, dependencyVersion) ) return false; return !( maximum && foundry.utils.isNewerVersion(dependencyVersion, maximum) ); } // Validate that the dependency is compatible if ( !isDependencyCompatible(dependency.version, relatedPackage.compatibility) ) { const range = [ relatedPackage.compatibility?.minimum ? `>= ${relatedPackage.compatibility.minimum}` : "", relatedPackage.compatibility?.maximum && relatedPackage.compatibility?.maximum ? " and " : "", relatedPackage.compatibility?.maximum ? `<= ${relatedPackage.compatibility.maximum}` : "" ].join(""); const msg = `No version of dependency "${relatedPackage.id}" found matching required range of ${range}.`; console.warn(msg); dependencyCheck.message = msg; dependencyChecks.set(dependencyCheck.id, dependencyCheck); return; } dependencyCheck.canInstall = true; dependencyChecks.set(dependencyCheck.id, dependencyCheck); // If the dependency has dependencies itself, take a fun trip down recursion lane for ( let d of dependency.relationships?.requires ?? [] ) { await this.#checkDependency(d, dependencyChecks); } for ( let d of dependency.relationships?.recommends ?? [] ) { await this.#checkDependency(d, dependencyChecks, false); } } /* -------------------------------------------- */ /** * Handle requests to uninstall a package. * @param {BasePackage} pkg The package to uninstall * @returns {Promise} */ static async uninstallPackage(pkg) { const typeLabel = game.i18n.localize(`PACKAGE.Type.${pkg.type}`); if ( pkg.locked ) { return ui.notifications.error(game.i18n.format("PACKAGE.UninstallLocked", {type: typeLabel, id: pkg.id})); } // TODO #8555 #10102 // Provide a deletion confirmation warning // For worlds, require the user to provide a deletion code const title = game.i18n.format("SETUP.PackageDeleteTitle", {type: typeLabel, title: pkg.title ?? pkg.id}); let content = `

    ${game.i18n.format("SETUP.PackageDeleteConfirm", {type: typeLabel, title: pkg.title ?? pkg.id})}

    `; let confirm; if ( pkg.type === "world" ) { content += `

    ${game.i18n.localize("SETUP.WorldDeleteConfirmWarning")}

    `; confirm = await this.confirmCodeDialog({ title, content }); } else { if ( pkg.hasStorage ) content += `

    ${game.i18n.localize("SETUP.PackageDeletePersistent")}

    `; content += `

    ${game.i18n.localize("SETUP.PackageDeleteNoUndo")}

    `; confirm = await Dialog.confirm({ title, content, options: { focus: false, width: 480 } }); } if ( !confirm ) return; // Submit the server request try { await this.post({action: "uninstallPackage", type: pkg.type, id: pkg.id}); } catch(err) { ui.notifications.error(`${game.i18n.localize("SETUP.UninstallFailure")}: ${err.message}`); throw err; } // Finalize the uninstallation PACKAGE_TYPES[pkg.type].uninstall(pkg.id); ui.notifications.info(`${typeLabel} ${pkg.id} ${game.i18n.localize("SETUP.UninstallSuccess")}.`); return this.reload(); } /* -------------------------------------------- */ /** * Retrieve compatibility data for installed packages in the next version of the core software. * @param {ReleaseData} release The release to check against. * @returns {Promise} */ static async previewCompatibility(release) { if ( Setup.#compatibilityPreview?.version === release.version ) return Setup.#compatibilityPreview; let preview = null; try { if ( !Setup.#checkingCompatibility ) { Setup.#checkingCompatibility = this.post({ action: "previewCompatibility", release }, { timeoutMs: null }); } preview = await Setup.#checkingCompatibility; } catch { // Ignored as notification is already raised inside the post method. } if ( preview ) Setup.#compatibilityPreview = preview; Setup.#checkingCompatibility = null; return preview; } /* -------------------------------------------- */ /* Backup Management */ /* -------------------------------------------- */ /** * Create a backup of a given package. * @param {BasePackage} pkg The package. * @param {object} [options] * @param {string} [options.note] An optional note for the backup. Ignored if dialog is true. * @param {boolean} [options.dialog] Spawn a dialog to prompt the user for a note. * @returns {Promise} */ static async createBackup({ type, id, title }, { note, dialog=false }={}) { if ( dialog ) { const result = await Setup.#createBackupDialog(title); if ( !result.confirm ) return; note = result.note; } const { ACTIONS, STEPS } = CONST.SETUP_PACKAGE_PROGRESS; const now = new Date(); const backupId = `${type}.${id}.${now.toDateInputString()}.${now.valueOf()}`; const backups = [{ type, packageId: id, note, id: backupId }]; this.toggleLock(true, { message: "SETUP.BACKUPS.BackingUp" }); let packet; setTimeout(() => { if ( (packet?.step === STEPS.ARCHIVE) && (packet?.pct === 0) ) { ui.notifications.info("SETUP.BACKUPS.LargePackageWarning", { localize: true, permanent: true }); } }, 15000); const progress = new ProgressReceiver(backupId, ACTIONS.CREATE_BACKUP, { backups }, { successMessage: game.i18n.format("SETUP.BACKUPS.CreateBackupComplete", { title }), failureMessage: game.i18n.format("SETUP.BACKUPS.CreateBackupFailure", { title }) }); progress.addEventListener("progress", ({ data }) => packet = data); const response = await progress.listen(); if ( Setup.backups && !(response instanceof Error) ) { Setup.backups[type] ??= {}; Setup.backups[type][id] ??= []; Setup.backups[type][id].unshift(response.backupData); } this.toggleLock(false); } /* -------------------------------------------- */ /** * Create a snapshot of the current installation state. * @param {object} [options] * @param {string} [options.note] An optional note for the snapshot. Ignored if dialog is true. * @param {boolean} [options.dialog] Spawn a dialog to prompt the user to confirm, and to supply a note. * @param {Partial} [dialogOptions] Options to forward to the dialog. * @returns {Promise} */ static async createSnapshot({ note, dialog=false }={}, dialogOptions={}) { const { CREATE_SNAPSHOT, CREATE_BACKUP } = CONST.SETUP_PACKAGE_PROGRESS.ACTIONS; if ( dialog ) { const result = await SnapshotOperationDialog.wait({ title: game.i18n.localize("SETUP.BACKUPS.CreateSnapshot") }, { message: "SETUP.BACKUPS.CreateSnapshotHint", confirm: "SETUP.BACKUPS.CreateSnapshotConfirm", packageList: true, note: true, diskSpaceAction: "checkCreateSnapshotDiskSpace", ...dialogOptions }); if ( !result.confirm ) return; note = result.note; } this.toggleLock(true, { message: "SETUP.BACKUPS.BackingUp" }); const now = new Date(); const id = `snapshot.${now.toDateInputString()}.${now.valueOf()}`; await new SnapshotProgressReceiver(id, CREATE_SNAPSHOT, CREATE_BACKUP, { note, id }, { title: "", successMessage: game.i18n.localize("SETUP.BACKUPS.CreateSnapshotComplete"), failureMessage: game.i18n.localize("SETUP.BACKUPS.CreateSnapshotFailure") }).listen(); Setup.backups = null; this.toggleLock(false); } /* -------------------------------------------- */ /** * Delete backups. * @param {BasePackage} pkg The package whose backups are being deleted. * @param {string[]} backupIds The IDs of the backups to delete. * @param {object} [options] * @param {boolean} [options.dialog] Spawn a warning dialog and ask the user to confirm the action. * @returns {Promise} */ static async deleteBackups({ type, id, title }, backupIds, { dialog=false }={}) { const count = backupIds.length; if ( !count ) return; if ( dialog ) { const confirm = await this.confirmCodeDialog({ title: game.i18n.format("SETUP.BACKUPS.DeleteBackupTitle", { title }), content: `

    ${game.i18n.format(`SETUP.BACKUPS.DeleteBackupWarning${count === 1 ? "" : "Pl"}`, { count })}

    ` }); if ( !confirm ) return; } const { DELETE_BACKUP } = CONST.SETUP_PACKAGE_PROGRESS.ACTIONS; const ids = new Set(backupIds); const backups = Setup.backups[type][id].filter(backupData => ids.has(backupData.id)); this.toggleLock(true, { message: "SETUP.BACKUPS.DeletingBackup" }); await new ProgressReceiver(DELETE_BACKUP, DELETE_BACKUP, { backups }, { failureMessage: game.i18n.format("SETUP.BACKUPS.DeleteBackupFailure", { title }), successMessage: game.i18n.format(`SETUP.BACKUPS.DeleteBackupComplete${count === 1 ? "" : "Pl"}`, { title, count }) }).listen(); if ( Setup.backups ) { Setup.backups[type][id] = Setup.backups[type][id].filter(backupData => !ids.has(backupData.id)); } this.toggleLock(false); } /* -------------------------------------------- */ /** * Delete snapshots. * @param {string[]} snapshotIds The IDs of the snapshots to delete. * @param {object} [options] * @param {boolean} [options.dialog] Spawn a warning dialog and ask the user to confirm the action. * @returns {Promise} */ static async deleteSnapshots(snapshotIds, { dialog=false }={}) { const count = snapshotIds.length; if ( !count ) return; if ( dialog ) { const confirm = await this.confirmCodeDialog({ title: game.i18n.localize("SETUP.BACKUPS.DeleteSnapshotTitle"), content: `

    ${game.i18n.format(`SETUP.BACKUPS.DeleteSnapshotWarning${count === 1 ? "" : "Pl"}`, { count })}

    ` }); if ( !confirm ) return; } const { DELETE_SNAPSHOT, DELETE_BACKUP } = CONST.SETUP_PACKAGE_PROGRESS.ACTIONS; const snapshots = snapshotIds.map(id => Setup.backups.snapshots[id]); this.toggleLock(true, { message: "SETUP.BACKUPS.DeletingSnapshot" }); await new SnapshotProgressReceiver(DELETE_SNAPSHOT, DELETE_SNAPSHOT, DELETE_BACKUP, { snapshots }, { failureMessage: game.i18n.localize("SETUP.BACKUPS.DeleteSnapshotFailure"), successMessage: game.i18n.format(`SETUP.BACKUPS.DeleteSnapshotComplete${count === 1 ? "" : "Pl"}`, { count }) }).listen(); Setup.backups = null; this.toggleLock(false); } /* -------------------------------------------- */ /** * Restore a backup. * @param {BackupData} backupData The backup to restore. * @param {object} [options] * @param {boolean} [options.dialog] Spawn a warning dialog and ask the user to confirm the action. * @returns {Promise} */ static async restoreBackup(backupData, { dialog=false }={}) { const { title, id } = backupData; if ( dialog ) { const confirm = await this.confirmCodeDialog({ title: game.i18n.format("SETUP.BACKUPS.RestoreBackupTitle", { title }), content: `

    ${game.i18n.localize("SETUP.BACKUPS.RestoreBackupWarning")}

    ` }); if ( !confirm ) return; } const backups = [backupData]; const dateFormatter = new Intl.DateTimeFormat(game.i18n.lang, { dateStyle: "full", timeStyle: "short" }); this.toggleLock(true, { message: "SETUP.BACKUPS.Restoring" }); await new ProgressReceiver(id, CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.RESTORE_BACKUP, { backups }, { failureMessage: game.i18n.format("SETUP.BACKUPS.RestoreBackupFailure", { title }), successMessage: game.i18n.format("SETUP.BACKUPS.RestoreBackupComplete", { title, date: dateFormatter.format(backupData.createdAt) }) }).listen(); await Setup.reload(); this.toggleLock(false); } /* -------------------------------------------- */ /** * Restore a snapshot. * @param {SnapshotData} snapshotData The snapshot to restore. * @param {object} [options] * @param {boolean} [options.dialog] Spawn a warning dialog and ask the user to confirm the action. * @returns {Promise} */ static async restoreSnapshot(snapshotData, { dialog=false }={}) { if ( dialog ) { const { confirm } = await SnapshotOperationDialog.wait({ title: game.i18n.localize("SETUP.BACKUPS.RestoreSnapshotTitle") }, { snapshotData, message: "SETUP.BACKUPS.RestoreSnapshotWarning", warning: true, confirmCode: true, diskSpaceAction: "checkRestoreSnapshotDiskSpace" }); if ( !confirm ) return; } const { id, createdAt } = snapshotData; const { ACTIONS } = CONST.SETUP_PACKAGE_PROGRESS; const dateFormatter = new Intl.DateTimeFormat(game.i18n.lang, { dateStyle: "full", timeStyle: "short" }); this.toggleLock(true, { message: "SETUP.BACKUPS.Restoring" }); await new SnapshotProgressReceiver(id, ACTIONS.RESTORE_SNAPSHOT, ACTIONS.RESTORE_BACKUP, { snapshotData }, { title: "", failureMessage: game.i18n.localize("SETUP.BACKUPS.RestoreSnapshotFailure"), successMessage: game.i18n.format("SETUP.BACKUPS.RestoreSnapshotComplete", { date: dateFormatter.format(createdAt) }) }).listen(); await Setup.reload(); this.toggleLock(false); } /* -------------------------------------------- */ /** * Restore the latest backup for a given package. * @param {BasePackage} pkg The package. * @param {object} [options] * @param {boolean} [options.dialog] Spawn a warning dialog and ask the user to confirm the action. * @returns {Promise} */ static async restoreLatestBackup({ id, type }, options={}) { if ( !this.backups ) await this.listBackups(); const [backupData] = this.backups?.[type]?.[id] ?? []; if ( backupData ) return this.restoreBackup(backupData, options); } /* -------------------------------------------- */ /* Socket Listeners and Handlers */ /* -------------------------------------------- */ /** * Activate socket listeners related to the Setup view. */ static _activateSocketListeners() { game.socket.on("progress", Setup._onProgress); } /* --------------------------------------------- */ /** * A list of functions to call on progress events. * @type {Function[]} */ static _progressListeners = []; /* --------------------------------------------- */ /** * Handle a progress event from the server. * @param {object} data The progress update data. * @private */ static _onProgress(data) { Setup._progressListeners.forEach(l => l(data)); } /* --------------------------------------------- */ /** * Add a function to be called on a progress event. * @param {Function} listener * @internal */ static _addProgressListener(listener) { Setup._progressListeners.push(listener); } /* --------------------------------------------- */ /** * Stop sending progress events to a given function. * @param {Function} listener * @internal */ static _removeProgressListener(listener) { Setup._progressListeners = Setup._progressListeners.filter(l => l !== listener); } /* --------------------------------------------- */ /** * Reload package data from the server and update its display * @returns {Promise} */ static async reload() { return this.getData(game.socket, game.view).then(setupData => { foundry.utils.mergeObject(game.data, setupData); game.setupPackages(setupData); ui.setupPackages.render(); ui.installPackages?.render(); }); } /* -------------------------------------------- */ /* Helper Functions */ /* -------------------------------------------- */ /** * Post to the Setup endpoint. * @param {object} requestData An object of data which should be included with the POST request * @param {object} [options] An object of options passed to the fetchWithTimeout method * @param {boolean} [requestOptions.notify] Whether to spawn notification dialogs when errors are encountered. * @returns {Promise} A Promise resolving to the returned response data * @throws An error if the request was not successful */ static async post(requestData, { notify=true, ...requestOptions }={}) { if ( game.ready ) { throw new Error("You may not submit POST requests to the setup page while a game world is currently active."); } // Post the request and handle redirects const url = foundry.utils.getRoute(game.view); let responseData; try { const response = await foundry.utils.fetchWithTimeout(url, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(requestData) }, requestOptions); // Handle redirect if ( response.redirected ) return window.location.href = response.url; // Process response responseData = await response.json(); } catch(error) { const annotatedError = new Error(`[${requestData.id}] ${game.i18n.localize(error)}`); if ( notify ) ui.notifications.error(annotatedError, {permanent: true}); throw annotatedError; } // Handle server-side errors if ( responseData.error ) { const { error, ...data } = responseData; const err = new Error(`[${requestData.id}] ${game.i18n.localize(error)}`); Object.assign(err, data); if ( notify ) ui.notifications.error(err, {permanent: true}); throw err; } return responseData; } /* -------------------------------------------- */ /** * Create a confirmation dialog that prompts the user to enter a code to proceed. * Base on https://stackoverflow.com/a/8084248 * @param {object} [options] * @param {string} [options.title] The dialog title. * @param {string} [options.content] Additional dialog content. * @returns {Promise} Returns true if the user chose to proceed and the code was correct. Returns * false if the code was incorrect or the user chose to not proceed. Returns null * if the user dismissed the dialog. */ static confirmCodeDialog({ title, content }={}) { const code = (Math.random() + 1).toString(36).substring(7, 11); content = ` ${content ?? ""}

    ${game.i18n.localize("SETUP.WorldDeleteConfirmCode")}

    ${code}

    `; return Dialog.confirm({ title, content, options: { jQuery: false, focus: false, width: 480 }, yes: html => { const confirm = html.querySelector("#delete-confirm")?.value; if ( confirm === code ) return true; ui.notifications.error("SETUP.PackageDeleteWorldConfirm", { localize: true }); return false; } }); } /* -------------------------------------------- */ /** * @typedef {object} BackupNoteConfirmation * @property {string} [note] The user-supplied backup note. * @property {boolean} confirm Whether the user wishes to proceed. */ /** * Spawn the backup creation confirmation dialog. * @param {string} title The package title. * @returns {Promise} */ static async #createBackupDialog(title) { const result = await Dialog.prompt({ title: game.i18n.format("SETUP.BACKUPS.CreateBackup", { title }), content: `

    ${game.i18n.localize("SETUP.BACKUPS.NoteHint")}

    `, label: game.i18n.localize("SETUP.BACKUPS.Backup"), rejectClose: false, callback: html => html.querySelector("input").value, options: { width: 480, jQuery: false } }); if ( result === null ) return { confirm: false }; return { note: result, confirm: true }; } /* -------------------------------------------- */ /** * Toggle the locked state of the interface. * @param {boolean} locked Is the interface locked? * @param {object} [options] */ static toggleLock(locked, options={}) { ui.setupMenu?.toggleLock(locked, options); ui.setupPackages?.toggleLock(locked, options); Object.values(ui.windows).forEach(app => app.toggleLock?.(locked, options)); } }; var applications = /*#__PURE__*/Object.freeze({ __proto__: null, EULA: EULA, JoinGameForm: JoinGameForm, SetupAuthenticationForm: SetupAuthenticationForm, SetupMenu: SetupMenu, SetupPackages: SetupPackages, UserManagement: UserManagement }); // Add Global Exports globalThis.Setup = Setup$1; Setup$1.applications = applications; foundry.setup = { Setup: Setup$1, applications }; })();