Complete Story 3.3: Preset Import & Export
Implements FR-19: Preset import/export as JSON New Files: - src/core/PresetImportExportManager.js - Core logic for export/import with merge/replace - src/ui/gm/PresetExportDialog.js - Export dialog with file download - src/ui/gm/PresetImportDialog.js - Import dialog with file picker, preview, merge/replace - templates/preset-export.hbs - Export dialog template - templates/preset-import.hbs - Import dialog template - styles/components/_preset-import-export.less - Dialog styles - tests/unit/core/PresetImportExportManager.test.js - 38 unit tests - _bmad-output/implementation-artifacts/3-3-preset-import-and-export.md - Story file Modified Files: - src/ui/gm/DirectorsBoard.js - Added export/import button handlers - templates/directors-board.hbs - Added Export/Import buttons to footer - styles/scrying-pool.less - Added stylesheet import - lang/en.json - Added localization strings for new UI - _bmad-output/implementation-artifacts/sprint-status.yaml - Story status: review Features: - Export all presets from current scene as JSON file - Import presets with merge (add new, skip duplicates) or replace (overwrite all) modes - Preview of presets before import with validation status - Confirmation dialog for replace mode to prevent data loss - Comprehensive error handling and validation - All ACs satisfied (AC-9 deferred for README docs) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* 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 } 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 {1} _version - Export schema version.
|
||||
* @property {string} worldName - Name of the world being exported from.
|
||||
* @property {number} exportedAt - Timestamp when export was created.
|
||||
* @property {Object.<string, import('../contracts/scene-preset.js').ScenePreset>} 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');
|
||||
}
|
||||
|
||||
this._adapter = adapter;
|
||||
this._scenePresetManager = scenePresetManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports all presets from the current scene as a JSON string.
|
||||
*
|
||||
* @returns {Promise<string>} 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 = currentScene.name ?? 'unknown-world';
|
||||
|
||||
/** @type {ExportData} */
|
||||
const exportData = {
|
||||
_version: 1,
|
||||
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 name = worldName ?? this._adapter.scenes.current?.()?.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');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
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<string, unknown>} */ (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 !== 1) {
|
||||
throw new TypeError(`Import data: unsupported schema version ${obj._version}, expected 1`);
|
||||
}
|
||||
|
||||
// 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
|
||||
if (obj.worldName !== undefined && typeof obj.worldName !== 'string') {
|
||||
throw new TypeError('Import data: worldName must be a string if present');
|
||||
}
|
||||
|
||||
// Validate optional exportedAt
|
||||
if (obj.exportedAt !== undefined && (typeof obj.exportedAt !== 'number' || !Number.isFinite(obj.exportedAt))) {
|
||||
throw new TypeError('Import data: exportedAt must be a finite number if present');
|
||||
}
|
||||
|
||||
return /** @type {ExportData} */ (obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and validates individual presets from validated import data.
|
||||
*
|
||||
* @param {ExportData} data - Validated import data.
|
||||
* @returns {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset|null, error: string|null}>} Array of extraction results.
|
||||
* @private
|
||||
*/
|
||||
_extractAndValidatePresets(data) {
|
||||
const results = [];
|
||||
|
||||
for (const [name, presetData] of Object.entries(data.presets)) {
|
||||
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<ImportResult>} Result of the import operation.
|
||||
* @throws {TypeError} If JSON parsing fails or mode is invalid.
|
||||
*/
|
||||
async importPresets(jsonString, mode, options = {}) {
|
||||
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 (err) {
|
||||
throw new TypeError(`Import failed: invalid JSON format - ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Check if we would exceed max presets in merge mode
|
||||
const existingCount = this._scenePresetManager.list().length;
|
||||
const newCount = validPresets.length;
|
||||
|
||||
if (mode === 'merge' && existingCount + newCount > MAX_PRESETS_PER_WORLD) {
|
||||
errors.push(`Import would exceed maximum of ${MAX_PRESETS_PER_WORLD} presets (currently ${existingCount}, adding ${newCount})`);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Import cancelled: would exceed 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];
|
||||
return result;
|
||||
}
|
||||
const result = await this._mergePresets(data, validPresets);
|
||||
// Merge extraction errors with merge errors
|
||||
result.errors = [...errors, ...result.errors];
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges presets into current scene - adds new presets, skips duplicates.
|
||||
*
|
||||
* @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.
|
||||
* @returns {Promise<ImportResult>} Result of the merge operation.
|
||||
* @private
|
||||
*/
|
||||
async _mergePresets(data, validPresets) {
|
||||
const existingPresetNames = new Set(this._scenePresetManager.list().map(p => p.name));
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const { name, preset } of validPresets) {
|
||||
if (existingPresetNames.has(name)) {
|
||||
skipped++;
|
||||
} else {
|
||||
try {
|
||||
// Add preset directly to cache
|
||||
this._scenePresetManager._presetsCache.set(name, preset);
|
||||
added++;
|
||||
} catch (err) {
|
||||
errors.push(`Failed to add preset "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist changes after all presets processed
|
||||
try {
|
||||
await this._scenePresetManager._saveScenePresets();
|
||||
} catch (err) {
|
||||
errors.push(`Failed to persist merged presets: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
const message = `Imported ${added} presets (${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.
|
||||
*
|
||||
* @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<ImportResult>} Result of the replace operation.
|
||||
* @private
|
||||
*/
|
||||
async _replacePresets(data, validPresets, existingCount) {
|
||||
let added = 0;
|
||||
const errors = [];
|
||||
|
||||
// Clear existing presets
|
||||
const existingNames = Array.from(this._scenePresetManager._presetsCache.keys());
|
||||
for (const name of existingNames) {
|
||||
try {
|
||||
await this._scenePresetManager.delete(name);
|
||||
} catch (err) {
|
||||
errors.push(`Failed to delete existing preset "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add all imported presets
|
||||
for (const { name, preset } of validPresets) {
|
||||
try {
|
||||
this._scenePresetManager._presetsCache.set(name, preset);
|
||||
added++;
|
||||
} catch (err) {
|
||||
errors.push(`Failed to add preset "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist changes
|
||||
try {
|
||||
await this._scenePresetManager._saveScenePresets();
|
||||
} catch (err) {
|
||||
errors.push(`Failed to persist replaced presets: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
const message = `Replaced all ${existingCount} presets with ${added} from import`;
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
message,
|
||||
added,
|
||||
replaced: existingCount,
|
||||
skipped: 0,
|
||||
errors: errors.length > 0 ? errors : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user