1053 lines
42 KiB
JavaScript
1053 lines
42 KiB
JavaScript
/**
|
|
* 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
|
|
? `<span class="rpg-skill-link-badge" data-action="goto-linked-item" data-item="${escapeHtml(linkedItem)}" title="${i18n.getTranslation('skills.gotoItem')}: ${escapeHtml(linkedItem)}">
|
|
<i class="fa-solid fa-link"></i>
|
|
<span class="rpg-link-item-name">${escapeHtml(linkedItem)}</span>
|
|
</span>`
|
|
: (extensionSettings.enableItemSkillLinks
|
|
? `<button class="rpg-skill-link-btn" data-action="show-link-dropdown" data-skill="${escapeHtml(skillName)}" data-ability="${escapeHtml(normalizedAbility.name)}" data-index="${index}" title="${i18n.getTranslation('skills.linkToItem')}">
|
|
<i class="fa-solid fa-link"></i>
|
|
</button>`
|
|
: '');
|
|
|
|
// Unlink button
|
|
const unlinkBtn = hasLink && extensionSettings.enableItemSkillLinks
|
|
? `<button class="rpg-skill-unlink-btn" data-action="unlink-ability" data-skill="${escapeHtml(skillName)}" data-ability="${escapeHtml(normalizedAbility.name)}" title="${i18n.getTranslation('skills.unlinkItem')}">
|
|
<i class="fa-solid fa-unlink"></i>
|
|
</button>`
|
|
: '';
|
|
|
|
if (viewMode === 'list') {
|
|
return `
|
|
<div class="${itemClass} rpg-structured ${hasLink ? 'rpg-has-link' : ''}" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-ability="${escapeHtml(normalizedAbility.name)}">
|
|
<div class="rpg-skill-ability-row">
|
|
<span class="rpg-item-name rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit')}">${escapeHtml(normalizedAbility.name)}</span>
|
|
${linkIndicator}
|
|
${unlinkBtn}
|
|
<button class="rpg-item-remove" data-action="remove-skill-item" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('skills.removeAbility')}">
|
|
<i class="fa-solid fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div class="rpg-skill-ability-desc-row">
|
|
<span class="rpg-item-description rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-prop="description" title="Click to edit description">${escapeHtml(normalizedAbility.description)}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
return `
|
|
<div class="${itemClass} rpg-structured ${hasLink ? 'rpg-has-link' : ''}" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-ability="${escapeHtml(normalizedAbility.name)}">
|
|
<div class="rpg-card-actions">
|
|
${unlinkBtn}
|
|
<button class="rpg-item-remove" data-action="remove-skill-item" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('skills.removeAbility')}">
|
|
<i class="fa-solid fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<span class="rpg-item-name rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit')}">${escapeHtml(normalizedAbility.name)}</span>
|
|
${linkIndicator}
|
|
<div class="rpg-skill-ability-desc-row">
|
|
<span class="rpg-item-description rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-prop="description" title="Click to edit description">${escapeHtml(normalizedAbility.description)}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders a single skill ability item with link indicator (legacy string format)
|
|
* @param {string} skillName - The skill category name
|
|
* @param {string} abilityName - The ability name
|
|
* @param {number} index - The item index
|
|
* @param {string} viewMode - View mode ('list' or 'grid')
|
|
* @returns {string} HTML string
|
|
*/
|
|
function renderSkillAbilityItem(skillName, abilityName, index, viewMode) {
|
|
const linkedItem = extensionSettings.enableItemSkillLinks ? getLinkedItem(skillName, abilityName) : null;
|
|
const itemClass = viewMode === 'grid' ? 'rpg-item-card' : 'rpg-item-row';
|
|
const hasLink = !!linkedItem;
|
|
|
|
// Link indicator HTML
|
|
const linkIndicator = extensionSettings.enableItemSkillLinks ? (hasLink
|
|
? `<span class="rpg-skill-link-badge" data-action="goto-linked-item" data-item="${escapeHtml(linkedItem)}" title="${i18n.getTranslation('skills.gotoItem')}: ${escapeHtml(linkedItem)}">
|
|
<i class="fa-solid fa-link"></i>
|
|
<span class="rpg-link-item-name">${escapeHtml(linkedItem)}</span>
|
|
</span>`
|
|
: `<button class="rpg-skill-link-btn" data-action="show-link-dropdown" data-skill="${escapeHtml(skillName)}" data-ability="${escapeHtml(abilityName)}" data-index="${index}" title="${i18n.getTranslation('skills.linkToItem')}">
|
|
<i class="fa-solid fa-link"></i>
|
|
</button>`
|
|
) : '';
|
|
|
|
// Unlink button (only shown if linked)
|
|
const unlinkBtn = hasLink && extensionSettings.enableItemSkillLinks
|
|
? `<button class="rpg-skill-unlink-btn" data-action="unlink-ability" data-skill="${escapeHtml(skillName)}" data-ability="${escapeHtml(abilityName)}" title="${i18n.getTranslation('skills.unlinkItem')}">
|
|
<i class="fa-solid fa-unlink"></i>
|
|
</button>`
|
|
: '';
|
|
|
|
if (viewMode === 'list') {
|
|
return `
|
|
<div class="${itemClass} ${hasLink ? 'rpg-has-link' : ''}" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-ability="${escapeHtml(abilityName)}">
|
|
<span class="rpg-item-name rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit')}">${escapeHtml(abilityName)}</span>
|
|
${linkIndicator}
|
|
${unlinkBtn}
|
|
<button class="rpg-item-remove" data-action="remove-skill-item" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('skills.removeAbility')}">
|
|
<i class="fa-solid fa-times"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
} else {
|
|
return `
|
|
<div class="${itemClass} ${hasLink ? 'rpg-has-link' : ''}" data-skill="${escapeHtml(skillName)}" data-index="${index}" data-ability="${escapeHtml(abilityName)}">
|
|
<div class="rpg-card-actions">
|
|
${unlinkBtn}
|
|
<button class="rpg-item-remove" data-action="remove-skill-item" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('skills.removeAbility')}">
|
|
<i class="fa-solid fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<span class="rpg-item-name rpg-editable" contenteditable="true" data-skill="${escapeHtml(skillName)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit')}">${escapeHtml(abilityName)}</span>
|
|
${linkIndicator}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if we have structured skills data (v2 format)
|
|
* @param {string} skillName - The skill category name
|
|
* @returns {Array|null} Structured abilities array or null
|
|
*/
|
|
function getStructuredSkillAbilities(skillName) {
|
|
const skillsV2 = extensionSettings.skillsV2;
|
|
if (skillsV2 && skillsV2[skillName] && Array.isArray(skillsV2[skillName])) {
|
|
return skillsV2[skillName];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Renders a single skill category section
|
|
* @param {string} skillName - The skill category name
|
|
* @param {string} viewMode - View mode ('list' or 'grid')
|
|
* @returns {string} HTML string
|
|
*/
|
|
function renderSkillCategory(skillName, viewMode) {
|
|
// Check for structured data first
|
|
const structuredAbilities = getStructuredSkillAbilities(skillName);
|
|
const isStructured = structuredAbilities !== null;
|
|
|
|
const items = isStructured ? structuredAbilities : parseItems(getSkillItems(skillName));
|
|
const safeSkillName = skillName.replace(/[^a-zA-Z0-9]/g, '_');
|
|
const isFormOpen = openAddForms[skillName];
|
|
|
|
let itemsHtml = '';
|
|
if (items.length === 0) {
|
|
itemsHtml = `<div class="rpg-skill-items-empty" data-i18n-key="skills.noAbilities">${i18n.getTranslation('skills.noAbilities')}</div>`;
|
|
} else {
|
|
if (isStructured) {
|
|
// Render structured abilities with name + description
|
|
itemsHtml = items.map((ability, index) => renderStructuredSkillAbility(skillName, ability, index, viewMode)).join('');
|
|
} else {
|
|
// Render legacy string-based abilities
|
|
itemsHtml = items.map((item, index) => renderSkillAbilityItem(skillName, item, index, viewMode)).join('');
|
|
}
|
|
}
|
|
|
|
const listViewClass = viewMode === 'list' ? 'rpg-item-list-view' : 'rpg-item-grid-view';
|
|
|
|
return `
|
|
<div class="rpg-skill-category" data-skill="${escapeHtml(skillName)}">
|
|
<div class="rpg-skill-category-header">
|
|
<h5 class="rpg-skill-category-title">
|
|
<i class="fa-solid fa-star"></i>
|
|
<span>${escapeHtml(skillName)}</span>
|
|
<span class="rpg-skill-category-count">(${items.length})</span>
|
|
</h5>
|
|
<div class="rpg-skill-category-actions">
|
|
<div class="rpg-view-toggle">
|
|
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-skill-view" data-skill="${escapeHtml(skillName)}" data-view="list" title="${i18n.getTranslation('global.listView')}">
|
|
<i class="fa-solid fa-list"></i>
|
|
</button>
|
|
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-skill-view" data-skill="${escapeHtml(skillName)}" data-view="grid" title="${i18n.getTranslation('global.gridView')}">
|
|
<i class="fa-solid fa-th"></i>
|
|
</button>
|
|
</div>
|
|
<button class="rpg-skill-add-btn" data-action="show-add-skill-item" data-skill="${escapeHtml(skillName)}" title="${i18n.getTranslation('skills.addAbility')}">
|
|
<i class="fa-solid fa-plus"></i> <span data-i18n-key="skills.addAbilityButton">${i18n.getTranslation('skills.addAbilityButton')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="rpg-skill-category-content">
|
|
<div class="rpg-inline-form" id="rpg-add-skill-form-${safeSkillName}" style="display: ${isFormOpen ? 'flex' : 'none'};">
|
|
<input type="text" class="rpg-inline-input" id="rpg-new-skill-item-${safeSkillName}" placeholder="${i18n.getTranslation('skills.addAbilityPlaceholder')}" />
|
|
<div class="rpg-inline-buttons">
|
|
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-skill-item" data-skill="${escapeHtml(skillName)}">
|
|
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span>
|
|
</button>
|
|
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-skill-item" data-skill="${escapeHtml(skillName)}">
|
|
<i class="fa-solid fa-check"></i> <span data-i18n-key="global.add">${i18n.getTranslation('global.add')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="rpg-item-list ${listViewClass}">
|
|
${itemsHtml}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Generates the full skills section HTML
|
|
* @returns {string} HTML string
|
|
*/
|
|
export function generateSkillsHTML() {
|
|
const skillCategories = getSkillCategories();
|
|
|
|
if (skillCategories.length === 0) {
|
|
return `
|
|
<div class="rpg-skills-container">
|
|
<div class="rpg-skills-empty">
|
|
<i class="fa-solid fa-star"></i>
|
|
<p data-i18n-key="skills.empty">${i18n.getTranslation('skills.empty')}</p>
|
|
<small data-i18n-key="skills.emptyNote">${i18n.getTranslation('skills.emptyNote')}</small>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Get view modes for each skill (default to list)
|
|
const viewModes = extensionSettings.skillsViewModes || {};
|
|
|
|
let html = `<div class="rpg-skills-container">`;
|
|
|
|
// Render each skill category
|
|
for (const skillName of skillCategories) {
|
|
const viewMode = viewModes[skillName] || 'list';
|
|
html += renderSkillCategory(skillName, viewMode);
|
|
}
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Sets up event listeners for skills section
|
|
*/
|
|
function setupSkillsEventListeners() {
|
|
if (!$skillsContainer || $skillsContainer.length === 0) return;
|
|
|
|
// Show add form
|
|
$skillsContainer.off('click', '[data-action="show-add-skill-item"]').on('click', '[data-action="show-add-skill-item"]', function() {
|
|
const skillName = $(this).data('skill');
|
|
showAddForm(skillName);
|
|
});
|
|
|
|
// Cancel add form
|
|
$skillsContainer.off('click', '[data-action="cancel-add-skill-item"]').on('click', '[data-action="cancel-add-skill-item"]', function() {
|
|
const skillName = $(this).data('skill');
|
|
hideAddForm(skillName);
|
|
});
|
|
|
|
// Save add form
|
|
$skillsContainer.off('click', '[data-action="save-add-skill-item"]').on('click', '[data-action="save-add-skill-item"]', function() {
|
|
const skillName = $(this).data('skill');
|
|
saveAddItem(skillName);
|
|
});
|
|
|
|
// Enter key in add form
|
|
$skillsContainer.off('keypress', '.rpg-inline-input').on('keypress', '.rpg-inline-input', function(e) {
|
|
if (e.which === 13) { // Enter key
|
|
const skillName = $(this).closest('.rpg-skill-category').data('skill');
|
|
saveAddItem(skillName);
|
|
}
|
|
});
|
|
|
|
// Remove item
|
|
$skillsContainer.off('click', '[data-action="remove-skill-item"]').on('click', '[data-action="remove-skill-item"]', function() {
|
|
const skillName = $(this).data('skill');
|
|
const index = $(this).data('index');
|
|
removeSkillItem(skillName, index);
|
|
renderSkills();
|
|
});
|
|
|
|
// Edit item name (blur event for contenteditable)
|
|
$skillsContainer.off('blur', '.rpg-item-name.rpg-editable').on('blur', '.rpg-item-name.rpg-editable', function() {
|
|
const skillName = $(this).data('skill');
|
|
const index = $(this).data('index');
|
|
const newValue = $(this).text().trim();
|
|
if (newValue) {
|
|
updateSkillItem(skillName, index, newValue);
|
|
} else {
|
|
// If empty, remove the item
|
|
removeSkillItem(skillName, index);
|
|
renderSkills();
|
|
}
|
|
});
|
|
|
|
// Edit item description (for structured skills)
|
|
$skillsContainer.off('blur', '.rpg-item-description.rpg-editable').on('blur', '.rpg-item-description.rpg-editable', function() {
|
|
const skillName = $(this).data('skill');
|
|
const index = $(this).data('index');
|
|
const newDesc = $(this).text().trim();
|
|
updateStructuredSkillDescription(skillName, index, newDesc);
|
|
});
|
|
|
|
// Switch view mode for a skill category
|
|
$skillsContainer.off('click', '[data-action="switch-skill-view"]').on('click', '[data-action="switch-skill-view"]', function() {
|
|
const skillName = $(this).data('skill');
|
|
const view = $(this).data('view');
|
|
if (!extensionSettings.skillsViewModes) {
|
|
extensionSettings.skillsViewModes = {};
|
|
}
|
|
extensionSettings.skillsViewModes[skillName] = view;
|
|
saveSettings();
|
|
renderSkills();
|
|
});
|
|
|
|
// Show link dropdown
|
|
$skillsContainer.off('click', '[data-action="show-link-dropdown"]').on('click', '[data-action="show-link-dropdown"]', function(e) {
|
|
e.stopPropagation();
|
|
const $btn = $(this);
|
|
const skillName = $btn.data('skill');
|
|
const abilityName = $btn.data('ability');
|
|
showLinkDropdown($btn, skillName, abilityName);
|
|
});
|
|
|
|
// Go to linked item in inventory
|
|
$skillsContainer.off('click', '[data-action="goto-linked-item"]').on('click', '[data-action="goto-linked-item"]', function() {
|
|
const itemName = $(this).data('item');
|
|
if (itemName) {
|
|
navigateToInventoryItem(itemName);
|
|
}
|
|
});
|
|
|
|
// Unlink ability from item
|
|
$skillsContainer.off('click', '[data-action="unlink-ability"]').on('click', '[data-action="unlink-ability"]', function(e) {
|
|
e.stopPropagation();
|
|
const skillName = $(this).data('skill');
|
|
const abilityName = $(this).data('ability');
|
|
unlinkAbility(skillName, abilityName);
|
|
renderSkills();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Shows a dropdown to select an inventory item to link
|
|
*/
|
|
function showLinkDropdown($btn, skillName, abilityName) {
|
|
// Remove any existing dropdown
|
|
$('.rpg-link-dropdown').remove();
|
|
|
|
const inventoryItems = getAllInventoryItems();
|
|
|
|
if (inventoryItems.length === 0) {
|
|
toastr.info(i18n.getTranslation('skills.noItemsToLink'));
|
|
return;
|
|
}
|
|
|
|
// Build dropdown HTML
|
|
let dropdownHtml = `<div class="rpg-link-dropdown">
|
|
<div class="rpg-link-dropdown-header">
|
|
<span>${i18n.getTranslation('skills.selectItemToLink')}</span>
|
|
<button class="rpg-link-dropdown-close" data-action="close-link-dropdown"><i class="fa-solid fa-times"></i></button>
|
|
</div>
|
|
<div class="rpg-link-dropdown-list">`;
|
|
|
|
for (const item of inventoryItems) {
|
|
dropdownHtml += `<div class="rpg-link-dropdown-item" data-action="link-to-item" data-skill="${escapeHtml(skillName)}" data-ability="${escapeHtml(abilityName)}" data-item="${escapeHtml(item)}">
|
|
<i class="fa-solid fa-box"></i> <span>${escapeHtml(item)}</span>
|
|
</div>`;
|
|
}
|
|
|
|
dropdownHtml += `</div></div>`;
|
|
|
|
const $dropdown = $(dropdownHtml);
|
|
$('body').append($dropdown);
|
|
|
|
// Position near the button
|
|
const btnOffset = $btn.offset();
|
|
$dropdown.css({
|
|
position: 'fixed',
|
|
top: btnOffset.top + $btn.outerHeight() + 5,
|
|
left: Math.min(btnOffset.left, $(window).width() - $dropdown.outerWidth() - 10),
|
|
zIndex: 10000
|
|
});
|
|
|
|
// Event handlers
|
|
$dropdown.on('click', '[data-action="close-link-dropdown"]', () => $dropdown.remove());
|
|
$dropdown.on('click', '[data-action="link-to-item"]', function() {
|
|
linkAbilityToItem($(this).data('skill'), $(this).data('ability'), $(this).data('item'));
|
|
$dropdown.remove();
|
|
renderSkills();
|
|
toastr.success(i18n.getTranslation('skills.linkCreated'));
|
|
});
|
|
|
|
// Close when clicking outside
|
|
setTimeout(() => {
|
|
$(document).one('click', function(e) {
|
|
if (!$(e.target).closest('.rpg-link-dropdown, [data-action="show-link-dropdown"]').length) {
|
|
$dropdown.remove();
|
|
}
|
|
});
|
|
}, 100);
|
|
}
|
|
|
|
/**
|
|
* Main render function for skills section
|
|
*/
|
|
export function renderSkills() {
|
|
if (!extensionSettings.showSkills || !$skillsContainer || $skillsContainer.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const html = generateSkillsHTML();
|
|
|
|
$skillsContainer.html(html);
|
|
setupSkillsEventListeners();
|
|
|
|
// Apply i18n translations (pass DOM element, not jQuery object)
|
|
const domElement = $skillsContainer[0];
|
|
if (domElement) {
|
|
i18n.applyTranslations(domElement);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds a summary string of all skills for prompt injection
|
|
* @returns {string} Formatted skills summary
|
|
*/
|
|
export function buildSkillsSummary() {
|
|
const skillCategories = getSkillCategories();
|
|
if (skillCategories.length === 0) return '';
|
|
|
|
let summary = '';
|
|
for (const skillName of skillCategories) {
|
|
const items = getSkillItems(skillName);
|
|
summary += `${skillName}: ${items}\n`;
|
|
}
|
|
return summary.trim();
|
|
}
|
|
|