Merge pull request #13 from paperboygold/feature/inventory-bugfixes
feat: inventory bugfixes, status polish, editable inventory fields
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user