(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 = $(`
${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${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${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${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