(function () { 'use strict'; /** * Display the End User License Agreement and prompt the user to agree before moving forwards. */ class EULA extends Application { /** @inheritdoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "eula", template: "templates/setup/eula.hbs", title: "End User License Agreement", width: 720, popOut: true }); } /* -------------------------------------------- */ /** * A reference to the setup URL used under the current route prefix, if any * @type {string} */ get licenseURL() { return foundry.utils.getRoute("license"); } /* -------------------------------------------- */ /** @override */ async getData(options) { const html = await foundry.utils.fetchWithTimeout("license.html").then(r => r.text()); return { html }; } /* -------------------------------------------- */ /** @override */ async _renderOuter() { const id = this.id; const classes = Array.from(this.options.classes).join(" "); // Override the normal window app header, so it cannot be closed or minimized const html = $(`

${this.title}

`); // Make the outer window draggable const header = html.find("header")[0]; new Draggable(this, html, header, this.options.resizable); // Set the outer frame z-index if ( Object.keys(ui.windows).length === 0 ) _maxZ = 100; html.css({zIndex: Math.min(++_maxZ, 9999)}); return html; } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @override */ activateListeners(html) { super.activateListeners(html); const form = html.toArray().find(el => el.id === "eula-sign"); form.querySelector("#decline").addEventListener("click", EULA.#onDecline); form.onsubmit = EULA.#onSubmit; } /* -------------------------------------------- */ /** * Handle refusal of the EULA by checking the decline button * @param {MouseEvent} event The originating click event */ static #onDecline(event) { const button = event.currentTarget; ui.notifications.error("You have declined the End User License Agreement and cannot use the software."); button.form.dataset.clicked = "decline"; } /* -------------------------------------------- */ /** * Validate form submission before sending it onwards to the server * @param {Event} event The originating form submission event */ static #onSubmit(event) { /** @type {HTMLFormElement} */ const form = event.target; if ( form.dataset.clicked === "decline" ) { return setTimeout(() => window.location.href = CONST.WEBSITE_URL, 1000); } if ( !form.agree.checked ) { event.preventDefault(); ui.notifications.error("You must indicate your agreement before proceeding."); } } } /** * The Join Game setup application. */ class JoinGameForm extends FormApplication { constructor(object, options) { super(object, options); game.users.apps.push(this); } /* -------------------------------------------- */ /** @override */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "join-game", template: "templates/setup/join-game.hbs", popOut: false, closeOnSubmit: false, scrollY: ["#world-description"] }); } /* -------------------------------------------- */ /** @inheritDoc */ getData(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 */ activateListeners(html) { super.activateListeners(html); this.form.userid.addEventListener("focus", this.#setMode.bind(this, "join")); this.form.password.addEventListener("focus", this.#setMode.bind(this, "join")); this.form.adminPassword?.addEventListener("focus", this.#setMode.bind(this, "shutdown")); this.form.shutdown.addEventListener("click", this.#onShutdown.bind(this)); } /* -------------------------------------------- */ /** @inheritDoc */ async _render(force, options) { if ( !this.form ) return super._render(force, options); // Preserve form state across re-renders. const data = this._getSubmitData(); const focus = this.form.querySelector(":focus"); await super._render(force, options); Object.entries(data).forEach(([k, v]) => this.form.elements[k].value = v); if ( focus?.name ) this.form.elements[focus.name].focus(); if ( this.form.userid.selectedOptions[0]?.disabled ) this.form.userid.value = ""; } /* -------------------------------------------- */ /** * Toggle the submission mode of the form to alter what pressing the "ENTER" key will do * @param {string} mode */ #setMode(mode) { switch (mode) { case "join": this.form.shutdown.type = "button"; this.form.join.type = "submit"; break; case "shutdown": this.form.join.type = "button"; this.form.shutdown.type = "submit"; break; } } /* -------------------------------------------- */ /** @inheritDoc */ async _onSubmit(event, options) { event.preventDefault(); const form = event.target; form.submit.disabled = true; const data = this._getSubmitData(); data.action = "join"; return this.#post(data, form.submit); } /* -------------------------------------------- */ /** * Handle requests to shut down the currently active world * @param {MouseEvent} event The originating click event * @returns {Promise} */ async #onShutdown(event) { event.preventDefault(); const button = this.form.shutdown; button.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 ) { button.disabled = false; return; } } // Submit the request const data = this._getSubmitData(); data.action = "shutdown"; return this.#post(data, button); } /* -------------------------------------------- */ /** * Submit join view POST requests to the server for handling. * @param {object} formData The processed form data * @param {EventTarget|HTMLButtonElement} button The triggering button element * @returns {Promise} */ async #post(formData, button) { const joinURL = foundry.utils.getRoute("join"); button.disabled = true; // Look up some data const user = game.users.get(formData.userid)?.name || formData.userid; let response; try { response = await fetchJsonWithTimeout(joinURL, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(formData) }); } catch(e) { if (e instanceof HttpError) { const error = game.i18n.format(e.displayMessage, {user}); ui.notifications.error(error); } else { ui.notifications.error(e); } button.disabled = false; return; } // Redirect on success ui.notifications.info(game.i18n.format(response.message, {user})); setTimeout(() => window.location.href = response.redirect, 500 ); } /* -------------------------------------------- */ /** @override */ async _updateObject(event, formData) { throw new Error("Not implemented for this class"); } } /** * 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)); return { noAdminPW: !game.data.options.adminPassword, config: this.config.toObject(), cssThemes: CONST.CSS_THEMES, languages: game.data.languages, fields: this.config.schema.fields, worlds: worlds }; } /* -------------------------------------------- */ /** @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); } } } /** * The Setup Authentication Form. */ class SetupAuthenticationForm extends Application { /** @inheritdoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "setup-authentication", template: "templates/setup/setup-authentication.hbs", popOut: false }); } } /** * An application that renders the floating setup menu buttons. */ class SetupWarnings extends Application { /** @inheritdoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "setup-warnings", template: "templates/setup/setup-warnings.hbs", title: "SETUP.WarningsTitle", popOut: true, width: 680 }); } /* -------------------------------------------- */ /** @override */ get title() { return `${game.i18n.localize(this.options.title)} (${game.issueCount.total})`; } /* -------------------------------------------- */ /** @override */ async getData(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}; } /* -------------------------------------------- */ /** @inheritDoc */ activateListeners(html) { super.activateListeners(html); html.find("a.manage").click(this.#onManagePackage.bind(this)); html.find("[data-action]").on("click", this.#onAction.bind(this)); } /* -------------------------------------------- */ /** * Handle button press actions. * @param {PointerEvent} event The triggering event. */ async #onAction(event) { const target = event.currentTarget; const action = target.dataset.action; const pkg = target.closest("[data-package-id]"); const id = pkg.dataset.packageId; const type = pkg.dataset.packageType; switch ( action ) { case "reinstallPackage": target.querySelector("i").classList.add("fa-spin"); await this.#reinstallPackage({ id, type }); break; case "uninstallPackage": await this.#uninstallPackage({ id, type }); break; } this.render(); } /* -------------------------------------------- */ /** * Handle button clicks in the warnings view to manage the package. * @param {PointerEvent} event The initiating click event */ #onManagePackage(event) { event.preventDefault(); const li = event.currentTarget.closest(".package"); // Activate the correct tab: 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 reinstalling a package. * @param {object} pkg The package information. * @param {string} pkg.id The package ID. * @param {string} pkg.type The package type. */ async #reinstallPackage({ id, type }) { await this.#uninstallPackage({ id, type }); await Setup.warmPackages({ type }); const pkg = Setup.cache[type].packages.get(id); const warnInfo = game.data.packageWarnings[id]; if ( !pkg && !warnInfo?.manifest ) { return ui.notifications.error("SETUP.ReinstallPackageNotFound", { localize: true, permanent: true }); } return Setup.installPackage({ type, id, manifest: warnInfo?.manifest ?? pkg.manifest }); } /* -------------------------------------------- */ /** * Handle uninstalling a package. * @param {object} pkg The package information. * @param {string} pkg.id The package ID. * @param {string} pkg.type The package type. */ async #uninstallPackage({ id, type }) { 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); } /* -------------------------------------------- */ /** @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(); 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); } } /** * An application that renders the floating setup menu buttons. */ class SetupMenu extends Application { /** @inheritdoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "setup-menu", template: "templates/setup/setup-menu.hbs", popOut: false }); } /* -------------------------------------------- */ /** @override */ async getData(options) { const pips = {}; // Package Warnings Pip if ( game.issueCount.total ) { pips.warnings = { type: game.issueCount.error > 0 ? "error" : "warning", label: game.issueCount.total }; } // Config Menu Pip if ( !game.data.options.adminPassword ) { pips.config = { type: "warning", label: "!" }; } // Available Update Pip if ( game.data.coreUpdate.hasUpdate ) { pips.update = { type: "warning", label: "!" }; } return { canBackup: !game.data.options.noBackups, canLogOut: !!game.data.options.adminPassword, pips }; } /* -------------------------------------------- */ /** @inheritDoc */ activateListeners(html) { super.activateListeners(html); html.find("button[data-action]").click(this.#onClickButton.bind(this)); } /* -------------------------------------------- */ /** * Handle setup menu button clicks * @param {PointerEvent} event The initiating click event */ #onClickButton(event) { event.preventDefault(); const button = event.currentTarget; switch ( button.dataset.action ) { case "adminLogout": Setup.post({action: button.dataset.action}); // redirects break; case "backups": new BackupManager().render(true); break; case "configure": new SetupApplicationConfiguration(game.data.options).render(true); break; case "update": window.location.href = foundry.utils.getRoute("update"); break; case "viewWarnings": const warnings = new SetupWarnings(); const {bottom, right} = button.parentElement.getBoundingClientRect(); warnings.render(true, {left: right - warnings.options.width, top: bottom + 20}); 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("button").forEach(el => el.disabled = locked); } } /** * 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(); } } /** * 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); } /* -------------------------------------------- */ /** * 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); } /* -------------------------------------------- */ /* 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} Returned response from the server which automatically redirects */ 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"); // Fire world launch request. const error = ({ message, stack }) => { const err = new Error(message); err.stack = stack; console.error(err); ui.notifications.error(game.i18n.format("SETUP.WorldLaunchFailure", { message }), { console: false, permanent: true }); Setup._removeProgressListener(progress); this.render(); }; const progress = data => { this.onProgress(data); if ( data.step === CONST.SETUP_PACKAGE_PROGRESS.STEPS.ERROR ) error(data); if ( data.step === CONST.SETUP_PACKAGE_PROGRESS.STEPS.COMPLETE ) location.href = foundry.utils.getRoute("/game"); }; Setup._addProgressListener(progress); return Setup.post({action: "launchWorld", world: world.id}, {timeoutMs: null}); } /* -------------------------------------------- */ /** * 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 * @returns {Promise} */ async #updateAll(packageType) { 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 => p.manifest && !p.locked); 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`); // 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": 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 if ( results.length ) { let content = await renderTemplate("templates/setup/updated-packages.html", {changed: results}); await Dialog.prompt({ 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; } /* -------------------------------------------- */ /** * 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": 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 */ /** * 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 = { 6: "Insufficient", 7: "UpdateNeeded", 8: "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.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; } /* -------------------------------------------- */ /* 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 */ /** * An application that renders the Setup sidebar containing News and Featured Content widgets */ class SetupSidebar extends Application { /** @inheritdoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "setup-sidebar", template: "templates/setup/setup-sidebar.hbs", popOut: false }); } /* -------------------------------------------- */ /** @override */ async getData(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); } /* -------------------------------------------- */ /** * 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; return { coreVersion: game.version, release: game.release, coreVersionHint: game.i18n.format("SETUP.CoreVersionHint", {versionDisplay: game.release.display}), updateChannel: game.data.options.updateChannel, updateChannels: Object.entries(CONST.SOFTWARE_UPDATE_CHANNELS).reduce((obj, c) => { obj[c[0]] = game.i18n.localize(c[1]); return obj; }, {}), updateChannelHints: Object.entries(CONST.SOFTWARE_UPDATE_CHANNELS).reduce((obj, c) => { obj[c[0]] = game.i18n.localize(`${c[1]}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; // Bind the progress listener if ( this.#action === "updateDownload" ) { this.#onProgress = this.#onUpdateProgress.bind(this); Setup._addProgressListener(this.#onProgress); } // Prepare request data const requestData = { action: this.#action, updateChannel: form.updateChannel.value, forceUpdate: form.forceUpdate.checked }; // 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); } /* -------------------------------------------- */ /* Socket Listeners and Handlers */ /* -------------------------------------------- */ /** * The progress function registered with Setup._progressListeners * @param {{type: string, step: string, pct: number, message: string}} data Progress data emitted by the server */ #onUpdateProgress(data) { const steps = CONST.SETUP_PACKAGE_PROGRESS.STEPS; // Complete update if ( [steps.COMPLETE, steps.ERROR].includes(data.step) ) { Setup._removeProgressListener(this.#onProgress); this.#onProgress = undefined; // Re-enable the form const form = this.element[0]; form.disabled = false; // Display a notification message const level = data.step === steps.COMPLETE ? "info" : "error"; ui.notifications[level](data.message, {localize: true, permanent: true}); ui.updateNotes.close(); } // Update the release notes else { UpdateNotes.updateButton(data); ui.updateNotes.setPosition({height: "auto"}); } // Update progress bar this.#updateProgressBar(data); this.#updateProgressButton(data); } /* -------------------------------------------- */ /** * 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(data) { const button = document.getElementById("update-core"); button.disabled = data.pct < 100; // Update Icon const steps = CONST.SETUP_PACKAGE_PROGRESS.STEPS; const icon = button.firstElementChild; if ( data.step === steps.ERROR ) icon.className = "fas fa-times"; else if ( data.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(data.message); } } /** * The User Management setup application. * @param {Users} object The {@link Users} object being configured. * @param {FormApplicationOptions} [options] Application configuration options. */ class UserManagement extends FormApplication { /** @inheritdoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "manage-players", template: "templates/setup/user-management.hbs", popOut: false, closeOnSubmit: false, scrollY: ["#player-list"] }); } /* -------------------------------------------- */ /** * The template path used to render a single user entry in the configuration view * @type {string} */ static USER_TEMPLATE = "templates/setup/manage-user.hbs"; /* -------------------------------------------- */ /** @inheritdoc */ async _render(...args) { await getTemplate(this.constructor.USER_TEMPLATE); return super._render(...args); } /* -------------------------------------------- */ /** @inheritdoc */ getData(options={}) { return { user: game.user, users: this.object, roles: UserManagement.#getRoleLabels(), options: this.options, 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 */ activateListeners(html) { super.activateListeners(html); const password = html.find("input[type='password']"); password.focus(UserManagement.#onPasswordFocus).keydown(UserManagement.#onPasswordKeydown); html.on("click", "[data-action]", UserManagement.#onAction); html.find("label.show").click(UserManagement.#onShowPassword); } /* -------------------------------------------- */ /** @inheritdoc */ async _updateObject(event, formData) { // Construct updates array const userData = foundry.utils.expandObject(formData).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 { 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) { this.render(); } } /* -------------------------------------------- */ /** * 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; } /* -------------------------------------------- */ /** * Handle new user creation event. * @param {PointerEvent} event The originating click event */ static async #onAction(event) { event.preventDefault(); const button = event.currentTarget; button.disabled = true; switch ( button.dataset.action ) { case "create-user": await UserManagement.#onUserCreate(); break; case "deleteUser": await UserManagement.#onUserDelete(button); break; case "configure-permissions": new PermissionConfig().render(true); break; case "showPassword": UserManagement.#onShowPassword(button); break; } button.disabled = false; } /* -------------------------------------------- */ /** * Reveal the password that is being configured so the user can verify they have typed it correctly. * @param {HTMLAnchorElement} button The clicked control button */ static #onShowPassword(button) { const li = button.closest(".player"); const label = li.querySelector(".password"); const input = label.firstElementChild; input.type = input.type === "password" ? "text" : "password"; } /* -------------------------------------------- */ /** * Handle creating a new User record in the form. */ static async #onUserCreate() { // 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 html = await renderTemplate(UserManagement.USER_TEMPLATE, { user, roles: UserManagement.#getRoleLabels() }); // Append the player to the list and restore the button $("#player-list").append(html); } /* -------------------------------------------- */ /** * Handle user deletion event. * @param {HTMLAnchorElement} button The clicked control button */ static #onUserDelete(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); } } /** * 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 a server-side operation's progress lifecycle. */ class ProgressReceiver { /** * @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. * @property {ProgressReceiverProgress} [onProgress] A callback to invoke on every progress tick. */ /** * @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={}) { this.#operationId = operationId; this.#action = action; this.#context = context; this.#options = { notify: true, ...options }; } /** * 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} */ #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); 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({ error, stack }) { const { notify, failureMessage } = this.#options; const err = new Error(error); err.stack = stack; if ( notify && failureMessage ) ui.notifications.error(failureMessage, { console: false, permanent: true }); console.error(err); ui.setupPackages?.removeProgressBar(this.#operationId); Setup._removeProgressListener(this.#progressListener); this.#resolve(err); } /* -------------------------------------------- */ /** * Handle progress ticks. * @param {ProgressReceiverPacket} data Progress event data. * @protected */ _onProgress(data) { const { STEPS } = CONST.SETUP_PACKAGE_PROGRESS; const { action, step } = data; if ( action !== this.#action ) return; const context = { ...data, id: this.#operationId }; if ( (this.#options.title !== undefined) && !("title" in context) ) context.title = this.#options.title; ui.setupPackages?.onProgress(context); if ( this.#options.onProgress instanceof Function ) this.#options.onProgress(context); 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); }); } } /** * 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}); ui.notifications = (new Notifications()).render(true); ui.setupMenu = (new SetupMenu()).render(true); ui.setupPackages = (new SetupPackages()).render(true); ui.setupSidebar = (new SetupSidebar()).render(true); Setup._activateSocketListeners(); ContextMenu.eventListeners(); FontConfig._loadFonts(); await SetupApplicationConfiguration.telemetryRequestDialog(); if ( !game.data.options.noBackups ) { const tour = game.tours.get("core.backupsOverview"); if ( tour?.status === Tour.STATUS.UNSTARTED ) tour.start(); 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(this.users); 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().render(true); ui.join = new JoinGameForm().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", 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}={}) { return new Promise(async (resolve, reject) => { /** * Handles an Install error * @param {InstallPackageError} response */ const error = response => { if ( response.packageWarnings ) { ui.notifications.error(game.i18n.localize(response.error)); Setup.#logPackageWarnings(response.packageWarnings, {notify: false}); } else { const err = new Error(response.error); err.stack = response.stack; if ( notify ) { // Display a user-friendly UI notification const message = response.error.split("\n")[0]; ui.notifications.error(game.i18n.format("SETUP.InstallFailure", {message}), {console: false}); } console.error(err); // Log the full error details to console } Setup._removeProgressListener(progress); resolve(response); ui.setupPackages?.render(); }; /** * Handles successful Package installation * @param {InstallPackageSuccess} data * @returns {Promise} */ const done = async data => { const pkg = new PACKAGE_TYPES[type](data.pkg); if ( notify ) { ui.notifications.info(game.i18n.format("SETUP.InstallSuccess", {type: type.titleCase(), 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(); // Update application views Setup._removeProgressListener(progress); await this.reload(); resolve(pkg); }; const progress = data => { if ( !((data.action === CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.INSTALL_PKG) && (data.manifest === manifest)) ) return; ui.setupPackages.onProgress(data); if ( data.step === CONST.SETUP_PACKAGE_PROGRESS.STEPS.ERROR ) return error(data); if ( data.step === CONST.SETUP_PACKAGE_PROGRESS.STEPS.VEND ) return done(data); }; Setup._addProgressListener(progress); // Submit the POST request let response; try { response = await this.post({action: CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.INSTALL_PKG, type, id, manifest}); } catch(err) { return reject(err); } // Handle errors and warnings if ( response.error ) error(response); if ( response.warning && notify ) ui.notifications.warn(response.warning); }); } /* -------------------------------------------- */ /** * 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: () => { return 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 backups = [{ type, packageId: id, note }]; 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 response = await new ProgressReceiver(id, ACTIONS.CREATE_BACKUP, { backups }, { successMessage: game.i18n.format("SETUP.BACKUPS.CreateBackupComplete", { title }), failureMessage: game.i18n.format("SETUP.BACKUPS.CreateBackupFailure", { title }), onProgress: data => packet = data }).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 operationId = foundry.utils.randomID(); await new SnapshotProgressReceiver(operationId, CREATE_SNAPSHOT, CREATE_BACKUP, { note }, { 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 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(id, CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.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(foundry.utils.randomID(), 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(err) { if ( notify ) ui.notifications.error(err, {permanent: true}); throw err; } // Handle server-side errors if ( responseData.error ) { const { error, ...data } = responseData; const err = new Error(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; })();