10cfe581ac
Add a new Equipment tab to manage player gear and stat bonuses. Features: - 19 equipment slots across 8 categories (helmet, necklace, body armor, gloves, pants, shoes, rings, accessories) - Type-to-slot validation: each type has max equipped limits (1 helmet, 10 rings, 3 accessories, etc.) - Auto-slot assignment: equipping a ring fills the first available ring slot - Stat bonuses from equipped items display on RPG attributes (e.g. STR 10 +2) - Create/edit modal with stat checkboxes per RPG attribute - Inventory list for unequipped items Architecture: - Shared constants in src/systems/equipment/constants.js - Category-based types (Ring, Accessory) with auto-slot assignment - v7 migration converts legacy slot-specific types to generic categories - Full i18n support for all UI strings Files: - New: src/systems/equipment/constants.js - New: src/systems/interaction/equipmentActions.js - New: src/systems/rendering/equipment.js - Modified: state.js, persistence.js, template.html, index.js - Modified: userStats.js, desktop.js, mobile.js, layout.js, modals.js - Modified: apiClient.js, sillytavern.js, style.css, en.json
1895 lines
69 KiB
JavaScript
1895 lines
69 KiB
JavaScript
/**
|
|
* Core Persistence Module
|
|
* Handles saving/loading extension settings and chat data
|
|
*/
|
|
|
|
import { saveSettingsDebounced, chat_metadata, saveChatDebounced, getCurrentChatId } from '../../../../../../script.js';
|
|
import { getContext } from '../../../../../extensions.js';
|
|
import {
|
|
extensionSettings,
|
|
lastGeneratedData,
|
|
committedTrackerData,
|
|
thoughtBasedExpressionPortraits,
|
|
setExtensionSettings,
|
|
updateExtensionSettings,
|
|
setLastGeneratedData,
|
|
setCommittedTrackerData,
|
|
setThoughtBasedExpressionPortraits,
|
|
clearThoughtBasedExpressionPortraits,
|
|
FEATURE_FLAGS
|
|
} from './state.js';
|
|
import { migrateInventory } from '../utils/migration.js';
|
|
import { validateStoredInventory, cleanItemString } from '../utils/security.js';
|
|
import { migrateToV3JSON } from '../utils/jsonMigration.js';
|
|
|
|
const extensionName = 'third-party/rpg-companion-sillytavern';
|
|
const CURRENT_SETTINGS_VERSION = 7;
|
|
|
|
const DEFAULT_USER_STATS = {
|
|
health: 100,
|
|
satiety: 100,
|
|
energy: 100,
|
|
hygiene: 100,
|
|
arousal: 0,
|
|
mood: '😐',
|
|
conditions: 'None',
|
|
skills: [],
|
|
inventory: {
|
|
version: 2,
|
|
onPerson: "None",
|
|
clothing: "None",
|
|
stored: {},
|
|
assets: "None"
|
|
}
|
|
};
|
|
|
|
const DEFAULT_EXTENSION_SETTINGS = cloneSerializable(extensionSettings);
|
|
DEFAULT_EXTENSION_SETTINGS.settingsVersion = CURRENT_SETTINGS_VERSION;
|
|
|
|
let hasDeferredChatDataSave = false;
|
|
|
|
function cloneSerializable(value) {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
return structuredClone(value);
|
|
} catch {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function mergeWithDefaults(defaultValue, savedValue) {
|
|
if (savedValue === undefined) {
|
|
return cloneSerializable(defaultValue);
|
|
}
|
|
|
|
if (isPlainObject(defaultValue) && isPlainObject(savedValue)) {
|
|
const merged = cloneSerializable(defaultValue);
|
|
for (const [key, value] of Object.entries(savedValue)) {
|
|
merged[key] = mergeWithDefaults(defaultValue[key], value);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
return cloneSerializable(savedValue);
|
|
}
|
|
|
|
function parseMaybeJSON(value) {
|
|
if (typeof value !== 'string') {
|
|
return value;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) {
|
|
return value;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(trimmed);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function stringifyInventoryItems(items) {
|
|
if (typeof items === 'string') {
|
|
return items.trim() || 'None';
|
|
}
|
|
|
|
if (!Array.isArray(items)) {
|
|
return 'None';
|
|
}
|
|
|
|
const text = items
|
|
.map(item => {
|
|
if (isPlainObject(item) && item.name) {
|
|
const quantity = Number(item.quantity);
|
|
return quantity > 1 ? `${quantity}x ${item.name}` : item.name;
|
|
}
|
|
return String(item || '').trim();
|
|
})
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
|
|
return text || 'None';
|
|
}
|
|
|
|
function normalizeStoredInventory(stored) {
|
|
if (!isPlainObject(stored)) {
|
|
return {};
|
|
}
|
|
|
|
const normalized = {};
|
|
for (const [location, items] of Object.entries(stored)) {
|
|
normalized[location] = stringifyInventoryItems(items);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeInventoryValue(inventory) {
|
|
const parsedInventory = parseMaybeJSON(inventory);
|
|
|
|
if (isPlainObject(parsedInventory) && (
|
|
Array.isArray(parsedInventory.onPerson)
|
|
|| Array.isArray(parsedInventory.clothing)
|
|
|| Array.isArray(parsedInventory.assets)
|
|
|| isPlainObject(parsedInventory.stored)
|
|
)) {
|
|
return {
|
|
version: 2,
|
|
onPerson: stringifyInventoryItems(parsedInventory.onPerson),
|
|
clothing: stringifyInventoryItems(parsedInventory.clothing),
|
|
stored: normalizeStoredInventory(parsedInventory.stored),
|
|
assets: stringifyInventoryItems(parsedInventory.assets)
|
|
};
|
|
}
|
|
|
|
const migrationResult = migrateInventory(parsedInventory);
|
|
return mergeWithDefaults(DEFAULT_USER_STATS.inventory, migrationResult.inventory);
|
|
}
|
|
|
|
function normalizeUserStatsValue(userStats) {
|
|
const parsedStats = parseMaybeJSON(userStats);
|
|
const normalized = cloneSerializable(DEFAULT_USER_STATS);
|
|
|
|
if (!isPlainObject(parsedStats)) {
|
|
return normalized;
|
|
}
|
|
|
|
if (Array.isArray(parsedStats.stats)) {
|
|
for (const stat of parsedStats.stats) {
|
|
if (!stat || typeof stat !== 'object') continue;
|
|
const id = stat.id || stat.name?.toLowerCase?.();
|
|
if (id && stat.value !== undefined) {
|
|
normalized[id] = stat.value;
|
|
}
|
|
}
|
|
} else {
|
|
for (const [key, value] of Object.entries(parsedStats)) {
|
|
if (!['stats', 'status', 'inventory', 'quests'].includes(key) && value !== undefined) {
|
|
normalized[key] = cloneSerializable(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isPlainObject(parsedStats.status)) {
|
|
for (const [key, value] of Object.entries(parsedStats.status)) {
|
|
if (value !== undefined) {
|
|
normalized[key] = cloneSerializable(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (parsedStats.inventory !== undefined) {
|
|
normalized.inventory = normalizeInventoryValue(parsedStats.inventory);
|
|
}
|
|
|
|
for (const [key, defaultValue] of Object.entries(DEFAULT_USER_STATS)) {
|
|
if (typeof defaultValue !== 'number') continue;
|
|
|
|
const numericValue = Number(normalized[key]);
|
|
normalized[key] = Number.isFinite(numericValue) ? numericValue : defaultValue;
|
|
}
|
|
|
|
return mergeWithDefaults(DEFAULT_USER_STATS, normalized);
|
|
}
|
|
|
|
function normalizeQuestValue(quest) {
|
|
let value = quest;
|
|
while (isPlainObject(value) && value.value !== undefined) {
|
|
value = value.value;
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
return value.trim() || 'None';
|
|
}
|
|
|
|
if (isPlainObject(value)) {
|
|
return value.title || value.description || JSON.stringify(value);
|
|
}
|
|
|
|
return value == null ? 'None' : String(value);
|
|
}
|
|
|
|
function normalizeQuestsValue(quests) {
|
|
if (!isPlainObject(quests)) {
|
|
return { main: 'None', optional: [] };
|
|
}
|
|
|
|
const optionalSource = Array.isArray(quests.optional)
|
|
? quests.optional
|
|
: (Array.isArray(quests.active) ? quests.active : []);
|
|
|
|
return {
|
|
main: normalizeQuestValue(quests.main),
|
|
optional: optionalSource
|
|
.map(normalizeQuestValue)
|
|
.filter(quest => quest && quest !== 'None')
|
|
};
|
|
}
|
|
|
|
function normalizeSettings(savedSettings) {
|
|
const sourceSettings = isPlainObject(savedSettings) ? savedSettings : {};
|
|
const normalized = mergeWithDefaults(DEFAULT_EXTENSION_SETTINGS, sourceSettings);
|
|
const savedVersion = Number(sourceSettings.settingsVersion);
|
|
normalized.settingsVersion = Number.isFinite(savedVersion) && savedVersion > 0 ? savedVersion : 1;
|
|
normalized.userStats = normalizeUserStatsValue(sourceSettings.userStats);
|
|
|
|
const parsedUserStats = parseMaybeJSON(sourceSettings.userStats);
|
|
if (sourceSettings.quests !== undefined) {
|
|
normalized.quests = normalizeQuestsValue(sourceSettings.quests);
|
|
} else if (isPlainObject(parsedUserStats) && parsedUserStats.quests !== undefined) {
|
|
normalized.quests = normalizeQuestsValue(parsedUserStats.quests);
|
|
}
|
|
|
|
return {
|
|
settings: normalized,
|
|
changed: JSON.stringify(normalized) !== JSON.stringify(savedSettings)
|
|
};
|
|
}
|
|
|
|
function isChatDataSaveReady() {
|
|
return !!(
|
|
chat_metadata
|
|
&& typeof chat_metadata === 'object'
|
|
&& chat_metadata.integrity
|
|
&& getCurrentChatId()
|
|
);
|
|
}
|
|
|
|
function hasTrackerPayload(payload) {
|
|
return !!(payload && typeof payload === 'object' && (
|
|
payload.userStats
|
|
|| payload.infoBox
|
|
|| payload.characterThoughts
|
|
));
|
|
}
|
|
|
|
function getCurrentTrackerPayloadFromSwipeStore(store, preferredSwipeId = 0) {
|
|
if (!store) {
|
|
return null;
|
|
}
|
|
|
|
if (hasTrackerPayload(store)) {
|
|
return store;
|
|
}
|
|
|
|
const preferredKey = String(preferredSwipeId);
|
|
const preferredPayload = store[preferredKey] ?? store[preferredSwipeId];
|
|
if (hasTrackerPayload(preferredPayload)) {
|
|
return preferredPayload;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getTrackerPayloadFromSwipeStore(store, preferredSwipeId = 0) {
|
|
const currentPayload = getCurrentTrackerPayloadFromSwipeStore(store, preferredSwipeId);
|
|
if (currentPayload) {
|
|
return currentPayload;
|
|
}
|
|
|
|
if (!store || typeof store !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const numericKeys = Object.keys(store)
|
|
.filter(key => /^\d+$/.test(key))
|
|
.sort((a, b) => Number(b) - Number(a));
|
|
|
|
for (const key of numericKeys) {
|
|
const payload = store[key];
|
|
if (hasTrackerPayload(payload)) {
|
|
return payload;
|
|
}
|
|
}
|
|
|
|
for (const payload of Object.values(store)) {
|
|
if (hasTrackerPayload(payload)) {
|
|
return payload;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function ensureTrackerPayloadSlot(store, swipeId = 0) {
|
|
if (!store || typeof store !== 'object' || Array.isArray(store)) {
|
|
return null;
|
|
}
|
|
|
|
if (hasTrackerPayload(store)) {
|
|
return store;
|
|
}
|
|
|
|
if (!store[swipeId] || typeof store[swipeId] !== 'object' || Array.isArray(store[swipeId])) {
|
|
store[swipeId] = {};
|
|
}
|
|
|
|
return store[swipeId];
|
|
}
|
|
|
|
function ensureSwipeInfoEntry(message, swipeId = 0) {
|
|
if (!Array.isArray(message?.swipe_info)) {
|
|
return null;
|
|
}
|
|
|
|
if (!message.swipe_info[swipeId] || typeof message.swipe_info[swipeId] !== 'object') {
|
|
message.swipe_info[swipeId] = {
|
|
send_date: message.send_date,
|
|
gen_started: message.gen_started,
|
|
gen_finished: message.gen_finished,
|
|
extra: {}
|
|
};
|
|
}
|
|
|
|
if (!message.swipe_info[swipeId].extra || typeof message.swipe_info[swipeId].extra !== 'object') {
|
|
message.swipe_info[swipeId].extra = {};
|
|
}
|
|
|
|
return message.swipe_info[swipeId];
|
|
}
|
|
|
|
export function getCurrentMessageSwipeTrackerData(message) {
|
|
if (!message || message.is_user) {
|
|
return null;
|
|
}
|
|
|
|
const swipeId = Number(message.swipe_id ?? 0);
|
|
|
|
const preferredSources = [
|
|
message.extra?.rpg_companion_swipes,
|
|
message.swipe_info?.[swipeId]?.extra?.rpg_companion_swipes
|
|
];
|
|
|
|
for (const source of preferredSources) {
|
|
const payload = getCurrentTrackerPayloadFromSwipeStore(source, swipeId);
|
|
if (payload) {
|
|
return payload;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function getMessageSwipeTrackerData(message) {
|
|
if (!message || message.is_user) {
|
|
return null;
|
|
}
|
|
|
|
const swipeId = Number(message.swipe_id ?? 0);
|
|
const currentPayload = getCurrentMessageSwipeTrackerData(message);
|
|
if (currentPayload) {
|
|
return currentPayload;
|
|
}
|
|
|
|
const preferredSources = [
|
|
message.extra?.rpg_companion_swipes,
|
|
message.swipe_info?.[swipeId]?.extra?.rpg_companion_swipes
|
|
];
|
|
|
|
for (const source of preferredSources) {
|
|
const payload = getTrackerPayloadFromSwipeStore(source, swipeId);
|
|
if (payload) {
|
|
return payload;
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(message.swipe_info)) {
|
|
for (let i = message.swipe_info.length - 1; i >= 0; i--) {
|
|
const payload = getTrackerPayloadFromSwipeStore(message.swipe_info[i]?.extra?.rpg_companion_swipes, swipeId);
|
|
if (payload) {
|
|
return payload;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function getLatestTrackerDataFromChat(chatMessages) {
|
|
if (!Array.isArray(chatMessages)) {
|
|
return null;
|
|
}
|
|
|
|
for (let i = chatMessages.length - 1; i >= 0; i--) {
|
|
const message = chatMessages[i];
|
|
if (message?.is_user) continue;
|
|
|
|
const swipeData = getCurrentMessageSwipeTrackerData(message);
|
|
if (!swipeData) continue;
|
|
|
|
return {
|
|
userStats: swipeData.userStats || null,
|
|
infoBox: swipeData.infoBox || null,
|
|
characterThoughts: typeof swipeData.characterThoughts === 'object'
|
|
? JSON.stringify(swipeData.characterThoughts, null, 2)
|
|
: (swipeData.characterThoughts || null)
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function restoreLatestTrackerStateFromChat(chatMessages) {
|
|
const latestData = getLatestTrackerDataFromChat(chatMessages);
|
|
if (!latestData) {
|
|
return false;
|
|
}
|
|
|
|
setLastGeneratedData({
|
|
userStats: latestData.userStats || null,
|
|
infoBox: latestData.infoBox || null,
|
|
characterThoughts: latestData.characterThoughts || null,
|
|
html: lastGeneratedData.html || null
|
|
});
|
|
|
|
setCommittedTrackerData({
|
|
userStats: latestData.userStats || committedTrackerData.userStats || null,
|
|
infoBox: latestData.infoBox || committedTrackerData.infoBox || null,
|
|
characterThoughts: latestData.characterThoughts || committedTrackerData.characterThoughts || null
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
export function setMessageSwipeTrackerData(message, swipeId = 0, trackerData = {}) {
|
|
if (!message || message.is_user || !trackerData || typeof trackerData !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
if (!message.extra || typeof message.extra !== 'object') {
|
|
message.extra = {};
|
|
}
|
|
if (!message.extra.rpg_companion_swipes || typeof message.extra.rpg_companion_swipes !== 'object' || Array.isArray(message.extra.rpg_companion_swipes)) {
|
|
message.extra.rpg_companion_swipes = {};
|
|
}
|
|
|
|
const extraPayload = ensureTrackerPayloadSlot(message.extra.rpg_companion_swipes, swipeId);
|
|
if (extraPayload) {
|
|
Object.assign(extraPayload, trackerData);
|
|
}
|
|
|
|
const swipeInfoEntry = ensureSwipeInfoEntry(message, swipeId);
|
|
if (swipeInfoEntry) {
|
|
if (!swipeInfoEntry.extra.rpg_companion_swipes || typeof swipeInfoEntry.extra.rpg_companion_swipes !== 'object' || Array.isArray(swipeInfoEntry.extra.rpg_companion_swipes)) {
|
|
swipeInfoEntry.extra.rpg_companion_swipes = {};
|
|
}
|
|
|
|
const swipePayload = ensureTrackerPayloadSlot(swipeInfoEntry.extra.rpg_companion_swipes, swipeId);
|
|
if (swipePayload) {
|
|
Object.assign(swipePayload, trackerData);
|
|
}
|
|
}
|
|
|
|
return extraPayload;
|
|
}
|
|
|
|
export function setMessageSwipeTrackerField(message, swipeId = 0, field, value) {
|
|
if (!field) {
|
|
return null;
|
|
}
|
|
|
|
return setMessageSwipeTrackerData(message, swipeId, { [field]: value });
|
|
}
|
|
|
|
/**
|
|
* Validates extension settings structure
|
|
* @param {Object} settings - Settings object to validate
|
|
* @returns {boolean} True if valid, false otherwise
|
|
*/
|
|
function validateSettings(settings) {
|
|
if (!settings || typeof settings !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
// Check for required top-level properties
|
|
if (typeof settings.enabled !== 'boolean' ||
|
|
typeof settings.autoUpdate !== 'boolean' ||
|
|
!settings.userStats || typeof settings.userStats !== 'object' ||
|
|
Array.isArray(settings.userStats)) {
|
|
console.warn('[RPG Companion] Settings validation failed: missing required properties');
|
|
return false;
|
|
}
|
|
|
|
// Validate userStats structure
|
|
const stats = settings.userStats;
|
|
if (typeof stats.health !== 'number' ||
|
|
typeof stats.satiety !== 'number' ||
|
|
typeof stats.energy !== 'number' ||
|
|
!stats.inventory || typeof stats.inventory !== 'object') {
|
|
console.warn('[RPG Companion] Settings validation failed: invalid userStats structure');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Loads the extension settings from the global settings object.
|
|
* Automatically migrates v1 inventory to v2 format if needed.
|
|
*/
|
|
export function loadSettings() {
|
|
try {
|
|
const context = getContext();
|
|
const extension_settings = context.extension_settings || context.extensionSettings;
|
|
|
|
// Validate extension_settings structure
|
|
if (!extension_settings || typeof extension_settings !== 'object') {
|
|
console.warn('[RPG Companion] extension_settings is not available, using default settings');
|
|
return;
|
|
}
|
|
|
|
if (extension_settings[extensionName]) {
|
|
const savedSettings = extension_settings[extensionName];
|
|
const normalizedResult = normalizeSettings(savedSettings);
|
|
const normalizedSettings = normalizedResult.settings;
|
|
|
|
// Validate loaded settings after schema repair/normalization
|
|
if (!validateSettings(normalizedSettings)) {
|
|
console.warn('[RPG Companion] Loaded settings failed validation, using defaults');
|
|
console.warn('[RPG Companion] Invalid settings:', normalizedSettings);
|
|
// Save valid defaults to replace corrupt data
|
|
saveSettings();
|
|
return;
|
|
}
|
|
|
|
updateExtensionSettings(normalizedSettings);
|
|
|
|
// Perform settings migrations based on version
|
|
const currentVersion = extensionSettings.settingsVersion || 1;
|
|
let settingsChanged = normalizedResult.changed;
|
|
|
|
// Migration to version 2: Enable dynamic weather for existing users
|
|
if (currentVersion < 2) {
|
|
// console.log('[RPG Companion] Migrating settings to version 2 (enabling dynamic weather)');
|
|
extensionSettings.enableDynamicWeather = true;
|
|
extensionSettings.settingsVersion = 2;
|
|
settingsChanged = true;
|
|
}
|
|
|
|
// Migration to version 3: Convert text trackers to JSON format
|
|
if (currentVersion < 3) {
|
|
// console.log('[RPG Companion] Migrating settings to version 3 (JSON tracker format)');
|
|
migrateToV3JSON();
|
|
extensionSettings.settingsVersion = 3;
|
|
settingsChanged = true;
|
|
}
|
|
|
|
// Migration to version 4: Enable FAB widgets by default
|
|
if (currentVersion < 4) {
|
|
// console.log('[RPG Companion] Migrating settings to version 4 (enabling FAB widgets)');
|
|
if (!extensionSettings.mobileFabWidgets) {
|
|
extensionSettings.mobileFabWidgets = {};
|
|
}
|
|
extensionSettings.mobileFabWidgets.enabled = true;
|
|
extensionSettings.mobileFabWidgets.weatherIcon = { enabled: true };
|
|
extensionSettings.mobileFabWidgets.weatherDesc = { enabled: true };
|
|
extensionSettings.mobileFabWidgets.clock = { enabled: true };
|
|
extensionSettings.mobileFabWidgets.date = { enabled: true };
|
|
extensionSettings.mobileFabWidgets.location = { enabled: true };
|
|
extensionSettings.mobileFabWidgets.stats = { enabled: true };
|
|
extensionSettings.mobileFabWidgets.attributes = { enabled: true };
|
|
extensionSettings.settingsVersion = 4;
|
|
settingsChanged = true;
|
|
}
|
|
|
|
// Migration to version 5: Add opacity properties for all colors
|
|
if (currentVersion < 5) {
|
|
// console.log('[RPG Companion] Migrating settings to version 5 (adding color opacity)');
|
|
if (!extensionSettings.customColors) {
|
|
extensionSettings.customColors = {};
|
|
}
|
|
if (extensionSettings.customColors.bgOpacity === undefined) extensionSettings.customColors.bgOpacity = 100;
|
|
if (extensionSettings.customColors.accentOpacity === undefined) extensionSettings.customColors.accentOpacity = 100;
|
|
if (extensionSettings.customColors.textOpacity === undefined) extensionSettings.customColors.textOpacity = 100;
|
|
if (extensionSettings.customColors.highlightOpacity === undefined) extensionSettings.customColors.highlightOpacity = 100;
|
|
if (extensionSettings.statBarColorLowOpacity === undefined) extensionSettings.statBarColorLowOpacity = 100;
|
|
if (extensionSettings.statBarColorHighOpacity === undefined) extensionSettings.statBarColorHighOpacity = 100;
|
|
extensionSettings.settingsVersion = 5;
|
|
settingsChanged = true;
|
|
}
|
|
|
|
// Migration to version 6: Add equipment data structure
|
|
if (currentVersion < 6) {
|
|
// console.log('[RPG Companion] Migrating settings to version 6 (adding equipment)');
|
|
if (!extensionSettings.userStats.equipment) {
|
|
extensionSettings.userStats.equipment = {
|
|
items: [],
|
|
slots: {
|
|
helmet: null,
|
|
ring1: null,
|
|
ring2: null,
|
|
ring3: null,
|
|
ring4: null,
|
|
ring5: null,
|
|
ring6: null,
|
|
ring7: null,
|
|
ring8: null,
|
|
ring9: null,
|
|
ring10: null,
|
|
necklace: null,
|
|
bodyArmor: null,
|
|
pants: null,
|
|
shoes: null,
|
|
gloves: null,
|
|
accessory1: null,
|
|
accessory2: null,
|
|
accessory3: null
|
|
}
|
|
};
|
|
}
|
|
if (extensionSettings.showEquipment === undefined) {
|
|
extensionSettings.showEquipment = true;
|
|
}
|
|
extensionSettings.settingsVersion = 6;
|
|
settingsChanged = true;
|
|
}
|
|
|
|
// Migration to version 7: Convert equipment types to generic categories + add item.slot
|
|
if (currentVersion < 7) {
|
|
const equipment = extensionSettings.userStats?.equipment;
|
|
if (equipment) {
|
|
const typeMap = {
|
|
ring1: 'ring', ring2: 'ring', ring3: 'ring', ring4: 'ring', ring5: 'ring',
|
|
ring6: 'ring', ring7: 'ring', ring8: 'ring', ring9: 'ring', ring10: 'ring',
|
|
accessory1: 'accessory', accessory2: 'accessory', accessory3: 'accessory'
|
|
};
|
|
for (const item of equipment.items || []) {
|
|
if (!item.slot && equipment.slots) {
|
|
for (const [slotId, itemId] of Object.entries(equipment.slots)) {
|
|
if (itemId === item.id) {
|
|
item.slot = slotId;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (item.type && typeMap[item.type]) {
|
|
item.type = typeMap[item.type];
|
|
}
|
|
}
|
|
}
|
|
extensionSettings.settingsVersion = 7;
|
|
settingsChanged = true;
|
|
}
|
|
|
|
// Normalize additive settings without introducing another schema bump.
|
|
if (!extensionSettings.thoughtsInChatStyle) {
|
|
extensionSettings.thoughtsInChatStyle = 'corner';
|
|
settingsChanged = true;
|
|
}
|
|
|
|
if (extensionSettings.showAlternatePresentCharactersPanel === undefined) {
|
|
extensionSettings.showAlternatePresentCharactersPanel = false;
|
|
settingsChanged = true;
|
|
}
|
|
|
|
if (extensionSettings.hideDefaultExpressionDisplay === undefined) {
|
|
extensionSettings.hideDefaultExpressionDisplay = false;
|
|
settingsChanged = true;
|
|
}
|
|
|
|
// Save migrated settings
|
|
if (settingsChanged) {
|
|
saveSettings();
|
|
}
|
|
|
|
// console.log('[RPG Companion] Settings loaded:', extensionSettings);
|
|
} else {
|
|
// console.log('[RPG Companion] No saved settings found, using defaults');
|
|
}
|
|
|
|
// Migrate inventory if feature flag enabled
|
|
if (FEATURE_FLAGS.useNewInventory) {
|
|
const migrationResult = migrateInventory(extensionSettings.userStats.inventory);
|
|
if (migrationResult.migrated) {
|
|
// console.log(`[RPG Companion] Inventory migrated from ${migrationResult.source} to v2 format`);
|
|
extensionSettings.userStats.inventory = migrationResult.inventory;
|
|
saveSettings(); // Persist migrated inventory
|
|
}
|
|
}
|
|
|
|
// Migrate to trackerConfig if it doesn't exist
|
|
if (!extensionSettings.trackerConfig) {
|
|
// console.log('[RPG Companion] Migrating to trackerConfig format');
|
|
migrateToTrackerConfig();
|
|
saveSettings(); // Persist migration
|
|
}
|
|
|
|
// Migrate to preset manager system if presets don't exist
|
|
migrateToPresetManager();
|
|
|
|
// Initialize custom status fields
|
|
initializeCustomStatusFields();
|
|
|
|
// Ensure all stats have maxValue (for number display mode)
|
|
ensureStatsHaveMaxValue();
|
|
} catch (error) {
|
|
console.error('[RPG Companion] Error loading settings:', error);
|
|
console.error('[RPG Companion] Error details:', error.message, error.stack);
|
|
console.warn('[RPG Companion] Using default settings due to load error');
|
|
// Settings will remain at defaults from state.js
|
|
}
|
|
|
|
// Validate inventory structure (Bug #3 fix)
|
|
validateInventoryStructure(extensionSettings.userStats.inventory, 'settings');
|
|
}
|
|
|
|
/**
|
|
* Saves the extension settings to the global settings object.
|
|
*/
|
|
export function saveSettings() {
|
|
const context = getContext();
|
|
const extension_settings = context.extension_settings || context.extensionSettings;
|
|
|
|
if (!extension_settings) {
|
|
console.error('[RPG Companion] extension_settings is not available, cannot save');
|
|
return;
|
|
}
|
|
|
|
extension_settings[extensionName] = extensionSettings;
|
|
saveSettingsDebounced();
|
|
}
|
|
|
|
/**
|
|
* Saves RPG data to the current chat's metadata.
|
|
*/
|
|
export function saveChatData() {
|
|
if (!isChatDataSaveReady()) {
|
|
hasDeferredChatDataSave = true;
|
|
return;
|
|
}
|
|
|
|
// console.log('[RPG Companion] 💾 saveChatData called - committedTrackerData:', {
|
|
// userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
|
|
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
|
|
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
|
|
// });
|
|
// console.log('[RPG Companion] 💾 saveChatData RAW committedTrackerData:', committedTrackerData);
|
|
// console.log('[RPG Companion] 💾 saveChatData RAW lastGeneratedData:', lastGeneratedData);
|
|
|
|
chat_metadata.rpg_companion = {
|
|
userStats: extensionSettings.userStats,
|
|
classicStats: extensionSettings.classicStats,
|
|
quests: extensionSettings.quests,
|
|
lastGeneratedData: lastGeneratedData,
|
|
committedTrackerData: committedTrackerData,
|
|
thoughtBasedExpressionPortraits: thoughtBasedExpressionPortraits,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
saveChatDebounced();
|
|
}
|
|
|
|
export function flushDeferredChatDataSave() {
|
|
if (!hasDeferredChatDataSave || !isChatDataSaveReady()) {
|
|
return false;
|
|
}
|
|
|
|
hasDeferredChatDataSave = false;
|
|
saveChatData();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function updateMessageSwipeData() {
|
|
const chat = getContext().chat;
|
|
if (!chat || chat.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Find the last assistant message
|
|
for (let i = chat.length - 1; i >= 0; i--) {
|
|
const message = chat[i];
|
|
if (!message.is_user && !message.is_system) {
|
|
// Found last assistant message - update its swipe data
|
|
if (!message.extra) {
|
|
message.extra = {};
|
|
}
|
|
if (!message.extra.rpg_companion_swipes) {
|
|
message.extra.rpg_companion_swipes = {};
|
|
}
|
|
|
|
const swipeId = message.swipe_id || 0;
|
|
setMessageSwipeTrackerData(message, swipeId, {
|
|
userStats: lastGeneratedData.userStats,
|
|
infoBox: lastGeneratedData.infoBox,
|
|
characterThoughts: lastGeneratedData.characterThoughts
|
|
});
|
|
|
|
// console.log('[RPG Companion] Updated message swipe data after user edit');
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Resolve active swipe index for a message.
|
|
* Falls back to message.swipe_id, but prefers exact match against current
|
|
* message text when available to avoid stale swipe_id during event timing races.
|
|
*
|
|
* @param {Object} message - Assistant message object
|
|
* @returns {number} Active swipe index
|
|
*/
|
|
function resolveActiveSwipeId(message) {
|
|
const fallbackSwipeId = Number(message?.swipe_id ?? 0);
|
|
const swipes = Array.isArray(message?.swipes) ? message.swipes : null;
|
|
|
|
if (!swipes || swipes.length === 0) {
|
|
return Math.max(0, fallbackSwipeId);
|
|
}
|
|
|
|
const currentText = typeof message?.mes === 'string' ? message.mes : '';
|
|
if (currentText) {
|
|
for (let i = swipes.length - 1; i >= 0; i--) {
|
|
if (typeof swipes[i] === 'string' && swipes[i] === currentText) {
|
|
return i;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fallbackSwipeId < 0) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.min(fallbackSwipeId, swipes.length - 1);
|
|
}
|
|
|
|
/**
|
|
* 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 = resolveActiveSwipeId(message);
|
|
const swipeData = getSwipeData(message, swipeId);
|
|
|
|
if (!swipeData) {
|
|
// Keep searching backward for a valid state if this assistant message has no data
|
|
continue;
|
|
}
|
|
|
|
committedTrackerData.userStats = swipeData.userStats || null;
|
|
committedTrackerData.infoBox = swipeData.infoBox || null;
|
|
const rawCharacterThoughts = swipeData.characterThoughts;
|
|
committedTrackerData.characterThoughts =
|
|
rawCharacterThoughts == null
|
|
? null
|
|
: (typeof rawCharacterThoughts === 'string'
|
|
? rawCharacterThoughts
|
|
: JSON.stringify(rawCharacterThoughts));
|
|
|
|
return;
|
|
}
|
|
|
|
// No prior assistant message found — use empty context
|
|
committedTrackerData.userStats = null;
|
|
committedTrackerData.infoBox = null;
|
|
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 = resolveActiveSwipeId(msg);
|
|
const swipeData = getSwipeData(msg, swipeId);
|
|
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 = {};
|
|
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.
|
|
*/
|
|
export function loadChatData() {
|
|
const savedData = chat_metadata?.rpg_companion;
|
|
|
|
if (!savedData) {
|
|
// Reset to defaults if no metadata exists, then try to rebuild from message swipe data below.
|
|
updateExtensionSettings({
|
|
userStats: {
|
|
health: 100,
|
|
satiety: 100,
|
|
energy: 100,
|
|
hygiene: 100,
|
|
arousal: 0,
|
|
mood: '😐',
|
|
conditions: 'None',
|
|
// Use v2 inventory format for defaults
|
|
inventory: {
|
|
version: 2,
|
|
onPerson: "None",
|
|
clothing: "None",
|
|
stored: {},
|
|
assets: "None"
|
|
}
|
|
},
|
|
quests: {
|
|
main: "None",
|
|
optional: []
|
|
}
|
|
});
|
|
setLastGeneratedData({
|
|
userStats: null,
|
|
infoBox: null,
|
|
characterThoughts: null,
|
|
html: null
|
|
});
|
|
setCommittedTrackerData({
|
|
userStats: null,
|
|
infoBox: null,
|
|
characterThoughts: null
|
|
});
|
|
clearThoughtBasedExpressionPortraits();
|
|
}
|
|
|
|
// Restore stats
|
|
if (savedData?.userStats) {
|
|
extensionSettings.userStats = { ...savedData.userStats };
|
|
}
|
|
|
|
// Restore classic stats
|
|
if (savedData?.classicStats) {
|
|
extensionSettings.classicStats = { ...savedData.classicStats };
|
|
}
|
|
|
|
// Restore quests
|
|
if (savedData?.quests) {
|
|
extensionSettings.quests = { ...savedData.quests };
|
|
} else {
|
|
// Initialize with defaults if not present
|
|
extensionSettings.quests = {
|
|
main: "None",
|
|
optional: []
|
|
};
|
|
}
|
|
|
|
// Restore committed tracker data first
|
|
if (savedData?.committedTrackerData) {
|
|
// console.log('[RPG Companion] 📥 loadChatData restoring committedTrackerData:', {
|
|
// userStats: savedData.committedTrackerData.userStats ? `${savedData.committedTrackerData.userStats.substring(0, 50)}...` : 'null',
|
|
// infoBox: savedData.committedTrackerData.infoBox ? 'exists' : 'null',
|
|
// characterThoughts: savedData.committedTrackerData.characterThoughts ? 'exists' : 'null'
|
|
// });
|
|
// console.log('[RPG Companion] 📥 RAW savedData.committedTrackerData:', savedData.committedTrackerData);
|
|
// console.log('[RPG Companion] 📥 Type check:', {
|
|
// userStatsType: typeof savedData.committedTrackerData.userStats,
|
|
// infoBoxType: typeof savedData.committedTrackerData.infoBox,
|
|
// characterThoughtsType: typeof savedData.committedTrackerData.characterThoughts
|
|
// });
|
|
setCommittedTrackerData({ ...savedData.committedTrackerData });
|
|
}
|
|
|
|
// Restore last generated data (for display)
|
|
// Always prefer lastGeneratedData as it contains the most recent generation (including swipes)
|
|
if (savedData?.lastGeneratedData) {
|
|
// console.log('[RPG Companion] 📥 loadChatData restoring lastGeneratedData');
|
|
setLastGeneratedData({ ...savedData.lastGeneratedData });
|
|
} else {
|
|
// console.log('[RPG Companion] ⚠️ No lastGeneratedData found in save');
|
|
}
|
|
|
|
if (savedData?.thoughtBasedExpressionPortraits && typeof savedData.thoughtBasedExpressionPortraits === 'object') {
|
|
setThoughtBasedExpressionPortraits(savedData.thoughtBasedExpressionPortraits);
|
|
} else {
|
|
clearThoughtBasedExpressionPortraits();
|
|
}
|
|
|
|
// Migrate inventory in chat data if feature flag enabled
|
|
if (FEATURE_FLAGS.useNewInventory && extensionSettings.userStats.inventory) {
|
|
const migrationResult = migrateInventory(extensionSettings.userStats.inventory);
|
|
if (migrationResult.migrated) {
|
|
// console.log(`[RPG Companion] Chat inventory migrated from ${migrationResult.source} to v2 format`);
|
|
extensionSettings.userStats.inventory = migrationResult.inventory;
|
|
saveChatData(); // Persist migrated inventory to chat metadata
|
|
}
|
|
}
|
|
|
|
// Validate inventory structure (Bug #3 fix)
|
|
validateInventoryStructure(extensionSettings.userStats.inventory, 'chat');
|
|
|
|
|
|
// Sync display data from the latest assistant message's stored swipe payload.
|
|
// This is more reliable than chat metadata alone on chat re-entry because the
|
|
// latest rendered swipe data may exist on the message even if the debounced
|
|
// metadata save did not flush yet.
|
|
try {
|
|
const chatContext = getContext();
|
|
const chatMessages = chatContext?.chat;
|
|
restoreLatestTrackerStateFromChat(chatMessages);
|
|
} catch (e) {
|
|
console.warn('[RPG Companion] Per-message data sync skipped:', e.message);
|
|
}
|
|
|
|
// console.log('[RPG Companion] Loaded chat data:', savedData);
|
|
}
|
|
|
|
/**
|
|
* Validates and repairs inventory structure to prevent corruption.
|
|
* Ensures all v2 fields exist and are the correct type.
|
|
* Fixes Bug #3: Location disappears when switching tabs
|
|
*
|
|
* @param {Object} inventory - Inventory object to validate
|
|
* @param {string} source - Source of load ('settings' or 'chat') for logging
|
|
* @private
|
|
*/
|
|
function validateInventoryStructure(inventory, source) {
|
|
if (!inventory || typeof inventory !== 'object') {
|
|
console.error(`[RPG Companion] Invalid inventory from ${source}, resetting to defaults`);
|
|
extensionSettings.userStats.inventory = {
|
|
version: 2,
|
|
onPerson: "None",
|
|
clothing: "None",
|
|
stored: {},
|
|
assets: "None"
|
|
};
|
|
saveSettings();
|
|
return;
|
|
}
|
|
|
|
let needsSave = false;
|
|
|
|
// Ensure v2 structure
|
|
if (inventory.version !== 2) {
|
|
console.warn(`[RPG Companion] Inventory from ${source} missing version, setting to 2`);
|
|
inventory.version = 2;
|
|
needsSave = true;
|
|
}
|
|
|
|
// Validate onPerson field
|
|
if (typeof inventory.onPerson !== 'string') {
|
|
console.warn(`[RPG Companion] Invalid onPerson from ${source}, resetting to "None"`);
|
|
inventory.onPerson = "None";
|
|
needsSave = true;
|
|
} else {
|
|
// Clean items in onPerson (removes corrupted/dangerous items)
|
|
const cleanedOnPerson = cleanItemString(inventory.onPerson);
|
|
if (cleanedOnPerson !== inventory.onPerson) {
|
|
console.warn(`[RPG Companion] Cleaned corrupted items from onPerson inventory (${source})`);
|
|
inventory.onPerson = cleanedOnPerson;
|
|
needsSave = true;
|
|
}
|
|
}
|
|
|
|
// Validate clothing field
|
|
if (typeof inventory.clothing !== 'string') {
|
|
console.warn(`[RPG Companion] Invalid clothing from ${source}, resetting to "None"`);
|
|
inventory.clothing = "None";
|
|
needsSave = true;
|
|
} else {
|
|
const cleanedClothing = cleanItemString(inventory.clothing);
|
|
if (cleanedClothing !== inventory.clothing) {
|
|
console.warn(`[RPG Companion] Cleaned corrupted items from clothing inventory (${source})`);
|
|
inventory.clothing = cleanedClothing;
|
|
needsSave = true;
|
|
}
|
|
}
|
|
|
|
// Validate stored field (CRITICAL for Bug #3)
|
|
if (!inventory.stored || typeof inventory.stored !== 'object' || Array.isArray(inventory.stored)) {
|
|
console.error(`[RPG Companion] Corrupted stored inventory from ${source}, resetting to empty object`);
|
|
inventory.stored = {};
|
|
needsSave = true;
|
|
} else {
|
|
// Validate stored object keys/values
|
|
const cleanedStored = validateStoredInventory(inventory.stored);
|
|
if (JSON.stringify(cleanedStored) !== JSON.stringify(inventory.stored)) {
|
|
console.warn(`[RPG Companion] Cleaned dangerous/invalid stored locations from ${source}`);
|
|
inventory.stored = cleanedStored;
|
|
needsSave = true;
|
|
}
|
|
}
|
|
|
|
// Validate assets field
|
|
if (typeof inventory.assets !== 'string') {
|
|
console.warn(`[RPG Companion] Invalid assets from ${source}, resetting to "None"`);
|
|
inventory.assets = "None";
|
|
needsSave = true;
|
|
} else {
|
|
// Clean items in assets (removes corrupted/dangerous items)
|
|
const cleanedAssets = cleanItemString(inventory.assets);
|
|
if (cleanedAssets !== inventory.assets) {
|
|
console.warn(`[RPG Companion] Cleaned corrupted items from assets inventory (${source})`);
|
|
inventory.assets = cleanedAssets;
|
|
needsSave = true;
|
|
}
|
|
}
|
|
|
|
// Persist repairs if needed
|
|
if (needsSave) {
|
|
// console.log(`[RPG Companion] Repaired inventory structure from ${source}, saving...`);
|
|
saveSettings();
|
|
if (source === 'chat') {
|
|
saveChatData();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Migrates old settings format to new trackerConfig format
|
|
* Converts statNames to customStats array and sets up default config
|
|
*/
|
|
function migrateToTrackerConfig() {
|
|
// Initialize trackerConfig if it doesn't exist
|
|
if (!extensionSettings.trackerConfig) {
|
|
extensionSettings.trackerConfig = {
|
|
userStats: {
|
|
customStats: [],
|
|
showRPGAttributes: true,
|
|
rpgAttributes: [
|
|
{ id: 'str', name: 'STR', enabled: true },
|
|
{ id: 'dex', name: 'DEX', enabled: true },
|
|
{ id: 'con', name: 'CON', enabled: true },
|
|
{ id: 'int', name: 'INT', enabled: true },
|
|
{ id: 'wis', name: 'WIS', enabled: true },
|
|
{ id: 'cha', name: 'CHA', enabled: true }
|
|
],
|
|
statusSection: {
|
|
enabled: true,
|
|
showMoodEmoji: true,
|
|
customFields: ['Conditions']
|
|
},
|
|
skillsSection: {
|
|
enabled: false,
|
|
label: 'Skills'
|
|
}
|
|
},
|
|
infoBox: {
|
|
widgets: {
|
|
date: { enabled: true, format: 'Weekday, Month, Year' },
|
|
weather: { enabled: true },
|
|
temperature: { enabled: true, unit: 'C' },
|
|
time: { enabled: true },
|
|
location: { enabled: true },
|
|
recentEvents: { enabled: true }
|
|
}
|
|
},
|
|
presentCharacters: {
|
|
showEmoji: true,
|
|
showName: true,
|
|
customFields: [
|
|
{ id: 'physicalState', label: 'Physical State', enabled: true, placeholder: 'Visible Physical State (up to three traits)' },
|
|
{ id: 'demeanor', label: 'Demeanor Cue', enabled: true, placeholder: 'Observable Demeanor Cue (one trait)' },
|
|
{ id: 'relationship', label: 'Relationship', enabled: true, type: 'relationship', placeholder: 'Enemy/Neutral/Friend/Lover' },
|
|
{ id: 'internalMonologue', label: 'Internal Monologue', enabled: true, placeholder: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)' }
|
|
],
|
|
characterStats: {
|
|
enabled: false,
|
|
stats: []
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// Migrate old statNames to customStats if statNames exists
|
|
if (extensionSettings.statNames && extensionSettings.trackerConfig.userStats.customStats.length === 0) {
|
|
const statOrder = ['health', 'satiety', 'energy', 'hygiene', 'arousal'];
|
|
extensionSettings.trackerConfig.userStats.customStats = statOrder.map(id => ({
|
|
id: id,
|
|
name: extensionSettings.statNames[id] || id.charAt(0).toUpperCase() + id.slice(1),
|
|
enabled: true
|
|
}));
|
|
// console.log('[RPG Companion] Migrated statNames to customStats array');
|
|
}
|
|
|
|
// Ensure all stats have corresponding values in userStats
|
|
if (extensionSettings.userStats) {
|
|
for (const stat of extensionSettings.trackerConfig.userStats.customStats) {
|
|
if (extensionSettings.userStats[stat.id] === undefined) {
|
|
extensionSettings.userStats[stat.id] = stat.id === 'arousal' ? 0 : 100;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Migrate old showRPGAttributes boolean to rpgAttributes array
|
|
if (extensionSettings.trackerConfig.userStats.showRPGAttributes !== undefined) {
|
|
const shouldShow = extensionSettings.trackerConfig.userStats.showRPGAttributes;
|
|
extensionSettings.trackerConfig.userStats.rpgAttributes = [
|
|
{ id: 'str', name: 'STR', enabled: shouldShow },
|
|
{ id: 'dex', name: 'DEX', enabled: shouldShow },
|
|
{ id: 'con', name: 'CON', enabled: shouldShow },
|
|
{ id: 'int', name: 'INT', enabled: shouldShow },
|
|
{ id: 'wis', name: 'WIS', enabled: shouldShow },
|
|
{ id: 'cha', name: 'CHA', enabled: shouldShow }
|
|
];
|
|
delete extensionSettings.trackerConfig.userStats.showRPGAttributes;
|
|
// console.log('[RPG Companion] Migrated showRPGAttributes to rpgAttributes array');
|
|
}
|
|
|
|
// Ensure rpgAttributes exists even if no migration was needed
|
|
if (!extensionSettings.trackerConfig.userStats.rpgAttributes) {
|
|
extensionSettings.trackerConfig.userStats.rpgAttributes = [
|
|
{ id: 'str', name: 'STR', enabled: true },
|
|
{ id: 'dex', name: 'DEX', enabled: true },
|
|
{ id: 'con', name: 'CON', enabled: true },
|
|
{ id: 'int', name: 'INT', enabled: true },
|
|
{ id: 'wis', name: 'WIS', enabled: true },
|
|
{ id: 'cha', name: 'CHA', enabled: true }
|
|
];
|
|
}
|
|
|
|
// Ensure showRPGAttributes exists (defaults to true)
|
|
if (extensionSettings.trackerConfig.userStats.showRPGAttributes === undefined) {
|
|
extensionSettings.trackerConfig.userStats.showRPGAttributes = true;
|
|
}
|
|
|
|
// Ensure all rpgAttributes have corresponding values in classicStats
|
|
if (extensionSettings.classicStats) {
|
|
for (const attr of extensionSettings.trackerConfig.userStats.rpgAttributes) {
|
|
if (extensionSettings.classicStats[attr.id] === undefined) {
|
|
extensionSettings.classicStats[attr.id] = 10;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Migrate old presentCharacters structure to new format
|
|
if (extensionSettings.trackerConfig.presentCharacters) {
|
|
const pc = extensionSettings.trackerConfig.presentCharacters;
|
|
|
|
// Check if using old flat customFields structure (has 'label' or 'placeholder' keys)
|
|
if (pc.customFields && pc.customFields.length > 0) {
|
|
const hasOldFormat = pc.customFields.some(f => f.label || f.placeholder || f.type === 'relationship');
|
|
|
|
if (hasOldFormat) {
|
|
// console.log('[RPG Companion] Migrating Present Characters to new structure');
|
|
|
|
// Extract relationship fields from old customFields
|
|
const relationshipFields = ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'];
|
|
|
|
// Extract non-relationship fields and convert to new format
|
|
const newCustomFields = pc.customFields
|
|
.filter(f => f.type !== 'relationship' && f.id !== 'internalMonologue')
|
|
.map(f => ({
|
|
id: f.id,
|
|
name: f.label || f.name || 'Field',
|
|
enabled: f.enabled !== false,
|
|
description: f.placeholder || f.description || ''
|
|
}));
|
|
|
|
// Extract thoughts config from old Internal Monologue field
|
|
const thoughtsField = pc.customFields.find(f => f.id === 'internalMonologue');
|
|
const thoughts = {
|
|
enabled: thoughtsField ? (thoughtsField.enabled !== false) : true,
|
|
name: 'Thoughts',
|
|
description: thoughtsField?.placeholder || 'Internal Monologue (in first person from character\'s POV, up to three sentences long)'
|
|
};
|
|
|
|
// Update to new structure
|
|
pc.relationshipFields = relationshipFields;
|
|
pc.customFields = newCustomFields;
|
|
pc.thoughts = thoughts;
|
|
|
|
// console.log('[RPG Companion] Present Characters migration complete');
|
|
saveSettings(); // Persist the migration
|
|
}
|
|
}
|
|
|
|
// Ensure new structure exists even if migration wasn't needed
|
|
if (!pc.relationshipFields) {
|
|
pc.relationshipFields = ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'];
|
|
}
|
|
if (!pc.relationshipEmojis) {
|
|
// Create default emoji mapping from relationshipFields
|
|
pc.relationshipEmojis = {
|
|
'Lover': '❤️',
|
|
'Friend': '⭐',
|
|
'Ally': '🤝',
|
|
'Enemy': '⚔️',
|
|
'Neutral': '⚖️'
|
|
};
|
|
}
|
|
|
|
// Migrate to new relationships structure if not already present
|
|
if (!pc.relationships) {
|
|
pc.relationships = {
|
|
enabled: true, // Default to enabled for backward compatibility
|
|
relationshipEmojis: pc.relationshipEmojis || {
|
|
'Lover': '❤️',
|
|
'Friend': '⭐',
|
|
'Ally': '🤝',
|
|
'Enemy': '⚔️',
|
|
'Neutral': '⚖️'
|
|
}
|
|
};
|
|
}
|
|
|
|
if (!pc.thoughts) {
|
|
pc.thoughts = {
|
|
enabled: true,
|
|
name: 'Thoughts',
|
|
description: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)'
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Preset Management Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Gets the entity key for the current character or group
|
|
* @returns {string|null} Entity key in format "char_{id}" or "group_{id}", or null if no character selected
|
|
*/
|
|
export function getCurrentEntityKey() {
|
|
const context = getContext();
|
|
if (context.groupId) {
|
|
return `group_${context.groupId}`;
|
|
} else if (context.characterId !== undefined && context.characterId !== null) {
|
|
return `char_${context.characterId}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Gets the display name for the current character or group
|
|
* @returns {string} Display name for the current entity
|
|
*/
|
|
export function getCurrentEntityName() {
|
|
const context = getContext();
|
|
if (context.groupId) {
|
|
const group = context.groups?.find(g => g.id === context.groupId);
|
|
return group?.name || 'Group Chat';
|
|
} else if (context.characterId !== undefined && context.characterId !== null) {
|
|
return context.name2 || 'Character';
|
|
}
|
|
return 'No Character';
|
|
}
|
|
|
|
/**
|
|
* Migrates existing trackerConfig to the preset system if presetManager doesn't exist
|
|
* Creates a "Default" preset from the current trackerConfig
|
|
*/
|
|
export function migrateToPresetManager() {
|
|
if (!extensionSettings.presetManager || Object.keys(extensionSettings.presetManager.presets || {}).length === 0) {
|
|
// console.log('[RPG Companion] Migrating to preset manager system');
|
|
|
|
// Initialize presetManager if it doesn't exist
|
|
if (!extensionSettings.presetManager) {
|
|
extensionSettings.presetManager = {
|
|
presets: {},
|
|
characterAssociations: {},
|
|
activePresetId: null,
|
|
defaultPresetId: null
|
|
};
|
|
}
|
|
|
|
// Create default preset from existing trackerConfig
|
|
const defaultPresetId = 'preset_default';
|
|
extensionSettings.presetManager.presets[defaultPresetId] = {
|
|
id: defaultPresetId,
|
|
name: 'Default',
|
|
trackerConfig: JSON.parse(JSON.stringify(extensionSettings.trackerConfig))
|
|
};
|
|
extensionSettings.presetManager.activePresetId = defaultPresetId;
|
|
extensionSettings.presetManager.defaultPresetId = defaultPresetId;
|
|
|
|
// console.log('[RPG Companion] Created Default preset from existing trackerConfig');
|
|
saveSettings();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initializes custom status fields in userStats based on trackerConfig
|
|
* Ensures all defined custom status fields have a value in the userStats object
|
|
*/
|
|
function initializeCustomStatusFields() {
|
|
const customFields = extensionSettings.trackerConfig?.userStats?.statusSection?.customFields || [];
|
|
|
|
// Initialize each custom field if it doesn't exist
|
|
for (const fieldName of customFields) {
|
|
const fieldKey = fieldName.toLowerCase();
|
|
if (extensionSettings.userStats[fieldKey] === undefined) {
|
|
extensionSettings.userStats[fieldKey] = 'None';
|
|
// console.log(`[RPG Companion] Initialized custom status field: ${fieldKey}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensures all custom stats have a maxValue property
|
|
* This migration supports the number display mode feature
|
|
*/
|
|
function ensureStatsHaveMaxValue() {
|
|
const customStats = extensionSettings.trackerConfig?.userStats?.customStats || [];
|
|
|
|
for (const stat of customStats) {
|
|
if (stat && stat.maxValue === undefined) {
|
|
stat.maxValue = 100; // Default to 100 for backward compatibility
|
|
// console.log(`[RPG Companion] Added maxValue to stat: ${stat.id || stat.name}`);
|
|
}
|
|
}
|
|
|
|
// Ensure statsDisplayMode is set (default to percentage)
|
|
if (extensionSettings.trackerConfig?.userStats &&
|
|
extensionSettings.trackerConfig.userStats.statsDisplayMode === undefined) {
|
|
extensionSettings.trackerConfig.userStats.statsDisplayMode = 'percentage';
|
|
// console.log('[RPG Companion] Initialized statsDisplayMode to percentage');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets all available presets
|
|
* @returns {Object} Map of preset ID to preset data
|
|
*/
|
|
export function getPresets() {
|
|
return extensionSettings.presetManager?.presets || {};
|
|
}
|
|
|
|
/**
|
|
* Gets a specific preset by ID
|
|
* @param {string} presetId - The preset ID
|
|
* @returns {Object|null} The preset object or null if not found
|
|
*/
|
|
export function getPreset(presetId) {
|
|
return extensionSettings.presetManager?.presets?.[presetId] || null;
|
|
}
|
|
|
|
/**
|
|
* Gets the currently active preset ID
|
|
* @returns {string|null} The active preset ID or null
|
|
*/
|
|
export function getActivePresetId() {
|
|
return extensionSettings.presetManager?.activePresetId || null;
|
|
}
|
|
|
|
/**
|
|
* Gets the default preset ID
|
|
* @returns {string|null} The default preset ID or null
|
|
*/
|
|
export function getDefaultPresetId() {
|
|
return extensionSettings.presetManager?.defaultPresetId || null;
|
|
}
|
|
|
|
/**
|
|
* Sets a preset as the default
|
|
* @param {string} presetId - The preset ID to set as default
|
|
*/
|
|
export function setDefaultPreset(presetId) {
|
|
if (extensionSettings.presetManager.presets[presetId]) {
|
|
extensionSettings.presetManager.defaultPresetId = presetId;
|
|
saveSettings();
|
|
// console.log(`[RPG Companion] Set preset ${presetId} as default`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the given preset is the default
|
|
* @param {string} presetId - The preset ID to check
|
|
* @returns {boolean} True if it's the default preset
|
|
*/
|
|
export function isDefaultPreset(presetId) {
|
|
return extensionSettings.presetManager?.defaultPresetId === presetId;
|
|
}
|
|
|
|
/**
|
|
* Creates a new preset from the current trackerConfig
|
|
* @param {string} name - Name for the new preset
|
|
* @returns {string} The ID of the newly created preset
|
|
*/
|
|
export function createPreset(name) {
|
|
const presetId = `preset_${Date.now()}`;
|
|
extensionSettings.presetManager.presets[presetId] = {
|
|
id: presetId,
|
|
name: name,
|
|
trackerConfig: JSON.parse(JSON.stringify(extensionSettings.trackerConfig)),
|
|
historyPersistence: extensionSettings.historyPersistence
|
|
? JSON.parse(JSON.stringify(extensionSettings.historyPersistence))
|
|
: null
|
|
};
|
|
// Also set it as the active preset so edits go to the new preset
|
|
extensionSettings.presetManager.activePresetId = presetId;
|
|
saveSettings();
|
|
// console.log(`[RPG Companion] Created preset "${name}" with ID ${presetId}`);
|
|
return presetId;
|
|
}
|
|
|
|
/**
|
|
* Saves the current trackerConfig and historyPersistence to the specified preset
|
|
* @param {string} presetId - The preset ID to save to
|
|
*/
|
|
export function saveToPreset(presetId) {
|
|
const preset = extensionSettings.presetManager.presets[presetId];
|
|
if (preset) {
|
|
preset.trackerConfig = JSON.parse(JSON.stringify(extensionSettings.trackerConfig));
|
|
preset.historyPersistence = extensionSettings.historyPersistence
|
|
? JSON.parse(JSON.stringify(extensionSettings.historyPersistence))
|
|
: null;
|
|
saveSettings();
|
|
// console.log(`[RPG Companion] Saved current config to preset "${preset.name}"`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads a preset's trackerConfig and historyPersistence as the active configuration
|
|
* @param {string} presetId - The preset ID to load
|
|
* @returns {boolean} True if loaded successfully, false otherwise
|
|
*/
|
|
export function loadPreset(presetId) {
|
|
const preset = extensionSettings.presetManager.presets[presetId];
|
|
if (preset && preset.trackerConfig) {
|
|
extensionSettings.trackerConfig = JSON.parse(JSON.stringify(preset.trackerConfig));
|
|
// Load historyPersistence if present, otherwise use defaults
|
|
if (preset.historyPersistence) {
|
|
extensionSettings.historyPersistence = JSON.parse(JSON.stringify(preset.historyPersistence));
|
|
} else {
|
|
// Default values for presets that don't have historyPersistence yet
|
|
extensionSettings.historyPersistence = {
|
|
enabled: false,
|
|
messageCount: 5,
|
|
injectionPosition: 'assistant_message_end',
|
|
contextPreamble: ''
|
|
};
|
|
}
|
|
extensionSettings.presetManager.activePresetId = presetId;
|
|
saveSettings();
|
|
// console.log(`[RPG Companion] Loaded preset "${preset.name}"`);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Renames a preset
|
|
* @param {string} presetId - The preset ID to rename
|
|
* @param {string} newName - The new name for the preset
|
|
*/
|
|
export function renamePreset(presetId, newName) {
|
|
const preset = extensionSettings.presetManager.presets[presetId];
|
|
if (preset) {
|
|
preset.name = newName;
|
|
saveSettings();
|
|
// console.log(`[RPG Companion] Renamed preset to "${newName}"`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes a preset
|
|
* @param {string} presetId - The preset ID to delete
|
|
* @returns {boolean} True if deleted, false if it's the last preset (can't delete)
|
|
*/
|
|
export function deletePreset(presetId) {
|
|
const presets = extensionSettings.presetManager.presets;
|
|
const presetIds = Object.keys(presets);
|
|
|
|
// Don't delete if it's the last preset
|
|
if (presetIds.length <= 1) {
|
|
// console.warn('[RPG Companion] Cannot delete the last preset');
|
|
return false;
|
|
}
|
|
|
|
// Remove any character associations using this preset
|
|
const associations = extensionSettings.presetManager.characterAssociations;
|
|
for (const entityKey of Object.keys(associations)) {
|
|
if (associations[entityKey] === presetId) {
|
|
delete associations[entityKey];
|
|
}
|
|
}
|
|
|
|
// Delete the preset
|
|
delete presets[presetId];
|
|
|
|
// If the deleted preset was active, switch to the first available preset
|
|
if (extensionSettings.presetManager.activePresetId === presetId) {
|
|
const remainingIds = Object.keys(presets);
|
|
if (remainingIds.length > 0) {
|
|
loadPreset(remainingIds[0]);
|
|
}
|
|
}
|
|
|
|
saveSettings();
|
|
// console.log(`[RPG Companion] Deleted preset ${presetId}`);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Associates the current preset with the current character/group
|
|
*/
|
|
export function associatePresetWithCurrentEntity() {
|
|
const entityKey = getCurrentEntityKey();
|
|
const activePresetId = extensionSettings.presetManager.activePresetId;
|
|
|
|
if (entityKey && activePresetId) {
|
|
extensionSettings.presetManager.characterAssociations[entityKey] = activePresetId;
|
|
saveSettings();
|
|
// console.log(`[RPG Companion] Associated preset ${activePresetId} with ${entityKey}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes the preset association for the current character/group
|
|
*/
|
|
export function removePresetAssociationForCurrentEntity() {
|
|
const entityKey = getCurrentEntityKey();
|
|
if (entityKey && extensionSettings.presetManager.characterAssociations[entityKey]) {
|
|
delete extensionSettings.presetManager.characterAssociations[entityKey];
|
|
saveSettings();
|
|
// console.log(`[RPG Companion] Removed preset association for ${entityKey}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the preset ID associated with the current character/group
|
|
* @returns {string|null} The associated preset ID or null
|
|
*/
|
|
export function getPresetForCurrentEntity() {
|
|
const entityKey = getCurrentEntityKey();
|
|
if (entityKey) {
|
|
return extensionSettings.presetManager.characterAssociations[entityKey] || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Checks if the current character/group has a preset association
|
|
* @returns {boolean} True if there's an association
|
|
*/
|
|
export function hasPresetAssociation() {
|
|
const entityKey = getCurrentEntityKey();
|
|
return entityKey && extensionSettings.presetManager.characterAssociations[entityKey] !== undefined;
|
|
}
|
|
|
|
/**
|
|
* Checks if the current character/group is associated with the currently active preset
|
|
* @returns {boolean} True if the current entity is associated with the active preset
|
|
*/
|
|
export function isAssociatedWithCurrentPreset() {
|
|
const entityKey = getCurrentEntityKey();
|
|
const activePresetId = extensionSettings.presetManager?.activePresetId;
|
|
if (!entityKey || !activePresetId) return false;
|
|
return extensionSettings.presetManager.characterAssociations[entityKey] === activePresetId;
|
|
}
|
|
|
|
/**
|
|
* Auto-switches to the preset associated with the current character/group
|
|
* Called when character changes. Falls back to default preset if no association.
|
|
* @returns {boolean} True if a preset was switched, false otherwise
|
|
*/
|
|
export function autoSwitchPresetForEntity() {
|
|
const associatedPresetId = getPresetForCurrentEntity();
|
|
|
|
// If there's a character-specific preset, use it
|
|
if (associatedPresetId && associatedPresetId !== extensionSettings.presetManager.activePresetId) {
|
|
// Check if the preset still exists
|
|
if (extensionSettings.presetManager.presets[associatedPresetId]) {
|
|
return loadPreset(associatedPresetId);
|
|
} else {
|
|
// Preset was deleted, remove the stale association
|
|
removePresetAssociationForCurrentEntity();
|
|
}
|
|
}
|
|
|
|
// No character association - fall back to default preset if set
|
|
if (!associatedPresetId) {
|
|
const defaultPresetId = extensionSettings.presetManager.defaultPresetId;
|
|
if (defaultPresetId &&
|
|
defaultPresetId !== extensionSettings.presetManager.activePresetId &&
|
|
extensionSettings.presetManager.presets[defaultPresetId]) {
|
|
return loadPreset(defaultPresetId);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Exports presets for sharing (without character associations)
|
|
* @param {string[]} presetIds - Array of preset IDs to export, or empty for all
|
|
* @returns {Object} Export data object
|
|
*/
|
|
export function exportPresets(presetIds = []) {
|
|
const presetsToExport = {};
|
|
const allPresets = extensionSettings.presetManager.presets;
|
|
|
|
// If no specific IDs provided, export all
|
|
const idsToExport = presetIds.length > 0 ? presetIds : Object.keys(allPresets);
|
|
|
|
for (const id of idsToExport) {
|
|
if (allPresets[id]) {
|
|
presetsToExport[id] = {
|
|
id: allPresets[id].id,
|
|
name: allPresets[id].name,
|
|
trackerConfig: allPresets[id].trackerConfig
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
version: '1.0',
|
|
exportDate: new Date().toISOString(),
|
|
presets: presetsToExport
|
|
// Note: characterAssociations are intentionally NOT exported
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Imports presets from an export file
|
|
* @param {Object} importData - The imported data object
|
|
* @param {boolean} overwrite - If true, overwrites existing presets with same name
|
|
* @returns {number} Number of presets imported
|
|
*/
|
|
export function importPresets(importData, overwrite = false) {
|
|
if (!importData.presets || typeof importData.presets !== 'object') {
|
|
throw new Error('Invalid import data: missing presets');
|
|
}
|
|
|
|
let importCount = 0;
|
|
const existingNames = new Set(
|
|
Object.values(extensionSettings.presetManager.presets).map(p => p.name.toLowerCase())
|
|
);
|
|
|
|
for (const [originalId, preset] of Object.entries(importData.presets)) {
|
|
if (!preset.name || !preset.trackerConfig) {
|
|
continue; // Skip invalid presets
|
|
}
|
|
|
|
let name = preset.name;
|
|
const nameLower = name.toLowerCase();
|
|
|
|
// Check for name collision
|
|
if (existingNames.has(nameLower)) {
|
|
if (overwrite) {
|
|
// Find and delete the existing preset with this name
|
|
for (const [existingId, existingPreset] of Object.entries(extensionSettings.presetManager.presets)) {
|
|
if (existingPreset.name.toLowerCase() === nameLower) {
|
|
delete extensionSettings.presetManager.presets[existingId];
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// Generate a unique name
|
|
let counter = 1;
|
|
while (existingNames.has(`${nameLower} (${counter})`)) {
|
|
counter++;
|
|
}
|
|
name = `${preset.name} (${counter})`;
|
|
}
|
|
}
|
|
|
|
// Create new preset with new ID
|
|
const newId = `preset_${Date.now()}_${importCount}`;
|
|
extensionSettings.presetManager.presets[newId] = {
|
|
id: newId,
|
|
name: name,
|
|
trackerConfig: JSON.parse(JSON.stringify(preset.trackerConfig))
|
|
};
|
|
existingNames.add(name.toLowerCase());
|
|
importCount++;
|
|
}
|
|
|
|
if (importCount > 0) {
|
|
saveSettings();
|
|
}
|
|
|
|
return importCount;
|
|
}
|