Merge pull request #13 from paperboygold/feature/inventory-bugfixes

feat: inventory bugfixes, status polish, editable inventory fields
This commit is contained in:
Spicy Marinara
2025-10-20 02:03:06 +02:00
committed by GitHub
10 changed files with 844 additions and 67 deletions
+93
View File
@@ -17,6 +17,7 @@ import {
FEATURE_FLAGS FEATURE_FLAGS
} from './state.js'; } from './state.js';
import { migrateInventory } from '../utils/migration.js'; import { migrateInventory } from '../utils/migration.js';
import { validateStoredInventory, cleanItemString } from '../utils/security.js';
const extensionName = 'third-party/rpg-companion-sillytavern'; 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'); console.warn('[RPG Companion] Using default settings due to load error');
// Settings will remain at defaults from state.js // 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); // 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();
}
}
}
+130 -7
View File
@@ -3,11 +3,12 @@
* Handles all user interactions with the inventory v2 system * 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 { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js';
import { buildInventorySummary } from '../generation/promptBuilder.js'; import { buildInventorySummary } from '../generation/promptBuilder.js';
import { renderInventory, getLocationId } from '../rendering/inventory.js'; import { renderInventory, getLocationId } from '../rendering/inventory.js';
import { parseItems, serializeItems } from '../../utils/itemParser.js'; import { parseItems, serializeItems } from '../../utils/itemParser.js';
import { sanitizeLocationName, sanitizeItemName } from '../../utils/security.js';
// Type imports // Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -25,15 +26,27 @@ let currentActiveSubTab = 'onPerson';
let collapsedLocations = []; let collapsedLocations = [];
/** /**
* Updates lastGeneratedData.userStats to include current inventory in text format. * Tracks which inline forms are currently open
* This ensures the AI context stays synced with manual edits. * @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() { function updateLastGeneratedDataInventory() {
const stats = extensionSettings.userStats; const stats = extensionSettings.userStats;
const inventorySummary = buildInventorySummary(stats.inventory); const inventorySummary = buildInventorySummary(stats.inventory);
// Rebuild the lastGeneratedData.userStats text format // Rebuild the userStats text format
lastGeneratedData.userStats = const statsText =
`Health: ${stats.health}%\n` + `Health: ${stats.health}%\n` +
`Satiety: ${stats.satiety}%\n` + `Satiety: ${stats.satiety}%\n` +
`Energy: ${stats.energy}%\n` + `Energy: ${stats.energy}%\n` +
@@ -41,6 +54,11 @@ function updateLastGeneratedDataInventory() {
`Arousal: ${stats.arousal}%\n` + `Arousal: ${stats.arousal}%\n` +
`${stats.mood}: ${stats.conditions}\n` + `${stats.mood}: ${stats.conditions}\n` +
`${inventorySummary}`; `${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); const locationId = getLocationId(location);
formId = `rpg-add-item-form-stored-${locationId}`; formId = `rpg-add-item-form-stored-${locationId}`;
inputId = `.rpg-location-item-input[data-location="${location}"]`; inputId = `.rpg-location-item-input[data-location="${location}"]`;
// Track in state
if (!openForms.addItemStored) openForms.addItemStored = {};
openForms.addItemStored[location] = true;
} else { } else {
formId = `rpg-add-item-form-${field}`; formId = `rpg-add-item-form-${field}`;
inputId = `#rpg-new-item-${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}`); const form = $(`#${formId}`);
@@ -81,9 +108,19 @@ export function hideAddItemForm(field, location) {
const locationId = getLocationId(location); const locationId = getLocationId(location);
formId = `rpg-add-item-form-stored-${locationId}`; formId = `rpg-add-item-form-stored-${locationId}`;
inputId = `.rpg-location-item-input[data-location="${location}"]`; inputId = `.rpg-location-item-input[data-location="${location}"]`;
// Clear from state
if (openForms.addItemStored && openForms.addItemStored[location]) {
delete openForms.addItemStored[location];
}
} else { } else {
formId = `rpg-add-item-form-${field}`; formId = `rpg-add-item-form-${field}`;
inputId = `#rpg-new-item-${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}`); const form = $(`#${formId}`);
@@ -109,9 +146,17 @@ export function saveAddItem(field, location) {
} }
const input = $(inputId); 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) { if (!itemName) {
alert('Invalid item name.');
hideAddItemForm(field, location); hideAddItemForm(field, location);
return; return;
} }
@@ -198,6 +243,9 @@ export function showAddLocationForm() {
const form = $('#rpg-add-location-form'); const form = $('#rpg-add-location-form');
const input = $('#rpg-new-location-name'); const input = $('#rpg-new-location-name');
// Track in state
openForms.addLocation = true;
form.show(); form.show();
input.val('').focus(); input.val('').focus();
} }
@@ -209,6 +257,9 @@ export function hideAddLocationForm() {
const form = $('#rpg-add-location-form'); const form = $('#rpg-add-location-form');
const input = $('#rpg-new-location-name'); const input = $('#rpg-new-location-name');
// Clear from state
openForms.addLocation = false;
form.hide(); form.hide();
input.val(''); input.val('');
} }
@@ -219,9 +270,17 @@ export function hideAddLocationForm() {
export function saveAddLocation() { export function saveAddLocation() {
const inventory = extensionSettings.userStats.inventory; const inventory = extensionSettings.userStats.inventory;
const input = $('#rpg-new-location-name'); 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) { if (!locationName) {
alert('Invalid location name. Avoid special names like "__proto__" or "constructor".');
hideAddLocationForm(); hideAddLocationForm();
return; return;
} }
@@ -500,3 +559,67 @@ export function getInventoryRenderOptions() {
collapsedLocations 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];
}
}
}
+104
View File
@@ -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;
}
+5
View File
@@ -7,6 +7,7 @@ import { getContext } from '../../../../../../extensions.js';
import { import {
extensionSettings, extensionSettings,
lastGeneratedData, lastGeneratedData,
committedTrackerData,
$infoBoxContainer $infoBoxContainer
} from '../../core/state.js'; } from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js'; import { saveChatData } from '../../core/persistence.js';
@@ -429,6 +430,10 @@ export function updateInfoBoxField(field, value) {
lastGeneratedData.infoBox = updatedLines.join('\n'); 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 // Update the message's swipe data
const chat = getContext().chat; const chat = getContext().chat;
if (chat && chat.length > 0) { if (chat && chat.length > 0) {
+28 -10
View File
@@ -4,7 +4,8 @@
*/ */
import { extensionSettings, $inventoryContainer } from '../../core/state.js'; 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'; import { parseItems } from '../../utils/itemParser.js';
// Type imports // Type imports
@@ -62,14 +63,14 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item"> <button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
<span class="rpg-item-name">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
</div> </div>
`).join(''); `).join('');
} else { } else {
// List view: full-width rows // List view: full-width rows
itemsHtml = items.map((item, index) => ` itemsHtml = items.map((item, index) => `
<div class="rpg-item-row" data-field="onPerson" data-index="${index}"> <div class="rpg-item-row" data-field="onPerson" data-index="${index}">
<span class="rpg-item-name">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item"> <button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
@@ -184,14 +185,14 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item"> <button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
<span class="rpg-item-name">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
</div> </div>
`).join(''); `).join('');
} else { } else {
// List view: full-width rows // List view: full-width rows
itemsHtml = items.map((item, index) => ` itemsHtml = items.map((item, index) => `
<div class="rpg-item-row" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}"> <div class="rpg-item-row" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
<span class="rpg-item-name">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item"> <button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
@@ -210,9 +211,6 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
</button> </button>
<h5 class="rpg-storage-name">${escapeHtml(location)}</h5> <h5 class="rpg-storage-name">${escapeHtml(location)}</h5>
<div class="rpg-storage-actions"> <div class="rpg-storage-actions">
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="stored" data-location="${escapeHtml(location)}" title="Add item to this location">
<i class="fa-solid fa-plus"></i>
</button>
<button class="rpg-inventory-remove-btn" data-action="remove-location" data-location="${escapeHtml(location)}" title="Remove this storage location"> <button class="rpg-inventory-remove-btn" data-action="remove-location" data-location="${escapeHtml(location)}" title="Remove this storage location">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
</button> </button>
@@ -233,6 +231,11 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
<div class="rpg-item-list ${listViewClass}"> <div class="rpg-item-list ${listViewClass}">
${itemsHtml} ${itemsHtml}
</div> </div>
<div class="rpg-storage-add-item-container">
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="stored" data-location="${escapeHtml(location)}" title="Add item to this location">
<i class="fa-solid fa-plus"></i> Add Item
</button>
</div>
</div> </div>
<div class="rpg-inline-confirmation" id="rpg-remove-confirm-${locationId}" style="display: none;"> <div class="rpg-inline-confirmation" id="rpg-remove-confirm-${locationId}" style="display: none;">
<p>Remove "${escapeHtml(location)}"? This will delete all items stored there.</p> <p>Remove "${escapeHtml(location)}"? This will delete all items stored there.</p>
@@ -278,14 +281,14 @@ export function renderAssetsView(assets, viewMode = 'list') {
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset"> <button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
<span class="rpg-item-name">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
</div> </div>
`).join(''); `).join('');
} else { } else {
// List view: full-width rows // List view: full-width rows
itemsHtml = items.map((item, index) => ` itemsHtml = items.map((item, index) => `
<div class="rpg-item-row" data-field="assets" data-index="${index}"> <div class="rpg-item-row" data-field="assets" data-index="${index}">
<span class="rpg-item-name">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset"> <button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
@@ -436,6 +439,9 @@ export function updateInventoryDisplay(containerId, options = {}) {
const inventory = extensionSettings.userStats.inventory; const inventory = extensionSettings.userStats.inventory;
const html = generateInventoryHTML(inventory, options); const html = generateInventoryHTML(inventory, options);
container.innerHTML = html; container.innerHTML = html;
// Restore form states after re-rendering
restoreFormStates();
} }
/** /**
@@ -458,6 +464,18 @@ export function renderInventory() {
// Generate HTML and update DOM // Generate HTML and update DOM
const html = generateInventoryHTML(inventory, options); const html = generateInventoryHTML(inventory, options);
$inventoryContainer.html(html); $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);
});
} }
/** /**
+36 -3
View File
@@ -9,12 +9,41 @@ import { selected_group, getGroupMembers } from '../../../../../../group-chats.j
import { import {
extensionSettings, extensionSettings,
lastGeneratedData, lastGeneratedData,
committedTrackerData,
$thoughtsContainer, $thoughtsContainer,
FALLBACK_AVATAR_DATA_URI FALLBACK_AVATAR_DATA_URI
} from '../../core/state.js'; } from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js'; import { saveChatData } from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.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. * Renders character thoughts (Present Characters) panel.
* Displays character cards with avatars, relationship badges, and traits. * Displays character cards with avatars, relationship badges, and traits.
@@ -138,7 +167,7 @@ export function renderThoughts() {
if (selected_group) { if (selected_group) {
const groupMembers = getGroupMembers(selected_group); const groupMembers = getGroupMembers(selected_group);
const matchingMember = groupMembers.find(member => 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') { 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 // For regular chats or if not found in group, search all characters
if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) { if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) {
const matchingCharacter = characters.find(c => 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') { 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 is the current character in a 1-on-1 chat, use their portrait
if (this_chid !== undefined && characters[this_chid] && 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); const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
if (thumbnailUrl) { if (thumbnailUrl) {
characterPortrait = thumbnailUrl; characterPortrait = thumbnailUrl;
@@ -320,6 +349,10 @@ export function updateCharacterField(characterName, field, value) {
lastGeneratedData.characterThoughts = updatedLines.join('\n'); lastGeneratedData.characterThoughts = updatedLines.join('\n');
// console.log('[RPG Companion] 💾 Updated lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); // 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 // Also update the last assistant message's swipe data
const chat = getContext().chat; const chat = getContext().chat;
if (chat && chat.length > 0) { if (chat && chat.length > 0) {
+27 -11
View File
@@ -8,6 +8,7 @@ import { user_avatar } from '../../../../../../../script.js';
import { import {
extensionSettings, extensionSettings,
lastGeneratedData, lastGeneratedData,
committedTrackerData,
$userStatsContainer, $userStatsContainer,
FALLBACK_AVATAR_DATA_URI FALLBACK_AVATAR_DATA_URI
} from '../../core/state.js'; } from '../../core/state.js';
@@ -17,6 +18,7 @@ import {
updateMessageSwipeData updateMessageSwipeData
} from '../../core/persistence.js'; } from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.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. * Renders the user stats panel with health bars, mood, inventory, and classic stats.
@@ -53,7 +55,7 @@ export function renderUserStats() {
const html = ` const html = `
<div class="rpg-stats-content"> <div class="rpg-stats-content">
<div class="rpg-stats-left"> <div class="rpg-stats-left">
<div style="display: flex; gap: clamp(4px, 0.8vh, 8px); align-items: center; flex-shrink: 0;"> <div style="display: flex; gap: clamp(4px, 0.8vh, 8px); align-items: center; justify-content: center; flex-shrink: 0;">
<img src="${userPortrait}" alt="${userName}" class="rpg-user-portrait" onerror="this.style.opacity='0.5';this.onerror=null;" /> <img src="${userPortrait}" alt="${userName}" class="rpg-user-portrait" onerror="this.style.opacity='0.5';this.onerror=null;" />
</div> </div>
<div class="rpg-stats-grid"> <div class="rpg-stats-grid">
@@ -178,13 +180,15 @@ export function renderUserStats() {
// Update the setting // Update the setting
extensionSettings.userStats[field] = value; extensionSettings.userStats[field] = value;
// Also update lastGeneratedData to keep it in sync // Rebuild userStats text with proper inventory format
if (!lastGeneratedData.userStats) { const stats = extensionSettings.userStats;
lastGeneratedData.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}`;
// 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}`; // Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText; lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
saveSettings(); saveSettings();
saveChatData(); saveChatData();
@@ -199,9 +203,15 @@ export function renderUserStats() {
const value = $(this).text().trim(); const value = $(this).text().trim();
extensionSettings.userStats.mood = value || '😐'; extensionSettings.userStats.mood = value || '😐';
// Update lastGeneratedData // Rebuild userStats text with proper inventory format
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}`; 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; lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
saveSettings(); saveSettings();
saveChatData(); saveChatData();
@@ -212,9 +222,15 @@ export function renderUserStats() {
const value = $(this).text().trim(); const value = $(this).text().trim();
extensionSettings.userStats.conditions = value || 'None'; extensionSettings.userStats.conditions = value || 'None';
// Update lastGeneratedData // Rebuild userStats text with proper inventory format
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}`; 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; lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
saveSettings(); saveSettings();
saveChatData(); saveChatData();
+203 -36
View File
@@ -3,17 +3,46 @@
* Utilities for parsing item strings into arrays and vice versa * 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. * Parses item strings from AI responses into clean arrays.
* Filters out empty strings and handles "None" gracefully. * Handles numerous AI formatting quirks and edge cases.
* Smart handling: collapses newlines inside parentheses, preserves them outside.
* *
* @param {string} itemString - Comma-separated items (e.g., "Sword, Shield, 3x Potions") * Smart handling:
* @returns {string[]} Array of item names, or empty array if none * - 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 * @example
* // Standard comma-separated
* parseItems("Sword, Shield, 3x Potions") // ["Sword", "Shield", "3x Potions"] * 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("None") // []
* parseItems("") // [] * parseItems("") // []
* parseItems(null) // [] * parseItems(null) // []
@@ -24,44 +53,182 @@ export function parseItems(itemString) {
return []; return [];
} }
// Trim and check for "None" (case-insensitive) let processed = itemString.trim();
const trimmed = itemString.trim();
if (trimmed === '' || trimmed.toLowerCase() === 'none') { // Quick check for "None" or empty
if (processed === '' || processed.toLowerCase() === 'none') {
return []; return [];
} }
// Collapse newlines inside parentheses // STEP 1: Strip wrapping brackets/braces (AI sometimes wraps entire lists)
let processed = ''; // Handle: [], {}, [[]], etc.
let parenDepth = 0; while (
(processed.startsWith('[') && processed.endsWith(']')) ||
for (let i = 0; i < trimmed.length; i++) { (processed.startsWith('{') && processed.endsWith('}'))
const char = trimmed[i]; ) {
processed = processed.slice(1, -1).trim();
if (char === '(') { if (processed === '' || processed.toLowerCase() === 'none') {
parenDepth++; return [];
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;
} }
} }
// 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, ' '); processed = processed.replace(/\s+/g, ' ');
// Split by comma, trim each item, filter empties // STEP 6: Smart comma splitting (only split on commas OUTSIDE parentheses)
return processed // Also handles list markers, quotes, and security validation per-item
.split(',') const items = [];
.map(item => item.trim()) let currentItem = '';
.filter(item => item !== '' && item.toLowerCase() !== 'none'); 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;
} }
/** /**
+212
View File
@@ -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);
}
+6
View File
@@ -4080,6 +4080,12 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
display: none; display: none;
} }
.rpg-storage-add-item-container {
display: flex;
justify-content: center;
margin-top: 0.75rem;
}
/* Buttons */ /* Buttons */
.rpg-inventory-edit-btn, .rpg-inventory-edit-btn,
.rpg-inventory-add-btn, .rpg-inventory-add-btn,