Files
rpg-companion-sillytavern/src/systems/generation/parser.js
T
Spicy_Marinara 105e20e97a v3.7.2: Fix status field key generation for parenthetical names & scroll preservation
- Fix: Status fields with parenthetical descriptions (e.g., 'Conditions (up to 5 traits)') now use the base name for the JSON key ('conditions' instead of 'conditions_up_to_5_traits')
- Fix: Status field value templates no longer repeat the field name with numbered suffixes
- Fix: Editing fields in Present Characters no longer scrolls the panel to the top
- Updated jsonPromptHelpers.js, parser.js, and userStats.js to use new toFieldKey() helper
- Added scroll position preservation to renderThoughts() when re-rendering after field edits
2026-02-13 18:34:44 +01:00

881 lines
41 KiB
JavaScript

/**
* Parser Module
* Handles parsing of AI responses to extract tracker data
* Supports both legacy text format and new v3 JSON format
*/
import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { extractInventory } from './inventoryParser.js';
import { repairJSON } from '../../utils/jsonRepair.js';
/**
* Extracts the base name (before parentheses) and converts to snake_case for use as JSON key.
* Example: "Conditions (up to 5 traits)" -> "conditions"
* @param {string} name - Field name, possibly with parenthetical description
* @returns {string} snake_case key from the base name only
*/
function toFieldKey(name) {
const baseName = name.replace(/\s*\(.*\)\s*$/, '').trim();
return baseName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Helper to separate emoji from text in a string
* Handles cases where there's no comma or space after emoji
* @param {string} str - String potentially containing emoji followed by text
* @returns {{emoji: string, text: string}} Separated emoji and text
*/
function separateEmojiFromText(str) {
if (!str) return { emoji: '', text: '' };
str = str.trim();
// Regex to match emoji at the start (handles most emoji including compound ones)
// This matches emoji sequences including skin tones, gender modifiers, etc.
const emojiRegex = /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F910}-\u{1F96B}\u{1F980}-\u{1F9E0}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]+/u;
const emojiMatch = str.match(emojiRegex);
if (emojiMatch) {
const emoji = emojiMatch[0];
let text = str.substring(emoji.length).trim();
// Remove leading comma or space if present
text = text.replace(/^[,\s]+/, '');
return { emoji, text };
}
// No emoji found - check if there's a comma separator anyway
const commaParts = str.split(',');
if (commaParts.length >= 2) {
return {
emoji: commaParts[0].trim(),
text: commaParts.slice(1).join(',').trim()
};
}
// No clear separation - return original as text
return { emoji: '', text: str };
}
/**
* Helper to strip enclosing brackets from text and remove placeholder brackets
* Removes [], {}, and () from the entire text if it's wrapped, plus removes
* placeholder content like [Location], [Mood Emoji], etc.
* @param {string} text - Text that may contain brackets
* @returns {string} Text with brackets and placeholders removed
*/
function stripBrackets(text) {
if (!text) return text;
// Remove leading and trailing whitespace first
text = text.trim();
// Check if the entire text is wrapped in brackets and remove them
// This handles cases where models wrap entire sections in brackets
while (
(text.startsWith('[') && text.endsWith(']')) ||
(text.startsWith('{') && text.endsWith('}')) ||
(text.startsWith('(') && text.endsWith(')'))
) {
text = text.substring(1, text.length - 1).trim();
}
// Remove placeholder text patterns like [Location], [Mood Emoji], [Name], etc.
// Pattern matches: [anything with letters/spaces inside]
// This preserves actual content while removing template placeholders
const placeholderPattern = /\[([A-Za-z\s\/]+)\]/g;
// Check if a bracketed text looks like a placeholder vs real content
const isPlaceholder = (match, content) => {
// Common placeholder words to detect
const placeholderKeywords = [
'location', 'mood', 'emoji', 'name', 'description', 'placeholder',
'time', 'date', 'weather', 'temperature', 'action', 'appearance',
'skill', 'quest', 'item', 'character', 'field', 'value', 'details',
'relationship', 'thoughts', 'stat', 'status', 'lover', 'friend',
'enemy', 'neutral', 'weekday', 'month', 'year', 'forecast'
];
const lowerContent = content.toLowerCase().trim();
// If it contains common placeholder keywords, it's likely a placeholder
if (placeholderKeywords.some(keyword => lowerContent.includes(keyword))) {
return true;
}
// If it's a short generic phrase (1-3 words) with only letters/spaces, might be placeholder
const wordCount = content.trim().split(/\s+/).length;
if (wordCount <= 3 && /^[A-Za-z\s\/]+$/.test(content)) {
return true;
}
return false;
};
// Replace placeholders with empty string, keep real content
text = text.replace(placeholderPattern, (match, content) => {
if (isPlaceholder(match, content)) {
return ''; // Remove placeholder
}
return match; // Keep real bracketed content
});
// Clean up any resulting empty labels (e.g., "Status: " with nothing after)
text = text.replace(/^([A-Za-z\s]+):\s*$/gm, ''); // Remove lines that are just "Label: " with nothing
text = text.replace(/^([A-Za-z\s]+):\s*,/gm, '$1:'); // Fix "Label: ," patterns
text = text.replace(/:\s*\|/g, ':'); // Fix ": |" patterns
text = text.replace(/\|\s*\|/g, '|'); // Fix "| |" patterns (double pipes from removed content)
text = text.replace(/\|\s*$/gm, ''); // Remove trailing pipes at end of lines
// Clean up multiple spaces and empty lines
text = text.replace(/\s{2,}/g, ' '); // Multiple spaces to single space
text = text.replace(/^\s*\n/gm, ''); // Remove empty lines
return text.trim();
}
/**
* Helper to log to both console and debug logs array
*/
function debugLog(message, data = null) {
// console.log(message, data || '');
if (extensionSettings.debugMode) {
addDebugLog(message, data);
}
}
/**
* Parses the model response to extract the different data sections.
* Extracts tracker data from markdown code blocks in the AI response.
* Handles both separate code blocks and combined code blocks gracefully.
*
* @param {string} responseText - The raw AI response text
* @returns {{userStats: string|null, infoBox: string|null, characterThoughts: string|null}} Parsed tracker data
*/
export function parseResponse(responseText) {
const result = {
userStats: null,
infoBox: null,
characterThoughts: null
};
// DEBUG: Log full response for troubleshooting
debugLog('[RPG Parser] ==================== PARSING AI RESPONSE ====================');
debugLog('[RPG Parser] Response length:', responseText.length + ' chars');
debugLog('[RPG Parser] First 500 chars:', responseText.substring(0, 500));
// Remove content inside thinking tags first (model's internal reasoning)
// This prevents parsing code blocks from the model's thinking process
let cleanedResponse = responseText.replace(/<think>[\s\S]*?<\/think>/gi, '');
cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
debugLog('[RPG Parser] Removed thinking tags, new length:', cleanedResponse.length + ' chars');
// Remove "FORMAT:" markers that the model might accidentally output
cleanedResponse = cleanedResponse.replace(/FORMAT:\s*/gi, '');
debugLog('[RPG Parser] Removed FORMAT: markers, new length:', cleanedResponse.length + ' chars');
// First, try to extract raw JSON objects (v3 format)
// Note: Prompts now instruct models to use ```json``` code blocks, but we extract
// from any JSON found using brace-matching for maximum compatibility
// Use brace-matching to find complete JSON objects
const extractedObjects = [];
let i = 0;
while (i < cleanedResponse.length) {
if (cleanedResponse[i] === '{') {
// Found opening brace, find matching closing brace
let depth = 1;
let j = i + 1;
let inString = false;
let escapeNext = false;
while (j < cleanedResponse.length && depth > 0) {
const char = cleanedResponse[j];
if (escapeNext) {
escapeNext = false;
} else if (char === '\\') {
escapeNext = true;
} else if (char === '"') {
inString = !inString;
} else if (!inString) {
if (char === '{') depth++;
else if (char === '}') depth--;
}
j++;
}
if (depth === 0) {
// Found complete JSON object
const jsonContent = cleanedResponse.substring(i, j).trim();
if (jsonContent) {
extractedObjects.push(jsonContent);
}
i = j;
} else {
i++;
}
} else {
i++;
}
}
if (extractedObjects.length > 0) {
// console.log(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
debugLog(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
// First, try to parse as unified JSON structure (new v3.1 format)
if (extractedObjects.length === 1) {
const parsed = repairJSON(extractedObjects[0]);
if (parsed && (parsed.userStats || parsed.infoBox || parsed.characters)) {
// console.log('[RPG Parser] ✓ Detected unified JSON structure (v3.1 format)');
if (parsed.userStats) {
result.userStats = JSON.stringify(parsed.userStats);
// console.log('[RPG Parser] ✓ Extracted userStats from unified structure');
}
if (parsed.infoBox) {
result.infoBox = JSON.stringify(parsed.infoBox);
// console.log('[RPG Parser] ✓ Extracted infoBox from unified structure');
}
if (parsed.characters) {
result.characterThoughts = JSON.stringify(parsed.characters);
// console.log('[RPG Parser] ✓ Extracted characters from unified structure');
}
if (result.userStats || result.infoBox || result.characterThoughts) {
// console.log('[RPG Parser] ✓ Returning unified JSON parse results');
debugLog('[RPG Parser] Returning unified JSON parse results');
return result;
}
}
}
// Fall back to parsing multiple separate JSON objects (legacy v3.0 format)
for (let idx = 0; idx < extractedObjects.length; idx++) {
const jsonContent = extractedObjects[idx];
// console.log(`[RPG Parser] Parsing object ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
// console.log(`[RPG Parser] Full object ${idx + 1} length:`, jsonContent.length);
const parsed = repairJSON(jsonContent);
if (parsed) {
// console.log(`[RPG Parser] Object ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Check if object is wrapped (e.g., {"userStats": {...}})
// Unwrap single-key objects that match our tracker types
let unwrapped = parsed;
if (Object.keys(parsed).length === 1) {
const key = Object.keys(parsed)[0];
if (key === 'userStats' || key === 'infoBox' || key === 'characters') {
unwrapped = parsed[key];
// console.log(`[RPG Parser] ✓ Unwrapped ${key} object`);
}
}
// Detect tracker type by checking for top-level fields
if (unwrapped.stats || unwrapped.status || unwrapped.skills || unwrapped.inventory || unwrapped.quests) {
result.userStats = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to User Stats');
debugLog('[RPG Parser] ✓ Extracted raw JSON User Stats');
} else if (unwrapped.date || unwrapped.location || unwrapped.weather || unwrapped.temperature || unwrapped.time) {
result.infoBox = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Info Box');
debugLog('[RPG Parser] ✓ Extracted raw JSON Info Box');
} else if (unwrapped.characters || Array.isArray(unwrapped)) {
result.characterThoughts = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Characters');
debugLog('[RPG Parser] ✓ Extracted raw JSON Characters');
} else {
console.warn('[RPG Parser] ⚠️ Could not categorize object with keys:', Object.keys(parsed));
}
} else {
console.error('[RPG Parser] ✗ Failed to parse raw JSON object', idx + 1);
}
}
if (result.userStats || result.infoBox || result.characterThoughts) {
// console.log('[RPG Parser] ✓ Returning raw JSON parse results:', {
// hasUserStats: !!result.userStats,
// hasInfoBox: !!result.infoBox,
// hasCharacters: !!result.characterThoughts
// });
debugLog('[RPG Parser] Returning raw JSON parse results');
return result;
} else {
console.warn('[RPG Parser] ⚠️ No tracker data extracted from', extractedObjects.length, 'objects');
}
}
// Check for JSON code blocks (legacy v3 format with ```json fences)
// Look for ```json code blocks which indicate JSON format
const jsonBlockRegex = /```json\s*\n([\s\S]*?)```/g;
const jsonMatches = [...cleanedResponse.matchAll(jsonBlockRegex)];
if (jsonMatches.length > 0) {
// console.log('[RPG Parser] ✓ Found', jsonMatches.length, 'JSON code blocks (v3 format with fences)');
debugLog('[RPG Parser] ✓ Found JSON code blocks (v3 format), parsing as JSON');
for (let idx = 0; idx < jsonMatches.length; idx++) {
const match = jsonMatches[idx];
const jsonContent = match[1].trim();
if (!jsonContent) continue;
// console.log(`[RPG Parser] Parsing JSON block ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
const parsed = repairJSON(jsonContent);
if (parsed) {
// console.log(`[RPG Parser] JSON block ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Detect tracker type by checking for top-level fields
if (parsed.stats || parsed.status || parsed.skills || parsed.inventory || parsed.quests) {
result.userStats = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to User Stats');
debugLog('[RPG Parser] ✓ Extracted JSON User Stats');
} else if (parsed.date || parsed.location || parsed.weather || parsed.temperature || parsed.time) {
result.infoBox = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Info Box');
debugLog('[RPG Parser] ✓ Extracted JSON Info Box');
} else if (parsed.characters || Array.isArray(parsed)) {
result.characterThoughts = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Characters');
debugLog('[RPG Parser] ✓ Extracted JSON Characters');
} else {
console.warn('[RPG Parser] ⚠️ Could not categorize JSON block with keys:', Object.keys(parsed));
}
} else {
console.error('[RPG Parser] ✗ Failed to parse JSON code block', idx + 1);
debugLog('[RPG Parser] ✗ Failed to parse JSON block, will try text fallback');
}
}
// If we found at least one valid JSON block, return the result
// Mixed formats (some JSON, some text) will still work
if (result.userStats || result.infoBox || result.characterThoughts) {
// console.log('[RPG Parser] ✓ Returning JSON code block parse results:', {
// hasUserStats: !!result.userStats,
// hasInfoBox: !!result.infoBox,
// hasCharacters: !!result.characterThoughts
// });
debugLog('[RPG Parser] Returning JSON parse results');
return result;
} else {
console.warn('[RPG Parser] ⚠️ No tracker data extracted from', jsonMatches.length, 'JSON blocks');
}
}
// Check if response uses XML <trackers> tags (hybrid format)
const xmlMatch = cleanedResponse.match(/<trackers>([\s\S]*?)<\/trackers>/i);
if (xmlMatch) {
debugLog('[RPG Parser] ✓ Found XML <trackers> tags, using XML parser');
const trackersContent = xmlMatch[1].trim();
// Try to parse JSON blocks within XML first
const xmlJsonMatches = [...trackersContent.matchAll(jsonBlockRegex)];
if (xmlJsonMatches.length > 0) {
debugLog('[RPG Parser] Found JSON blocks within XML tags');
for (const match of xmlJsonMatches) {
const jsonContent = match[1].trim();
if (!jsonContent) continue;
const parsed = repairJSON(jsonContent);
if (parsed) {
if (parsed.type === 'userStats' || parsed.stats) {
result.userStats = jsonContent;
} else if (parsed.type === 'infoBox' || parsed.date || parsed.location) {
result.infoBox = jsonContent;
} else if (parsed.type === 'characters' || parsed.characters || Array.isArray(parsed)) {
result.characterThoughts = jsonContent;
}
}
}
} else {
// Fallback to text extraction from XML content (legacy v2 text format)
const statsMatch = trackersContent.match(/(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i);
if (statsMatch) {
result.userStats = stripBrackets(statsMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Stats from XML (text format)');
}
const infoBoxMatch = trackersContent.match(/Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i);
if (infoBoxMatch) {
result.infoBox = stripBrackets(infoBoxMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Info Box from XML (text format)');
}
const charactersMatch = trackersContent.match(/Present Characters\s*\n\s*---[\s\S]*$/i);
if (charactersMatch) {
result.characterThoughts = stripBrackets(charactersMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Present Characters from XML (text format)');
}
}
debugLog('[RPG Parser] Parsed from XML:', result);
return result;
}
// Fallback to markdown code block parsing (old text format or mixed format)
debugLog('[RPG Parser] No XML tags found, using code block parser');
// Extract code blocks
const codeBlockRegex = /```([^`]+)```/g;
const matches = [...cleanedResponse.matchAll(codeBlockRegex)];
debugLog('[RPG Parser] Found', matches.length + ' code blocks');
for (let i = 0; i < matches.length; i++) {
const match = matches[i];
const content = match[1].trim();
debugLog(`[RPG Parser] --- Code Block ${i + 1} ---`);
debugLog('[RPG Parser] First 300 chars:', content.substring(0, 300));
// Check if this is a combined code block with multiple sections
const hasMultipleSections = (
content.match(/Stats\s*\n\s*---/i) &&
(content.match(/Info Box\s*\n\s*---/i) || content.match(/Present Characters\s*\n\s*---/i))
);
if (hasMultipleSections) {
// Split the combined code block into individual sections
debugLog('[RPG Parser] ✓ Found combined code block with multiple sections');
// Extract User Stats section
const statsMatch = content.match(/(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i);
if (statsMatch && !result.userStats) {
result.userStats = stripBrackets(statsMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Stats from combined block');
}
// Extract Info Box section
const infoBoxMatch = content.match(/Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i);
if (infoBoxMatch && !result.infoBox) {
result.infoBox = stripBrackets(infoBoxMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Info Box from combined block');
}
// Extract Present Characters section
const charactersMatch = content.match(/Present Characters\s*\n\s*---[\s\S]*$/i);
if (charactersMatch && !result.characterThoughts) {
result.characterThoughts = stripBrackets(charactersMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Present Characters from combined block');
}
} else {
// Handle separate code blocks with flexible pattern matching
// Match Stats section - flexible patterns
const isStats =
content.match(/Stats\s*\n\s*---/i) ||
content.match(/User Stats\s*\n\s*---/i) ||
content.match(/Player Stats\s*\n\s*---/i) ||
// Fallback: look for stat keywords without strict header
(content.match(/Health:\s*\d+%/i) && content.match(/Energy:\s*\d+%/i));
// Match Info Box section - flexible patterns
const isInfoBox =
content.match(/Info Box\s*\n\s*---/i) ||
content.match(/Scene Info\s*\n\s*---/i) ||
content.match(/Information\s*\n\s*---/i) ||
// Fallback: look for info box keywords
(content.match(/Date:/i) && content.match(/Location:/i) && content.match(/Time:/i));
// Match Present Characters section - flexible patterns
const isCharacters =
content.match(/Present Characters\s*\n\s*---/i) ||
content.match(/Characters\s*\n\s*---/i) ||
content.match(/Character Thoughts\s*\n\s*---/i) ||
// Fallback: look for new multi-line format patterns
(content.match(/^-\s+\w+/m) && content.match(/Details:/i));
if (isStats && !result.userStats) {
result.userStats = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Stats section');
} else if (isInfoBox && !result.infoBox) {
result.infoBox = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Info Box section');
} else if (isCharacters && !result.characterThoughts) {
result.characterThoughts = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Present Characters section');
debugLog('[RPG Parser] Full content:', content);
} else {
debugLog('[RPG Parser] ✗ No match - checking patterns:');
debugLog('[RPG Parser] - Has "Stats\\n---"?', !!content.match(/Stats\s*\n\s*---/i));
debugLog('[RPG Parser] - Has stat keywords?', !!(content.match(/Health:\s*\d+%/i) && content.match(/Energy:\s*\d+%/i)));
debugLog('[RPG Parser] - Has "Info Box\\n---"?', !!content.match(/Info Box\s*\n\s*---/i));
debugLog('[RPG Parser] - Has info keywords?', !!(content.match(/Date:/i) && content.match(/Location:/i)));
debugLog('[RPG Parser] - Has "Present Characters\\n---"?', !!content.match(/Present Characters\s*\n\s*---/i));
debugLog('[RPG Parser] - Has new format ("- Name" + "Details:")?', !!(content.match(/^-\s+\w+/m) && content.match(/Details:/i)));
}
}
}
debugLog('[RPG Parser] ==================== PARSE RESULTS ====================');
debugLog('[RPG Parser] Found Stats:', !!result.userStats);
debugLog('[RPG Parser] Found Info Box:', !!result.infoBox);
debugLog('[RPG Parser] Found Characters:', !!result.characterThoughts);
debugLog('[RPG Parser] =======================================================');
// Check if we found at least one section - if not, mark as parsing failure
if (!result.userStats && !result.infoBox && !result.characterThoughts) {
result.parsingFailed = true;
console.error('[RPG Parser] ❌ No tracker data found in response - parsing failed');
}
return result;
} // End parseResponse
/**
* Parses user stats from the text and updates the extensionSettings.
* Extracts percentages, mood, conditions, and inventory from the stats text.
*
* @param {string} statsText - The raw stats text from AI response
*/
export function parseUserStats(statsText) {
debugLog('[RPG Parser] ==================== PARSING USER STATS ====================');
debugLog('[RPG Parser] Stats text length:', statsText.length + ' chars');
debugLog('[RPG Parser] Stats text preview:', statsText.substring(0, 200));
try {
// Check if this is v3 JSON format - try to parse it first
let statsData = null;
const trimmed = statsText.trim();
if (trimmed && (trimmed.startsWith('{') || trimmed.startsWith('['))) {
statsData = repairJSON(statsText);
if (statsData) {
debugLog('[RPG Parser] ✓ Parsed as v3 JSON format');
// Extract stats from v3 JSON structure
if (statsData.stats && Array.isArray(statsData.stats)) {
// console.log('[RPG Parser] ✓ Extracting stats array, count:', statsData.stats.length);
statsData.stats.forEach(stat => {
if (stat.id && typeof stat.value !== 'undefined') {
extensionSettings.userStats[stat.id] = stat.value;
// console.log(`[RPG Parser] ✓ Set ${stat.id} = ${stat.value}`);
}
});
}
// Extract status
if (statsData.status) {
// console.log('[RPG Parser] ✓ Extracting status:', statsData.status);
if (statsData.status.mood) {
extensionSettings.userStats.mood = statsData.status.mood;
// console.log('[RPG Parser] ✓ Set mood =', statsData.status.mood);
}
// Extract all custom status fields
const trackerConfig = extensionSettings.trackerConfig;
const customFields = trackerConfig?.userStats?.statusSection?.customFields || [];
for (const fieldName of customFields) {
const fieldKey = toFieldKey(fieldName);
// Try the base key first (e.g., "conditions"), then fall back to full lowercase name
const value = statsData.status[fieldKey] || statsData.status[fieldName.toLowerCase()];
if (value) {
extensionSettings.userStats[fieldKey] = value;
// console.log(`[RPG Parser] ✓ Set ${fieldKey} =`, value);
}
}
}
// Extract inventory (convert v3 array format to v2 string format)
if (statsData.inventory) {
const inv = statsData.inventory;
// Convert arrays of {name, quantity} objects to comma-separated strings
const convertItems = (items) => {
if (!items || !Array.isArray(items)) return '';
return items.map(item => {
if (typeof item === 'object' && item.name) {
// Include quantity if > 1
return item.quantity && item.quantity > 1
? `${item.quantity}x ${item.name}`
: item.name;
}
return String(item);
}).join(', ');
};
// Convert stored object {location: [items]} to {location: "item1, item2"}
const convertStoredInventory = (stored) => {
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return {};
const result = {};
for (const [location, items] of Object.entries(stored)) {
if (Array.isArray(items)) {
result[location] = convertItems(items);
} else if (typeof items === 'string') {
result[location] = items;
} else {
result[location] = '';
}
}
return result;
};
extensionSettings.userStats.inventory = {
onPerson: convertItems(inv.onPerson),
clothing: convertItems(inv.clothing),
stored: convertStoredInventory(inv.stored),
assets: convertItems(inv.assets)
};
// console.log('[RPG Parser] ✓ Converted v3 inventory:', extensionSettings.userStats.inventory);
}
// Extract quests (convert v3 object format to v2 string format)
if (statsData.quests) {
// Convert quest objects to strings
const convertQuest = (quest) => {
if (!quest) return '';
if (typeof quest === 'string') return quest;
if (typeof quest === 'object') {
// Check for locked format: {value, locked}
// Recursively extract value if it's nested
let extracted = quest;
while (typeof extracted === 'object' && extracted.value !== undefined) {
extracted = extracted.value;
}
if (typeof extracted === 'string') return extracted;
// v3 format: {title, description, status}
return quest.title || quest.description || JSON.stringify(quest);
}
return String(quest);
};
extensionSettings.quests = {
main: convertQuest(statsData.quests.main),
optional: Array.isArray(statsData.quests.optional)
? statsData.quests.optional.map(convertQuest)
: []
};
// console.log('[RPG Parser] ✓ Converted v3 quests:', extensionSettings.quests);
}
// Extract skills if present (store as object, not JSON string)
if (statsData.skills && Array.isArray(statsData.skills)) {
extensionSettings.userStats.skills = statsData.skills;
// console.log('[RPG Parser] ✓ Set skills:', extensionSettings.userStats.skills);
}
debugLog('[RPG Parser] ✓ Successfully extracted v3 JSON data');
saveSettings();
return; // Done processing v3 format
}
}
// Fall back to v2 text format parsing if JSON parsing failed
debugLog('[RPG Parser] Falling back to v2 text format parsing');
// Get custom stat configuration
const trackerConfig = extensionSettings.trackerConfig;
const customStats = trackerConfig?.userStats?.customStats || [];
const enabledStats = customStats.filter(s => s && s.enabled && s.name && s.id);
debugLog('[RPG Parser] Enabled custom stats:', enabledStats.map(s => s.name));
// Dynamically parse custom stats
for (const stat of enabledStats) {
const statRegex = new RegExp(`${stat.name}:\\s*(\\d+)%`, 'i');
const match = statsText.match(statRegex);
if (match) {
// Store using the stat ID (lowercase normalized name)
const statId = stat.id;
extensionSettings.userStats[statId] = parseInt(match[1]);
debugLog(`[RPG Parser] Parsed ${stat.name}:`, match[1]);
} else {
debugLog(`[RPG Parser] ${stat.name} NOT FOUND`);
}
}
// Parse RPG attributes if enabled
if (trackerConfig?.userStats?.showRPGAttributes) {
const strMatch = statsText.match(/STR:\s*(\d+)/i);
const dexMatch = statsText.match(/DEX:\s*(\d+)/i);
const conMatch = statsText.match(/CON:\s*(\d+)/i);
const intMatch = statsText.match(/INT:\s*(\d+)/i);
const wisMatch = statsText.match(/WIS:\s*(\d+)/i);
const chaMatch = statsText.match(/CHA:\s*(\d+)/i);
const lvlMatch = statsText.match(/LVL:\s*(\d+)/i);
if (strMatch) extensionSettings.classicStats.str = parseInt(strMatch[1]);
if (dexMatch) extensionSettings.classicStats.dex = parseInt(dexMatch[1]);
if (conMatch) extensionSettings.classicStats.con = parseInt(conMatch[1]);
if (intMatch) extensionSettings.classicStats.int = parseInt(intMatch[1]);
if (wisMatch) extensionSettings.classicStats.wis = parseInt(wisMatch[1]);
if (chaMatch) extensionSettings.classicStats.cha = parseInt(chaMatch[1]);
if (lvlMatch) extensionSettings.level = parseInt(lvlMatch[1]);
debugLog('[RPG Parser] RPG Attributes parsed');
}
// Match status section if enabled
const statusConfig = trackerConfig?.userStats?.statusSection;
if (statusConfig?.enabled) {
let moodMatch = null;
const customFields = statusConfig.customFields || [];
// Try Status: format
const statusMatch = statsText.match(/Status:\s*(.+)/i);
if (statusMatch) {
const statusContent = statusMatch[1].trim();
// Extract mood emoji if enabled
if (statusConfig.showMoodEmoji) {
const { emoji, text } = separateEmojiFromText(statusContent);
if (emoji) {
extensionSettings.userStats.mood = emoji;
// Remaining text contains custom status fields
if (text && customFields.length > 0) {
// For first custom field, use the remaining text
const firstFieldKey = customFields[0].toLowerCase();
extensionSettings.userStats[firstFieldKey] = text;
}
moodMatch = true;
}
} else {
// No mood emoji, whole status goes to first custom field
if (customFields.length > 0) {
const firstFieldKey = customFields[0].toLowerCase();
extensionSettings.userStats[firstFieldKey] = statusContent;
}
moodMatch = true;
}
}
// Try to extract individual custom status fields by name
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
const fieldRegex = new RegExp(`${fieldName}:\\s*(.+?)(?:,|$)`, 'i');
const fieldMatch = statsText.match(fieldRegex);
if (fieldMatch) {
extensionSettings.userStats[fieldKey] = fieldMatch[1].trim();
moodMatch = true;
}
}
debugLog('[RPG Parser] Status match:', {
found: !!moodMatch,
mood: extensionSettings.userStats.mood,
customFields: customFields.map(f => ({
name: f,
value: extensionSettings.userStats[f.toLowerCase()]
}))
});
}
// Parse skills section if enabled
const skillsConfig = trackerConfig?.userStats?.skillsSection;
if (skillsConfig?.enabled) {
const skillsMatch = statsText.match(/Skills:\s*(.+)/i);
if (skillsMatch) {
extensionSettings.userStats.skills = skillsMatch[1].trim();
debugLog('[RPG Parser] Skills extracted:', skillsMatch[1].trim());
}
}
// Extract inventory - use v2 parser if feature flag enabled, otherwise fallback to v1
if (FEATURE_FLAGS.useNewInventory) {
const inventoryData = extractInventory(statsText);
if (inventoryData) {
extensionSettings.userStats.inventory = inventoryData;
debugLog('[RPG Parser] Inventory v2 extracted:', inventoryData);
} else {
debugLog('[RPG Parser] Inventory v2 extraction failed');
}
} else {
// Legacy v1 parsing for backward compatibility
const inventoryMatch = statsText.match(/Inventory:\s*(.+)/i);
if (inventoryMatch) {
extensionSettings.userStats.inventory = inventoryMatch[1].trim();
debugLog('[RPG Parser] Inventory v1 extracted:', inventoryMatch[1].trim());
} else {
debugLog('[RPG Parser] Inventory v1 not found');
}
}
// Extract quests
const mainQuestMatch = statsText.match(/Main Quests?:\s*(.+)/i);
if (mainQuestMatch) {
extensionSettings.quests.main = mainQuestMatch[1].trim();
debugLog('[RPG Parser] Main quests extracted:', mainQuestMatch[1].trim());
}
const optionalQuestsMatch = statsText.match(/Optional Quests:\s*(.+)/i);
if (optionalQuestsMatch) {
const questsText = optionalQuestsMatch[1].trim();
if (questsText && questsText !== 'None') {
// Split by comma and clean up
extensionSettings.quests.optional = questsText
.split(',')
.map(q => q.trim())
.filter(q => q && q !== 'None');
} else {
extensionSettings.quests.optional = [];
}
debugLog('[RPG Parser] Optional quests extracted:', extensionSettings.quests.optional);
}
debugLog('[RPG Parser] Final userStats after parsing:', {
health: extensionSettings.userStats.health,
satiety: extensionSettings.userStats.satiety,
energy: extensionSettings.userStats.energy,
hygiene: extensionSettings.userStats.hygiene,
arousal: extensionSettings.userStats.arousal,
mood: extensionSettings.userStats.mood,
conditions: extensionSettings.userStats.conditions,
inventory: FEATURE_FLAGS.useNewInventory ? 'v2 object' : extensionSettings.userStats.inventory
});
saveSettings();
debugLog('[RPG Parser] Settings saved successfully');
debugLog('[RPG Parser] =======================================================');
} catch (error) {
console.error('[RPG Companion] Error parsing user stats:', error);
console.error('[RPG Companion] Stack trace:', error.stack);
debugLog('[RPG Parser] ERROR:', error.message);
debugLog('[RPG Parser] Stack:', error.stack);
}
}
/**
* Helper: Extract code blocks from text
* @param {string} text - Text containing markdown code blocks
* @returns {Array<string>} Array of code block contents
*/
export function extractCodeBlocks(text) {
const codeBlockRegex = /```([^`]+)```/g;
const matches = [...text.matchAll(codeBlockRegex)];
return matches.map(match => match[1].trim());
}
/**
* Helper: Parse stats section from code block content
* @param {string} content - Code block content
* @returns {boolean} True if this is a stats section
*/
export function isStatsSection(content) {
return content.match(/Stats\s*\n\s*---/i) !== null;
}
/**
* Helper: Parse info box section from code block content
* @param {string} content - Code block content
* @returns {boolean} True if this is an info box section
*/
export function isInfoBoxSection(content) {
return content.match(/Info Box\s*\n\s*---/i) !== null;
}
/**
* Helper: Parse character thoughts section from code block content
* @param {string} content - Code block content
* @returns {boolean} True if this is a character thoughts section
*/
export function isCharacterThoughtsSection(content) {
return content.match(/Present Characters\s*\n\s*---/i) !== null || content.includes(" | ");
}