/** * Skills Rendering Module * Handles rendering of the skills section with skill categories (like inventory) * Each configured skill becomes a category, and abilities/items can be added within each */ import { extensionSettings, $skillsContainer } from '../../core/state.js'; import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js'; import { i18n } from '../../core/i18n.js'; import { parseItems } from '../../utils/itemParser.js'; /** * Escapes HTML special characters to prevent XSS * @param {string} text - Text to escape * @returns {string} Escaped text */ function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Serializes an array of items into a comma-separated string * @param {string[]} items - Array of items * @returns {string} Comma-separated string or 'None' */ function serializeItems(items) { if (!items || items.length === 0) return 'None'; return items.join(', '); } /** * Gets the configured skill categories from settings * @returns {string[]} Array of skill category names */ export function getSkillCategories() { const categories = extensionSettings.trackerConfig?.userStats?.skillsSection?.customFields || []; // Migration function handles string array → object array conversion on load return categories .filter(cat => cat.enabled !== false) .map(cat => cat.name); } /** * Gets the items/abilities for a specific skill category * Checks both skillsData (from parser) and skills.categories (manual entries) * @param {string} skillName - The skill category name * @returns {string} Comma-separated items string or 'None' */ export function getSkillItems(skillName) { // Check skillsData first (populated by parser) if (extensionSettings.skillsData?.[skillName]) { return extensionSettings.skillsData[skillName]; } // Fall back to skills.categories (manual entries) if (extensionSettings.skills?.categories?.[skillName]) { return extensionSettings.skills.categories[skillName]; } return 'None'; } /** * Sets the items/abilities for a specific skill category * @param {string} skillName - The skill category name * @param {string} itemsString - Comma-separated items string */ export function setSkillItems(skillName, itemsString) { // Initialize structures if needed if (!extensionSettings.skillsData) { extensionSettings.skillsData = {}; } if (!extensionSettings.skills) { extensionSettings.skills = { categories: {}, list: [] }; } if (!extensionSettings.skills.categories) { extensionSettings.skills.categories = {}; } // Store in both places for compatibility extensionSettings.skillsData[skillName] = itemsString || 'None'; extensionSettings.skills.categories[skillName] = itemsString || 'None'; saveSettings(); saveChatData(); updateMessageSwipeData(); } /** * Adds an item to a skill category * @param {string} skillName - The skill category name * @param {string} item - The item to add */ export function addSkillItem(skillName, item, description = '') { // Check for structured data first const skillsV2 = extensionSettings.skillsV2; if (skillsV2 && skillsV2[skillName] !== undefined) { if (!Array.isArray(skillsV2[skillName])) { skillsV2[skillName] = []; } // Check if ability already exists const exists = skillsV2[skillName].some(a => a.name === item); if (!exists) { skillsV2[skillName].push({ name: item, description: description, grantedBy: null }); saveSettings(); saveChatData(); } return; } // Fall back to legacy format const currentItems = parseItems(getSkillItems(skillName)); if (!currentItems.includes(item)) { currentItems.push(item); setSkillItems(skillName, serializeItems(currentItems)); } } /** * Removes an item from a skill category * @param {string} skillName - The skill category name * @param {number} index - Index of item to remove */ export function removeSkillItem(skillName, index) { // Check for structured data first const skillsV2 = extensionSettings.skillsV2; if (skillsV2 && skillsV2[skillName] !== undefined && Array.isArray(skillsV2[skillName])) { if (index >= 0 && index < skillsV2[skillName].length) { const removedAbility = skillsV2[skillName][index]; skillsV2[skillName].splice(index, 1); // Remove any skill-ability links if (extensionSettings.skillAbilityLinks) { const linkKey = `${skillName}::${removedAbility.name}`; delete extensionSettings.skillAbilityLinks[linkKey]; } saveSettings(); saveChatData(); } return; } // Fall back to legacy format const currentItems = parseItems(getSkillItems(skillName)); if (index >= 0 && index < currentItems.length) { const removedItem = currentItems[index]; currentItems.splice(index, 1); setSkillItems(skillName, serializeItems(currentItems)); // Handle item-skill link removal if enabled if (extensionSettings.enableItemSkillLinks && extensionSettings.itemSkillLinks) { // Check if this item was linked and remove the link for (const [itemName, linkedSkill] of Object.entries(extensionSettings.itemSkillLinks)) { if (linkedSkill === skillName && itemName === removedItem) { delete extensionSettings.itemSkillLinks[itemName]; break; } } } } } /** * Updates an item in a skill category * @param {string} skillName - The skill category name * @param {number} index - Index of item to update * @param {string} newValue - New item value */ export function updateSkillItem(skillName, index, newValue) { // Check for structured data first const skillsV2 = extensionSettings.skillsV2; if (skillsV2 && skillsV2[skillName] && Array.isArray(skillsV2[skillName]) && skillsV2[skillName][index]) { skillsV2[skillName][index].name = newValue; saveSettings(); saveChatData(); return; } // Fall back to legacy format const currentItems = parseItems(getSkillItems(skillName)); if (index >= 0 && index < currentItems.length) { currentItems[index] = newValue; setSkillItems(skillName, serializeItems(currentItems)); } } /** * Updates a skill ability's description (structured format only) * @param {string} skillName - The skill category name * @param {number} index - Index of ability to update * @param {string} newDescription - New description */ function updateStructuredSkillDescription(skillName, index, newDescription) { const skillsV2 = extensionSettings.skillsV2; if (skillsV2 && skillsV2[skillName] && Array.isArray(skillsV2[skillName]) && skillsV2[skillName][index]) { skillsV2[skillName][index].description = newDescription; saveSettings(); saveChatData(); } } /** * Called when an item is removed from inventory * Based on deleteSkillWithItem setting: * - false (default): Just removes the link, skill remains * - true: Deletes the linked skill abilities entirely * @param {string} itemName - The name of the removed item */ export function handleItemRemoved(itemName) { if (!extensionSettings.enableItemSkillLinks) return; if (!extensionSettings.skillAbilityLinks) return; const itemNameLower = itemName.toLowerCase().trim(); const linksToRemove = []; // Find all skill abilities linked to this item for (const [key, linkedItem] of Object.entries(extensionSettings.skillAbilityLinks)) { if (linkedItem && linkedItem.toLowerCase().trim() === itemNameLower) { linksToRemove.push(key); } } if (linksToRemove.length === 0) return; // Remove the links for (const key of linksToRemove) { delete extensionSettings.skillAbilityLinks[key]; // If deleteSkillWithItem is enabled, also delete the skill ability itself if (extensionSettings.deleteSkillWithItem) { const [skillName, abilityName] = key.split('::'); deleteSkillAbility(skillName, abilityName); } } saveSettings(); saveChatData(); renderSkills(); } /** * Deletes a skill ability from the skills data * @param {string} skillName - The skill category name * @param {string} abilityName - The ability name to delete */ function deleteSkillAbility(skillName, abilityName) { // Delete from structured skills (skillsV2) if (extensionSettings.skillsV2 && extensionSettings.skillsV2[skillName]) { const abilities = extensionSettings.skillsV2[skillName]; if (Array.isArray(abilities)) { const index = abilities.findIndex(a => (typeof a === 'string' ? a : a.name)?.toLowerCase().trim() === abilityName.toLowerCase().trim() ); if (index !== -1) { abilities.splice(index, 1); } } } // Delete from legacy skillsData if (extensionSettings.skillsData && extensionSettings.skillsData[skillName]) { const currentItems = parseItems(extensionSettings.skillsData[skillName]); const index = currentItems.findIndex(item => item.toLowerCase().trim() === abilityName.toLowerCase().trim() ); if (index !== -1) { currentItems.splice(index, 1); extensionSettings.skillsData[skillName] = currentItems.length > 0 ? currentItems.join(', ') : 'None'; } } } /** * Gets the linked item for a skill ability * @param {string} skillName - The skill category name * @param {string} abilityName - The ability name * @returns {string|null} The linked item name or null */ export function getLinkedItem(skillName, abilityName) { if (!extensionSettings.skillAbilityLinks) return null; const key = `${skillName}::${abilityName}`; return extensionSettings.skillAbilityLinks[key] || null; } /** * Links a skill ability to an inventory item * @param {string} skillName - The skill category name * @param {string} abilityName - The ability name * @param {string} itemName - The inventory item name */ export function linkAbilityToItem(skillName, abilityName, itemName) { if (!extensionSettings.skillAbilityLinks) { extensionSettings.skillAbilityLinks = {}; } const key = `${skillName}::${abilityName}`; extensionSettings.skillAbilityLinks[key] = itemName; saveSettings(); saveChatData(); } /** * Unlinks a skill ability from its inventory item * @param {string} skillName - The skill category name * @param {string} abilityName - The ability name */ export function unlinkAbility(skillName, abilityName) { if (!extensionSettings.skillAbilityLinks) return; const key = `${skillName}::${abilityName}`; delete extensionSettings.skillAbilityLinks[key]; saveSettings(); saveChatData(); } /** * Gets all skill abilities linked to a specific inventory item * Checks both manual skillAbilityLinks and structured skillsV2 with grantedBy * @param {string} itemName - The inventory item name * @returns {Array<{skillName: string, abilityName: string}>} Array of linked abilities */ export function getAbilitiesLinkedToItem(itemName) { if (!itemName) return []; const linked = []; const normalizedItemName = itemName.toLowerCase().trim(); // Check manual skillAbilityLinks if (extensionSettings.skillAbilityLinks) { for (const [key, linkedItem] of Object.entries(extensionSettings.skillAbilityLinks)) { // Case-insensitive comparison if (linkedItem && linkedItem.toLowerCase().trim() === normalizedItemName) { const [skillName, abilityName] = key.split('::'); linked.push({ skillName, abilityName }); } } } // Check structured skillsV2 for abilities with grantedBy matching this item const skillsV2 = extensionSettings.skillsV2; if (skillsV2 && typeof skillsV2 === 'object') { for (const [skillName, abilities] of Object.entries(skillsV2)) { if (!Array.isArray(abilities)) continue; for (const ability of abilities) { if (!ability || typeof ability !== 'object') continue; const grantedBy = (ability.grantedBy || '').toLowerCase().trim(); if (grantedBy === normalizedItemName) { // Avoid duplicates const exists = linked.some(l => l.skillName === skillName && l.abilityName === ability.name); if (!exists) { linked.push({ skillName, abilityName: ability.name }); } } } } } return linked; } /** * Checks if an inventory item has any linked skills * Checks both manual skillAbilityLinks and structured grantsSkill property * @param {string} itemName - The inventory item name * @returns {boolean} True if item has linked skills */ export function itemHasLinkedSkills(itemName) { // Check manual links first if (getAbilitiesLinkedToItem(itemName).length > 0) { return true; } // Check structured inventory for grantsSkill property const inv = extensionSettings.inventoryV3; if (!inv || !itemName) return false; const normalizedName = itemName.toLowerCase().trim(); // Helper to check if an item array contains the item with grantsSkill const checkItems = (items) => { if (!Array.isArray(items)) return false; return items.some(item => { if (!item || typeof item !== 'object') return false; const name = (item.name || '').toLowerCase().trim(); return name === normalizedName && item.grantsSkill; }); }; // Check onPerson if (checkItems(inv.onPerson)) return true; // Check simplified if (checkItems(inv.simplified)) return true; // Check assets if (checkItems(inv.assets)) return true; // Check stored locations if (inv.stored && typeof inv.stored === 'object') { for (const items of Object.values(inv.stored)) { if (checkItems(items)) return true; } } return false; } /** * Navigates to the inventory tab and highlights an item * @param {string} itemName - The item to highlight */ export function navigateToInventoryItem(itemName) { // Switch to inventory tab if on desktop if (window.innerWidth > 1000) { const $inventoryTab = $('.rpg-tab-btn[data-tab="inventory"]'); if ($inventoryTab.length) { $inventoryTab.click(); } } // Find and highlight the item after a delay for tab switch animation setTimeout(() => { // Search in inventory container specifically const $inventoryContainer = $('#rpg-inventory'); const $items = $inventoryContainer.find('.rpg-item-name'); let found = false; $items.each(function() { const text = $(this).text().trim(); // Match exact or partial (for items that might have quantities etc) if (text === itemName || text.toLowerCase() === itemName.toLowerCase()) { const $row = $(this).closest('.rpg-item-row, .rpg-item-card'); if ($row.length) { // Scroll into view $row[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); // Add highlight class $row.addClass('rpg-highlight-item'); found = true; // Remove after 3.5 seconds (after 3 animation cycles) setTimeout(() => { $row.removeClass('rpg-highlight-item'); }, 3500); } return false; // Break the loop } }); if (!found) { toastr.warning(`Item "${itemName}" not found in inventory`); } }, 300); } /** * Navigates to the skills tab and highlights abilities linked to an item * @param {string} itemName - The item whose linked abilities to highlight */ export function navigateToLinkedSkills(itemName) { const linkedAbilities = getAbilitiesLinkedToItem(itemName); if (linkedAbilities.length === 0) { toastr.info(`No skills linked to "${itemName}"`); return; } // Switch to skills tab if on desktop if (window.innerWidth > 1000) { const $skillsTab = $('.rpg-tab-btn[data-tab="skills"]'); if ($skillsTab.length) { $skillsTab.click(); } } // Highlight all linked abilities after a delay for tab switch setTimeout(() => { let firstHighlighted = false; linkedAbilities.forEach(({ skillName, abilityName }) => { // Find the skill category const $category = $(`.rpg-skill-category[data-skill="${skillName}"]`); if ($category.length) { // Find items within this category const $items = $category.find('.rpg-item-row, .rpg-item-card'); $items.each(function() { const $row = $(this); const $name = $row.find('.rpg-item-name'); if ($name.text().trim() === abilityName) { // Scroll first match into view if (!firstHighlighted) { $row[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); firstHighlighted = true; } // Add highlight class $row.addClass('rpg-highlight-item'); // Remove after 3.5 seconds setTimeout(() => { $row.removeClass('rpg-highlight-item'); }, 3500); } }); } }); }, 300); } /** * Gets all inventory items (from all categories) for linking dropdown * Supports both legacy (v2) and structured (v3) inventory formats * @returns {string[]} Array of item names */ function getAllInventoryItems() { const items = []; // Check structured inventory (v3) first const inv3 = extensionSettings.inventoryV3; if (inv3) { // On Person if (inv3.onPerson && Array.isArray(inv3.onPerson)) { items.push(...inv3.onPerson.map(i => typeof i === 'string' ? i : i.name).filter(Boolean)); } // Stored if (inv3.stored && typeof inv3.stored === 'object') { for (const locationItems of Object.values(inv3.stored)) { if (Array.isArray(locationItems)) { items.push(...locationItems.map(i => typeof i === 'string' ? i : i.name).filter(Boolean)); } } } // Assets if (inv3.assets && Array.isArray(inv3.assets)) { items.push(...inv3.assets.map(i => typeof i === 'string' ? i : i.name).filter(Boolean)); } } // Fall back to legacy inventory if no v3 items found if (items.length === 0) { const inventory = extensionSettings.userStats?.inventory; if (inventory) { // On Person if (inventory.onPerson && inventory.onPerson.toLowerCase() !== 'none') { items.push(...parseItems(inventory.onPerson)); } // Stored locations if (inventory.stored && typeof inventory.stored === 'object') { for (const locationItems of Object.values(inventory.stored)) { if (locationItems && locationItems.toLowerCase() !== 'none') { items.push(...parseItems(locationItems)); } } } // Assets if (inventory.assets && inventory.assets.toLowerCase() !== 'none') { items.push(...parseItems(inventory.assets)); } // Simplified inventory if (inventory.items && inventory.items.toLowerCase() !== 'none') { items.push(...parseItems(inventory.items)); } } } return [...new Set(items)]; } // Track open add forms let openAddForms = {}; /** * Shows the add item form for a skill category * @param {string} skillName - The skill category name */ function showAddForm(skillName) { openAddForms[skillName] = true; renderSkills(); // Focus the input after render setTimeout(() => { $(`#rpg-new-skill-item-${CSS.escape(skillName)}`).focus(); }, 50); } /** * Hides the add item form for a skill category * @param {string} skillName - The skill category name */ function hideAddForm(skillName) { openAddForms[skillName] = false; renderSkills(); } /** * Saves a new item from the add form * @param {string} skillName - The skill category name */ function saveAddItem(skillName) { const input = $(`#rpg-new-skill-item-${CSS.escape(skillName)}`); const value = input.val()?.trim(); if (value) { addSkillItem(skillName, value); } hideAddForm(skillName); } /** * Renders a structured skill ability (with name + description) * @param {string} skillName - The skill category name * @param {Object} ability - Structured ability object {name, description, grantedBy} * @param {number} index - The item index * @param {string} viewMode - View mode ('list' or 'grid') * @returns {string} HTML string */ function renderStructuredSkillAbility(skillName, ability, index, viewMode) { // Normalize ability - handle both string and object formats const normalizedAbility = typeof ability === 'string' ? { name: ability, description: '', grantedBy: null } : { name: ability?.name || 'Unknown', description: ability?.description || '', grantedBy: ability?.grantedBy || null }; // Check for linked item - first from ability.grantedBy, then from skillAbilityLinks const linkedItem = normalizedAbility.grantedBy || (extensionSettings.enableItemSkillLinks ? getLinkedItem(skillName, normalizedAbility.name) : null); const itemClass = viewMode === 'grid' ? 'rpg-item-card' : 'rpg-item-row'; const hasLink = !!linkedItem; // Link indicator HTML - shows the item that grants this skill const linkIndicator = hasLink ? ` ${escapeHtml(linkedItem)} ` : (extensionSettings.enableItemSkillLinks ? `` : ''); // Unlink button const unlinkBtn = hasLink && extensionSettings.enableItemSkillLinks ? `` : ''; if (viewMode === 'list') { return `
${i18n.getTranslation('skills.empty')}
${i18n.getTranslation('skills.emptyNote')}