diff --git a/src/core/persistence.js b/src/core/persistence.js index c4ba4cf..a0f3c69 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -17,6 +17,7 @@ import { FEATURE_FLAGS } from './state.js'; import { migrateInventory } from '../utils/migration.js'; +import { validateStoredInventory, cleanItemString } from '../utils/security.js'; const extensionName = 'third-party/rpg-companion-sillytavern'; @@ -100,6 +101,9 @@ export function loadSettings() { console.warn('[RPG Companion] Using default settings due to load error'); // Settings will remain at defaults from state.js } + + // Validate inventory structure (Bug #3 fix) + validateInventoryStructure(extensionSettings.userStats.inventory, 'settings'); } /** @@ -238,5 +242,94 @@ export function loadChatData() { } } + // Validate inventory structure (Bug #3 fix) + validateInventoryStructure(extensionSettings.userStats.inventory, 'chat'); + // console.log('[RPG Companion] Loaded chat data:', savedData); } + +/** + * Validates and repairs inventory structure to prevent corruption. + * Ensures all v2 fields exist and are the correct type. + * Fixes Bug #3: Location disappears when switching tabs + * + * @param {Object} inventory - Inventory object to validate + * @param {string} source - Source of load ('settings' or 'chat') for logging + * @private + */ +function validateInventoryStructure(inventory, source) { + if (!inventory || typeof inventory !== 'object') { + console.error(`[RPG Companion] Invalid inventory from ${source}, resetting to defaults`); + extensionSettings.userStats.inventory = { + version: 2, + onPerson: "None", + stored: {}, + assets: "None" + }; + saveSettings(); + return; + } + + let needsSave = false; + + // Ensure v2 structure + if (inventory.version !== 2) { + console.warn(`[RPG Companion] Inventory from ${source} missing version, setting to 2`); + inventory.version = 2; + needsSave = true; + } + + // Validate onPerson field + if (typeof inventory.onPerson !== 'string') { + console.warn(`[RPG Companion] Invalid onPerson from ${source}, resetting to "None"`); + inventory.onPerson = "None"; + needsSave = true; + } else { + // Clean items in onPerson (removes corrupted/dangerous items) + const cleanedOnPerson = cleanItemString(inventory.onPerson); + if (cleanedOnPerson !== inventory.onPerson) { + console.warn(`[RPG Companion] Cleaned corrupted items from onPerson inventory (${source})`); + inventory.onPerson = cleanedOnPerson; + needsSave = true; + } + } + + // Validate stored field (CRITICAL for Bug #3) + if (!inventory.stored || typeof inventory.stored !== 'object' || Array.isArray(inventory.stored)) { + console.error(`[RPG Companion] Corrupted stored inventory from ${source}, resetting to empty object`); + inventory.stored = {}; + needsSave = true; + } else { + // Validate stored object keys/values + const cleanedStored = validateStoredInventory(inventory.stored); + if (JSON.stringify(cleanedStored) !== JSON.stringify(inventory.stored)) { + console.warn(`[RPG Companion] Cleaned dangerous/invalid stored locations from ${source}`); + inventory.stored = cleanedStored; + needsSave = true; + } + } + + // Validate assets field + if (typeof inventory.assets !== 'string') { + console.warn(`[RPG Companion] Invalid assets from ${source}, resetting to "None"`); + inventory.assets = "None"; + needsSave = true; + } else { + // Clean items in assets (removes corrupted/dangerous items) + const cleanedAssets = cleanItemString(inventory.assets); + if (cleanedAssets !== inventory.assets) { + console.warn(`[RPG Companion] Cleaned corrupted items from assets inventory (${source})`); + inventory.assets = cleanedAssets; + needsSave = true; + } + } + + // Persist repairs if needed + if (needsSave) { + console.log(`[RPG Companion] Repaired inventory structure from ${source}, saving...`); + saveSettings(); + if (source === 'chat') { + saveChatData(); + } + } +} diff --git a/src/systems/interaction/inventoryActions.js b/src/systems/interaction/inventoryActions.js index a6355f1..2bbd511 100644 --- a/src/systems/interaction/inventoryActions.js +++ b/src/systems/interaction/inventoryActions.js @@ -3,11 +3,12 @@ * Handles all user interactions with the inventory v2 system */ -import { extensionSettings, lastGeneratedData } from '../../core/state.js'; +import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js'; import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js'; import { buildInventorySummary } from '../generation/promptBuilder.js'; import { renderInventory, getLocationId } from '../rendering/inventory.js'; import { parseItems, serializeItems } from '../../utils/itemParser.js'; +import { sanitizeLocationName, sanitizeItemName } from '../../utils/security.js'; // Type imports /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ @@ -25,15 +26,27 @@ let currentActiveSubTab = 'onPerson'; let collapsedLocations = []; /** - * Updates lastGeneratedData.userStats to include current inventory in text format. - * This ensures the AI context stays synced with manual edits. + * Tracks which inline forms are currently open + * @type {Object} + */ +let openForms = { + addLocation: false, + addItemOnPerson: false, + addItemStored: {}, // { [locationName]: true/false } + addItemAssets: false +}; + +/** + * Updates lastGeneratedData.userStats AND committedTrackerData.userStats to include + * current inventory in text format. + * This ensures manual edits are immediately visible to AI in next generation. */ function updateLastGeneratedDataInventory() { const stats = extensionSettings.userStats; const inventorySummary = buildInventorySummary(stats.inventory); - // Rebuild the lastGeneratedData.userStats text format - lastGeneratedData.userStats = + // Rebuild the userStats text format + const statsText = `Health: ${stats.health}%\n` + `Satiety: ${stats.satiety}%\n` + `Energy: ${stats.energy}%\n` + @@ -41,6 +54,11 @@ function updateLastGeneratedDataInventory() { `Arousal: ${stats.arousal}%\n` + `${stats.mood}: ${stats.conditions}\n` + `${inventorySummary}`; + + // Update BOTH lastGeneratedData AND committedTrackerData + // This makes manual edits immediately visible to AI + lastGeneratedData.userStats = statsText; + committedTrackerData.userStats = statsText; } /** @@ -56,9 +74,18 @@ export function showAddItemForm(field, location) { const locationId = getLocationId(location); formId = `rpg-add-item-form-stored-${locationId}`; inputId = `.rpg-location-item-input[data-location="${location}"]`; + // Track in state + if (!openForms.addItemStored) openForms.addItemStored = {}; + openForms.addItemStored[location] = true; } else { formId = `rpg-add-item-form-${field}`; inputId = `#rpg-new-item-${field}`; + // Track in state + if (field === 'onPerson') { + openForms.addItemOnPerson = true; + } else if (field === 'assets') { + openForms.addItemAssets = true; + } } const form = $(`#${formId}`); @@ -81,9 +108,19 @@ export function hideAddItemForm(field, location) { const locationId = getLocationId(location); formId = `rpg-add-item-form-stored-${locationId}`; inputId = `.rpg-location-item-input[data-location="${location}"]`; + // Clear from state + if (openForms.addItemStored && openForms.addItemStored[location]) { + delete openForms.addItemStored[location]; + } } else { formId = `rpg-add-item-form-${field}`; inputId = `#rpg-new-item-${field}`; + // Clear from state + if (field === 'onPerson') { + openForms.addItemOnPerson = false; + } else if (field === 'assets') { + openForms.addItemAssets = false; + } } const form = $(`#${formId}`); @@ -109,9 +146,17 @@ export function saveAddItem(field, location) { } const input = $(inputId); - const itemName = input.val().trim(); + const rawItemName = input.val().trim(); + if (!rawItemName) { + hideAddItemForm(field, location); + return; + } + + // Security: Validate and sanitize item name + const itemName = sanitizeItemName(rawItemName); if (!itemName) { + alert('Invalid item name.'); hideAddItemForm(field, location); return; } @@ -198,6 +243,9 @@ export function showAddLocationForm() { const form = $('#rpg-add-location-form'); const input = $('#rpg-new-location-name'); + // Track in state + openForms.addLocation = true; + form.show(); input.val('').focus(); } @@ -209,6 +257,9 @@ export function hideAddLocationForm() { const form = $('#rpg-add-location-form'); const input = $('#rpg-new-location-name'); + // Clear from state + openForms.addLocation = false; + form.hide(); input.val(''); } @@ -219,9 +270,17 @@ export function hideAddLocationForm() { export function saveAddLocation() { const inventory = extensionSettings.userStats.inventory; const input = $('#rpg-new-location-name'); - const locationName = input.val().trim(); + const rawLocationName = input.val().trim(); + if (!rawLocationName) { + hideAddLocationForm(); + return; + } + + // Security: Validate and sanitize location name + const locationName = sanitizeLocationName(rawLocationName); if (!locationName) { + alert('Invalid location name. Avoid special names like "__proto__" or "constructor".'); hideAddLocationForm(); return; } @@ -500,3 +559,67 @@ export function getInventoryRenderOptions() { collapsedLocations }; } + +/** + * Restores the state of inline forms after re-rendering. + * This ensures forms that were open before re-render are shown again. + * Also cleans up orphaned form states for deleted locations (Bug #3 fix). + */ +export function restoreFormStates() { + // Restore add location form + if (openForms.addLocation) { + const form = $('#rpg-add-location-form'); + const input = $('#rpg-new-location-name'); + if (form.length > 0) { + form.show(); + // Don't refocus to avoid disrupting user interaction + } + } + + // Restore add item on person form + if (openForms.addItemOnPerson) { + const form = $('#rpg-add-item-form-onPerson'); + const input = $('#rpg-new-item-onPerson'); + if (form.length > 0) { + form.show(); + } + } + + // Restore add item assets form + if (openForms.addItemAssets) { + const form = $('#rpg-add-item-form-assets'); + const input = $('#rpg-new-item-assets'); + if (form.length > 0) { + form.show(); + } + } + + // Restore add item stored forms (for each location) + // Clean up orphaned states for deleted locations (Bug #3 fix) + if (openForms.addItemStored && typeof openForms.addItemStored === 'object') { + const inventory = extensionSettings.userStats.inventory; + const locationsToDelete = []; + + for (const location in openForms.addItemStored) { + if (openForms.addItemStored[location]) { + // Check if location still exists in inventory + if (inventory?.stored && inventory.stored.hasOwnProperty(location)) { + // Location exists, restore form + const locationId = location.replace(/\s+/g, '-'); + const form = $(`#rpg-add-item-form-stored-${locationId}`); + if (form.length > 0) { + form.show(); + } + } else { + // Location was deleted, mark for cleanup + locationsToDelete.push(location); + } + } + } + + // Clean up orphaned form states + for (const location of locationsToDelete) { + delete openForms.addItemStored[location]; + } + } +} diff --git a/src/systems/interaction/inventoryEdit.js b/src/systems/interaction/inventoryEdit.js new file mode 100644 index 0000000..8da3a98 --- /dev/null +++ b/src/systems/interaction/inventoryEdit.js @@ -0,0 +1,104 @@ +/** + * Inventory Item Editing Module + * Handles inline editing of inventory item names + */ + +import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js'; +import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js'; +import { buildInventorySummary } from '../generation/promptBuilder.js'; +import { renderInventory } from '../rendering/inventory.js'; +import { parseItems, serializeItems } from '../../utils/itemParser.js'; +import { sanitizeItemName } from '../../utils/security.js'; + +/** + * Updates an existing inventory item's name. + * Validates, sanitizes, and persists the change. + * + * @param {string} field - Field name ('onPerson', 'stored', 'assets') + * @param {number} index - Index of item in the array + * @param {string} newName - New name for the item + * @param {string} [location] - Location name (required for 'stored' field) + */ +export function updateInventoryItem(field, index, newName, location) { + const inventory = extensionSettings.userStats.inventory; + + // Validate and sanitize the new item name + const sanitizedName = sanitizeItemName(newName); + if (!sanitizedName) { + console.warn('[RPG Companion] Invalid item name, reverting change'); + // Re-render to revert the change in UI + renderInventory(); + return; + } + + // Get current items for the field + let currentString; + if (field === 'stored') { + if (!location) { + console.error('[RPG Companion] Location required for stored items'); + return; + } + currentString = inventory.stored[location] || 'None'; + } else { + currentString = inventory[field] || 'None'; + } + + // Parse current items + const items = parseItems(currentString); + + // Validate index + if (index < 0 || index >= items.length) { + console.error(`[RPG Companion] Invalid item index: ${index}`); + return; + } + + // Update the item at this index + items[index] = sanitizedName; + + // Serialize back to string + const newItemString = serializeItems(items); + + // Update the inventory + if (field === 'stored') { + inventory.stored[location] = newItemString; + } else { + inventory[field] = newItemString; + } + + // Update lastGeneratedData and committedTrackerData with new inventory + updateLastGeneratedDataInventory(); + + // Save changes + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + + // Re-render inventory + renderInventory(); +} + +/** + * Updates lastGeneratedData.userStats AND committedTrackerData.userStats to include + * current inventory in text format. + * This ensures manual edits are immediately visible to AI in next generation. + * @private + */ +function updateLastGeneratedDataInventory() { + const stats = extensionSettings.userStats; + const inventorySummary = buildInventorySummary(stats.inventory); + + // Rebuild the userStats text format + const statsText = + `Health: ${stats.health}%\n` + + `Satiety: ${stats.satiety}%\n` + + `Energy: ${stats.energy}%\n` + + `Hygiene: ${stats.hygiene}%\n` + + `Arousal: ${stats.arousal}%\n` + + `${stats.mood}: ${stats.conditions}\n` + + `${inventorySummary}`; + + // Update BOTH lastGeneratedData AND committedTrackerData + // This makes manual edits immediately visible to AI + lastGeneratedData.userStats = statsText; + committedTrackerData.userStats = statsText; +} diff --git a/src/systems/rendering/infoBox.js b/src/systems/rendering/infoBox.js index 281f388..e73ce45 100644 --- a/src/systems/rendering/infoBox.js +++ b/src/systems/rendering/infoBox.js @@ -7,6 +7,7 @@ import { getContext } from '../../../../../../extensions.js'; import { extensionSettings, lastGeneratedData, + committedTrackerData, $infoBoxContainer } from '../../core/state.js'; import { saveChatData } from '../../core/persistence.js'; @@ -429,6 +430,10 @@ export function updateInfoBoxField(field, value) { lastGeneratedData.infoBox = updatedLines.join('\n'); + // Update BOTH lastGeneratedData AND committedTrackerData + // This makes manual edits immediately visible to AI + committedTrackerData.infoBox = updatedLines.join('\n'); + // Update the message's swipe data const chat = getContext().chat; if (chat && chat.length > 0) { diff --git a/src/systems/rendering/inventory.js b/src/systems/rendering/inventory.js index 3f76b84..310261b 100644 --- a/src/systems/rendering/inventory.js +++ b/src/systems/rendering/inventory.js @@ -4,7 +4,8 @@ */ import { extensionSettings, $inventoryContainer } from '../../core/state.js'; -import { getInventoryRenderOptions } from '../interaction/inventoryActions.js'; +import { getInventoryRenderOptions, restoreFormStates } from '../interaction/inventoryActions.js'; +import { updateInventoryItem } from '../interaction/inventoryEdit.js'; import { parseItems } from '../../utils/itemParser.js'; // Type imports @@ -62,14 +63,14 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') { - ${escapeHtml(item)} + ${escapeHtml(item)} `).join(''); } else { // List view: full-width rows itemsHtml = items.map((item, index) => `