Release v3.0.0 - Major update with JSON format, lock/unlock trackers, reorganized UI, colored dialogues, editable prompts, and numerous bug fixes

This commit is contained in:
Spicy_Marinara
2026-01-07 17:22:22 +01:00
parent 8df6548e0b
commit c3cdac24c6
46 changed files with 6241 additions and 3571 deletions
+433
View File
@@ -0,0 +1,433 @@
/**
* JSON Migration Module
* Migrates committed tracker data from v2 text format to v3 JSON format
*/
import { committedTrackerData, extensionSettings, updateCommittedTrackerData, updateExtensionSettings } from '../core/state.js';
import { saveSettings, saveChatData } from '../core/persistence.js';
/**
* Helper to separate emoji from text in a string
* @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
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
text = text.replace(/^[,\s]+/, '');
return { emoji, text };
}
// 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 };
}
/**
* Parses item text to JSON format
* Handles "3x Item Name" or "Item Name" formats
* @param {string} itemsText - Comma-separated items string
* @returns {Array<{name: string, quantity?: number}>} Array of item objects
*/
function parseItemsToJSON(itemsText) {
if (!itemsText || itemsText.trim() === '' || itemsText.toLowerCase() === 'none') {
return [];
}
const items = itemsText.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
// Parse "3x Health Potion" format
const qtyMatch = item.match(/^(\d+)x\s*(.+)/i);
if (qtyMatch) {
return {
name: qtyMatch[2].trim(),
quantity: parseInt(qtyMatch[1])
};
}
return { name: item, quantity: 1 };
});
}
/**
* Migrates User Stats from v2 text format to v3 JSON format
* @param {string} textData - V2 text format user stats
* @returns {object} V3 JSON format user stats
*/
export function migrateUserStatsToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const lines = textData.split('\n');
const result = {
version: 3,
stats: [],
status: {},
skills: [],
inventory: {
onPerson: [],
clothing: [],
stored: {},
assets: []
},
quests: {
main: null,
optional: []
}
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === '---' || trimmed.startsWith('```')) continue;
// Parse "- StatName: X%" format
const statMatch = trimmed.match(/^-\s*([^:]+):\s*(\d+)%/);
if (statMatch) {
const name = statMatch[1].trim();
const id = name.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
result.stats.push({
id: id,
name: name,
value: parseInt(statMatch[2])
});
continue;
}
// Parse "Status: emoji, text" or "Status: text" format
const statusMatch = trimmed.match(/^Status:\s*(.+)/i);
if (statusMatch) {
const { emoji, text } = separateEmojiFromText(statusMatch[1]);
if (emoji) result.status.mood = emoji;
if (text) result.status.conditions = text;
continue;
}
// Parse "Skills: skill1, skill2" format
const skillsMatch = trimmed.match(/^Skills:\s*(.+)/i);
if (skillsMatch) {
const skillsText = skillsMatch[1].trim();
if (skillsText && skillsText.toLowerCase() !== 'none') {
const skills = skillsText.split(',').map(s => s.trim()).filter(s => s);
result.skills = skills.map(name => ({ name }));
}
continue;
}
// Parse inventory lines
const onPersonMatch = trimmed.match(/^On Person:\s*(.+)/i);
if (onPersonMatch) {
result.inventory.onPerson = parseItemsToJSON(onPersonMatch[1]);
continue;
}
const clothingMatch = trimmed.match(/^Clothing:\s*(.+)/i);
if (clothingMatch) {
result.inventory.clothing = parseItemsToJSON(clothingMatch[1]);
continue;
}
const storedMatch = trimmed.match(/^Stored\s*-\s*([^:]+):\s*(.+)/i);
if (storedMatch) {
const location = storedMatch[1].trim();
result.inventory.stored[location] = parseItemsToJSON(storedMatch[2]);
continue;
}
const assetsMatch = trimmed.match(/^Assets:\s*(.+)/i);
if (assetsMatch) {
const assetsText = assetsMatch[1].trim();
if (assetsText && assetsText.toLowerCase() !== 'none') {
result.inventory.assets = assetsText.split(',').map(s => s.trim()).filter(s => s).map(name => ({ name }));
}
continue;
}
// Parse quest lines
const mainQuestMatch = trimmed.match(/^Main Quests?:\s*(.+)/i);
if (mainQuestMatch) {
const questText = mainQuestMatch[1].trim();
if (questText && questText.toLowerCase() !== 'none') {
result.quests.main = { title: questText };
}
continue;
}
const optionalQuestsMatch = trimmed.match(/^Optional Quests?:\s*(.+)/i);
if (optionalQuestsMatch) {
const questsText = optionalQuestsMatch[1].trim();
if (questsText && questsText.toLowerCase() !== 'none') {
const quests = questsText.split(',').map(s => s.trim()).filter(s => s);
result.quests.optional = quests.map(title => ({ title }));
}
continue;
}
}
return result;
}
/**
* Migrates Info Box from v2 text format to v3 JSON format
* @param {string} textData - V2 text format info box
* @returns {object} V3 JSON format info box
*/
export function migrateInfoBoxToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const lines = textData.split('\n');
const result = {
version: 3
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === '---' || trimmed.startsWith('```') || trimmed.toLowerCase() === 'info box') continue;
// Parse "Date: value" format
const dateMatch = trimmed.match(/^Date:\s*(.+)/i);
if (dateMatch) {
result.date = { value: dateMatch[1].trim() };
continue;
}
// Parse "Weather: emoji, text" or "Weather: text" format
const weatherMatch = trimmed.match(/^Weather:\s*(.+)/i);
if (weatherMatch) {
const { emoji, text } = separateEmojiFromText(weatherMatch[1]);
result.weather = {
emoji: emoji || '',
forecast: text || weatherMatch[1].trim()
};
continue;
}
// Parse "Temperature: X°C" or "Temperature: X°F" format
const tempMatch = trimmed.match(/^Temperature:\s*(\d+)\s*°?([CF])?/i);
if (tempMatch) {
result.temperature = {
value: parseInt(tempMatch[1]),
unit: tempMatch[2] ? tempMatch[2].toUpperCase() : 'C'
};
continue;
}
// Parse "Time: start → end" format
const timeMatch = trimmed.match(/^Time:\s*(.+?)\s*→\s*(.+)/i);
if (timeMatch) {
result.time = {
start: timeMatch[1].trim(),
end: timeMatch[2].trim()
};
continue;
}
// Parse "Location: value" format
const locationMatch = trimmed.match(/^Location:\s*(.+)/i);
if (locationMatch) {
result.location = { value: locationMatch[1].trim() };
continue;
}
// Parse "Recent Events: event1, event2, event3" format
const eventsMatch = trimmed.match(/^Recent Events:\s*(.+)/i);
if (eventsMatch) {
const eventsText = eventsMatch[1].trim();
if (eventsText && eventsText.toLowerCase() !== 'none') {
result.recentEvents = eventsText.split(',').map(s => s.trim()).filter(s => s);
}
continue;
}
}
return result;
}
/**
* Migrates Present Characters from v2 text format to v3 JSON format
* @param {string} textData - V2 text format present characters
* @returns {object} V3 JSON format present characters
*/
export function migrateCharactersToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const result = {
version: 3,
characters: []
};
// Split by character blocks (marked by "- Name")
const blocks = ('\n' + textData).split(/\n-\s+/);
for (const block of blocks) {
if (!block.trim()) continue;
const lines = block.trim().split('\n');
if (lines.length === 0) continue;
const character = {
name: lines[0].trim()
};
// Parse subsequent lines for this character
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Parse "Details: emoji | field1 | field2" format
const detailsMatch = line.match(/^Details:\s*(.+)/i);
if (detailsMatch) {
const detailsText = detailsMatch[1].trim();
const parts = detailsText.split('|').map(s => s.trim());
const { emoji } = separateEmojiFromText(parts[0] || '');
if (emoji) character.emoji = emoji;
character.details = {};
for (let j = 1; j < parts.length; j++) {
const fieldName = `field${j}`;
character.details[fieldName] = parts[j];
}
continue;
}
// Parse "Relationship: status" format
const relationshipMatch = line.match(/^Relationship:\s*(.+)/i);
if (relationshipMatch) {
character.relationship = { status: relationshipMatch[1].trim() };
continue;
}
// Parse "Stats: stat1: X% | stat2: Y%" format
const statsMatch = line.match(/^Stats:\s*(.+)/i);
if (statsMatch) {
const statsText = statsMatch[1].trim();
const statParts = statsText.split('|').map(s => s.trim());
character.stats = [];
for (const statPart of statParts) {
const statValueMatch = statPart.match(/^([^:]+):\s*(\d+)%/);
if (statValueMatch) {
character.stats.push({
name: statValueMatch[1].trim(),
value: parseInt(statValueMatch[2])
});
}
}
continue;
}
// Parse "Thoughts: content" format
const thoughtsMatch = line.match(/^Thoughts:\s*(.+)/i);
if (thoughtsMatch) {
character.thoughts = { content: thoughtsMatch[1].trim() };
continue;
}
}
result.characters.push(character);
}
return result;
}
/**
* Main migration function - migrates all committed tracker data to v3 JSON format
* @returns {Promise<void>}
*/
export async function migrateToV3JSON() {
console.log('[RPG Migration] Starting migration to v3 JSON format...');
const migrated = {
userStats: null,
infoBox: null,
characterThoughts: null
};
// Migrate User Stats
if (committedTrackerData.userStats && typeof committedTrackerData.userStats === 'string') {
console.log('[RPG Migration] Migrating User Stats...');
migrated.userStats = migrateUserStatsToJSON(committedTrackerData.userStats);
if (migrated.userStats) {
console.log('[RPG Migration] ✓ User Stats migrated');
}
}
// Migrate Info Box
if (committedTrackerData.infoBox && typeof committedTrackerData.infoBox === 'string') {
console.log('[RPG Migration] Migrating Info Box...');
migrated.infoBox = migrateInfoBoxToJSON(committedTrackerData.infoBox);
if (migrated.infoBox) {
console.log('[RPG Migration] ✓ Info Box migrated');
}
}
// Migrate Present Characters
if (committedTrackerData.characterThoughts && typeof committedTrackerData.characterThoughts === 'string') {
console.log('[RPG Migration] Migrating Present Characters...');
migrated.characterThoughts = migrateCharactersToJSON(committedTrackerData.characterThoughts);
if (migrated.characterThoughts) {
console.log('[RPG Migration] ✓ Present Characters migrated');
}
}
// Update committed data
updateCommittedTrackerData(migrated);
// Initialize lockedItems if not present
if (!extensionSettings.lockedItems) {
console.log('[RPG Migration] Initializing lockedItems structure...');
updateExtensionSettings({
lockedItems: {
stats: [],
skills: [],
inventory: {
onPerson: [],
clothing: [],
stored: {},
assets: []
},
quests: {
main: false,
optional: []
},
infoBox: {
date: false,
weather: false,
temperature: false,
time: false,
location: false,
recentEvents: false
},
characters: {}
}
});
}
// Save migrated data
await saveChatData();
await saveSettings();
console.log('[RPG Migration] ✅ Migration to v3 JSON format complete');
}
+220
View File
@@ -0,0 +1,220 @@
/**
* JSON Repair Utilities
* Handles parsing and repairing malformed JSON from AI responses
*/
/**
* Repairs malformed JSON from AI responses
* Handles common AI mistakes like trailing commas, missing commas, wrong quotes, etc.
*
* @param {string} jsonString - Potentially malformed JSON string
* @returns {object|null} Repaired JSON object or null if repair fails
*/
export function repairJSON(jsonString) {
if (!jsonString || typeof jsonString !== 'string') {
console.warn('[RPG JSON Repair] Invalid input:', typeof jsonString);
return null;
}
let cleaned = jsonString.trim();
// Remove markdown code fences
cleaned = cleaned.replace(/```json\s*/gi, '');
cleaned = cleaned.replace(/```\s*/g, '');
// Remove thinking tags (model's internal reasoning)
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>/gi, '');
cleaned = cleaned.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
// Fix common JSON errors:
// 1. Trailing commas before closing brackets
cleaned = cleaned.replace(/,(\s*[}\]])/g, '$1');
// 2. Missing commas between properties - DISABLED because it corrupts valid JSON
// Modern AI models send properly formatted JSON, so this aggressive repair is not needed
// cleaned = cleaned.replace(/("\s*:\s*(?:"[^"]*"|[^,}\]]+))(\s+")/g, '$1,$2');
// 3. Single quotes to double quotes - DISABLED because it corrupts apostrophes in text
// Apostrophes in strings like "Zandik's Office" would become "Zandik"s Office" (invalid JSON)
// Modern AI models already use double quotes for JSON strings
// cleaned = cleaned.replace(/'/g, '"');
// 4. Unquoted keys - DISABLED because it corrupts valid JSON string values
// The AI models already send properly quoted JSON, so this is not needed
// cleaned = cleaned.replace(/(\{|,)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":');
// 5. Remove JavaScript comments
cleaned = cleaned.replace(/\/\/.*$/gm, '');
cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, '');
// Attempt 1: Standard JSON.parse
try {
return JSON.parse(cleaned);
} catch (e) {
}
// Attempt 2: Extract JSON object between first { and last }
const objectMatch = cleaned.match(/\{[\s\S]*\}/);
if (objectMatch) {
try {
return JSON.parse(objectMatch[0]);
} catch (e) {
// Silent fail, try next method
}
}
// Attempt 3: Try to extract JSON array between first [ and last ]
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
if (arrayMatch) {
try {
return JSON.parse(arrayMatch[0]);
} catch (e) {
// Silent fail, try next method
}
}
// Attempt 4: Use Function constructor (safer than eval, still controlled)
// Only as last resort for trusted AI output
try {
const fn = new Function(`"use strict"; return (${cleaned});`);
const result = fn();
// Validate it's actually an object or array
if (result && (typeof result === 'object')) {
console.log('[RPG JSON Repair] ✓ Repaired using Function constructor');
return result;
}
} catch (e) {
console.error('[RPG JSON Repair] ✗ All repair attempts failed:', e.message);
}
return null;
}
/**
* Validates JSON structure matches expected schema for a tracker type
*
* @param {object} data - Parsed JSON data to validate
* @param {string} type - Type of tracker ('userStats', 'infoBox', 'characters')
* @returns {boolean} True if valid, false otherwise
*/
export function validateJSONSchema(data, type) {
if (!data || typeof data !== 'object') {
return false;
}
try {
switch (type) {
case 'userStats':
return Array.isArray(data.stats) &&
data.stats.every(s =>
s &&
typeof s === 'object' &&
s.id &&
s.name &&
typeof s.value === 'number'
);
case 'infoBox':
return (data.date || data.weather || data.time || data.location || data.temperature || data.recentEvents);
case 'characters':
return Array.isArray(data.characters) &&
data.characters.every(c => c && c.name);
default:
console.warn('[RPG JSON Validation] Unknown tracker type:', type);
return false;
}
} catch (e) {
console.error('[RPG JSON Validation] Error during validation:', e);
return false;
}
}
/**
* Extracts JSON from text that may contain other content
* Looks for JSON blocks within ```json fences or standalone JSON objects
*
* @param {string} text - Text potentially containing JSON
* @returns {string|null} Extracted JSON string or null
*/
export function extractJSONFromText(text) {
if (!text || typeof text !== 'string') {
return null;
}
// Try to extract from ```json code fence
const fenceMatch = text.match(/```json\s*([\s\S]*?)```/i);
if (fenceMatch && fenceMatch[1]) {
return fenceMatch[1].trim();
}
// Try to extract from ``` code fence (without json label)
const genericFenceMatch = text.match(/```\s*([\s\S]*?)```/);
if (genericFenceMatch && genericFenceMatch[1]) {
const content = genericFenceMatch[1].trim();
// Check if it looks like JSON (starts with { or [)
if (content.startsWith('{') || content.startsWith('[')) {
return content;
}
}
// Try to find standalone JSON object
const objectMatch = text.match(/\{[\s\S]*\}/);
if (objectMatch) {
return objectMatch[0];
}
// Try to find standalone JSON array
const arrayMatch = text.match(/\[[\s\S]*\]/);
if (arrayMatch) {
return arrayMatch[0];
}
return null;
}
/**
* Safely parses JSON with automatic repair attempts
* Combines extraction, repair, and validation in one call
*
* @param {string} text - Text containing JSON (with or without code fences)
* @param {string} expectedType - Expected tracker type for validation ('userStats', 'infoBox', 'characters')
* @returns {{data: object|null, success: boolean, error: string|null}} Result object
*/
export function safeParseJSON(text, expectedType = null) {
const result = {
data: null,
success: false,
error: null
};
// Extract JSON from text
const jsonString = extractJSONFromText(text);
if (!jsonString) {
result.error = 'No JSON found in text';
return result;
}
// Attempt to repair and parse
const parsed = repairJSON(jsonString);
if (!parsed) {
result.error = 'Failed to parse JSON after repair attempts';
return result;
}
// Validate schema if type specified
if (expectedType) {
const valid = validateJSONSchema(parsed, expectedType);
if (!valid) {
result.error = `JSON does not match expected schema for type: ${expectedType}`;
result.data = parsed; // Return data anyway, might be partially useful
return result;
}
}
result.data = parsed;
result.success = true;
return result;
}
+20 -3
View File
@@ -28,7 +28,24 @@ const DEFAULT_INVENTORY_V2 = {
* @returns {MigrationResult} Migration result with v2 inventory and metadata
*/
export function migrateInventory(inventory) {
// Case 1: Already v2 format (has version property and is an object)
// Case 1: v2 format missing version property (parser output)
// Parser returns v2 structure but without the version tag
if (inventory && typeof inventory === 'object' &&
'onPerson' in inventory && 'clothing' in inventory &&
'stored' in inventory && 'assets' in inventory &&
!('version' in inventory)) {
// console.log('[RPG Companion Migration] v2 inventory missing version tag, adding it');
return {
inventory: {
version: 2,
...inventory
},
migrated: true,
source: 'parser-output'
};
}
// Case 2: Already v2 format (has version property and is an object)
if (inventory && typeof inventory === 'object' && inventory.version === 2) {
// Check if clothing field exists (v2.1 upgrade)
if (!inventory.hasOwnProperty('clothing')) {
@@ -49,7 +66,7 @@ export function migrateInventory(inventory) {
};
}
// Case 2: null or undefined → use defaults
// Case 3: null or undefined → use defaults
if (inventory === null || inventory === undefined) {
// console.log('[RPG Companion Migration] Inventory is null/undefined, using defaults');
return {
@@ -59,7 +76,7 @@ export function migrateInventory(inventory) {
};
}
// Case 3: v1 string format → migrate to v2
// Case 4: v1 string format → migrate to v2
if (typeof inventory === 'string') {
// Check if it's an empty/default string
const trimmed = inventory.trim();