diff --git a/lang/en.json b/lang/en.json index 5bb5c8d..bd360f0 100644 --- a/lang/en.json +++ b/lang/en.json @@ -135,34 +135,6 @@ } }, "SCRYING_POOL": { - "ExportPresetsTitle": "Export Scene Presets", - "ExportPresetsDescription": "Download all scene presets as a JSON file that can be imported into another world.", - "Scene": "Scene", - "PresetCount": "Presets", - "Filename": "Filename", - "Export": "Export", - "Cancel": "Cancel", - "Exporting": "Exporting…", - "ExportSuccess": "Scene presets exported successfully.", - "ExportFailed": "Failed to export presets", - "ImportPresetsTitle": "Import Scene Presets", - "ImportPresetsDescription": "Upload a JSON file containing scene presets to add to this scene.", - "SelectFile": "Select File", - "ChooseFile": "Choose a JSON file…", - "ImportMode": "Import Mode", - "ImportModeMerge": "Merge", - "ImportModeReplace": "Replace", - "ImportModeMergeHint": "Add new presets, skip duplicates", - "ImportModeReplaceHint": "Delete all existing presets and import new ones", - "PreviewTitle": "Preview", - "PresetExistsWillBeSkipped": "Already exists - will be skipped", - "Import": "Import", - "ConfirmReplace": "Replace All", - "Importing": "Importing…", - "ImportFailed": "Failed to import presets", - "SelectFileFirst": "Please select a file first", - "ExistingPresetsWarning": "This scene has {existingPresetCount} existing preset(s).", - "ReplaceConfirmation": "This will delete all {existingPresetCount} existing preset(s) and replace them with the imported ones. This cannot be undone.", "UnknownScene": "Unknown Scene", "firstBadgeEncounter": "First Badge Encounter" } diff --git a/src/core/PresetImportExportManager.js b/src/core/PresetImportExportManager.js index cba41b6..3958eb2 100644 --- a/src/core/PresetImportExportManager.js +++ b/src/core/PresetImportExportManager.js @@ -10,7 +10,7 @@ * @module core/PresetImportExportManager */ -import { isValidScenePreset, MAX_PRESETS_PER_WORLD } from '../contracts/scene-preset.js'; +import { isValidScenePreset, MAX_PRESETS_PER_WORLD, SCENE_PRESET_VERSION } from '../contracts/scene-preset.js'; /** * Result of an import operation. @@ -26,9 +26,9 @@ import { isValidScenePreset, MAX_PRESETS_PER_WORLD } from '../contracts/scene-pr /** * 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 {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. */ @@ -51,6 +51,19 @@ export class PresetImportExportManager { 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; } @@ -68,11 +81,11 @@ export class PresetImportExportManager { } const presets = this._scenePresetManager.list(); - const worldName = currentScene.name ?? 'unknown-world'; + const worldName = this._adapter.scenes.current?.()?.parent?.name ?? currentScene.name ?? 'world'; /** @type {ExportData} */ const exportData = { - _version: 1, + _version: SCENE_PRESET_VERSION, worldName, exportedAt: Date.now(), presets: {}, @@ -119,12 +132,16 @@ export class PresetImportExportManager { 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); + + try { + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + } finally { + document.body.removeChild(a); + URL.revokeObjectURL(url); + } } /** @@ -147,8 +164,8 @@ export class PresetImportExportManager { } // Check schema version - if (obj._version !== 1) { - throw new TypeError(`Import data: unsupported schema version ${obj._version}, expected 1`); + if (obj._version !== SCENE_PRESET_VERSION) { + throw new TypeError(`Import data: unsupported schema version ${obj._version}, expected ${SCENE_PRESET_VERSION}`); } // Check for presets @@ -156,30 +173,73 @@ export class PresetImportExportManager { 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 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 - 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'); + // 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 validated import data. + * Extracts and validates individual presets from import data. * - * @param {ExportData} data - Validated 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\s._-]+$/.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 }); @@ -203,6 +263,9 @@ export class PresetImportExportManager { * @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 @@ -214,8 +277,8 @@ export class PresetImportExportManager { let parsedData; try { parsedData = JSON.parse(jsonString); - } catch (err) { - throw new TypeError(`Import failed: invalid JSON format - ${err instanceof Error ? err.message : String(err)}`); + } catch { + throw new TypeError('Import failed: invalid JSON format'); } // Validate structure @@ -286,6 +349,7 @@ export class PresetImportExportManager { /** * 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. @@ -303,8 +367,8 @@ export class PresetImportExportManager { skipped++; } else { try { - // Add preset directly to cache - this._scenePresetManager._presetsCache.set(name, preset); + // 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)}`); @@ -312,14 +376,7 @@ export class PresetImportExportManager { } } - // 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)`; + const message = `Imported ${validPresets.length} presets (${added} new, ${skipped} skipped as duplicates)`; return { success: errors.length === 0, message, @@ -332,6 +389,8 @@ export class PresetImportExportManager { /** * 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. @@ -342,35 +401,56 @@ export class PresetImportExportManager { async _replacePresets(data, validPresets, existingCount) { let added = 0; const errors = []; + /** @type {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset}>} */ + const deletedPresets = []; - // Clear existing presets - const existingNames = Array.from(this._scenePresetManager._presetsCache.keys()); + // 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)}`); } } - // Add all imported presets + // Step 2: Add all imported presets + const successfullyAdded = []; for (const { name, preset } of validPresets) { try { - this._scenePresetManager._presetsCache.set(name, preset); + 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)}`); } } - // Persist changes - try { - await this._scenePresetManager._saveScenePresets(); - } catch (err) { - errors.push(`Failed to persist replaced presets: ${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 = `Replaced all ${existingCount} presets with ${added} from import`; + 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, diff --git a/src/ui/gm/PresetExportDialog.js b/src/ui/gm/PresetExportDialog.js index e87497f..66753ab 100644 --- a/src/ui/gm/PresetExportDialog.js +++ b/src/ui/gm/PresetExportDialog.js @@ -8,6 +8,7 @@ */ import { PresetImportExportManager } from '../../core/PresetImportExportManager.js'; +import { escapeHtml } from '../../utils/html.js'; // Conditional base class — test environment lacks foundry globals. // At module load time in tests, foundry is undefined → fallback class is used. @@ -48,6 +49,9 @@ export class PresetExportDialog extends _AppBase { constructor(options = {}) { super(options); + if (!options || typeof options !== 'object') { + throw new TypeError('PresetExportDialog: options argument is required and must be an object'); + } if (!options.adapter || typeof options.adapter !== 'object') { throw new TypeError('PresetExportDialog: adapter option is required and must be an object'); } @@ -58,6 +62,10 @@ export class PresetExportDialog extends _AppBase { this._adapter = options.adapter; this._scenePresetManager = options.scenePresetManager; this._exportManager = new PresetImportExportManager(this._adapter, this._scenePresetManager); + + // Event listener tracking for cleanup + /** @type {Array<{element: Element, type: string, listener: EventListener}>} */ + this._eventListeners = []; } static DEFAULT_OPTIONS = { @@ -98,11 +106,22 @@ export class PresetExportDialog extends _AppBase { 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 = []; + // Export button click handler - root.querySelector('.sp-export-btn')?.addEventListener('click', async (event) => { - event.preventDefault(); - await this._onExport(); - }); + const exportBtn = root.querySelector('.sp-export-btn'); + if (exportBtn) { + const handler = async (event) => { + event.preventDefault(); + await this._onExport(); + }; + exportBtn.addEventListener('click', handler); + this._eventListeners.push({ element: exportBtn, type: 'click', listener: handler }); + } } /** @@ -111,6 +130,17 @@ export class PresetExportDialog extends _AppBase { */ 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; @@ -130,15 +160,20 @@ export class PresetExportDialog extends _AppBase { const originalLabel = btn.innerHTML; try { - // Disable button and show loading state + // Disable button and show loading state (using DOM methods for safety) btn.disabled = true; - btn.innerHTML = ' Exporting...'; + btn.textContent = ''; + const spinner = document.createElement('i'); + spinner.className = 'fas fa-spinner fa-spin'; + const text = document.createTextNode(' Exporting...'); + btn.appendChild(spinner); + btn.appendChild(text); // Export presets const jsonString = await this._exportManager.exportAllPresets(); const currentScene = this._adapter.scenes.current?.(); - const sceneName = currentScene?.name ?? 'world'; - const filename = this._exportManager.generateExportFilename(sceneName); + const worldName = this._adapter.scenes.current?.()?.parent?.name ?? currentScene?.name ?? 'world'; + const filename = this._exportManager.generateExportFilename(worldName); // Trigger download this._exportManager.downloadExportFile(jsonString, filename); @@ -153,7 +188,7 @@ export class PresetExportDialog extends _AppBase { } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); if (this._adapter.notifications) { - this._adapter.notifications.error('Failed to export presets: ' + errorMsg); + this._adapter.notifications.error('Failed to export presets: ' + escapeHtml(errorMsg)); } } finally { if (btn) { diff --git a/src/ui/gm/PresetImportDialog.js b/src/ui/gm/PresetImportDialog.js index 9f0a001..04dbb02 100644 --- a/src/ui/gm/PresetImportDialog.js +++ b/src/ui/gm/PresetImportDialog.js @@ -8,6 +8,12 @@ */ 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. @@ -48,6 +54,9 @@ export class PresetImportDialog extends _AppBase { 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'); } @@ -68,6 +77,10 @@ export class PresetImportDialog extends _AppBase { 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 = { @@ -111,41 +124,75 @@ export class PresetImportDialog extends _AppBase { 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 - root.querySelector('.sp-file-input')?.addEventListener('change', (event) => { - this._onFileSelected(event); - }); + 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 - root.querySelector('.sp-mode-merge')?.addEventListener('change', () => { - this._mode = 'merge'; - this._requiresConfirmation = false; - this.render(); - }); + 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 }); + } - root.querySelector('.sp-mode-replace')?.addEventListener('change', () => { - this._mode = 'replace'; - this._requiresConfirmation = true; - this.render(); - }); + 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 - root.querySelector('.sp-import-btn:not(.sp-confirm-btn)')?.addEventListener('click', async (event) => { - event.preventDefault(); - await this._onImport(); - }); + 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) - root.querySelector('.sp-confirm-btn')?.addEventListener('click', async (event) => { - event.preventDefault(); - await this._onConfirmImport(); - }); + 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 - root.querySelector('.sp-cancel-btn')?.addEventListener('click', (event) => { - event.preventDefault(); - this.close(); - }); + 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 }); + } } /** @@ -154,6 +201,17 @@ export class PresetImportDialog extends _AppBase { */ 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; @@ -178,7 +236,33 @@ export class PresetImportDialog extends _AppBase { return; } - this._selectedFile = input.files[0]; + 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(); } @@ -218,7 +302,7 @@ export class PresetImportDialog extends _AppBase { } } catch (err) { valid = false; - error = err instanceof Error ? err.message : String(err); + error = escapeHtml(err instanceof Error ? err.message : String(err)); } this._previewItems.push({ name, valid, error }); @@ -234,7 +318,7 @@ export class PresetImportDialog extends _AppBase { this.render(); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); - this._previewItems = [{ name: this._selectedFile.name, valid: false, error: errorMsg }]; + this._previewItems = [{ name: this._selectedFile.name, valid: false, error: escapeHtml(errorMsg) }]; this._requiresConfirmation = false; this.render(); } @@ -314,9 +398,14 @@ export class PresetImportDialog extends _AppBase { const originalLabel = btn.innerHTML; try { - // Disable button and show loading state + // Disable button and show loading state (using textContent for safety) btn.disabled = true; - btn.innerHTML = ' Importing...'; + 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); @@ -329,7 +418,7 @@ export class PresetImportDialog extends _AppBase { this.close(); } else { // Show errors - const errorMessages = result.errors.join('\n'); + const errorMessages = result.errors.map(e => escapeHtml(e)).join('\n'); if (this._adapter.notifications) { this._adapter.notifications.error('Failed to import presets\n' + errorMessages); } @@ -337,7 +426,7 @@ export class PresetImportDialog extends _AppBase { } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); if (this._adapter.notifications) { - this._adapter.notifications.error('Failed to import presets: ' + errorMsg); + this._adapter.notifications.error('Failed to import presets: ' + escapeHtml(errorMsg)); } } finally { btn.disabled = false; diff --git a/src/utils/html.js b/src/utils/html.js new file mode 100644 index 0000000..5a06f73 --- /dev/null +++ b/src/utils/html.js @@ -0,0 +1,47 @@ +/** + * HTML escaping utilities for safe DOM manipulation. + * + * Import rule: may only import from src/contracts/. + * Constructors are side-effect free. + * + * @module utils/html + */ + +/** + * Escapes HTML special characters to prevent XSS injection. + * + * @param {string} str - String to escape. + * @returns {string} HTML-escaped string safe for innerHTML. + */ +export function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str).replace(/[&<>"']/g, (c) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[c])); +} + +/** + * Safely sets innerHTML with escaped content. + * + * @param {HTMLElement} element - Target DOM element. + * @param {string} html - HTML content to set (will be escaped). + * @returns {void} + */ +export function safeInnerHTML(element, html) { + if (!element) return; + element.innerHTML = escapeHtml(html); +} + +/** + * Creates a text node with escaped content. + * + * @param {string} text - Text content. + * @returns {Text} Text node. + */ +export function safeText(text) { + return document.createTextNode(escapeHtml(text)); +} diff --git a/templates/preset-export.hbs b/templates/preset-export.hbs index 6a44125..0b552ea 100644 --- a/templates/preset-export.hbs +++ b/templates/preset-export.hbs @@ -1,29 +1,29 @@

- {{localize "SCRYING_POOL.ExportPresetsDescription"}} + {{localize "video-view-manager.presetExport.description"}}

- {{localize "SCRYING_POOL.Scene"}}: + {{localize "video-view-manager.presetExport.scene"}}: {{sceneName}}
- {{localize "SCRYING_POOL.PresetCount"}}: + {{localize "video-view-manager.presetExport.presetCount"}}: {{presetCount}}
- {{localize "SCRYING_POOL.Filename"}}: + {{localize "video-view-manager.presetExport.filename"}}: {{filename}}
diff --git a/templates/preset-import.hbs b/templates/preset-import.hbs index 531f3b3..b137b57 100644 --- a/templates/preset-import.hbs +++ b/templates/preset-import.hbs @@ -1,23 +1,23 @@

- {{localize "SCRYING_POOL.ImportPresetsDescription"}} + {{localize "video-view-manager.presetImport.description"}}

{{#if hasExistingPresets}}
- {{localize "SCRYING_POOL.ExistingPresetsWarning" existingPresetCount=existingPresetCount}} + {{localize "video-view-manager.presetImport.existingPresetsWarning" existingPresetCount=existingPresetCount}}
{{/if}} {{!-- File Selection --}}
- +
{{#if selectedFileName}} @@ -30,17 +30,17 @@ {{!-- Mode Selection --}}
- +
@@ -48,7 +48,7 @@ {{!-- Preview Section --}} {{#if previewItems.length}}
-

{{localize "SCRYING_POOL.PreviewTitle"}}

+

{{localize "video-view-manager.presetImport.previewTitle"}}

    {{#each previewItems as |item|}}
  • @@ -68,7 +68,7 @@
    - {{localize "SCRYING_POOL.ReplaceConfirmation" existingPresetCount=existingPresetCount}} + {{localize "video-view-manager.presetImport.replaceConfirmation" existingPresetCount=existingPresetCount}}
    {{/if}} @@ -77,14 +77,14 @@
    {{#unless requiresConfirmation}} {{else}} {{/unless}}