/** * PresetImportExportManager — Handles import and export of scene presets. * * Provides: JSON export of all presets, file download, JSON import with validation, * merge/replace modes, and comprehensive error handling. * * Import rule: may only import from src/contracts/. * Constructors are side-effect free — call init() from module.js Hooks.once('ready'). * * @module core/PresetImportExportManager */ import { isValidScenePreset, MAX_PRESETS_PER_WORLD, SCENE_PRESET_VERSION } from '../contracts/scene-preset.js'; /** * Result of an import operation. * @typedef {Object} ImportResult * @property {boolean} success - Whether the import operation succeeded. * @property {string} message - Human-readable summary message. * @property {number} added - Number of presets added (merge mode). * @property {number} replaced - Number of presets replaced (replace mode). * @property {number} skipped - Number of presets skipped (duplicates in merge mode). * @property {string[]} errors - Array of error messages for failed validations. */ /** * Export data structure. * @typedef {Object} ExportData * @property {number} _version - Export schema version. * @property {string} [worldName] - Name of the world being exported from (optional). * @property {number} [exportedAt] - Timestamp when export was created (optional). * @property {Object.} presets - All presets by name. */ /** * Manages import and export of scene presets. * Provides JSON export, file download, and import with merge/replace modes. */ export class PresetImportExportManager { /** * @param {import('../foundry/FoundryAdapter.js').FoundryAdapter} adapter * Injected FoundryAdapter surface. * @param {import('./ScenePresetManager.js').ScenePresetManager} scenePresetManager * Injected ScenePresetManager for preset operations. */ constructor(adapter, scenePresetManager) { if (!adapter || typeof adapter !== 'object') { throw new TypeError('PresetImportExportManager: adapter argument is required and must be an object'); } if (!scenePresetManager || typeof scenePresetManager !== 'object') { throw new TypeError('PresetImportExportManager: scenePresetManager argument is required and must be an object'); } // Validate adapter has required methods if (typeof adapter.scenes?.current !== 'function') { throw new TypeError('PresetImportExportManager: adapter.scenes.current must be a function'); } // Validate scenePresetManager has required methods const requiredMethods = ['list', 'save', 'delete', 'get']; for (const method of requiredMethods) { if (typeof scenePresetManager[method] !== 'function') { throw new TypeError(`PresetImportExportManager: scenePresetManager.${method} must be a function`); } } this._adapter = adapter; this._scenePresetManager = scenePresetManager; } /** * Exports all presets from the current scene as a JSON string. * * @returns {Promise} JSON string containing all presets. * @throws {TypeError} If no scene is active. */ async exportAllPresets() { const currentScene = this._adapter.scenes.current?.(); if (!currentScene) { throw new TypeError('PresetImportExportManager.exportAllPresets: no active scene'); } const presets = this._scenePresetManager.list(); const worldName = this._adapter.scenes.current?.()?.parent?.name ?? currentScene.name ?? 'world'; /** @type {ExportData} */ const exportData = { _version: SCENE_PRESET_VERSION, worldName, exportedAt: Date.now(), presets: {}, }; for (const preset of presets) { exportData.presets[preset.name] = preset; } return JSON.stringify(exportData, null, 2); } /** * Generates a filename for the export file. * * @param {string} [worldName] - Name of the world. Defaults to current scene name or 'world'. * @param {boolean} [includeTimestamp=true] - Whether to include timestamp in filename. * @returns {string} Generated filename. */ generateExportFilename(worldName, includeTimestamp = true) { const currentScene = this._adapter.scenes.current?.(); const name = worldName ?? currentScene?.parent?.name ?? currentScene?.name ?? 'world'; // Sanitize name: replace any non-alphanumeric, dash, or underscore with underscore // Also handle empty string by using 'world' as fallback const safeName = name.replace(/[^a-zA-Z0-9\-_]/g, '_').toLowerCase() || 'world'; const timestamp = includeTimestamp ? `_${Date.now()}` : ''; return `scrying-pool-presets-${safeName}${timestamp}.json`; } /** * Triggers a browser download of the export file. * * @param {string} jsonString - JSON string to download. * @param {string} filename - Name for the downloaded file. * @throws {TypeError} If arguments are invalid. */ downloadExportFile(jsonString, filename) { if (typeof jsonString !== 'string') { throw new TypeError('PresetImportExportManager.downloadExportFile: jsonString must be a string'); } if (typeof filename !== 'string' || filename.length === 0) { throw new TypeError('PresetImportExportManager.downloadExportFile: filename must be a non-empty string'); } const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); try { a.href = url; a.download = filename; document.body.appendChild(a); a.click(); } finally { document.body.removeChild(a); URL.revokeObjectURL(url); } } /** * Validates imported JSON data structure. * * @param {unknown} data - Parsed JSON data to validate. * @returns {ExportData} Validated export data object. * @throws {TypeError} If data structure is invalid. */ validateImportData(data) { if (data === null || typeof data !== 'object') { throw new TypeError('Import data must be a non-null object'); } const obj = /** @type {Record} */ (data); // Check for _version if (typeof obj._version !== 'number') { throw new TypeError(`Import data: missing or invalid _version, got ${typeof obj._version}`); } // Check schema version if (obj._version !== SCENE_PRESET_VERSION) { throw new TypeError(`Import data: unsupported schema version ${obj._version}, expected ${SCENE_PRESET_VERSION}`); } // Check for presets if (!obj.presets || typeof obj.presets !== 'object' || Array.isArray(obj.presets)) { throw new TypeError('Import data: missing or invalid presets object'); } // Validate optional worldName - must be non-empty string if present if (obj.worldName !== undefined) { if (typeof obj.worldName !== 'string') { throw new TypeError('Import data: worldName must be a string if present'); } if (obj.worldName.length === 0) { throw new TypeError('Import data: worldName must be a non-empty string if present'); } if (obj.worldName.length > 250) { throw new TypeError('Import data: worldName exceeds maximum length of 250 characters'); } } // Validate optional exportedAt - must be non-negative finite number if present if (obj.exportedAt !== undefined) { if (typeof obj.exportedAt !== 'number' || !Number.isFinite(obj.exportedAt)) { throw new TypeError('Import data: exportedAt must be a finite number if present'); } if (obj.exportedAt < 0) { throw new TypeError('Import data: exportedAt must be a non-negative number if present'); } } return /** @type {ExportData} */ (obj); } /** * Extracts and validates individual presets from import data. * * @param {ExportData} data - Import data (may be partially validated if skipValidation was used). * @returns {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset|null, error: string|null}>} Array of extraction results. * @private */ _extractAndValidatePresets(data) { const results = []; const seenNames = new Set(); // Handle missing or invalid presets if (!data.presets || typeof data.presets !== 'object' || Array.isArray(data.presets)) { return results; // Return empty array - errors will be caught by validation } for (const [name, presetData] of Object.entries(data.presets)) { // Check for duplicate names in the import file if (seenNames.has(name)) { results.push({ name, preset: null, error: `Preset "${name}": duplicate name in import file` }); continue; } seenNames.add(name); // Validate preset name if (typeof name !== 'string' || name.length === 0) { results.push({ name: String(name), preset: null, error: `Preset: name must be a non-empty string, got "${String(name)}"` }); continue; } if (name.length > 100) { results.push({ name, preset: null, error: `Preset "${name}": name exceeds maximum length of 100 characters` }); continue; } // Validate preset name characters (alphanumeric, dash, underscore, space, dot) if (!/^[a-zA-Z0-9 ._-]+$/.test(name)) { results.push({ name, preset: null, error: `Preset "${name}": name contains invalid characters (only alphanumeric, space, dot, dash, underscore allowed)` }); continue; } try { const validated = isValidScenePreset(presetData); results.push({ name, preset: validated, error: null }); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); results.push({ name, preset: null, error: `Preset "${name}": ${errorMsg}` }); } } return results; } /** * Imports presets from JSON string with specified mode. * * @param {string} jsonString - JSON string containing export data. * @param {'merge'|'replace'} mode - Import mode: 'merge' adds new presets, 'replace' overwrites all. * @param {object} [options] - Import options. * @param {boolean} [options.skipValidation=false] - Skip JSON structure validation (for testing). * @returns {Promise} Result of the import operation. * @throws {TypeError} If JSON parsing fails or mode is invalid. */ async importPresets(jsonString, mode, options = {}) { if (!options || typeof options !== 'object') { throw new TypeError('PresetImportExportManager.importPresets: options must be an object'); } const { skipValidation = false } = options; // Validate mode if (mode !== 'merge' && mode !== 'replace') { throw new TypeError('PresetImportExportManager.importPresets: mode must be "merge" or "replace"'); } // Parse JSON let parsedData; try { parsedData = JSON.parse(jsonString); } catch { throw new TypeError('Import failed: invalid JSON format'); } // Validate structure if (!skipValidation) { try { this.validateImportData(parsedData); } catch (err) { throw new TypeError(`Import failed: ${err instanceof Error ? err.message : String(err)}`); } } /** @type {ExportData} */ const data = parsedData; // Extract and validate presets const extractionResults = this._extractAndValidatePresets(data); const validPresets = extractionResults.filter(r => r.error === null); const errors = extractionResults.filter(r => r.error !== null).map(r => r.error); // Get existing preset names for duplicate detection and limit checking const existingPresetNames = new Set(this._scenePresetManager.list().map(p => p.name)); const existingCount = existingPresetNames.size; // Count how many presets would actually be added (excluding duplicates in merge mode) let netNewCount = validPresets.length; if (mode === 'merge') { // Count only presets that don't already exist netNewCount = validPresets.filter(r => !existingPresetNames.has(r.name)).length; } // Check preset limits if (mode === 'merge' && existingCount + netNewCount > MAX_PRESETS_PER_WORLD) { errors.push(`Import would exceed maximum of ${MAX_PRESETS_PER_WORLD} presets (currently ${existingCount}, adding ${netNewCount} new)`); return { success: false, message: 'Import cancelled: would exceed preset limit', added: 0, replaced: 0, skipped: 0, errors, }; } if (mode === 'replace' && validPresets.length > MAX_PRESETS_PER_WORLD) { errors.push(`Import file contains ${validPresets.length} presets, exceeding maximum of ${MAX_PRESETS_PER_WORLD}`); return { success: false, message: 'Import cancelled: file exceeds preset limit', added: 0, replaced: 0, skipped: 0, errors, }; } // Process based on mode if (mode === 'replace') { const result = await this._replacePresets(data, validPresets, existingCount); // Merge extraction errors with replace errors result.errors = [...errors, ...result.errors]; result.success = result.errors.length === 0; return result; } const result = await this._mergePresets(data, validPresets, existingPresetNames); // Merge extraction errors with merge errors result.errors = [...errors, ...result.errors]; result.success = result.errors.length === 0; return result; } /** * Merges presets into current scene - adds new presets, skips duplicates. * Uses public API to ensure proper validation and persistence. * * @param {ExportData} data - Validated import data. * @param {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset|null, error: string|null}>} validPresets - Validated presets to import. * @param {Set} existingPresetNames - Set of existing preset names for duplicate detection. * @returns {Promise} Result of the merge operation. * @private */ async _mergePresets(data, validPresets, existingPresetNames) { let added = 0; let skipped = 0; const errors = []; for (const { name, preset } of validPresets) { if (existingPresetNames.has(name)) { skipped++; } else { try { // Use public save method instead of direct cache manipulation await this._scenePresetManager.save(name, preset); added++; } catch (err) { errors.push(`Failed to add preset "${name}": ${err instanceof Error ? err.message : String(err)}`); } } } const message = `Imported ${validPresets.length} presets (${added} new, ${skipped} skipped as duplicates)`; return { success: errors.length === 0, message, added, replaced: 0, skipped, errors: errors.length > 0 ? errors : [], }; } /** * Replaces all existing presets with imported ones. * Uses a rollback mechanism: if any add fails after deletions, attempts to restore. * Uses public API to ensure proper validation and persistence. * * @param {ExportData} data - Validated import data. * @param {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset|null, error: string|null}>} validPresets - Validated presets to import. * @param {number} existingCount - Number of existing presets before replacement. * @returns {Promise} Result of the replace operation. * @private */ async _replacePresets(data, validPresets, existingCount) { let added = 0; const errors = []; /** @type {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset}>} */ const deletedPresets = []; // Step 1: Delete all existing presets, saving them for potential rollback const existingNames = this._scenePresetManager.list().map(p => p.name); for (const name of existingNames) { try { const preset = this._scenePresetManager.get(name); if (preset) { deletedPresets.push({ name, preset }); } await this._scenePresetManager.delete(name); } catch (err) { errors.push(`Failed to delete existing preset "${name}": ${err instanceof Error ? err.message : String(err)}`); } } // Step 2: Add all imported presets const successfullyAdded = []; for (const { name, preset } of validPresets) { try { await this._scenePresetManager.save(name, preset); successfullyAdded.push({ name, preset }); added++; } catch (err) { errors.push(`Failed to add preset "${name}": ${err instanceof Error ? err.message : String(err)}`); } } // Step 3: If there were errors, attempt rollback if (errors.length > 0 && deletedPresets.length > 0) { const rollbackErrors = []; for (const { name, preset } of deletedPresets) { try { await this._scenePresetManager.save(name, preset); } catch (err) { rollbackErrors.push(`Failed to rollback preset "${name}": ${err instanceof Error ? err.message : String(err)}`); } } if (rollbackErrors.length > 0) { errors.push(...rollbackErrors); } else { errors.push('Partial import failure - rolled back to previous state'); } } const message = errors.length === 0 ? `Replaced all presets with ${added} from import` : `Partial import: added ${added} of ${validPresets.length} (rolled back)`; return { success: errors.length === 0, message, added, replaced: existingCount, skipped: 0, errors: errors.length > 0 ? errors : [], }; } }