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) => `
- ${escapeHtml(item)} + ${escapeHtml(item)} @@ -184,14 +185,14 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li - ${escapeHtml(item)} + ${escapeHtml(item)}
`).join(''); } else { // List view: full-width rows itemsHtml = items.map((item, index) => `
- ${escapeHtml(item)} + ${escapeHtml(item)} @@ -210,9 +211,6 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
${escapeHtml(location)}
- @@ -233,6 +231,11 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
${itemsHtml}
+
+ +
`).join(''); } else { // List view: full-width rows itemsHtml = items.map((item, index) => `
- ${escapeHtml(item)} + ${escapeHtml(item)} @@ -436,6 +439,9 @@ export function updateInventoryDisplay(containerId, options = {}) { const inventory = extensionSettings.userStats.inventory; const html = generateInventoryHTML(inventory, options); container.innerHTML = html; + + // Restore form states after re-rendering + restoreFormStates(); } /** @@ -458,6 +464,18 @@ export function renderInventory() { // Generate HTML and update DOM const html = generateInventoryHTML(inventory, options); $inventoryContainer.html(html); + + // Restore form states after re-rendering (fixes Bug #1) + restoreFormStates(); + + // Event listener for editing item names (mobile-friendly contenteditable) + $inventoryContainer.find('.rpg-item-name.rpg-editable').on('blur', function() { + const field = $(this).data('field'); + const index = parseInt($(this).data('index')); + const location = $(this).data('location'); + const newName = $(this).text().trim(); + updateInventoryItem(field, index, newName, location); + }); } /** diff --git a/src/systems/rendering/thoughts.js b/src/systems/rendering/thoughts.js index 03d9eb4..25d90bc 100644 --- a/src/systems/rendering/thoughts.js +++ b/src/systems/rendering/thoughts.js @@ -9,12 +9,41 @@ import { selected_group, getGroupMembers } from '../../../../../../group-chats.j import { extensionSettings, lastGeneratedData, + committedTrackerData, $thoughtsContainer, FALLBACK_AVATAR_DATA_URI } from '../../core/state.js'; import { saveChatData } from '../../core/persistence.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js'; +/** + * Fuzzy name matching that handles: + * - Exact matches: "Sabrina" === "Sabrina" + * - Parenthetical additions: "Sabrina" matches "Sabrina (Margrokha's Avatar)" + * - Title additions: "Sabrina" matches "Princess Sabrina" + * - Word boundaries: "Sabrina" won't match "Sabrina's Mother" + * + * @param {string} cardName - Name from the character card + * @param {string} aiName - Name generated by the AI + * @returns {boolean} True if names match + */ +function namesMatch(cardName, aiName) { + if (!cardName || !aiName) return false; + + // 1. Exact match (fast path) + if (cardName.toLowerCase() === aiName.toLowerCase()) return true; + + // 2. Strip parentheses and match + const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim(); + const cardCore = stripParens(cardName).toLowerCase(); + const aiCore = stripParens(aiName).toLowerCase(); + if (cardCore === aiCore) return true; + + // 3. Check if card name appears as complete word in AI name + const wordBoundary = new RegExp(`\\b${cardCore}\\b`); + return wordBoundary.test(aiCore); +} + /** * Renders character thoughts (Present Characters) panel. * Displays character cards with avatars, relationship badges, and traits. @@ -138,7 +167,7 @@ export function renderThoughts() { if (selected_group) { const groupMembers = getGroupMembers(selected_group); const matchingMember = groupMembers.find(member => - member && member.name && member.name.toLowerCase() === char.name.toLowerCase() + member && member.name && namesMatch(member.name, char.name) ); if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { @@ -152,7 +181,7 @@ export function renderThoughts() { // For regular chats or if not found in group, search all characters if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) { const matchingCharacter = characters.find(c => - c && c.name && c.name.toLowerCase() === char.name.toLowerCase() + c && c.name && namesMatch(c.name, char.name) ); if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') { @@ -165,7 +194,7 @@ export function renderThoughts() { // If this is the current character in a 1-on-1 chat, use their portrait if (this_chid !== undefined && characters[this_chid] && - characters[this_chid].name && characters[this_chid].name.toLowerCase() === char.name.toLowerCase()) { + characters[this_chid].name && namesMatch(characters[this_chid].name, char.name)) { const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); if (thumbnailUrl) { characterPortrait = thumbnailUrl; @@ -320,6 +349,10 @@ export function updateCharacterField(characterName, field, value) { lastGeneratedData.characterThoughts = updatedLines.join('\n'); // console.log('[RPG Companion] πŸ’Ύ Updated lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); + // Update BOTH lastGeneratedData AND committedTrackerData + // This makes manual edits immediately visible to AI + committedTrackerData.characterThoughts = updatedLines.join('\n'); + // Also update the last assistant message's swipe data const chat = getContext().chat; if (chat && chat.length > 0) { diff --git a/src/systems/rendering/userStats.js b/src/systems/rendering/userStats.js index 6b907fb..d84e8e0 100644 --- a/src/systems/rendering/userStats.js +++ b/src/systems/rendering/userStats.js @@ -8,6 +8,7 @@ import { user_avatar } from '../../../../../../../script.js'; import { extensionSettings, lastGeneratedData, + committedTrackerData, $userStatsContainer, FALLBACK_AVATAR_DATA_URI } from '../../core/state.js'; @@ -17,6 +18,7 @@ import { updateMessageSwipeData } from '../../core/persistence.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js'; +import { buildInventorySummary } from '../generation/promptBuilder.js'; /** * Renders the user stats panel with health bars, mood, inventory, and classic stats. @@ -53,7 +55,7 @@ export function renderUserStats() { const html = `
-
+
${userName}
@@ -178,13 +180,15 @@ export function renderUserStats() { // Update the setting extensionSettings.userStats[field] = value; - // Also update lastGeneratedData to keep it in sync - if (!lastGeneratedData.userStats) { - lastGeneratedData.userStats = ''; - } - // Regenerate the userStats text with updated value - const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; + // Rebuild userStats text with proper inventory format + const stats = extensionSettings.userStats; + const inventorySummary = buildInventorySummary(stats.inventory); + const statsText = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${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; saveSettings(); saveChatData(); @@ -199,9 +203,15 @@ export function renderUserStats() { const value = $(this).text().trim(); extensionSettings.userStats.mood = value || '😐'; - // Update lastGeneratedData - const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; + // Rebuild userStats text with proper inventory format + const stats = extensionSettings.userStats; + const inventorySummary = buildInventorySummary(stats.inventory); + const statsText = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${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; saveSettings(); saveChatData(); @@ -212,9 +222,15 @@ export function renderUserStats() { const value = $(this).text().trim(); extensionSettings.userStats.conditions = value || 'None'; - // Update lastGeneratedData - const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; + // Rebuild userStats text with proper inventory format + const stats = extensionSettings.userStats; + const inventorySummary = buildInventorySummary(stats.inventory); + const statsText = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${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; saveSettings(); saveChatData(); diff --git a/src/utils/itemParser.js b/src/utils/itemParser.js index edf82a1..11651cb 100644 --- a/src/utils/itemParser.js +++ b/src/utils/itemParser.js @@ -3,17 +3,46 @@ * Utilities for parsing item strings into arrays and vice versa */ +import { sanitizeItemName, MAX_ITEMS_PER_SECTION } from './security.js'; + /** - * Parses a comma-separated item string into an array of trimmed item names. - * Filters out empty strings and handles "None" gracefully. - * Smart handling: collapses newlines inside parentheses, preserves them outside. + * Parses item strings from AI responses into clean arrays. + * Handles numerous AI formatting quirks and edge cases. * - * @param {string} itemString - Comma-separated items (e.g., "Sword, Shield, 3x Potions") - * @returns {string[]} Array of item names, or empty array if none + * Smart handling: + * - Strips wrapping brackets/braces: [], {}, [[]] + * - Strips wrapping quotes: "...", '...' + * - Converts newlines to commas (newline-based lists) + * - Strips markdown: **bold**, *italic*, `code`, ~~strikethrough~~ + * - Strips list markers: -, β€’, 1., 2., etc. + * - Collapses newlines inside parentheses to spaces + * - Only splits on commas OUTSIDE parentheses (preserves commas in descriptions) + * - Gracefully handles unmatched parentheses + * + * @param {string} itemString - Item string from AI (various formats supported) + * @returns {string[]} Array of clean item names, or empty array if none * * @example + * // Standard comma-separated * parseItems("Sword, Shield, 3x Potions") // ["Sword", "Shield", "3x Potions"] - * parseItems("Books (magical\ntomes), Sword") // ["Books (magical tomes)", "Sword"] + * + * // Newline-based lists + * parseItems("Sword\nShield\nPotion") // ["Sword", "Shield", "Potion"] + * parseItems("- Sword\n- Shield") // ["Sword", "Shield"] + * parseItems("1. Sword\n2. Shield") // ["Sword", "Shield"] + * + * // Commas in parentheses (preserved) + * parseItems("Potato (Cursed, Sexy, Your Mum & Dick, Etc), Sword") + * // β†’ ["Potato (Cursed, Sexy, Your Mum & Dick, Etc)", "Sword"] + * + * // Markdown formatting (stripped) + * parseItems("**Sword** (equipped), *Shield*") // ["Sword (equipped)", "Shield"] + * + * // Various brackets (stripped) + * parseItems("[Sword, Shield]") // ["Sword", "Shield"] + * parseItems("{Sword, Shield}") // ["Sword", "Shield"] + * + * // Edge cases * parseItems("None") // [] * parseItems("") // [] * parseItems(null) // [] @@ -24,44 +53,182 @@ export function parseItems(itemString) { return []; } - // Trim and check for "None" (case-insensitive) - const trimmed = itemString.trim(); - if (trimmed === '' || trimmed.toLowerCase() === 'none') { + let processed = itemString.trim(); + + // Quick check for "None" or empty + if (processed === '' || processed.toLowerCase() === 'none') { return []; } - // Collapse newlines inside parentheses - let processed = ''; - let parenDepth = 0; - - for (let i = 0; i < trimmed.length; i++) { - const char = trimmed[i]; - - if (char === '(') { - parenDepth++; - processed += char; - } else if (char === ')') { - parenDepth--; - processed += char; - } else if ((char === '\n' || char === '\r') && parenDepth > 0) { - // Inside parentheses: replace newline with space - // Skip if previous char was already a space - if (processed[processed.length - 1] !== ' ') { - processed += ' '; - } - } else { - processed += char; + // STEP 1: Strip wrapping brackets/braces (AI sometimes wraps entire lists) + // Handle: [], {}, [[]], etc. + while ( + (processed.startsWith('[') && processed.endsWith(']')) || + (processed.startsWith('{') && processed.endsWith('}')) + ) { + processed = processed.slice(1, -1).trim(); + if (processed === '' || processed.toLowerCase() === 'none') { + return []; } } - // Clean up multiple consecutive spaces + // STEP 2: Strip wrapping quotes (AI sometimes quotes entire lists) + // Handle: "...", '...' + if ((processed.startsWith('"') && processed.endsWith('"')) || + (processed.startsWith("'") && processed.endsWith("'"))) { + processed = processed.slice(1, -1).trim(); + if (processed === '' || processed.toLowerCase() === 'none') { + return []; + } + } + + // STEP 3: Convert newlines to commas (OUTSIDE parentheses) + // Handles newline-based lists: "Sword\nShield\nPotion" β†’ "Sword, Shield, Potion" + let withCommas = ''; + let parenDepth = 0; + + for (let i = 0; i < processed.length; i++) { + const char = processed[i]; + + if (char === '(') { + parenDepth++; + withCommas += char; + } else if (char === ')') { + parenDepth--; + withCommas += char; + } else if ((char === '\n' || char === '\r') && parenDepth === 0) { + // Newline outside parentheses - convert to comma separator + // Don't add if previous char was already a separator + const prevChar = withCommas[withCommas.length - 1]; + if (prevChar && prevChar !== ',' && prevChar !== '\n') { + withCommas += ','; + } + } else if ((char === '\n' || char === '\r') && parenDepth > 0) { + // Newline inside parentheses - convert to space + if (withCommas[withCommas.length - 1] !== ' ') { + withCommas += ' '; + } + } else { + withCommas += char; + } + } + processed = withCommas; + + // STEP 4: Strip markdown formatting + // Remove: **bold**, *italic*, `code`, ~~strikethrough~~ + processed = processed + .replace(/\*\*(.+?)\*\*/g, '$1') // **bold** β†’ bold + .replace(/\*(.+?)\*/g, '$1') // *italic* β†’ italic + .replace(/`(.+?)`/g, '$1') // `code` β†’ code + .replace(/~~(.+?)~~/g, '$1'); // ~~strike~~ β†’ strike + + // STEP 5: Normalize whitespace processed = processed.replace(/\s+/g, ' '); - // Split by comma, trim each item, filter empties - return processed - .split(',') - .map(item => item.trim()) - .filter(item => item !== '' && item.toLowerCase() !== 'none'); + // STEP 6: Smart comma splitting (only split on commas OUTSIDE parentheses) + // Also handles list markers, quotes, and security validation per-item + const items = []; + let currentItem = ''; + parenDepth = 0; + + for (let i = 0; i < processed.length; i++) { + const char = processed[i]; + + if (char === '(') { + parenDepth++; + currentItem += char; + } else if (char === ')') { + parenDepth--; + // Graceful handling: don't let depth go negative + if (parenDepth < 0) { + console.warn('[RPG Companion] Unmatched closing parenthesis in item parsing'); + parenDepth = 0; + } + currentItem += char; + } else if (char === ',' && parenDepth === 0) { + // Comma outside parentheses - this is a separator + const cleaned = cleanSingleItem(currentItem); + if (cleaned) { + // Security check: validate and sanitize item name + const sanitized = sanitizeItemName(cleaned); + if (sanitized) { + items.push(sanitized); + } + + // DoS protection: enforce max items limit + if (items.length >= MAX_ITEMS_PER_SECTION) { + console.warn(`[RPG Companion] Reached max items limit (${MAX_ITEMS_PER_SECTION}), truncating list`); + return items; + } + } + currentItem = ''; // Start new item + } else { + currentItem += char; + } + } + + // Don't forget the last item + const cleaned = cleanSingleItem(currentItem); + if (cleaned) { + // Security check: validate and sanitize item name + const sanitized = sanitizeItemName(cleaned); + if (sanitized) { + items.push(sanitized); + } + } + + // Warn if parentheses were unmatched + if (parenDepth > 0) { + console.warn('[RPG Companion] Unmatched opening parenthesis in item parsing'); + } + + return items; +} + +/** + * Cleans a single item string (helper for parseItems) + * Removes list markers, wrapping quotes, trims, and capitalizes first letter + * + * @param {string} item - Single item string to clean + * @returns {string|null} Cleaned item or null if empty/invalid + * @private + */ +function cleanSingleItem(item) { + if (!item || typeof item !== 'string') { + return null; + } + + let cleaned = item.trim(); + + // Filter "None" + if (cleaned === '' || cleaned.toLowerCase() === 'none') { + return null; + } + + // Strip list markers: "- Item", "β€’ Item", "1. Item", "2. Item", etc. + // Matches: -, β€’, *, 1., 2., a), etc. + cleaned = cleaned.replace(/^[-β€’*]\s+/, ''); // "- Item" β†’ "Item" + cleaned = cleaned.replace(/^\d+\.\s+/, ''); // "1. Item" β†’ "Item" + cleaned = cleaned.replace(/^[a-z]\)\s+/i, ''); // "a) Item" β†’ "Item" + + // Strip wrapping quotes from individual items + if ((cleaned.startsWith('"') && cleaned.endsWith('"')) || + (cleaned.startsWith("'") && cleaned.endsWith("'"))) { + cleaned = cleaned.slice(1, -1).trim(); + } + + // Final empty check + if (cleaned === '' || cleaned.toLowerCase() === 'none') { + return null; + } + + // Capitalize first letter for consistency + // Preserves rest of string case (e.g., "iPhone" stays "iPhone", not "Iphone") + if (cleaned.length > 0) { + cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1); + } + + return cleaned; } /** diff --git a/src/utils/security.js b/src/utils/security.js new file mode 100644 index 0000000..63ac2ab --- /dev/null +++ b/src/utils/security.js @@ -0,0 +1,212 @@ +/** + * Security Utilities Module + * Handles input sanitization and validation to prevent security vulnerabilities + */ + +import { parseItems, serializeItems } from './itemParser.js'; + +/** + * List of dangerous property names that could cause prototype pollution + * or shadow critical object methods. + * @private + */ +const BLOCKED_PROPERTY_NAMES = [ + '__proto__', + 'constructor', + 'prototype', + 'toString', + 'valueOf', + 'hasOwnProperty', + '__defineGetter__', + '__defineSetter__', + '__lookupGetter__', + '__lookupSetter__' +]; + +/** + * Validates and sanitizes storage location names. + * Prevents prototype pollution and object property shadowing attacks. + * + * @param {string} name - Location name to validate + * @returns {string|null} Sanitized location name or null if invalid/dangerous + * + * @example + * sanitizeLocationName("Home") // "Home" + * sanitizeLocationName("__proto__") // null (blocked, logs warning) + * sanitizeLocationName("A".repeat(300)) // "AAA..." (truncated to 200 chars) + */ +export function sanitizeLocationName(name) { + if (!name || typeof name !== 'string') { + return null; + } + + const trimmed = name.trim(); + + // Empty check + if (trimmed === '') { + return null; + } + + // Check for dangerous property names (case-insensitive) + const lowerName = trimmed.toLowerCase(); + if (BLOCKED_PROPERTY_NAMES.some(blocked => lowerName === blocked.toLowerCase())) { + console.warn(`[RPG Companion] Blocked dangerous location name: "${trimmed}"`); + return null; + } + + // Max length check (reasonable location name) + const MAX_LOCATION_LENGTH = 200; + if (trimmed.length > MAX_LOCATION_LENGTH) { + console.warn(`[RPG Companion] Location name too long (${trimmed.length} chars), truncating to ${MAX_LOCATION_LENGTH}`); + return trimmed.slice(0, MAX_LOCATION_LENGTH); + } + + return trimmed; +} + +/** + * Validates and sanitizes item names. + * Prevents excessively long item names that could cause DoS or UI issues. + * + * @param {string} name - Item name to validate + * @returns {string|null} Sanitized item name or null if invalid + * + * @example + * sanitizeItemName("Sword") // "Sword" + * sanitizeItemName("") // null + * sanitizeItemName("A".repeat(600)) // "AAA..." (truncated to 500 chars) + */ +export function sanitizeItemName(name) { + if (!name || typeof name !== 'string') { + return null; + } + + const trimmed = name.trim(); + + // Empty check + if (trimmed === '' || trimmed.toLowerCase() === 'none') { + return null; + } + + // Max length check (reasonable item name with description) + const MAX_ITEM_LENGTH = 500; + if (trimmed.length > MAX_ITEM_LENGTH) { + console.warn(`[RPG Companion] Item name too long (${trimmed.length} chars), truncating to ${MAX_ITEM_LENGTH}`); + return trimmed.slice(0, MAX_ITEM_LENGTH); + } + + return trimmed; +} + +/** + * Validates and cleans a stored inventory object. + * Ensures all keys are safe property names and all values are strings. + * Cleans items within each location (removes corrupted/dangerous items). + * Preserves empty locations (with "None") so users can add items later. + * Prevents prototype pollution attacks via object keys. + * + * @param {Object} stored - Raw stored inventory object + * @returns {Object} Cleaned stored inventory object (always a plain object) + * + * @example + * validateStoredInventory({ "Home": "Sword, Shield" }) + * // β†’ { "Home": "Sword, Shield" } + * + * validateStoredInventory({ "Home": "Sword, __proto__, Shield" }) + * // β†’ { "Home": "Sword, Shield" } (dangerous item removed, logged) + * + * validateStoredInventory({ "Home": "None" }) + * // β†’ { "Home": "None" } (empty location preserved) + * + * validateStoredInventory({ "__proto__": "malicious" }) + * // β†’ {} (dangerous key removed, logged) + * + * validateStoredInventory({ "BadLocation": "__proto__, constructor" }) + * // β†’ { "BadLocation": "None" } (all items removed, location kept empty) + * + * validateStoredInventory(null) + * // β†’ {} (invalid input, returns empty object) + */ +export function validateStoredInventory(stored) { + // Handle invalid input + if (!stored || typeof stored !== 'object' || Array.isArray(stored)) { + return {}; + } + + const cleaned = {}; + + // Validate each property + for (const key in stored) { + // Only check own properties (not inherited) + if (!Object.prototype.hasOwnProperty.call(stored, key)) { + continue; + } + + // Sanitize the location name + const sanitizedKey = sanitizeLocationName(key); + if (!sanitizedKey) { + // Key was invalid or dangerous, skip it + continue; + } + + // Ensure value is a string + const value = stored[key]; + if (typeof value !== 'string') { + console.warn(`[RPG Companion] Invalid stored inventory value for location "${sanitizedKey}", skipping`); + continue; + } + + // Clean items within this location (removes corrupted/dangerous items) + const cleanedValue = cleanItemString(value); + + // Always keep the location (even if empty/"None") + // "None" is a valid state - it means the location exists but has no items yet + cleaned[sanitizedKey] = cleanedValue; + + // Warn if we had to clean corrupted items (but only if original wasn't just "None") + if (value !== cleanedValue && value.toLowerCase() !== 'none') { + console.warn(`[RPG Companion] Cleaned corrupted items from location "${sanitizedKey}": "${value}" β†’ "${cleanedValue}"`); + } + } + + return cleaned; +} + +/** + * Maximum number of items allowed in a single inventory section. + * Prevents DoS via extremely large item lists. + * @constant {number} + */ +export const MAX_ITEMS_PER_SECTION = 500; + +/** + * Cleans an item string by parsing and re-serializing. + * Removes corrupted, dangerous, or invalid items while preserving valid ones. + * Applies ALL parsing rules: markdown stripping, sanitization, length limits, etc. + * + * This is used at LOAD time to clean persisted data immediately, not just at render time. + * + * @param {string} itemString - Raw item string (possibly corrupted) + * @returns {string} Clean item string with only valid items, or "None" if no valid items + * + * @example + * cleanItemString("Sword, Shield") // "Sword, Shield" (unchanged) + * cleanItemString("Sword, __proto__, Shield") // "Sword, Shield" (dangerous item removed) + * cleanItemString("A".repeat(600) + ", Sword") // "AAA... (truncated), Sword" + * cleanItemString("**Sword**, *Shield*") // "Sword, Shield" (markdown stripped) + * cleanItemString("__proto__, constructor") // "None" (all items invalid) + */ +export function cleanItemString(itemString) { + // Parse using robust parser (handles all edge cases, sanitizes each item) + // This applies: newlinesβ†’commas, markdown stripping, parenthesis-aware splitting, + // sanitizeItemName() validation, length limits, max items limit + const items = parseItems(itemString); + + // If no valid items remain after parsing/sanitization, return "None" + if (items.length === 0) { + return "None"; + } + + // Re-serialize clean items back to string format + return serializeItems(items); +} diff --git a/style.css b/style.css index b1732ce..d020c01 100644 --- a/style.css +++ b/style.css @@ -4080,6 +4080,12 @@ body:has(.rpg-panel.rpg-position-left) #sheld { display: none; } +.rpg-storage-add-item-container { + display: flex; + justify-content: center; + margin-top: 0.75rem; +} + /* Buttons */ .rpg-inventory-edit-btn, .rpg-inventory-add-btn,