/**
* Info Box Rendering Module
* Handles rendering of the info box dashboard with weather, date, time, and location widgets
*/
import { getContext } from '../../../../../../extensions.js';
import {
extensionSettings,
lastGeneratedData,
committedTrackerData,
$infoBoxContainer
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
/**
* Helper to separate emoji from text in a string
* Handles cases where there's no comma or space after emoji
* @param {string} str - String potentially containing emoji followed by text
* @returns {{emoji: string, text: string}} Separated emoji and text
*/
function separateEmojiFromText(str) {
if (!str) return { emoji: '', text: '' };
str = str.trim();
// Regex to match emoji at the start (handles most emoji including compound ones)
const emojiRegex = /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F910}-\u{1F96B}\u{1F980}-\u{1F9E0}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]+/u;
const emojiMatch = str.match(emojiRegex);
if (emojiMatch) {
const emoji = emojiMatch[0];
let text = str.substring(emoji.length).trim();
// Remove leading comma or space if present
text = text.replace(/^[,\s]+/, '');
return { emoji, text };
}
// No emoji found - check if there's a comma separator anyway
const commaParts = str.split(',');
if (commaParts.length >= 2) {
return {
emoji: commaParts[0].trim(),
text: commaParts.slice(1).join(',').trim()
};
}
// No clear separation - return original as text
return { emoji: '', text: str };
}
/**
* Checks if a value is valid (not null, undefined, or the string "null")
*/
function isValidValue(val) {
return val !== null && val !== undefined && val !== 'null' && val !== '';
}
/**
* Checks if we have valid structured infoBox data
* @param {Object} data - The infoBoxData object
* @returns {boolean}
*/
function hasStructuredInfoBoxData(data) {
if (!data) return false;
// Handle recentEvents as either string or array
const hasEvents = data.recentEvents && (
(Array.isArray(data.recentEvents) && data.recentEvents.length > 0) ||
(typeof data.recentEvents === 'string' && data.recentEvents.length > 0 && data.recentEvents !== 'null')
);
return isValidValue(data.date) || isValidValue(data.weather) || isValidValue(data.temperature) ||
isValidValue(data.time) || isValidValue(data.location) || hasEvents;
}
/**
* Renders the info box as a visual dashboard with calendar, weather, temperature, clock, and map widgets.
* Includes event listeners for editable fields.
*/
export function renderInfoBox() {
if (!extensionSettings.showInfoBox || !$infoBoxContainer) {
return;
}
// Add updating class for animation
if (extensionSettings.enableAnimations) {
$infoBoxContainer.addClass('rpg-content-updating');
}
// Convert structured JSON data to text format for the original fancy renderer
const structuredData = extensionSettings.infoBoxData;
let infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox;
// If we have structured data, convert it to text format
if (structuredData && hasStructuredInfoBoxData(structuredData)) {
const lines = [];
if (isValidValue(structuredData.date)) lines.push(`Date: ${structuredData.date}`);
if (isValidValue(structuredData.time)) lines.push(`Time: ${structuredData.time}`);
if (isValidValue(structuredData.weather)) lines.push(`Weather: ${structuredData.weather}`);
if (isValidValue(structuredData.temperature)) lines.push(`Temperature: ${structuredData.temperature}`);
if (isValidValue(structuredData.location)) lines.push(`Location: ${structuredData.location}`);
if (structuredData.recentEvents) {
const events = Array.isArray(structuredData.recentEvents)
? structuredData.recentEvents
: [structuredData.recentEvents];
events.filter(e => e && e !== 'null').forEach(e => lines.push(`Recent Events: ${e}`));
}
if (lines.length > 0) {
infoBoxData = lines.join('\n');
}
}
// If no data yet, show placeholder
if (!infoBoxData) {
const placeholderHtml = `
';
// Row 1: Date, Weather, Temperature, Time widgets
const row1Widgets = [];
// Calendar widget - show if enabled
if (config?.widgets?.date?.enabled) {
// Apply date format conversion
let monthDisplay = data.month || 'MON';
let weekdayDisplay = data.weekday || 'DAY';
let yearDisplay = data.year || 'YEAR';
// Apply format based on config
const dateFormat = config.widgets.date.format || 'dd/mm/yy';
if (dateFormat === 'dd/mm/yy') {
monthDisplay = monthDisplay.substring(0, 3).toUpperCase();
weekdayDisplay = weekdayDisplay.substring(0, 3).toUpperCase();
} else if (dateFormat === 'mm/dd/yy') {
// For US format, show month first, day second
monthDisplay = monthDisplay.substring(0, 3).toUpperCase();
weekdayDisplay = weekdayDisplay.substring(0, 3).toUpperCase();
} else if (dateFormat === 'yyyy-mm-dd') {
// ISO format - show full names
monthDisplay = monthDisplay;
weekdayDisplay = weekdayDisplay;
}
row1Widgets.push(`
`);
}
// Weather widget - show if enabled
if (config?.widgets?.weather?.enabled) {
const weatherEmoji = data.weatherEmoji || 'π€οΈ';
const weatherForecast = data.weatherForecast || 'Weather';
row1Widgets.push(`
`);
}
// Temperature widget - show if enabled
if (config?.widgets?.temperature?.enabled) {
let tempDisplay = data.temperature || '20Β°C';
let tempValue = data.tempValue || 20;
// Apply temperature unit conversion
const preferredUnit = config.widgets.temperature.unit || 'C';
if (data.temperature) {
// Detect current unit in the data
const isCelsius = tempDisplay.includes('Β°C');
const isFahrenheit = tempDisplay.includes('Β°F');
if (preferredUnit === 'F' && isCelsius) {
// Convert C to F
const fahrenheit = Math.round((tempValue * 9/5) + 32);
tempDisplay = `${fahrenheit}Β°F`;
tempValue = fahrenheit;
} else if (preferredUnit === 'C' && isFahrenheit) {
// Convert F to C
const celsius = Math.round((tempValue - 32) * 5/9);
tempDisplay = `${celsius}Β°C`;
tempValue = celsius;
}
} else {
// No data yet, use default for preferred unit
tempDisplay = preferredUnit === 'F' ? '68Β°F' : '20Β°C';
tempValue = preferredUnit === 'F' ? 68 : 20;
}
// Calculate thermometer display (convert to Celsius for consistent thresholds)
const tempInCelsius = preferredUnit === 'F' ? Math.round((tempValue - 32) * 5/9) : tempValue;
const tempPercent = Math.min(100, Math.max(0, ((tempInCelsius + 20) / 60) * 100));
const tempColor = tempInCelsius < 10 ? '#4a90e2' : tempInCelsius < 25 ? '#67c23a' : '#e94560';
row1Widgets.push(`
`);
}
// Time widget - show if enabled
if (config?.widgets?.time?.enabled) {
const timeDisplay = data.timeEnd || data.timeStart || '12:00';
// Parse time for clock hands
const timeMatch = timeDisplay.match(/(\d+):(\d+)/);
let hourAngle = 0;
let minuteAngle = 0;
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30Β° per hour + 0.5Β° per minute
minuteAngle = minutes * 6; // 6Β° per minute
}
row1Widgets.push(`
`);
}
// Only create row 1 if there are widgets to show
if (row1Widgets.length > 0) {
html += '
';
html += row1Widgets.join('');
html += '
';
}
// Row 2: Location widget (full width) - show if enabled
if (config?.widgets?.location?.enabled) {
const locationDisplay = data.location || 'Location';
html += `
`;
}
// Row 3: Recent Events widget (notebook style) - show if enabled
if (config?.widgets?.recentEvents?.enabled) {
// Get Recent Events from structured data (JSON) or text format
let recentEvents = [];
// First check structured infoBoxData (from JSON parsing)
if (extensionSettings.infoBoxData?.recentEvents) {
const events = extensionSettings.infoBoxData.recentEvents;
if (Array.isArray(events)) {
recentEvents = events.filter(e => e && e !== 'null');
} else if (typeof events === 'string' && events !== 'null') {
recentEvents = [events];
}
}
// Fallback to text format from committedTrackerData
if (recentEvents.length === 0 && committedTrackerData.infoBox) {
const recentEventsLine = committedTrackerData.infoBox.split('\n').find(line => line.startsWith('Recent Events:'));
if (recentEventsLine) {
const eventsString = recentEventsLine.replace('Recent Events:', '').trim();
if (eventsString) {
recentEvents = eventsString.split(',').map(e => e.trim()).filter(e => e);
}
}
}
const validEvents = recentEvents.filter(e => e && e.trim() && e !== 'Event 1' && e !== 'Event 2' && e !== 'Event 3');
// If no valid events, show at least one placeholder
if (validEvents.length === 0) {
validEvents.push('Click to add event');
}
html += `
`;
}
// Close the scrollable content wrapper
html += '
';
$infoBoxContainer.html(html);
// Add event handlers for editable Info Box fields
$infoBoxContainer.find('.rpg-editable').on('blur', function() {
const $this = $(this);
const field = $this.data('field');
const value = $this.text().trim();
// For date fields, update the data-full-value immediately
if (field === 'month' || field === 'weekday' || field === 'year') {
$this.data('full-value', value);
// Update the display to show abbreviated version
if (field === 'month' || field === 'weekday') {
$this.text(value.substring(0, 3).toUpperCase());
} else {
$this.text(value);
}
}
// Handle recent events separately
if (field === 'event1' || field === 'event2' || field === 'event3') {
updateRecentEvent(field, value);
} else {
updateInfoBoxField(field, value);
}
});
// For date fields, show full value on focus
$infoBoxContainer.find('[data-field="month"], [data-field="weekday"], [data-field="year"]').on('focus', function() {
const fullValue = $(this).data('full-value');
if (fullValue) {
$(this).text(fullValue);
}
});
// Remove updating class after animation
if (extensionSettings.enableAnimations) {
setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500);
}
}
/**
* Updates a specific field in the Info Box data and re-renders.
* Handles complex field reconstruction logic for date parts, weather, temperature, time, and location.
*
* @param {string} field - Field name to update
* @param {string} value - New value for the field
*/
export function updateInfoBoxField(field, value) {
if (!lastGeneratedData.infoBox) {
// Initialize with empty info box if it doesn't exist
lastGeneratedData.infoBox = 'Info Box\n---\n';
}
// Reconstruct the Info Box text with updated field
const lines = lastGeneratedData.infoBox.split('\n');
let dateLineFound = false;
let dateLineIndex = -1;
let weatherLineIndex = -1;
// Find the date line
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('ποΈ:') || lines[i].startsWith('Date:')) {
dateLineFound = true;
dateLineIndex = i;
break;
}
}
// Find the weather line (look for a line that's not date/temp/time/location)
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.match(/^[^:]+:\s*.+$/) &&
!line.includes('ποΈ') &&
!line.startsWith('Date:') &&
!line.includes('π‘οΈ') &&
!line.startsWith('Temperature:') &&
!line.includes('π') &&
!line.startsWith('Time:') &&
!line.includes('πΊοΈ') &&
!line.startsWith('Location:') &&
!line.includes('Info Box') &&
!line.includes('---')) {
weatherLineIndex = i;
break;
}
}
const updatedLines = lines.map((line, index) => {
if (field === 'month' && (line.includes('ποΈ:') || line.startsWith('Date:'))) {
const parts = line.split(',');
if (parts.length >= 2) {
// parts[0] = "Date: Weekday" or "ποΈ: Weekday", parts[1] = " Month", parts[2] = " Year"
parts[1] = ' ' + value;
return parts.join(',');
} else if (parts.length === 1) {
// No existing month/year, add them
return `${parts[0]}, ${value}, YEAR`;
}
} else if (field === 'weekday' && (line.includes('ποΈ:') || line.startsWith('Date:'))) {
const parts = line.split(',');
// Keep the format (text or emoji), just update the weekday
const month = parts[1] ? parts[1].trim() : 'Month';
const year = parts[2] ? parts[2].trim() : 'YEAR';
if (line.startsWith('Date:')) {
return `Date: ${value}, ${month}, ${year}`;
} else {
return `ποΈ: ${value}, ${month}, ${year}`;
}
} else if (field === 'year' && (line.includes('ποΈ:') || line.startsWith('Date:'))) {
const parts = line.split(',');
if (parts.length >= 3) {
parts[2] = ' ' + value;
return parts.join(',');
} else if (parts.length === 2) {
// No existing year, add it
return `${parts[0]}, ${parts[1]}, ${value}`;
} else if (parts.length === 1) {
// No existing month/year, add them
return `${parts[0]}, Month, ${value}`;
}
} else if (field === 'weatherEmoji' && index === weatherLineIndex) {
// Only update the specific weather line we found
if (line.startsWith('Weather:')) {
// New format: Weather: emoji, forecast
const weatherContent = line.replace('Weather:', '').trim();
const parts = weatherContent.split(',').map(p => p.trim());
const forecast = parts[1] || 'Weather';
return `Weather: ${value}, ${forecast}`;
} else {
// Legacy format: emoji: forecast
const parts = line.split(':');
if (parts.length >= 2) {
return `${value}: ${parts.slice(1).join(':').trim()}`;
}
}
} else if (field === 'weatherForecast' && index === weatherLineIndex) {
// Only update the specific weather line we found
if (line.startsWith('Weather:')) {
// New format: Weather: emoji, forecast
const weatherContent = line.replace('Weather:', '').trim();
const parts = weatherContent.split(',').map(p => p.trim());
const emoji = parts[0] || 'π€οΈ';
return `Weather: ${emoji}, ${value}`;
} else {
// Legacy format: emoji: forecast
const parts = line.split(':');
if (parts.length >= 2) {
return `${parts[0].trim()}: ${value}`;
}
}
} else if (field === 'temperature' && (line.includes('π‘οΈ:') || line.startsWith('Temperature:'))) {
// Support both emoji and text formats
if (line.startsWith('Temperature:')) {
return `Temperature: ${value}`;
} else {
return `π‘οΈ: ${value}`;
}
} else if (field === 'timeStart' && (line.includes('π:') || line.startsWith('Time:'))) {
// Update time format: "HH:MM β HH:MM"
// When user edits, set both start and end time to the new value
if (line.startsWith('Time:')) {
return `Time: ${value} β ${value}`;
} else {
return `π: ${value} β ${value}`;
}
} else if (field === 'location' && (line.includes('πΊοΈ:') || line.startsWith('Location:'))) {
// Support both emoji and text formats
if (line.startsWith('Location:')) {
return `Location: ${value}`;
} else {
return `πΊοΈ: ${value}`;
}
}
return line;
});
// If editing a date field but no date line exists, create one after the divider
if ((field === 'month' || field === 'weekday' || field === 'year') && !dateLineFound) {
// Find the divider line
const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
if (dividerIndex >= 0) {
// Create initial date line with the edited field (use text format to match current standard)
let newDateLine = '';
if (field === 'weekday') {
newDateLine = `Date: ${value}, Month, YEAR`;
} else if (field === 'month') {
newDateLine = `Date: Weekday, ${value}, YEAR`;
} else if (field === 'year') {
newDateLine = `Date: Weekday, Month, ${value}`;
}
// Insert after the divider
updatedLines.splice(dividerIndex + 1, 0, newDateLine);
}
}
// If editing weather but no weather line exists, create one
if ((field === 'weatherEmoji' || field === 'weatherForecast')) {
let weatherLineFound = false;
for (const line of updatedLines) {
// Check if this is a weather line (has emoji and forecast, not one of the special fields)
if (line.match(/^[^:]+:\s*.+$/) && !line.includes('ποΈ') && !line.startsWith('Date:') && !line.includes('π‘οΈ') && !line.startsWith('Temperature:') && !line.includes('π') && !line.startsWith('Time:') && !line.includes('πΊοΈ') && !line.startsWith('Location:') && !line.includes('Info Box') && !line.includes('---')) {
weatherLineFound = true;
break;
}
}
if (!weatherLineFound) {
const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
if (dividerIndex >= 0) {
let newWeatherLine = '';
if (field === 'weatherEmoji') {
newWeatherLine = `Weather: ${value}, Weather`;
} else if (field === 'weatherForecast') {
newWeatherLine = `Weather: π€οΈ, ${value}`;
}
// Insert after date line if it exists, otherwise after divider
const dateIndex = updatedLines.findIndex(line => line.includes('ποΈ:') || line.startsWith('Date:'));
const insertIndex = dateIndex >= 0 ? dateIndex + 1 : dividerIndex + 1;
updatedLines.splice(insertIndex, 0, newWeatherLine);
}
}
}
// If editing temperature but no temperature line exists, create one
if (field === 'temperature') {
const tempLineFound = updatedLines.some(line => line.includes('π‘οΈ:') || line.startsWith('Temperature:'));
if (!tempLineFound) {
const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
if (dividerIndex >= 0) {
const newTempLine = `Temperature: ${value}`;
// Find last non-empty line before creating position
let insertIndex = dividerIndex + 1;
for (let i = 0; i < updatedLines.length; i++) {
if (updatedLines[i].includes('ποΈ:') || updatedLines[i].startsWith('Date:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) {
insertIndex = i + 1;
}
}
updatedLines.splice(insertIndex, 0, newTempLine);
}
}
}
// If editing time but no time line exists, create one
if (field === 'timeStart') {
const timeLineFound = updatedLines.some(line => line.includes('π:') || line.startsWith('Time:'));
if (!timeLineFound) {
const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
if (dividerIndex >= 0) {
const newTimeLine = `Time: ${value} β ${value}`;
// Find last non-empty line before creating position
let insertIndex = dividerIndex + 1;
for (let i = 0; i < updatedLines.length; i++) {
if (updatedLines[i].includes('ποΈ:') || updatedLines[i].startsWith('Date:') || updatedLines[i].includes('π‘οΈ:') || updatedLines[i].startsWith('Temperature:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) {
insertIndex = i + 1;
}
}
updatedLines.splice(insertIndex, 0, newTimeLine);
}
}
}
// If editing location but no location line exists, create one
if (field === 'location') {
const locationLineFound = updatedLines.some(line => line.includes('πΊοΈ:') || line.startsWith('Location:'));
if (!locationLineFound) {
const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
if (dividerIndex >= 0) {
const newLocationLine = `Location: ${value}`;
// Insert at the end (before any empty lines)
let insertIndex = updatedLines.length;
for (let i = updatedLines.length - 1; i >= 0; i--) {
if (updatedLines[i].trim() !== '') {
insertIndex = i + 1;
break;
}
}
updatedLines.splice(insertIndex, 0, newLocationLine);
}
}
}
lastGeneratedData.infoBox = updatedLines.join('\n');
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
committedTrackerData.infoBox = updatedLines.join('\n');
// Update the message's swipe data
const chat = getContext().chat;
if (chat && chat.length > 0) {
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (!message.is_user) {
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n');
// console.log('[RPG Companion] Updated infoBox in message swipe data');
}
}
break;
}
}
}
saveChatData();
// Only re-render if NOT editing date fields
// Date fields will update on next tracker generation to avoid losing user input
if (field !== 'month' && field !== 'weekday' && field !== 'year') {
renderInfoBox();
}
}
/**
* Update a recent event in the committed tracker data
* @param {string} field - event1, event2, or event3
* @param {string} value - New event text
*/
function updateRecentEvent(field, value) {
// Map field to index
const eventIndex = {
'event1': 0,
'event2': 1,
'event3': 2
}[field];
if (eventIndex !== undefined) {
// Parse current infoBox to get existing events
const lines = (committedTrackerData.infoBox || '').split('\n');
let recentEvents = [];
// Find existing Recent Events line
const recentEventsLine = lines.find(line => line.startsWith('Recent Events:'));
if (recentEventsLine) {
const eventsString = recentEventsLine.replace('Recent Events:', '').trim();
if (eventsString) {
recentEvents = eventsString.split(',').map(e => e.trim()).filter(e => e);
}
}
// Ensure array has enough slots
while (recentEvents.length <= eventIndex) {
recentEvents.push('');
}
// Update the specific event
recentEvents[eventIndex] = value;
// Filter out empty events and rebuild the line
const validEvents = recentEvents.filter(e => e && e.trim());
const newRecentEventsLine = validEvents.length > 0
? `Recent Events: ${validEvents.join(', ')}`
: '';
// Update infoBox with new Recent Events line
const updatedLines = lines.filter(line => !line.startsWith('Recent Events:'));
if (newRecentEventsLine) {
// Add Recent Events line at the end (before any empty lines)
let insertIndex = updatedLines.length;
for (let i = updatedLines.length - 1; i >= 0; i--) {
if (updatedLines[i].trim() !== '') {
insertIndex = i + 1;
break;
}
}
updatedLines.splice(insertIndex, 0, newRecentEventsLine);
}
committedTrackerData.infoBox = updatedLines.join('\n');
lastGeneratedData.infoBox = updatedLines.join('\n');
// Update the message's swipe data
const chat = getContext().chat;
if (chat && chat.length > 0) {
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (!message.is_user) {
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n');
}
}
break;
}
}
}
saveChatData();
renderInfoBox();
console.log(`[RPG Companion] Updated recent event ${field}:`, value);
}
}