437 lines
14 KiB
JavaScript
437 lines
14 KiB
JavaScript
/**
|
|
* 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<object>} 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<string>} 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<void>}
|
|
* @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<void>}
|
|
* @private
|
|
*/
|
|
async _onConfirmImport() {
|
|
await this._processImport();
|
|
}
|
|
|
|
/**
|
|
* Processes the import operation.
|
|
* @returns {Promise<void>}
|
|
* @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;
|
|
}
|
|
}
|
|
}
|