Story 3.3 done

This commit is contained in:
2026-05-25 10:32:49 +02:00
parent 7b56d62563
commit 748c7d7f85
12 changed files with 451 additions and 105 deletions
+6 -3
View File
@@ -106,10 +106,11 @@ export class PresetImportExportManager {
* @returns {string} Generated filename.
*/
generateExportFilename(worldName, includeTimestamp = true) {
const name = worldName ?? this._adapter.scenes.current?.()?.name ?? 'world';
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 safeName = name.replace(/[^a-zA-Z0-9\-_]/g, '_').toLowerCase() || 'world';
const timestamp = includeTimestamp ? `_${Date.now()}` : '';
return `scrying-pool-presets-${safeName}${timestamp}.json`;
}
@@ -235,7 +236,7 @@ export class PresetImportExportManager {
}
// Validate preset name characters (alphanumeric, dash, underscore, space, dot)
if (!/^[a-zA-Z0-9\s._-]+$/.test(name)) {
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;
}
@@ -339,11 +340,13 @@ export class PresetImportExportManager {
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;
}
+6 -5
View File
@@ -93,7 +93,8 @@ export class PresetExportDialog extends _AppBase {
return {
presetCount,
sceneName,
filename: this._exportManager.generateExportFilename(sceneName, false),
hasScene: !!currentScene,
filename: this._exportManager.generateExportFilename(sceneName),
};
}
@@ -165,14 +166,14 @@ export class PresetExportDialog extends _AppBase {
btn.textContent = '';
const spinner = document.createElement('i');
spinner.className = 'fas fa-spinner fa-spin';
const text = document.createTextNode(' Exporting...');
const text = document.createTextNode(' ' + this._adapter.i18n.localize('scrying-pool.presetExport.exporting'));
btn.appendChild(spinner);
btn.appendChild(text);
// Export presets
const jsonString = await this._exportManager.exportAllPresets();
const currentScene = this._adapter.scenes.current?.();
const worldName = this._adapter.scenes.current?.()?.parent?.name ?? currentScene?.name ?? 'world';
const worldName = currentScene?.parent?.name ?? currentScene?.name ?? 'world';
const filename = this._exportManager.generateExportFilename(worldName);
// Trigger download
@@ -180,7 +181,7 @@ export class PresetExportDialog extends _AppBase {
// Show success notification
if (this._adapter.notifications) {
this._adapter.notifications.info('Scene presets exported successfully.');
this._adapter.notifications.info(this._adapter.i18n.localize('scrying-pool.presetExport.exportSuccess'));
}
// Close dialog
@@ -188,7 +189,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: ' + escapeHtml(errorMsg));
this._adapter.notifications.error(this._adapter.i18n.localize('scrying-pool.presetExport.exportFailed') + ': ' + escapeHtml(errorMsg));
}
} finally {
if (btn) {
+37 -22
View File
@@ -8,6 +8,7 @@
*/
import { PresetImportExportManager } from '../../core/PresetImportExportManager.js';
import { isValidScenePreset } from '../../contracts/scene-preset.js';
import { escapeHtml } from '../../utils/html.js';
// Maximum file size: 5MB
@@ -78,6 +79,10 @@ export class PresetImportDialog extends _AppBase {
/** @type {boolean} */
this._requiresConfirmation = false;
// Concurrency guard for async file operations
/** @type {boolean} */
this._parsingInProgress = false;
// Event listener tracking for cleanup
/** @type {Array<{element: Element, type: string, listener: EventListener}>} */
this._eventListeners = [];
@@ -110,8 +115,8 @@ export class PresetImportDialog extends _AppBase {
previewItems: this._previewItems,
requiresConfirmation: this._requiresConfirmation,
selectedFileName: this._selectedFile?.name ?? null,
mergeLabel: 'Merge',
replaceLabel: 'Replace',
mergeLabel: this._adapter.i18n.localize('scrying-pool.presetImport.importModeMerge'),
replaceLabel: this._adapter.i18n.localize('scrying-pool.presetImport.importModeReplace'),
};
}
@@ -272,14 +277,17 @@ export class PresetImportDialog extends _AppBase {
* @private
*/
async _parseAndPreviewFile() {
if (!this._selectedFile) {
this._previewItems = [];
this._requiresConfirmation = false;
this.render();
return;
}
if (this._parsingInProgress) return;
this._parsingInProgress = true;
try {
if (!this._selectedFile) {
this._previewItems = [];
this._requiresConfirmation = false;
this.render();
return;
}
const content = await this._readFileAsText(this._selectedFile);
const data = JSON.parse(content);
@@ -290,19 +298,24 @@ export class PresetImportDialog extends _AppBase {
this._previewItems = [];
const existingNames = new Set(this._scenePresetManager.list().map(p => p.name));
for (const [name] of Object.entries(data.presets || {})) {
for (const [name, presetData] 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) {
// Check if preset name already exists (for merge mode preview)
if (this._mode === 'merge' && existingNames.has(name)) {
valid = false;
error = escapeHtml(err instanceof Error ? err.message : String(err));
error = this._adapter.i18n.localize('scrying-pool.presetImport.previewWillSkip');
}
// Validate preset structure for preview accuracy
if (valid && presetData) {
try {
isValidScenePreset(presetData);
} catch (err) {
valid = false;
error = escapeHtml(err instanceof Error ? err.message : String(err));
}
}
this._previewItems.push({ name, valid, error });
@@ -318,9 +331,11 @@ 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: escapeHtml(errorMsg) }];
this._previewItems = [{ name: this._selectedFile?.name ?? 'unknown', valid: false, error: escapeHtml(errorMsg) }];
this._requiresConfirmation = false;
this.render();
} finally {
this._parsingInProgress = false;
}
}
@@ -356,7 +371,7 @@ export class PresetImportDialog extends _AppBase {
async _onImport() {
if (!this._selectedFile) {
if (this._adapter.notifications) {
this._adapter.notifications.warn('Please select a file first');
this._adapter.notifications.warn(this._adapter.i18n.localize('scrying-pool.presetImport.selectFileFirst'));
}
return;
}
@@ -403,7 +418,7 @@ export class PresetImportDialog extends _AppBase {
btn.textContent = '';
const spinner = document.createElement('i');
spinner.className = 'fas fa-spinner fa-spin';
const text = document.createTextNode(' Importing...');
const text = document.createTextNode(' ' + this._adapter.i18n.localize('scrying-pool.presetImport.importing'));
btn.appendChild(spinner);
btn.appendChild(text);
@@ -420,13 +435,13 @@ export class PresetImportDialog extends _AppBase {
// 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);
this._adapter.notifications.error(this._adapter.i18n.localize('scrying-pool.presetImport.importFailed') + '\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));
this._adapter.notifications.error(this._adapter.i18n.localize('scrying-pool.presetImport.importFailed') + ': ' + escapeHtml(errorMsg));
}
} finally {
btn.disabled = false;