From f3e751862235ee0868b01a3ad4c9a47ba2e54e10 Mon Sep 17 00:00:00 2001 From: Daryl Date: Sat, 21 Feb 2026 20:40:33 -0400 Subject: [PATCH 01/25] Enhance swipe data handling to correctly display swipe-specific tracker stats: add getSwipeData function and refactor commitTrackerData to utilize it --- src/core/persistence.js | 35 ++++++++++++++++++- src/systems/integration/sillytavern.js | 48 ++++++++++++-------------- 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/src/core/persistence.js b/src/core/persistence.js index e6343c8..1ad8e65 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -247,11 +247,23 @@ export function updateMessageSwipeData() { } const swipeId = message.swipe_id || 0; - message.extra.rpg_companion_swipes[swipeId] = { + const swipeEntry = { userStats: lastGeneratedData.userStats, infoBox: lastGeneratedData.infoBox, characterThoughts: lastGeneratedData.characterThoughts }; + message.extra.rpg_companion_swipes[swipeId] = swipeEntry; + + // Mirror to swipe_info so data survives page reloads regardless of active swipe + if (message.swipe_info && message.swipe_info[swipeId]) { + if (!message.swipe_info[swipeId].extra) { + message.swipe_info[swipeId].extra = {}; + } + if (!message.swipe_info[swipeId].extra.rpg_companion_swipes) { + message.swipe_info[swipeId].extra.rpg_companion_swipes = {}; + } + message.swipe_info[swipeId].extra.rpg_companion_swipes[swipeId] = swipeEntry; + } // console.log('[RPG Companion] Updated message swipe data after user edit'); break; @@ -259,6 +271,27 @@ export function updateMessageSwipeData() { } } +/** + * Reads RPG tracker data for a specific swipe from a message. + * Checks message.extra first (in-memory, current session), then message.swipe_info + * (serialized by SillyTavern on save, available after page reload). + * + * @param {Object} message - The chat message object + * @param {number} swipeId - The swipe index to read + * @returns {{userStats, infoBox, characterThoughts}|null} The swipe data or null + */ +export function getSwipeData(message, swipeId) { + // Primary: in-memory extra (current session or after a recent write) + const fromExtra = message.extra?.rpg_companion_swipes?.[swipeId]; + if (fromExtra) return fromExtra; + + // Fallback: swipe_info (populated by ST when loading from disk) + const fromSwipeInfo = message.swipe_info?.[swipeId]?.extra?.rpg_companion_swipes?.[swipeId]; + if (fromSwipeInfo) return fromSwipeInfo; + + return null; +} + /** * Loads RPG data from the current chat's metadata. * Automatically migrates v1 inventory to v2 format if needed. diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 3af43a8..0e121a4 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -22,7 +22,7 @@ import { updateCommittedTrackerData, $musicPlayerContainer } from '../../core/state.js'; -import { saveChatData, loadChatData, autoSwitchPresetForEntity } from '../../core/persistence.js'; +import { saveChatData, loadChatData, autoSwitchPresetForEntity, getSwipeData } from '../../core/persistence.js'; import { i18n } from '../../core/i18n.js'; // Generation & Parsing @@ -67,20 +67,19 @@ export function commitTrackerData() { const message = chat[i]; if (!message.is_user) { // Found last assistant message - commit its tracker data - if (message.extra && message.extra.rpg_companion_swipes) { - const swipeId = message.swipe_id || 0; - const swipeData = message.extra.rpg_companion_swipes[swipeId]; + const swipeId = message.swipe_id || 0; + const swipeData = getSwipeData(message, swipeId); - if (swipeData) { - // console.log('[RPG Companion] Committing tracker data from assistant message at index', i, 'swipe', swipeId); - committedTrackerData.userStats = swipeData.userStats || null; - committedTrackerData.infoBox = swipeData.infoBox || null; - committedTrackerData.characterThoughts = swipeData.characterThoughts || null; - } else { - // console.log('[RPG Companion] No swipe data found for swipe', swipeId); - } + if (swipeData) { + // console.log('[RPG Companion] Committing tracker data from assistant message at index', i, 'swipe', swipeId); + committedTrackerData.userStats = swipeData.userStats || null; + committedTrackerData.infoBox = swipeData.infoBox || null; + committedTrackerData.characterThoughts = swipeData.characterThoughts || null; } else { - // console.log('[RPG Companion] No RPG data found in last assistant message'); + // No saved swipe data β€” treat as empty (e.g. first message, no prior generation) + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; } break; } @@ -386,12 +385,12 @@ export function onMessageSwiped(messageIndex) { // console.log('[RPG Companion] Loading data for swipe', currentSwipeId); - // IMPORTANT: onMessageSwiped is for DISPLAY only! - // lastGeneratedData is for DISPLAY, committedTrackerData is for GENERATION - // It's safe to load swipe data into lastGeneratedData - it won't be committed due to !lastActionWasSwipe check - if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) { - const swipeData = message.extra.rpg_companion_swipes[currentSwipeId]; - + // Load saved swipe data into both display (lastGeneratedData) and extensionSettings. + // Safe to call parseUserStats() unconditionally because updateMessageSwipeData() is called + // on every manual edit, so the swipe store always reflects the latest user changes before + // any navigation can overwrite them. + const swipeData = getSwipeData(message, currentSwipeId); + if (swipeData) { // Load swipe data into lastGeneratedData for display (both modes) lastGeneratedData.userStats = swipeData.userStats || null; lastGeneratedData.infoBox = swipeData.infoBox || null; @@ -403,13 +402,12 @@ export function onMessageSwiped(messageIndex) { lastGeneratedData.characterThoughts = swipeData.characterThoughts || null; } - // DON'T parse user stats when loading swipe data - // This would overwrite manually edited fields (like Conditions) with old swipe data - // The lastGeneratedData is loaded for display purposes only - // parseUserStats() updates extensionSettings.userStats which should only be modified - // by new generations or manual edits, not by swipe navigation + // Sync extensionSettings.userStats so stat bars reflect this swipe + if (swipeData.userStats) { + parseUserStats(swipeData.userStats); + } - // console.log('[RPG Companion] πŸ”„ Loaded swipe data into lastGeneratedData for display:', currentSwipeId); + // console.log('[RPG Companion] πŸ”„ Loaded swipe data for swipe:', currentSwipeId); } else { // console.log('[RPG Companion] ℹ️ No stored data for swipe:', currentSwipeId); } From 8f2dbd2f88d8fed0d44467f5e92bbd1b2e646e5e Mon Sep 17 00:00:00 2001 From: Daryl Date: Sat, 21 Feb 2026 21:40:52 -0400 Subject: [PATCH 02/25] Implement swipe data persistence between reloads and ensure all tracker data commits are based on prior assistant message when generating/swiping --- src/core/persistence.js | 67 ++++++++++++++++++++++---- src/systems/generation/apiClient.js | 8 ++- src/systems/generation/injector.js | 61 ++++++----------------- src/systems/integration/sillytavern.js | 24 +++++---- 4 files changed, 92 insertions(+), 68 deletions(-) diff --git a/src/core/persistence.js b/src/core/persistence.js index 1ad8e65..18f5719 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -224,6 +224,26 @@ export function saveChatData() { saveChatDebounced(); } +/** + * Mirrors a tracker data entry into message.swipe_info so it survives page reloads. + * ST only serializes swipe_info to disk; message.extra is in-memory only. + * Guard: skips silently if swipe_info[swipeId] doesn't exist yet + * + * @param {Object} message - The chat message object + * @param {number} swipeId - The swipe index to mirror into + * @param {Object} swipeEntry - { userStats, infoBox, characterThoughts } + */ +export function mirrorToSwipeInfo(message, swipeId, swipeEntry) { + if (!message.swipe_info || !message.swipe_info[swipeId]) return; + if (!message.swipe_info[swipeId].extra) { + message.swipe_info[swipeId].extra = {}; + } + if (!message.swipe_info[swipeId].extra.rpg_companion_swipes) { + message.swipe_info[swipeId].extra.rpg_companion_swipes = {}; + } + message.swipe_info[swipeId].extra.rpg_companion_swipes[swipeId] = swipeEntry; +} + /** * Updates the last assistant message's swipe data with current tracker data. * This ensures user edits are preserved across swipes and included in generation context. @@ -255,15 +275,7 @@ export function updateMessageSwipeData() { message.extra.rpg_companion_swipes[swipeId] = swipeEntry; // Mirror to swipe_info so data survives page reloads regardless of active swipe - if (message.swipe_info && message.swipe_info[swipeId]) { - if (!message.swipe_info[swipeId].extra) { - message.swipe_info[swipeId].extra = {}; - } - if (!message.swipe_info[swipeId].extra.rpg_companion_swipes) { - message.swipe_info[swipeId].extra.rpg_companion_swipes = {}; - } - message.swipe_info[swipeId].extra.rpg_companion_swipes[swipeId] = swipeEntry; - } + mirrorToSwipeInfo(message, swipeId, swipeEntry); // console.log('[RPG Companion] Updated message swipe data after user edit'); break; @@ -292,6 +304,43 @@ export function getSwipeData(message, swipeId) { return null; } +/** + * Commits tracker data from the assistant message immediately before currentMessageIndex. + * Walks backward through the chat skipping the current message, user messages, and system + * messages until it finds the prior assistant message, then loads its active swipe data. + * If no prior assistant message exists or exists without a tracker state, nulls out all fields so + * the AI generates from an empty context rather than a ghost state. + * + * @param {number} currentMessageIndex - Index of the message to start searching before + */ +export function commitTrackerDataFromPriorMessage(currentMessageIndex) { + const chat = getContext().chat; + if (!chat || chat.length === 0) { + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; + return; + } + + for (let i = currentMessageIndex - 1; i >= 0; i--) { + const message = chat[i]; + if (message.is_user || message.is_system) continue; + + // Found the prior assistant message β€” commit its active swipe data + const swipeId = message.swipe_id || 0; + const swipeData = getSwipeData(message, swipeId); + committedTrackerData.userStats = swipeData?.userStats || null; + committedTrackerData.infoBox = swipeData?.infoBox || null; + committedTrackerData.characterThoughts = swipeData?.characterThoughts || null; + return; + } + + // No prior assistant message found β€” use empty context + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; +} + /** * Loads RPG data from the current chat's metadata. * Automatically migrates v1 inventory to v2 format if needed. diff --git a/src/systems/generation/apiClient.js b/src/systems/generation/apiClient.js index 4c38f68..5f48715 100644 --- a/src/systems/generation/apiClient.js +++ b/src/systems/generation/apiClient.js @@ -20,7 +20,7 @@ import { setLastActionWasSwipe, $musicPlayerContainer } from '../../core/state.js'; -import { saveChatData } from '../../core/persistence.js'; +import { saveChatData, mirrorToSwipeInfo } from '../../core/persistence.js'; import { generateSeparateUpdatePrompt } from './promptBuilder.js'; @@ -317,11 +317,15 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough } const currentSwipeId = lastMessage.swipe_id || 0; - lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { + const swipeEntry = { userStats: parsedData.userStats, infoBox: parsedData.infoBox, characterThoughts: parsedData.characterThoughts }; + lastMessage.extra.rpg_companion_swipes[currentSwipeId] = swipeEntry; + + // Mirror to swipe_info so this swipe survives page reload even if never manually edited + mirrorToSwipeInfo(lastMessage, currentSwipeId, swipeEntry); // console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId); } diff --git a/src/systems/generation/injector.js b/src/systems/generation/injector.js index c920db9..0e7cfcf 100644 --- a/src/systems/generation/injector.js +++ b/src/systems/generation/injector.js @@ -30,6 +30,7 @@ import { SPOTIFY_FORMAT_INSTRUCTION } from './promptBuilder.js'; import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js'; +import { commitTrackerDataFromPriorMessage } from '../../core/persistence.js'; // Track suppression state for event handler let currentSuppressionState = false; @@ -622,25 +623,15 @@ export async function onGenerationStarted(type, data, dryRun) { const shouldCommit = isUserMessage && !lastActionWasSwipe && currentChatLength !== lastCommittedChatLength; if (shouldCommit) { - // console.log('[RPG Companion] πŸ“ TOGETHER MODE COMMIT: User sent message - committing data from BEFORE user message'); + // console.log('[RPG Companion] πŸ“ TOGETHER MODE COMMIT: User sent message - committing from N-1 assistant message'); // console.log('[RPG Companion] Chat length:', currentChatLength, 'Last committed:', lastCommittedChatLength); - // console.log('[RPG Companion] BEFORE: committedTrackerData =', { - // userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null' - // // }); - // console.log('[RPG Companion] BEFORE: lastGeneratedData =', { - // userStats: lastGeneratedData.userStats ? `${lastGeneratedData.userStats.substring(0, 50)}...` : 'null', - // infoBox: lastGeneratedData.infoBox ? 'exists' : 'null', - // characterThoughts: lastGeneratedData.characterThoughts ? `${lastGeneratedData.characterThoughts.substring(0, 100)}...` : 'null' - // }); - // Commit displayed data (from before user sent message) - committedTrackerData.userStats = lastGeneratedData.userStats; - committedTrackerData.infoBox = lastGeneratedData.infoBox; - committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; + // Commit from the prior assistant message's swipe store (N-1 rule). + // currentChatLength - 1 is the new AI placeholder; the function walks backward + // past it and the user message to find the previous AI message's tracker state. + commitTrackerDataFromPriorMessage(currentChatLength - 1); - // Track chat length to prevent duplicate commits + // Track chat length to prevent duplicate commits from streaming lastCommittedChatLength = currentChatLength; // console.log('[RPG Companion] AFTER: committedTrackerData =', { @@ -668,38 +659,14 @@ export async function onGenerationStarted(type, data, dryRun) { // console.log('[RPG Companion DEBUG] Before generating:', lastGeneratedData.characterThoughts, ' , committed - ', committedTrackerData.characterThoughts); if ((extensionSettings.generationMode === 'separate' || extensionSettings.generationMode === 'external') && !isGenerating) { if (!lastActionWasSwipe) { - // User sent a new message - commit lastGeneratedData before generation - // console.log('[RPG Companion] πŸ“ COMMIT: New message - committing lastGeneratedData'); - // console.log('[RPG Companion] BEFORE commit - committedTrackerData:', { - // userStats: committedTrackerData.userStats ? 'exists' : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' - // // }); - // console.log('[RPG Companion] BEFORE commit - lastGeneratedData:', { - // userStats: lastGeneratedData.userStats ? 'exists' : 'null', - // infoBox: lastGeneratedData.infoBox ? 'exists' : 'null', - // characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null' - // }); - committedTrackerData.userStats = lastGeneratedData.userStats; - committedTrackerData.infoBox = lastGeneratedData.infoBox; - committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; - // console.log('[RPG Companion] AFTER commit - committedTrackerData:', { - // userStats: committedTrackerData.userStats ? 'exists' : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' - // }); - - // Reset flag after committing (ready for next cycle) - - } else { - // console.log('[RPG Companion] πŸ”„ SWIPE: Using existing committedTrackerData (no commit)'); - // console.log('[RPG Companion] committedTrackerData:', { - // userStats: committedTrackerData.userStats ? 'exists' : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' - // }); - // Reset flag after using it (swipe generation complete, ready for next action) + // User sent a new message - commit from the prior assistant message's swipe store + // (N-1 rule) rather than lastGeneratedData, which may reflect a sibling swipe's + // outcome and would poison the context for the new generation. + // currentChatLength - 1 is the new AI placeholder; search starts before it. + commitTrackerDataFromPriorMessage(currentChatLength - 1); } + // If lastActionWasSwipe, context was already committed by commitTrackerDataFromPriorMessage + // in onMessageSwiped before generation started. } // Use the committed tracker data as source for generation diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 0e121a4..9757701 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -22,7 +22,7 @@ import { updateCommittedTrackerData, $musicPlayerContainer } from '../../core/state.js'; -import { saveChatData, loadChatData, autoSwitchPresetForEntity, getSwipeData } from '../../core/persistence.js'; +import { saveChatData, loadChatData, autoSwitchPresetForEntity, getSwipeData, commitTrackerDataFromPriorMessage, mirrorToSwipeInfo } from '../../core/persistence.js'; import { i18n } from '../../core/i18n.js'; // Generation & Parsing @@ -118,15 +118,12 @@ export function onMessageSent() { // The RPG data comes embedded in the main response // FAB spinning is handled by apiClient.js for separate/external modes when updateRPGData() is called - // For separate mode with auto-update disabled, commit displayed tracker + // For separate mode with auto-update disabled, commit from the prior assistant message's + // swipe store rather than lastGeneratedData to avoid ghost context from sibling swipes. + // At this point chat[chat.length - 1] is the user message, so search from before it. if (extensionSettings.generationMode === 'separate' && !extensionSettings.autoUpdate) { - if (lastGeneratedData.userStats || lastGeneratedData.infoBox || lastGeneratedData.characterThoughts) { - committedTrackerData.userStats = lastGeneratedData.userStats; - committedTrackerData.infoBox = lastGeneratedData.infoBox; - committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; - - // console.log('[RPG Companion] πŸ’Ύ SEPARATE MODE: Committed displayed tracker (auto-update disabled)'); - } + commitTrackerDataFromPriorMessage(chat.length - 1); + // console.log('[RPG Companion] πŸ’Ύ SEPARATE MODE: Committed from prior assistant message (auto-update disabled)'); } } @@ -192,11 +189,15 @@ export async function onMessageReceived(data) { } const currentSwipeId = lastMessage.swipe_id || 0; - lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { + const swipeEntry = { userStats: parsedData.userStats, infoBox: parsedData.infoBox, characterThoughts: parsedData.characterThoughts }; + lastMessage.extra.rpg_companion_swipes[currentSwipeId] = swipeEntry; + + // Mirror to swipe_info so this swipe survives page reload even if never manually edited + mirrorToSwipeInfo(lastMessage, currentSwipeId, swipeEntry); // console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId); @@ -377,6 +378,9 @@ export function onMessageSwiped(messageIndex) { // This is a NEW swipe that will trigger generation setLastActionWasSwipe(true); setIsAwaitingNewMessage(true); + // Immediately commit context from the prior assistant message (N-1) so generation + // uses the world state before this message, not the last-viewed sibling swipe. + commitTrackerDataFromPriorMessage(messageIndex); // console.log('[RPG Companion] πŸ”΅ NEW swipe detected - Set lastActionWasSwipe = true'); } else { // This is navigating to an EXISTING swipe - don't change the flag From 4b816dd1fdd378c84e4d1131115864e0d9f17e17 Mon Sep 17 00:00:00 2001 From: Daryl Date: Sat, 21 Feb 2026 21:45:47 -0400 Subject: [PATCH 03/25] Add event handler for message deletion to sync tracker state and update UI to reflect the new most-recent message in chat --- index.js | 2 + src/systems/integration/sillytavern.js | 82 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/index.js b/index.js index 4237e5c..09b35ee 100644 --- a/index.js +++ b/index.js @@ -151,6 +151,7 @@ import { onMessageReceived, onCharacterChanged, onMessageSwiped, + onMessageDeleted, updatePersonaAvatar, clearExtensionPrompts, onGenerationEnded, @@ -1354,6 +1355,7 @@ jQuery(async () => { [event_types.GENERATION_ENDED]: onGenerationEnded, [event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts], [event_types.MESSAGE_SWIPED]: onMessageSwiped, + [event_types.MESSAGE_DELETED]: onMessageDeleted, [event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar, [event_types.SETTINGS_UPDATED]: updatePersonaAvatar }); diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 9757701..d40ddda 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -428,6 +428,88 @@ export function onMessageSwiped(messageIndex) { updateChatThoughts(); } +/** + * Event handler for when a message is deleted. + * Re-syncs lastGeneratedData, committedTrackerData, and all UI panels to the + * new last assistant message's active swipe β€” or clears everything if no + * assistant messages remain. + */ +export function onMessageDeleted() { + if (!extensionSettings.enabled) return; + + // console.log('[RPG Companion] πŸ—‘οΈ EVENT: onMessageDeleted'); + + const currentChat = getContext().chat; + + // Walk backward to find the new last assistant message. + let lastAssistantIndex = -1; + for (let i = currentChat.length - 1; i >= 0; i--) { + if (!currentChat[i].is_user && !currentChat[i].is_system) { + lastAssistantIndex = i; + break; + } + } + + if (lastAssistantIndex === -1) { + // No assistant messages remain β€” clear all state. + lastGeneratedData.userStats = null; + lastGeneratedData.infoBox = null; + lastGeneratedData.characterThoughts = null; + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; + // console.log('[RPG Companion] πŸ—‘οΈ No assistant messages remain β€” cleared all tracker state.'); + } else { + const message = currentChat[lastAssistantIndex]; + const swipeId = message.swipe_id || 0; + const swipeData = getSwipeData(message, swipeId); + + if (swipeData) { + // Restore display state from the new tail message's active swipe. + lastGeneratedData.userStats = swipeData.userStats || null; + lastGeneratedData.infoBox = swipeData.infoBox || null; + // Normalise characterThoughts to string (backward compat with old object format). + if (swipeData.characterThoughts && typeof swipeData.characterThoughts === 'object') { + lastGeneratedData.characterThoughts = JSON.stringify(swipeData.characterThoughts, null, 2); + } else { + lastGeneratedData.characterThoughts = swipeData.characterThoughts || null; + } + + // Sync stat bars. + if (swipeData.userStats) { + parseUserStats(swipeData.userStats); + } + + // console.log('[RPG Companion] πŸ—‘οΈ Restored display state from assistant message at index', lastAssistantIndex, 'swipe', swipeId); + } else { + // No swipe data for this message β€” clear display state. + lastGeneratedData.userStats = null; + lastGeneratedData.infoBox = null; + lastGeneratedData.characterThoughts = null; + // console.log('[RPG Companion] πŸ—‘οΈ No swipe data for last assistant message β€” cleared display state.'); + } + + // Commit context from the message *before* the new tail assistant message, + // so any subsequent generation uses the correct N-1 world state. + commitTrackerDataFromPriorMessage(lastAssistantIndex); + } + + // Re-render all panels. + renderUserStats(); + renderInfoBox(); + renderThoughts(); + renderInventory(); + renderQuests(); + renderMusicPlayer($musicPlayerContainer[0]); + + // Update widget strips. + updateFabWidgets(); + updateStripWidgets(); + + // Persist updated state. + saveChatData(); +} + /** * Update the persona avatar image when user switches personas */ From d96a1998902a6a88b570851f604036e396dfee5a Mon Sep 17 00:00:00 2001 From: Daryl Date: Sat, 21 Feb 2026 22:21:18 -0400 Subject: [PATCH 04/25] Implement separate generation ID to ensure that messages deleted during separate tracker generation do not attempt to apply the received data to a now non-existent message --- src/core/state.js | 26 ++++++++++++++++++++++++++ src/systems/generation/apiClient.js | 13 +++++++++++-- src/systems/integration/sillytavern.js | 15 +++++++++++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/core/state.js b/src/core/state.js index 693072a..e90367e 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -381,6 +381,32 @@ export let isPlotProgression = false; */ export let isAwaitingNewMessage = false; +/** + * Monotonically-increasing counter used to detect stale separate-mode tracker + * generation results. Incremented each time a new automated generation is + * triggered or a message deletion occurs so any in-flight (or pending) call + * from a previous generation can recognise that its result is no longer valid. + */ +let separateGenerationId = 0; + +/** + * Returns the current separate generation ID. + * @returns {number} + */ +export function getSeparateGenerationId() { + return separateGenerationId; +} + +/** + * Increments and returns the new separate generation ID. + * Call this when starting a new generation or when a deletion + * invalidates any pending/in-flight generation. + * @returns {number} The new ID + */ +export function incrementSeparateGenerationId() { + return ++separateGenerationId; +} + /** * Temporary storage for pending dice roll (not saved until user clicks "Save Roll") */ diff --git a/src/systems/generation/apiClient.js b/src/systems/generation/apiClient.js index 5f48715..d22b762 100644 --- a/src/systems/generation/apiClient.js +++ b/src/systems/generation/apiClient.js @@ -18,7 +18,8 @@ import { lastActionWasSwipe, setIsGenerating, setLastActionWasSwipe, - $musicPlayerContainer + $musicPlayerContainer, + getSeparateGenerationId } from '../../core/state.js'; import { saveChatData, mirrorToSwipeInfo } from '../../core/persistence.js'; import { @@ -218,7 +219,7 @@ export async function switchToPreset(presetName) { * @param {Function} renderThoughts - UI function to render character thoughts * @param {Function} renderInventory - UI function to render inventory */ -export async function updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory) { +export async function updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory, generationId = null) { if (isGenerating) { // console.log('[RPG Companion] Already generating, skipping...'); return; @@ -262,6 +263,14 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough }); } + // If a generationId was provided and the counter has since been incremented + // (by a deletion or a newer generation), discard this result entirely. + // The finally block still runs to restore button state. + if (generationId !== null && getSeparateGenerationId() !== generationId) { + // console.log('[RPG Companion] ⚠️ Separate generation result discarded β€” superseded (genId', generationId, '!= current', getSeparateGenerationId(), ')'); + return; + } + if (response) { // console.log('[RPG Companion] Raw AI response:', response); const parsedData = parseResponse(response); diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index d40ddda..b627236 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -20,7 +20,9 @@ import { setIsAwaitingNewMessage, updateLastGeneratedData, updateCommittedTrackerData, - $musicPlayerContainer + $musicPlayerContainer, + getSeparateGenerationId, + incrementSeparateGenerationId } from '../../core/state.js'; import { saveChatData, loadChatData, autoSwitchPresetForEntity, getSwipeData, commitTrackerDataFromPriorMessage, mirrorToSwipeInfo } from '../../core/persistence.js'; import { i18n } from '../../core/i18n.js'; @@ -266,8 +268,13 @@ export async function onMessageReceived(data) { // Trigger auto-update if enabled (for both separate and external modes) // Only trigger if this is a newly generated message, not loading chat history if (extensionSettings.autoUpdate && isAwaitingNewMessage) { + // Capture the current generation ID before the async gap so that any + // message deletion (or a newer generation) that increments the counter + // while the 500ms timer or the API call is in-flight will cause + // updateRPGData to discard its result rather than stomping the UI. + const genId = incrementSeparateGenerationId(); setTimeout(async () => { - await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); + await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory, genId); // Update FAB widgets and strip widgets after separate/external mode update completes setFabLoadingState(false); updateFabWidgets(); @@ -439,6 +446,10 @@ export function onMessageDeleted() { // console.log('[RPG Companion] πŸ—‘οΈ EVENT: onMessageDeleted'); + // Invalidate any pending or in-flight separate-mode generation so + // its result is not applied to the (now-changed) chat tail. + incrementSeparateGenerationId(); + const currentChat = getContext().chat; // Walk backward to find the new last assistant message. From 979525e372694ed8787f29e8a442f12be8f631c3 Mon Sep 17 00:00:00 2001 From: Daryl Date: Sat, 21 Feb 2026 23:47:47 -0400 Subject: [PATCH 05/25] Add syncLastGeneratedDataFromSwipeStore function to manage swipe data retrieval and update lastGeneratedData on message changes --- src/systems/integration/sillytavern.js | 86 +++++++++++++++++++------- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index b627236..0c36ef1 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -53,6 +53,45 @@ import { updateStripWidgets } from '../ui/desktop.js'; import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js'; import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js'; +/** + * Reads the swipe store of the last assistant message in `currentChat` and + * writes its data into `lastGeneratedData`, including syncing stat bars via + * `parseUserStats`. If no assistant message exists, or none has stored swipe + * data, `lastGeneratedData` is left unchanged. + * + * Use this wherever the displayed tracker state must be re-derived from the + * authoritative swipe store rather than from chat_metadata (e.g. after a + * CHAT_CHANGED caused by branching, or after a message deletion). + * + * @param {Array} currentChat - Live chat array from getContext().chat + * @returns {boolean} True if swipe data was found and applied + */ +function syncLastGeneratedDataFromSwipeStore(currentChat) { + for (let i = currentChat.length - 1; i >= 0; i--) { + const msg = currentChat[i]; + if (!msg.is_user && !msg.is_system) { + const swipeId = msg.swipe_id || 0; + const swipeData = getSwipeData(msg, swipeId); + if (swipeData) { + lastGeneratedData.userStats = swipeData.userStats || null; + lastGeneratedData.infoBox = swipeData.infoBox || null; + // Normalise characterThoughts to string (backward compat with old object format). + if (swipeData.characterThoughts && typeof swipeData.characterThoughts === 'object') { + lastGeneratedData.characterThoughts = JSON.stringify(swipeData.characterThoughts, null, 2); + } else { + lastGeneratedData.characterThoughts = swipeData.characterThoughts || null; + } + if (swipeData.userStats) { + parseUserStats(swipeData.userStats); + } + return true; + } + return false; // Last assistant message exists but has no swipe data yet + } + } + return false; // No assistant messages in chat +} + /** * Commits the tracker data from the last assistant message to be used as source for next generation. * This should be called when the user has replied to a message, ensuring all swipes of the next @@ -330,6 +369,22 @@ export function onCharacterChanged() { // Load chat-specific data when switching chats loadChatData(); + // chat_metadata may not reflect the actual chat tail for branches, so + // loadChatData() may have just restored stale data from the parent chat. + // Override lastGeneratedData from the swipe store of the last assistant message. + // The message objects in the branch already carry their full swipe stores, making this authoritative. + // If no swipe data exists (e.g. branching at message 0, or a chat with no generations yet), + // null out lastGeneratedData and committedTrackerData so we don't display stale values from the parent chat. + const hadSwipeData = syncLastGeneratedDataFromSwipeStore(getContext().chat); + if (!hadSwipeData) { + lastGeneratedData.userStats = null; + lastGeneratedData.infoBox = null; + lastGeneratedData.characterThoughts = null; + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; + } + // Don't call commitTrackerData() here - it would overwrite the loaded committedTrackerData // with data from the last message, which may be null/empty. The loaded committedTrackerData // already contains the committed state from when we last left this chat. @@ -471,32 +526,17 @@ export function onMessageDeleted() { committedTrackerData.characterThoughts = null; // console.log('[RPG Companion] πŸ—‘οΈ No assistant messages remain β€” cleared all tracker state.'); } else { - const message = currentChat[lastAssistantIndex]; - const swipeId = message.swipe_id || 0; - const swipeData = getSwipeData(message, swipeId); - - if (swipeData) { - // Restore display state from the new tail message's active swipe. - lastGeneratedData.userStats = swipeData.userStats || null; - lastGeneratedData.infoBox = swipeData.infoBox || null; - // Normalise characterThoughts to string (backward compat with old object format). - if (swipeData.characterThoughts && typeof swipeData.characterThoughts === 'object') { - lastGeneratedData.characterThoughts = JSON.stringify(swipeData.characterThoughts, null, 2); - } else { - lastGeneratedData.characterThoughts = swipeData.characterThoughts || null; - } - - // Sync stat bars. - if (swipeData.userStats) { - parseUserStats(swipeData.userStats); - } - - // console.log('[RPG Companion] πŸ—‘οΈ Restored display state from assistant message at index', lastAssistantIndex, 'swipe', swipeId); - } else { - // No swipe data for this message β€” clear display state. + // Restore display state from the new tail message's active swipe. + // If the message has no swipe data yet, null the fields so we + // don't show stale data from the deleted message. + const hadSwipeData = syncLastGeneratedDataFromSwipeStore(currentChat); + if (!hadSwipeData) { lastGeneratedData.userStats = null; lastGeneratedData.infoBox = null; lastGeneratedData.characterThoughts = null; + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; // console.log('[RPG Companion] πŸ—‘οΈ No swipe data for last assistant message β€” cleared display state.'); } From 178ced00be21aecf521c55ab68066fe3bea88d91 Mon Sep 17 00:00:00 2001 From: Daryl Date: Sun, 22 Feb 2026 00:35:26 -0400 Subject: [PATCH 06/25] Update widget displayed data when swiping --- src/systems/integration/sillytavern.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 0c36ef1..4e412e2 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -486,6 +486,10 @@ export function onMessageSwiped(messageIndex) { renderQuests(); renderMusicPlayer($musicPlayerContainer[0]); + // Update widget strips with the newly loaded swipe data + updateFabWidgets(); + updateStripWidgets(); + // Update chat thought overlays updateChatThoughts(); } From 76beb5dff4ded5db4cdc8d6c899c46fd0f5d2842 Mon Sep 17 00:00:00 2001 From: Daryl Date: Sun, 22 Feb 2026 03:53:59 -0400 Subject: [PATCH 07/25] Add inheritSwipeDataFromPriorMessage function to populate swipe slots with tracker data from prior messages in cases where tracker auto-update is disabled. This maintains the paradigm of every swipe having saved tracker data, which can then be regenerated by the user manually if they so choose. --- src/core/persistence.js | 50 ++++++++++++++++++++++++++ src/systems/integration/sillytavern.js | 9 ++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/core/persistence.js b/src/core/persistence.js index 18f5719..7819abd 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -341,6 +341,56 @@ export function commitTrackerDataFromPriorMessage(currentMessageIndex) { committedTrackerData.characterThoughts = null; } +/** + * Populates a message's current swipe slot with tracker data inherited from the + * nearest prior assistant message, when no tracker data has been generated for + * this swipe yet (e.g. auto-update is disabled). + * + * This ensures that commitTrackerDataFromPriorMessage can always find a tracker + * state to commit when the user sends the next message, rather than nulling + * everything out and resetting the tracker display to empty. + * + * Does nothing if the current swipe already has its own tracker data. + * + * @param {Object} message - The assistant message object to inherit into + * @param {number} messageIndex - Index of that message in chat + * @returns {boolean} True if inheritance was written, false otherwise + */ +export function inheritSwipeDataFromPriorMessage(message, messageIndex) { + const chat = getContext().chat; + if (!chat) return false; + + const currentSwipeId = message.swipe_id || 0; + + // Don't overwrite if this swipe already has its own tracker data. + if (getSwipeData(message, currentSwipeId)) return false; + + // Walk backward to find the nearest prior assistant message with swipe data. + for (let i = messageIndex - 1; i >= 0; i--) { + const msg = chat[i]; + if (msg.is_user || msg.is_system) continue; + + const swipeId = msg.swipe_id || 0; + const swipeData = getSwipeData(msg, swipeId); + if (!swipeData) return false; // Prior assistant also has no data β€” nothing to inherit + + // Write inherited data into this swipe slot. + if (!message.extra) message.extra = {}; + if (!message.extra.rpg_companion_swipes) message.extra.rpg_companion_swipes = {}; + + const inherited = { + userStats: swipeData.userStats, + infoBox: swipeData.infoBox, + characterThoughts: swipeData.characterThoughts + }; + message.extra.rpg_companion_swipes[currentSwipeId] = inherited; + mirrorToSwipeInfo(message, currentSwipeId, inherited); + // console.log('[RPG Companion] Inherited tracker data from chat[' + i + '] into current swipe slot', currentSwipeId); + return true; + } + return false; +} + /** * Loads RPG data from the current chat's metadata. * Automatically migrates v1 inventory to v2 format if needed. diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 4e412e2..566db9a 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -24,7 +24,7 @@ import { getSeparateGenerationId, incrementSeparateGenerationId } from '../../core/state.js'; -import { saveChatData, loadChatData, autoSwitchPresetForEntity, getSwipeData, commitTrackerDataFromPriorMessage, mirrorToSwipeInfo } from '../../core/persistence.js'; +import { saveChatData, loadChatData, autoSwitchPresetForEntity, getSwipeData, commitTrackerDataFromPriorMessage, inheritSwipeDataFromPriorMessage, mirrorToSwipeInfo } from '../../core/persistence.js'; import { i18n } from '../../core/i18n.js'; // Generation & Parsing @@ -302,6 +302,13 @@ export async function onMessageReceived(data) { // Just render the music player renderMusicPlayer($musicPlayerContainer[0]); } + + // When auto-update is disabled,no tracker API call will run for this message. + // Inherit the prior assistant message's tracker data into this swipe slot so that + // commitTrackerDataFromPriorMessage can find a valid state next turn instead of nulling everything + if (!extensionSettings.autoUpdate || !isAwaitingNewMessage) { + inheritSwipeDataFromPriorMessage(lastMessage, chat.length - 1); + } } // Trigger auto-update if enabled (for both separate and external modes) From 4d0de8419ca3e3290fca068d1d7f28402ab318a1 Mon Sep 17 00:00:00 2001 From: Alamion Date: Mon, 23 Feb 2026 21:42:25 +0300 Subject: [PATCH 08/25] fixes: - now stats, attributes, characters stats have a changeable id - now all additional promts are stacked in 2 lines --- .gitignore | 4 ++-- manifest.json | 2 +- src/systems/ui/trackerEditor.js | 29 +++++++++++++++++++++++++---- src/utils/transformations.js | 11 +++++++++++ style.css | 15 +++++++++------ 5 files changed, 48 insertions(+), 13 deletions(-) create mode 100644 src/utils/transformations.js diff --git a/.gitignore b/.gitignore index 789b14f..1e0df72 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,5 @@ node_modules/ # Environment variables .env -# Claude -CLAUDE.md \ No newline at end of file +# Claude +CLAUDE.md diff --git a/manifest.json b/manifest.json index ff16a2a..55c1c6d 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Marinara", - "version": "3.7.2", + "version": "3.7.3", "homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern" } diff --git a/src/systems/ui/trackerEditor.js b/src/systems/ui/trackerEditor.js index 90718ff..7a0cabf 100644 --- a/src/systems/ui/trackerEditor.js +++ b/src/systems/ui/trackerEditor.js @@ -31,6 +31,7 @@ import { renderUserStats } from '../rendering/userStats.js'; import { renderInfoBox } from '../rendering/infoBox.js'; import { renderThoughts } from '../rendering/thoughts.js'; import { updateFabWidgets } from './mobile.js'; +import { safeToSnake } from '../../utils/transformations.js'; let $editorModal = null; let activeTab = 'userStats'; @@ -38,6 +39,18 @@ let tempConfig = null; // Temporary config for cancel functionality let tempAssociation = null; // Temporary association state: { presetId: string|null, entityKey: string|null } let originalAssociation = null; // Original association when editor opened + +function set_ids_names(list_with_stats, index, value) { + list_with_stats[index].name = value; + const ids = list_with_stats.toSpliced(index, 1).map(stat => stat.id); + const snake_value = safeToSnake(value); // new id format + if (snake_value !== value && !ids.includes(snake_value)) { // check if this id already exists + list_with_stats[index].id = snake_value; + } + return list_with_stats; +} + + /** * Initialize the tracker editor modal */ @@ -885,7 +898,9 @@ function setupUserStatsListeners() { // Rename stat $('.rpg-stat-name').off('blur').on('blur', function () { const index = $(this).data('index'); - extensionSettings.trackerConfig.userStats.customStats[index].name = $(this).val(); + const value = $(this).val(); + const list_with_stats = extensionSettings.trackerConfig.userStats.customStats + set_ids_names(list_with_stats, index, value); }); // Change stat max value @@ -943,7 +958,9 @@ function setupUserStatsListeners() { // Rename attribute $('.rpg-attr-name').off('blur').on('blur', function () { const index = $(this).data('index'); - extensionSettings.trackerConfig.userStats.rpgAttributes[index].name = $(this).val(); + const value = $(this).val(); + const list_with_stats = extensionSettings.trackerConfig.userStats.rpgAttributes + set_ids_names(list_with_stats, index, value); }); // Enable/disable RPG Attributes section toggle @@ -1394,7 +1411,9 @@ function setupPresentCharactersListeners() { // Rename field $('.rpg-field-label').off('blur').on('blur', function () { const index = $(this).data('index'); - extensionSettings.trackerConfig.presentCharacters.customFields[index].name = $(this).val(); + const value = $(this).val(); + const list_with_stats = extensionSettings.trackerConfig.presentCharacters.customFields + set_ids_names(list_with_stats, index, value); }); // Update description @@ -1443,7 +1462,9 @@ function setupPresentCharactersListeners() { // Rename character stat $('.rpg-char-stat-label').off('blur').on('blur', function () { const index = $(this).data('index'); - extensionSettings.trackerConfig.presentCharacters.characterStats.customStats[index].name = $(this).val(); + const value = $(this).val(); + const list_with_stats = extensionSettings.trackerConfig.presentCharacters.characterStats.customStats + set_ids_names(list_with_stats, index, value); }); } diff --git a/src/utils/transformations.js b/src/utils/transformations.js new file mode 100644 index 0000000..6778ebe --- /dev/null +++ b/src/utils/transformations.js @@ -0,0 +1,11 @@ +const toSnake = str => str + .replace(/[^a-zA-Z]/g, '_') + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); + +export const safeToSnake = (str) => { + const res = toSnake(str); + return (res.length >= 2) ? res : str; // considering element with one symbol is too short to be safe +}; diff --git a/style.css b/style.css index 68b01ef..c1b22c1 100644 --- a/style.css +++ b/style.css @@ -10732,7 +10732,10 @@ body[data-theme="cyberpunk"] .rpg-music-widget-play { /* Features row container */ .rpg-features-row { - display: flex; + display: grid; + grid-template-rows: repeat(2, 1fr); + grid-auto-flow: column; + grid-auto-columns: min-content; gap: 8px; margin-bottom: 12px; overflow-x: auto; @@ -10743,11 +10746,11 @@ body[data-theme="cyberpunk"] .rpg-music-widget-play { } /* Center items when they fit, allow scrolling when they don't */ -.rpg-features-row::before, -.rpg-features-row::after { - content: ''; - margin: auto; -} +/*.rpg-features-row::before,*/ +/*.rpg-features-row::after {*/ +/* content: '';*/ +/* margin: auto;*/ +/*}*/ /* Hide scrollbar for cleaner look while maintaining functionality */ .rpg-features-row::-webkit-scrollbar { From ab848828e74fde1cc108c032dd573cdc41d3ebec Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Wed, 25 Feb 2026 23:57:09 +0100 Subject: [PATCH 09/25] Update src/systems/integration/sillytavern.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/systems/integration/sillytavern.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 566db9a..e65c748 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -306,7 +306,7 @@ export async function onMessageReceived(data) { // When auto-update is disabled,no tracker API call will run for this message. // Inherit the prior assistant message's tracker data into this swipe slot so that // commitTrackerDataFromPriorMessage can find a valid state next turn instead of nulling everything - if (!extensionSettings.autoUpdate || !isAwaitingNewMessage) { + if (!extensionSettings.autoUpdate && isAwaitingNewMessage) { inheritSwipeDataFromPriorMessage(lastMessage, chat.length - 1); } } From 733647084a0bf4202ef93743c87a59a2a0c83af9 Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Wed, 25 Feb 2026 23:57:16 +0100 Subject: [PATCH 10/25] Update src/systems/integration/sillytavern.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/systems/integration/sillytavern.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index e65c748..b4d22e2 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -21,7 +21,6 @@ import { updateLastGeneratedData, updateCommittedTrackerData, $musicPlayerContainer, - getSeparateGenerationId, incrementSeparateGenerationId } from '../../core/state.js'; import { saveChatData, loadChatData, autoSwitchPresetForEntity, getSwipeData, commitTrackerDataFromPriorMessage, inheritSwipeDataFromPriorMessage, mirrorToSwipeInfo } from '../../core/persistence.js'; From bb202aca9c03e419bfafc77ae514342ee58ef841 Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Wed, 25 Feb 2026 23:57:24 +0100 Subject: [PATCH 11/25] Update src/systems/integration/sillytavern.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/systems/integration/sillytavern.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index b4d22e2..41271ce 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -105,7 +105,7 @@ export function commitTrackerData() { // Find the last assistant message for (let i = chat.length - 1; i >= 0; i--) { const message = chat[i]; - if (!message.is_user) { + if (!message.is_user && !message.is_system) { // Found last assistant message - commit its tracker data const swipeId = message.swipe_id || 0; const swipeData = getSwipeData(message, swipeId); From 5b2cb331c8cf8204d69f1024f839f0422a0e2648 Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Wed, 25 Feb 2026 23:57:30 +0100 Subject: [PATCH 12/25] Update src/core/persistence.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/core/persistence.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/persistence.js b/src/core/persistence.js index 7819abd..d0f011a 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -372,7 +372,7 @@ export function inheritSwipeDataFromPriorMessage(message, messageIndex) { const swipeId = msg.swipe_id || 0; const swipeData = getSwipeData(msg, swipeId); - if (!swipeData) return false; // Prior assistant also has no data β€” nothing to inherit + if (!swipeData) continue; // No data on this assistant message; keep searching further back // Write inherited data into this swipe slot. if (!message.extra) message.extra = {}; From 66a22c74d0a6b0132638e0002d4cc44a5e7c2292 Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Wed, 25 Feb 2026 23:58:02 +0100 Subject: [PATCH 13/25] Update src/utils/transformations.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/utils/transformations.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/utils/transformations.js b/src/utils/transformations.js index 6778ebe..423cb7f 100644 --- a/src/utils/transformations.js +++ b/src/utils/transformations.js @@ -1,9 +1,14 @@ -const toSnake = str => str - .replace(/[^a-zA-Z]/g, '_') - .replace(/([A-Z])/g, '_$1') - .toLowerCase() +const toSnake = (str) => str + // replace any sequence of non-alphanumeric characters with a single underscore + .replace(/[^0-9A-Za-z]+/g, '_') + // insert underscore between a lower-case letter/digit and an upper-case letter (but not between consecutive uppers) + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + // collapse multiple underscores .replace(/_+/g, '_') - .replace(/^_|_$/g, ''); + // trim leading/trailing underscores + .replace(/^_+|_+$/g, '') + // finally, lowercase the result + .toLowerCase(); export const safeToSnake = (str) => { const res = toSnake(str); From 7305af8f8865fac380689c181ae644e405c9cd85 Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Wed, 25 Feb 2026 23:58:26 +0100 Subject: [PATCH 14/25] Update src/systems/ui/trackerEditor.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/systems/ui/trackerEditor.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/systems/ui/trackerEditor.js b/src/systems/ui/trackerEditor.js index 7a0cabf..d4f6f48 100644 --- a/src/systems/ui/trackerEditor.js +++ b/src/systems/ui/trackerEditor.js @@ -44,7 +44,8 @@ function set_ids_names(list_with_stats, index, value) { list_with_stats[index].name = value; const ids = list_with_stats.toSpliced(index, 1).map(stat => stat.id); const snake_value = safeToSnake(value); // new id format - if (snake_value !== value && !ids.includes(snake_value)) { // check if this id already exists + const currentId = list_with_stats[index].id; + if (snake_value !== currentId && !ids.includes(snake_value)) { // check if this id already exists list_with_stats[index].id = snake_value; } return list_with_stats; From 131b28fc1fad2e155b240ed3030dbd87d200569e Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Wed, 25 Feb 2026 23:58:37 +0100 Subject: [PATCH 15/25] Update src/systems/ui/trackerEditor.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/systems/ui/trackerEditor.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/systems/ui/trackerEditor.js b/src/systems/ui/trackerEditor.js index d4f6f48..4c27709 100644 --- a/src/systems/ui/trackerEditor.js +++ b/src/systems/ui/trackerEditor.js @@ -42,11 +42,28 @@ let originalAssociation = null; // Original association when editor opened function set_ids_names(list_with_stats, index, value) { list_with_stats[index].name = value; + const item = list_with_stats[index]; + const oldId = item?.id; + + item.name = value; const ids = list_with_stats.toSpliced(index, 1).map(stat => stat.id); const snake_value = safeToSnake(value); // new id format - const currentId = list_with_stats[index].id; - if (snake_value !== currentId && !ids.includes(snake_value)) { // check if this id already exists - list_with_stats[index].id = snake_value; + if (snake_value !== value && !ids.includes(snake_value)) { // check if this id already exists + item.id = snake_value; + } + + const newId = item.id; + // If the ID changed, migrate any stored values keyed by the old ID + if (oldId && newId && oldId !== newId) { + if (extensionSettings.userStats && Object.prototype.hasOwnProperty.call(extensionSettings.userStats, oldId)) { + extensionSettings.userStats[newId] = extensionSettings.userStats[oldId]; + delete extensionSettings.userStats[oldId]; + } + + if (extensionSettings.classicStats && Object.prototype.hasOwnProperty.call(extensionSettings.classicStats, oldId)) { + extensionSettings.classicStats[newId] = extensionSettings.classicStats[oldId]; + delete extensionSettings.classicStats[oldId]; + } } return list_with_stats; } From b1098a2721210db76feb5ac1caded223cc38f3fc Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Wed, 25 Feb 2026 23:58:44 +0100 Subject: [PATCH 16/25] Update src/systems/ui/trackerEditor.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/systems/ui/trackerEditor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/systems/ui/trackerEditor.js b/src/systems/ui/trackerEditor.js index 4c27709..f287847 100644 --- a/src/systems/ui/trackerEditor.js +++ b/src/systems/ui/trackerEditor.js @@ -46,7 +46,7 @@ function set_ids_names(list_with_stats, index, value) { const oldId = item?.id; item.name = value; - const ids = list_with_stats.toSpliced(index, 1).map(stat => stat.id); + const ids = list_with_stats.filter((_, i) => i !== index).map(stat => stat.id); const snake_value = safeToSnake(value); // new id format if (snake_value !== value && !ids.includes(snake_value)) { // check if this id already exists item.id = snake_value; From 75c8f9b63af2c54230ce27d9fbc4fa4e2bc3d39f Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Wed, 25 Feb 2026 23:58:51 +0100 Subject: [PATCH 17/25] Update src/systems/ui/trackerEditor.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/systems/ui/trackerEditor.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/systems/ui/trackerEditor.js b/src/systems/ui/trackerEditor.js index f287847..e84c75b 100644 --- a/src/systems/ui/trackerEditor.js +++ b/src/systems/ui/trackerEditor.js @@ -58,14 +58,14 @@ function set_ids_names(list_with_stats, index, value) { if (extensionSettings.userStats && Object.prototype.hasOwnProperty.call(extensionSettings.userStats, oldId)) { extensionSettings.userStats[newId] = extensionSettings.userStats[oldId]; delete extensionSettings.userStats[oldId]; - } - - if (extensionSettings.classicStats && Object.prototype.hasOwnProperty.call(extensionSettings.classicStats, oldId)) { - extensionSettings.classicStats[newId] = extensionSettings.classicStats[oldId]; - delete extensionSettings.classicStats[oldId]; - } +function setIdsNames(listWithStats, index, value) { + listWithStats[index].name = value; + const ids = listWithStats.toSpliced(index, 1).map(stat => stat.id); + const snakeValue = safeToSnake(value); // new id format + if (snakeValue !== value && !ids.includes(snakeValue)) { // check if this id already exists + listWithStats[index].id = snakeValue; } - return list_with_stats; + return listWithStats; } From 32280d60efe6e9ac005928f7a20ccee6516994ba Mon Sep 17 00:00:00 2001 From: Daryl Streete <66880175+DAurielS@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:32:54 -0400 Subject: [PATCH 18/25] Update src/systems/integration/sillytavern.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/systems/integration/sillytavern.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 41271ce..2cf96a1 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -302,7 +302,7 @@ export async function onMessageReceived(data) { renderMusicPlayer($musicPlayerContainer[0]); } - // When auto-update is disabled,no tracker API call will run for this message. + // When auto-update is disabled, no tracker API call will run for this message. // Inherit the prior assistant message's tracker data into this swipe slot so that // commitTrackerDataFromPriorMessage can find a valid state next turn instead of nulling everything if (!extensionSettings.autoUpdate && isAwaitingNewMessage) { From 9213d264a0cc07d835b2c600b4b7febb555b87bf Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Thu, 26 Feb 2026 00:40:22 +0100 Subject: [PATCH 19/25] Update src/systems/integration/sillytavern.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/systems/integration/sillytavern.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 2cf96a1..505c1d9 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -74,7 +74,7 @@ function syncLastGeneratedDataFromSwipeStore(currentChat) { if (swipeData) { lastGeneratedData.userStats = swipeData.userStats || null; lastGeneratedData.infoBox = swipeData.infoBox || null; - // Normalise characterThoughts to string (backward compat with old object format). + // Normalize characterThoughts to string (backward compat with old object format). if (swipeData.characterThoughts && typeof swipeData.characterThoughts === 'object') { lastGeneratedData.characterThoughts = JSON.stringify(swipeData.characterThoughts, null, 2); } else { From 8ea90444929341cc80ded06ff1038cd747cc4ed2 Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Thu, 26 Feb 2026 01:47:42 +0100 Subject: [PATCH 20/25] Update src/core/persistence.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/core/persistence.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/persistence.js b/src/core/persistence.js index d0f011a..adeeb53 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -331,7 +331,13 @@ export function commitTrackerDataFromPriorMessage(currentMessageIndex) { const swipeData = getSwipeData(message, swipeId); committedTrackerData.userStats = swipeData?.userStats || null; committedTrackerData.infoBox = swipeData?.infoBox || null; - committedTrackerData.characterThoughts = swipeData?.characterThoughts || null; + const rawCharacterThoughts = swipeData?.characterThoughts; + committedTrackerData.characterThoughts = + rawCharacterThoughts == null + ? null + : (typeof rawCharacterThoughts === 'string' + ? rawCharacterThoughts + : JSON.stringify(rawCharacterThoughts)); return; } From ce48ac0c34736ef4541a70dff15bb55bbf869170 Mon Sep 17 00:00:00 2001 From: Spicy Marinara Date: Thu, 26 Feb 2026 01:48:15 +0100 Subject: [PATCH 21/25] Update src/systems/integration/sillytavern.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/systems/integration/sillytavern.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 505c1d9..699e276 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -114,7 +114,14 @@ export function commitTrackerData() { // console.log('[RPG Companion] Committing tracker data from assistant message at index', i, 'swipe', swipeId); committedTrackerData.userStats = swipeData.userStats || null; committedTrackerData.infoBox = swipeData.infoBox || null; - committedTrackerData.characterThoughts = swipeData.characterThoughts || null; + const rawCharacterThoughts = swipeData.characterThoughts; + if (rawCharacterThoughts == null) { + committedTrackerData.characterThoughts = null; + } else if (typeof rawCharacterThoughts === 'object') { + committedTrackerData.characterThoughts = JSON.stringify(rawCharacterThoughts); + } else { + committedTrackerData.characterThoughts = String(rawCharacterThoughts); + } } else { // No saved swipe data β€” treat as empty (e.g. first message, no prior generation) committedTrackerData.userStats = null; From c442314c1078ea8165ef240c8184736a88494959 Mon Sep 17 00:00:00 2001 From: Daryl Date: Thu, 26 Feb 2026 17:09:33 -0400 Subject: [PATCH 22/25] Remove redundant data commit call in sillytavern.js to prevent n-2 tracker data commits in fresh message generations --- src/systems/integration/sillytavern.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 699e276..be13b47 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -164,14 +164,6 @@ export function onMessageSent() { // Note: FAB spinning is NOT shown for together mode since no extra API request is made // The RPG data comes embedded in the main response // FAB spinning is handled by apiClient.js for separate/external modes when updateRPGData() is called - - // For separate mode with auto-update disabled, commit from the prior assistant message's - // swipe store rather than lastGeneratedData to avoid ghost context from sibling swipes. - // At this point chat[chat.length - 1] is the user message, so search from before it. - if (extensionSettings.generationMode === 'separate' && !extensionSettings.autoUpdate) { - commitTrackerDataFromPriorMessage(chat.length - 1); - // console.log('[RPG Companion] πŸ’Ύ SEPARATE MODE: Committed from prior assistant message (auto-update disabled)'); - } } /** From c307f1a1bc62d508a034f3445cdabd0eaeee4451 Mon Sep 17 00:00:00 2001 From: Daryl Date: Thu, 26 Feb 2026 17:32:16 -0400 Subject: [PATCH 23/25] Revert Copilot mistake in inheriting prior swipe data; testing in practice reveals inheritance does not work after applying its suggestion. --- src/systems/integration/sillytavern.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index be13b47..d55d8f8 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -303,8 +303,9 @@ export async function onMessageReceived(data) { // When auto-update is disabled, no tracker API call will run for this message. // Inherit the prior assistant message's tracker data into this swipe slot so that - // commitTrackerDataFromPriorMessage can find a valid state next turn instead of nulling everything - if (!extensionSettings.autoUpdate && isAwaitingNewMessage) { + // commitTrackerDataFromPriorMessage can find a valid state next turn instead of nulling everything. + // Inheritance does not overwrite existing data, so it's safe to call even if the condition misses an edge case. + if (!extensionSettings.autoUpdate || !isAwaitingNewMessage) { inheritSwipeDataFromPriorMessage(lastMessage, chat.length - 1); } } From 03345b81f4a22156a547ae2146ad486de41a253f Mon Sep 17 00:00:00 2001 From: Daryl Date: Thu, 26 Feb 2026 17:59:45 -0400 Subject: [PATCH 24/25] Update Refresh RPG Info buttons in index.js to call commitTrackerDataFromPriorMessage() before updateRPGData() --- index.js | 24 +++++++++++++++++++++++- src/core/persistence.js | 3 +++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 09b35ee..7131b9e 100644 --- a/index.js +++ b/index.js @@ -42,7 +42,7 @@ import { setMusicPlayerContainer, clearSessionAvatarPrompts } from './src/core/state.js'; -import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData } from './src/core/persistence.js'; +import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData, commitTrackerDataFromPriorMessage } from './src/core/persistence.js'; import { registerAllEvents } from './src/core/events.js'; // Generation & Parsing modules @@ -800,6 +800,17 @@ async function initUI() { // console.log('[RPG Companion] Extension is disabled. Please enable it in the Extensions tab.'); return; } + const currentChat = getContext().chat; + let lastAssistantIndex = -1; + for (let i = currentChat.length - 1; i >= 0; i--) { + if (!currentChat[i].is_user && !currentChat[i].is_system) { + lastAssistantIndex = i; + break; + } + } + if (lastAssistantIndex !== -1) { + commitTrackerDataFromPriorMessage(lastAssistantIndex); + } await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); }); @@ -808,6 +819,17 @@ async function initUI() { if (!extensionSettings.enabled) { return; } + const currentChat = getContext().chat; + let lastAssistantIndex = -1; + for (let i = currentChat.length - 1; i >= 0; i--) { + if (!currentChat[i].is_user && !currentChat[i].is_system) { + lastAssistantIndex = i; + break; + } + } + if (lastAssistantIndex !== -1) { + commitTrackerDataFromPriorMessage(lastAssistantIndex); + } await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); }); diff --git a/src/core/persistence.js b/src/core/persistence.js index adeeb53..5cf7c96 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -322,6 +322,8 @@ export function commitTrackerDataFromPriorMessage(currentMessageIndex) { return; } + // console.log('[RPG Companion] commitTrackerDataFromPriorMessage called with index', currentMessageIndex, '| chat.length =', chat.length); + for (let i = currentMessageIndex - 1; i >= 0; i--) { const message = chat[i]; if (message.is_user || message.is_system) continue; @@ -329,6 +331,7 @@ export function commitTrackerDataFromPriorMessage(currentMessageIndex) { // Found the prior assistant message β€” commit its active swipe data const swipeId = message.swipe_id || 0; const swipeData = getSwipeData(message, swipeId); + // console.log('[RPG Companion] Committing from chat[' + i + '] swipe', swipeId, '| has swipe data:', !!swipeData); committedTrackerData.userStats = swipeData?.userStats || null; committedTrackerData.infoBox = swipeData?.infoBox || null; const rawCharacterThoughts = swipeData?.characterThoughts; From 933d78e192bce1dbd6cced48342619d22272c57e Mon Sep 17 00:00:00 2001 From: Alamion Date: Mon, 2 Mar 2026 20:54:15 +0300 Subject: [PATCH 25/25] feat: localization validator for missing internationalization fix: trackerEditor.js doesn't have syntax issues anymore. --- .gitignore | 1 + package-lock.json | 6 + package.json | 19 +++ src/i18n/validator.js | 264 +++++++++++++++++++++++++++++ src/systems/rendering/userStats.js | 2 +- src/systems/ui/trackerEditor.js | 14 +- 6 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/i18n/validator.js diff --git a/.gitignore b/.gitignore index 1e0df72..e7bb1e1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ node_modules/ # Claude CLAUDE.md +yarn.lock diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9dcafce --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "rpg-companion-sillytavern", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b290249 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "rpg-complanion-sillytavern", + "version": "3.7.3", + "description": "", + "main": "index.js", + "scripts": { + "validate_locale": "node src/i18n/validator.js --watch", + "validate_locale_once": "node src/i18n/validator.js" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "chokidar": "^5.0.0", + "fs-extra": "^11.3.3", + "glob": "^13.0.6" + }, + "dependencies": {} +} diff --git a/src/i18n/validator.js b/src/i18n/validator.js new file mode 100644 index 0000000..47dcace --- /dev/null +++ b/src/i18n/validator.js @@ -0,0 +1,264 @@ +const fs = require('fs-extra'); +const path = require('path'); +const chokidar = require('chokidar'); +const glob = require('glob'); + +const COMPILED_DIR = __dirname // path.join(__dirname, 'compiled'); + +function findUnlocalizedText() { + const srcArg = process.argv.find(arg => arg.startsWith('--src=')); + const srcDir = srcArg ? srcArg.split('=')[1] : '.'; + + console.log(`\nπŸ”Ž Scanning for unlocalized text in ${srcDir}...`); + + const files = glob.sync(`${srcDir}/**/*.{html,js,jsx}`, { + ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'] + }); + + if (files.length === 0) { + console.log('⚠️ No .html/.js/.jsx files found'); + return; + } + + let totalFound = 0; + + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + const lines = content.split('\n'); + const relPath = path.relative(process.cwd(), file); + + // Searching for string number + lines.forEach((line, index) => { + let match; + const localPattern = /<([a-zA-Z][a-zA-Z0-9]*)(?:\s(?:[^>](?!data-i18n-key))*)?>([\p{L}\p{N}\s\-.,!?:'"()]+)<\/\1>/gu; + + while ((match = localPattern.exec(line)) !== null) { + const text = match[2].trim(); + if (!text) continue; + + // Passing JSX expressions like {someVar} + if (text.includes('{') || text.includes('}')) continue; + + // Passing if tag has data-i18n-key + if (match[0].includes('data-i18n-key')) continue; + + console.log(` - ${relPath}:${index + 1} β€” <${match[1]}> "${text}"`); + totalFound++; + } + }); + } + + if (totalFound === 0) { + console.log('βœ… No unlocalized text found!'); + } else { + console.log(`\nπŸ“‹ Found ${totalFound} potentially unlocalized text node(s)`); + } +} + + +// Function to validate translations +function validateTranslations() { + console.log('πŸ” Validating translation files...'); + + // Parse --locales=en,fr argument + const localesArg = process.argv.find(arg => arg.startsWith('--locales=')); + const selectedLocales = localesArg + ? localesArg.split('=')[1].split(',').map(l => l.trim()) + : null; + + const files = fs.readdirSync(COMPILED_DIR) + .filter(file => file.endsWith('.json')) + .filter(file => { + const locale = path.basename(file, '.json'); + return !selectedLocales || selectedLocales.includes(locale); + }); + + if (files.length === 0) { + console.log('⚠️ No compiled translation files found'); + return; + } + + // Load all translation data + const translations = {}; + for (const file of files) { + const locale = path.basename(file, '.json'); + const filePath = path.join(COMPILED_DIR, file); + translations[locale] = fs.readJsonSync(filePath); + } + + // Get all locales + const locales = Object.keys(translations); + console.log(`πŸ“ Found ${locales.length} locales: ${locales.join(', ')}`); + + if (locales.length < 2) { + console.log('⚠️ Need at least 2 locales to compare'); + return; + } + + // Choose the first locale as reference + const referenceLocale = locales[0]; + console.log(`πŸ”‘ Using ${referenceLocale} as reference locale`); + + // Get all keys from reference locale + const referenceKeys = Object.keys(translations[referenceLocale]); + console.log(`πŸ”’ Reference locale has ${referenceKeys.size} unique keys`); + + // Track statistics + const stats = { + missingKeys: {}, + extraKeys: {}, + typeErrors: {} + }; + + // Initialize stats for each locale + for (const locale of locales) { + if (locale !== referenceLocale) { + stats.missingKeys[locale] = []; + stats.extraKeys[locale] = []; + stats.typeErrors[locale] = []; + } + } + + // Check each locale against the reference + for (const locale of locales) { + if (locale === referenceLocale) continue; + + const localeKeys = Object.keys(translations[locale]); + + // Check for missing keys + for (const key of referenceKeys) { + if (!key in translations[locale]) { + stats.missingKeys[locale].push(key); + } else { + // Check for type mismatches + const refValue = translations[referenceLocale][key]; + const localeValue = translations[locale][key]; + + if (typeof refValue !== typeof localeValue) { + stats.typeErrors[locale].push({ + key, + refType: typeof refValue, + localeType: typeof localeValue + }); + } + } + } + + // Check for extra keys + for (const key of localeKeys) { + if (!key in translations[referenceLocale]) { + stats.extraKeys[locale].push(key); + } + } + } + + // Print results + let hasIssues = false; + + // Print missing keys + for (const locale in stats.missingKeys) { + const missing = stats.missingKeys[locale]; + if (missing.length > 0) { + hasIssues = true; + console.log(`❌ ${locale} is missing ${missing.length} keys:`); + missing.forEach(key => { + console.log(` - ${key}`); + }); + } + } + + // Print extra keys + for (const locale in stats.extraKeys) { + const extra = stats.extraKeys[locale]; + if (extra.length > 0) { + hasIssues = true; + console.log(`⚠️ ${locale} has ${extra.length} extra keys:`); + extra.forEach(key => { + console.log(` - ${key}`); + }); + } + } + + // Print type errors + for (const locale in stats.typeErrors) { + const typeErrors = stats.typeErrors[locale]; + if (typeErrors.length > 0) { + hasIssues = true; + console.log(`⚠️ ${locale} has ${typeErrors.length} type mismatches:`); + typeErrors.forEach(err => { + console.log(` - ${err.key}: expected ${err.refType}, got ${err.localeType}`); + }); + } + } + + // Print empty values check if needed + console.log('\nπŸ“Š Checking for empty values...'); + for (const locale of locales) { + checkEmptyValues(translations[locale], locale); + } + + if (!hasIssues) { + console.log('βœ… All locales have consistent structure!'); + } + + return hasIssues; +} + +// Function to check for empty values +function checkEmptyValues(obj, locale, prefix = '') { + for (const key in obj) { + const fullKey = prefix ? `${prefix}.${key}` : key; + const value = obj[key]; + + if (value === '') { + console.log(`⚠️ ${locale} has empty string at ${fullKey}`); + } else if (value === null) { + console.log(`⚠️ ${locale} has null value at ${fullKey}`); + } else if (typeof value === 'object' && !Array.isArray(value)) { + checkEmptyValues(value, locale, fullKey); + } + } +} + +// Main function +function main() { + // Create compiled directory if it doesn't exist + fs.ensureDirSync(COMPILED_DIR); + + // Run validation + validateTranslations(); + + // Find unlocalized text + findUnlocalizedText(); +} + +// Watch mode +if (process.argv.includes('--watch')) { + console.log('πŸ‘€ Watching for changes...'); + + let debounceTimer; + const debounceDelay = 100; + + // Initial validation + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + main(); + }, debounceDelay); + + // Watch for changes in the compiled directory + chokidar.watch(COMPILED_DIR, { + ignoreInitial: true, + ignored: /.*~$/, // Π˜Π³Π½ΠΎΡ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ скрытыС Ρ„Π°ΠΉΠ»Ρ‹ + }).on('all', (event, path) => { + if (event === 'change' || event === 'add' || event === 'unlink') { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + console.log(`πŸ” Detected changes in ${path} (${event}), revalidating...`); + main(); + }, debounceDelay); + } + }); +} else { + // Run once + main(); +} diff --git a/src/systems/rendering/userStats.js b/src/systems/rendering/userStats.js index f44d9ac..7d98937 100644 --- a/src/systems/rendering/userStats.js +++ b/src/systems/rendering/userStats.js @@ -213,7 +213,7 @@ export function renderUserStats() { if (!lastGeneratedData.userStats && !committedTrackerData.userStats) { // Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM) - $userStatsContainer.html('
No statuses generated yet
'); + $userStatsContainer.html('
No statuses generated yet
'); return; } diff --git a/src/systems/ui/trackerEditor.js b/src/systems/ui/trackerEditor.js index e84c75b..f287847 100644 --- a/src/systems/ui/trackerEditor.js +++ b/src/systems/ui/trackerEditor.js @@ -58,14 +58,14 @@ function set_ids_names(list_with_stats, index, value) { if (extensionSettings.userStats && Object.prototype.hasOwnProperty.call(extensionSettings.userStats, oldId)) { extensionSettings.userStats[newId] = extensionSettings.userStats[oldId]; delete extensionSettings.userStats[oldId]; -function setIdsNames(listWithStats, index, value) { - listWithStats[index].name = value; - const ids = listWithStats.toSpliced(index, 1).map(stat => stat.id); - const snakeValue = safeToSnake(value); // new id format - if (snakeValue !== value && !ids.includes(snakeValue)) { // check if this id already exists - listWithStats[index].id = snakeValue; + } + + if (extensionSettings.classicStats && Object.prototype.hasOwnProperty.call(extensionSettings.classicStats, oldId)) { + extensionSettings.classicStats[newId] = extensionSettings.classicStats[oldId]; + delete extensionSettings.classicStats[oldId]; + } } - return listWithStats; + return list_with_stats; }