/** * PresetImportDialog — Dialog for importing scene presets from JSON file. * * Extends ApplicationV2 via HandlebarsApplicationMixin to provide FoundryVTT-native * dialog experience. Allows GM to upload a JSON file and choose between merge or replace modes. * * @module ui/gm/PresetImportDialog */ import { PresetImportExportManager } from '../../core/PresetImportExportManager.js'; import { escapeHtml } from '../../utils/html.js'; // Maximum file size: 5MB const MAX_FILE_SIZE = 5 * 1024 * 1024; // Allowed MIME types for JSON files const ALLOWED_MIME_TYPES = new Set(['application/json', 'text/plain', '']); // Conditional base class — test environment lacks foundry globals. // At module load time in tests, foundry is undefined → fallback class is used. /** @private */ const _AppBase = typeof foundry !== 'undefined' && foundry.applications?.api?.HandlebarsApplicationMixin && foundry.applications?.api?.ApplicationV2 ? foundry.applications.api.HandlebarsApplicationMixin( foundry.applications.api.ApplicationV2 ) : class _FallbackApp { static DEFAULT_OPTIONS = {}; static PARTS = {}; get rendered() { return this._rendered ?? false; } set rendered(v) { this._rendered = v; } get element() { return this._element ?? null; } set element(v) { this._element = v; } async render() { this._rendered = true; } async close() { this._rendered = false; } async _prepareContext() { return {}; } _onRender() {} _onClose() {} _onPosition() {} }; /** * Import dialog for scene presets. * Provides file picker, mode selection (merge/replace), preview, and confirmation. */ export class PresetImportDialog extends _AppBase { /** * @param {object} options - Dialog options. * @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} options.adapter - Foundry adapter. * @param {import('../../core/ScenePresetManager.js').ScenePresetManager} options.scenePresetManager - Scene preset manager. */ constructor(options = {}) { super(options); if (!options || typeof options !== 'object') { throw new TypeError('PresetImportDialog: options argument is required and must be an object'); } if (!options.adapter || typeof options.adapter !== 'object') { throw new TypeError('PresetImportDialog: adapter option is required and must be an object'); } if (!options.scenePresetManager || typeof options.scenePresetManager !== 'object') { throw new TypeError('PresetImportDialog: scenePresetManager option is required and must be an object'); } this._adapter = options.adapter; this._scenePresetManager = options.scenePresetManager; this._exportManager = new PresetImportExportManager(this._adapter, this._scenePresetManager); // State /** @type {File|null} */ this._selectedFile = null; /** @type {'merge'|'replace'} */ this._mode = 'merge'; /** @type {Array<{name: string, valid: boolean, error?: string}>} */ this._previewItems = []; /** @type {boolean} */ this._requiresConfirmation = false; // Event listener tracking for cleanup /** @type {Array<{element: Element, type: string, listener: EventListener}>} */ this._eventListeners = []; } static DEFAULT_OPTIONS = { id: 'scrying-pool-preset-import', classes: ['scrying-pool', 'dialog', 'preset-import'], window: { title: 'Import Scene Presets', resizable: false }, position: { width: 500, height: 'auto' }, }; static PARTS = { dialog: { template: 'modules/scrying-pool/templates/preset-import.hbs', }, }; /** * Get data for template rendering. * @returns {Promise} Template data. */ async _prepareContext() { const existingPresetCount = this._scenePresetManager.list().length; return { existingPresetCount, hasExistingPresets: existingPresetCount > 0, mode: this._mode, previewItems: this._previewItems, requiresConfirmation: this._requiresConfirmation, selectedFileName: this._selectedFile?.name ?? null, mergeLabel: 'Merge', replaceLabel: 'Replace', }; } /** * ApplicationV2 lifecycle — sets up event listeners on every render. * @inheritdoc */ _onRender(context, options) { super._onRender?.(context, options); const root = this.element; if (!root) return; // Clean up previous event listeners to prevent memory leaks for (const { element, type, listener } of this._eventListeners) { element.removeEventListener(type, listener); } this._eventListeners = []; // File input change handler const fileInput = root.querySelector('.sp-file-input'); if (fileInput) { const handler = (event) => this._onFileSelected(event); fileInput.addEventListener('change', handler); this._eventListeners.push({ element: fileInput, type: 'change', listener: handler }); } // Mode radio button handlers const mergeRadio = root.querySelector('.sp-mode-merge'); if (mergeRadio) { const handler = () => { this._mode = 'merge'; this._requiresConfirmation = false; this.render(); }; mergeRadio.addEventListener('change', handler); this._eventListeners.push({ element: mergeRadio, type: 'change', listener: handler }); } const replaceRadio = root.querySelector('.sp-mode-replace'); if (replaceRadio) { const handler = () => { this._mode = 'replace'; this._requiresConfirmation = true; this.render(); }; replaceRadio.addEventListener('change', handler); this._eventListeners.push({ element: replaceRadio, type: 'change', listener: handler }); } // Import button click handler const importBtn = root.querySelector('.sp-import-btn:not(.sp-confirm-btn)'); if (importBtn) { const handler = async (event) => { event.preventDefault(); await this._onImport(); }; importBtn.addEventListener('click', handler); this._eventListeners.push({ element: importBtn, type: 'click', listener: handler }); } // Confirm button handler (for replace mode) const confirmBtn = root.querySelector('.sp-confirm-btn'); if (confirmBtn) { const handler = async (event) => { event.preventDefault(); await this._onConfirmImport(); }; confirmBtn.addEventListener('click', handler); this._eventListeners.push({ element: confirmBtn, type: 'click', listener: handler }); } // Cancel button handler const cancelBtn = root.querySelector('.sp-cancel-btn'); if (cancelBtn) { const handler = (event) => { event.preventDefault(); this.close(); }; cancelBtn.addEventListener('click', handler); this._eventListeners.push({ element: cancelBtn, type: 'click', listener: handler }); } } /** * ApplicationV2 lifecycle — clean up event listeners when closed. * @inheritdoc */ async _onClose(options) { await super._onClose?.(options); // Clean up event listeners for (const { element, type, listener } of this._eventListeners) { try { element.removeEventListener(type, listener); } catch { // Ignore errors during cleanup } } this._eventListeners = []; // Clean up any references this._exportManager = null; this._scenePresetManager = null; this._adapter = null; this._selectedFile = null; this._previewItems = []; } /** * Handles file selection. * Validates the file and shows preview. * @param {Event} event - Change event from file input. * @private */ _onFileSelected(event) { const input = /** @type {HTMLInputElement} */ (event.target); if (!input.files || input.files.length === 0) { this._selectedFile = null; this._previewItems = []; this._requiresConfirmation = false; this.render(); return; } const file = input.files[0]; // Validate file type if (!ALLOWED_MIME_TYPES.has(file.type)) { // Check file extension as fallback const validExtensions = ['.json']; const hasValidExtension = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext)); if (!hasValidExtension) { if (this._adapter.notifications) { this._adapter.notifications.error('Please select a JSON file (.json extension required)'); } // Clear the input so user can select again input.value = ''; return; } } // Validate file size if (file.size > MAX_FILE_SIZE) { if (this._adapter.notifications) { this._adapter.notifications.error(`File is too large (${Math.round(file.size / 1024 / 1024)}MB). Maximum size is ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB.`); } input.value = ''; return; } this._selectedFile = file; this._previewItems = []; this._parseAndPreviewFile(); } /** * Parses the selected file and generates a preview. * @private */ async _parseAndPreviewFile() { if (!this._selectedFile) { this._previewItems = []; this._requiresConfirmation = false; this.render(); return; } try { const content = await this._readFileAsText(this._selectedFile); const data = JSON.parse(content); // Validate structure this._exportManager.validateImportData(data); // Generate preview items this._previewItems = []; const existingNames = new Set(this._scenePresetManager.list().map(p => p.name)); for (const [name] of Object.entries(data.presets || {})) { let valid = true; let error = undefined; try { // Check if preset name already exists (for merge mode preview) if (this._mode === 'merge' && existingNames.has(name)) { valid = false; error = 'Already exists - will be skipped'; } } catch (err) { valid = false; error = escapeHtml(err instanceof Error ? err.message : String(err)); } this._previewItems.push({ name, valid, error }); } // Determine if replace mode needs confirmation if (this._mode === 'replace' && existingNames.size > 0) { this._requiresConfirmation = true; } else if (this._mode === 'merge') { this._requiresConfirmation = false; } this.render(); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); this._previewItems = [{ name: this._selectedFile.name, valid: false, error: escapeHtml(errorMsg) }]; this._requiresConfirmation = false; this.render(); } } /** * Reads a file as text. * @param {File} file - File to read. * @returns {Promise} File content as text. * @private */ _readFileAsText(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (event) => { if (event.target && typeof event.target.result === 'string') { resolve(event.target.result); } else { reject(new Error('Failed to read file')); } }; reader.onerror = () => { reject(new Error('File read error')); }; reader.readAsText(file); }); } /** * Handles the import action. * For merge mode, processes immediately. For replace mode, shows confirmation. * @returns {Promise} * @private */ async _onImport() { if (!this._selectedFile) { if (this._adapter.notifications) { this._adapter.notifications.warn('Please select a file first'); } return; } if (this._mode === 'replace') { // Show confirmation for replace mode this._requiresConfirmation = true; this.render(); return; } // Direct import for merge mode await this._processImport(); } /** * Handles confirmed import (after user confirms replace mode). * @returns {Promise} * @private */ async _onConfirmImport() { await this._processImport(); } /** * Processes the import operation. * @returns {Promise} * @private */ async _processImport() { if (!this._selectedFile) { return; } const isReplaceMode = this._mode === 'replace'; const btn = this.element?.querySelector(isReplaceMode ? '.sp-confirm-btn' : '.sp-import-btn'); if (!btn) return; const originalLabel = btn.innerHTML; try { // Disable button and show loading state (using textContent for safety) btn.disabled = true; btn.textContent = ''; const spinner = document.createElement('i'); spinner.className = 'fas fa-spinner fa-spin'; const text = document.createTextNode(' Importing...'); btn.appendChild(spinner); btn.appendChild(text); // Read and import file const content = await this._readFileAsText(this._selectedFile); const result = await this._exportManager.importPresets(content, this._mode); if (result.success) { if (this._adapter.notifications) { this._adapter.notifications.info(result.message); } this.close(); } else { // Show errors const errorMessages = result.errors.map(e => escapeHtml(e)).join('\n'); if (this._adapter.notifications) { this._adapter.notifications.error('Failed to import presets\n' + errorMessages); } } } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); if (this._adapter.notifications) { this._adapter.notifications.error('Failed to import presets: ' + escapeHtml(errorMsg)); } } finally { btn.disabled = false; btn.innerHTML = originalLabel; } } }