@@ -250,68 +257,68 @@ export function setupTriggerWordsEditMode() {
let isTrainedWordsLoaded = false;
// Store original trigger words for restoring on cancel
let originalTriggerWords = [];
-
+
const editBtn = document.querySelector('.edit-trigger-words-btn');
if (!editBtn) return;
-
- editBtn.addEventListener('click', async function() {
+
+ editBtn.addEventListener('click', async function () {
const triggerWordsSection = this.closest('.trigger-words');
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
-
+
// Toggle edit mode UI elements
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const editControls = triggerWordsSection.querySelector('.metadata-edit-controls');
const addForm = triggerWordsSection.querySelector('.metadata-add-form');
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
-
+
if (isEditMode) {
this.innerHTML = '
'; // Change to cancel icon
this.title = translate('modals.model.triggerWords.cancel');
-
+
// Store original trigger words for potential restoration
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
-
+
// Show edit controls and input form
editControls.style.display = 'flex';
addForm.style.display = 'flex';
-
+
// If we have no trigger words yet, hide the "No trigger word needed" text
// and show the empty tags container
if (noTriggerWords) {
noTriggerWords.style.display = 'none';
if (tagsContainer) tagsContainer.style.display = 'flex';
}
-
+
// Disable click-to-copy and show delete buttons
triggerWordTags.forEach(tag => {
tag.onclick = null;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
-
+
if (copyIcon) copyIcon.style.display = 'none';
if (deleteBtn) {
deleteBtn.style.display = 'block';
-
+
// Re-attach event listener to ensure it works every time
// First remove any existing listeners to avoid duplication
deleteBtn.removeEventListener('click', deleteTriggerWord);
deleteBtn.addEventListener('click', deleteTriggerWord);
}
});
-
+
// Load trained words and display dropdown when entering edit mode
// Add loading indicator
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'metadata-loading';
loadingIndicator.innerHTML = `
${translate('modals.model.triggerWords.suggestions.loading')}`;
addForm.appendChild(loadingIndicator);
-
+
// Get currently added trigger words
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
-
+
// Asynchronously load trained words if not already loaded
if (!isTrainedWordsLoaded) {
const result = await fetchTrainedWords(filePath);
@@ -319,25 +326,25 @@ export function setupTriggerWordsEditMode() {
classTokensValue = result.classTokens;
isTrainedWordsLoaded = true;
}
-
+
// Remove loading indicator
loadingIndicator.remove();
-
+
// Create and display suggestion dropdown
const dropdown = createSuggestionDropdown(trainedWordsList, classTokensValue, existingWords);
addForm.appendChild(dropdown);
-
+
// Focus the input
addForm.querySelector('input').focus();
-
+
} else {
this.innerHTML = '
'; // Change back to edit icon
this.title = translate('modals.model.triggerWords.edit');
-
+
// Hide edit controls and input form
editControls.style.display = 'none';
addForm.style.display = 'none';
-
+
// Check if we're exiting edit mode due to "Save" or "Cancel"
if (!this.dataset.skipRestore) {
// If canceling, restore original trigger words
@@ -348,7 +355,7 @@ export function setupTriggerWordsEditMode() {
// Reset the skip restore flag
delete this.dataset.skipRestore;
}
-
+
// If we have no trigger words, show the "No trigger word needed" text
// and hide the empty tags container
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
@@ -356,19 +363,19 @@ export function setupTriggerWordsEditMode() {
noTriggerWords.style.display = '';
if (tagsContainer) tagsContainer.style.display = 'none';
}
-
+
// Remove dropdown if present
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
if (dropdown) dropdown.remove();
}
});
-
+
// Set up input for adding trigger words
const triggerWordInput = document.querySelector('.metadata-input');
-
+
if (triggerWordInput) {
// Add keydown event to input
- triggerWordInput.addEventListener('keydown', function(e) {
+ triggerWordInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
addNewTriggerWord(this.value);
@@ -376,13 +383,13 @@ export function setupTriggerWordsEditMode() {
}
});
}
-
+
// Set up save button
const saveBtn = document.querySelector('.metadata-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', saveTriggerWords);
}
-
+
// Set up delete buttons
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
// Remove any existing listeners to avoid duplication
@@ -399,7 +406,7 @@ function deleteTriggerWord(e) {
e.stopPropagation();
const tag = this.closest('.trigger-word-tag');
tag.remove();
-
+
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
@@ -410,15 +417,15 @@ function deleteTriggerWord(e) {
*/
function resetTriggerWordsUIState(section) {
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
-
+
triggerWordTags.forEach(tag => {
const word = tag.dataset.word;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
-
+
// Restore click-to-copy functionality
- tag.onclick = () => copyTriggerWord(word);
-
+ tag.onclick = () => copyTriggerWord(tag.dataset.word);
+
// Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = '';
if (deleteBtn) deleteBtn.style.display = 'none';
@@ -433,30 +440,32 @@ function resetTriggerWordsUIState(section) {
function restoreOriginalTriggerWords(section, originalWords) {
const tagsContainer = section.querySelector('.trigger-words-tags');
const noTriggerWords = section.querySelector('.no-trigger-words');
-
+
if (!tagsContainer) return;
-
+
// Clear current tags
tagsContainer.innerHTML = '';
-
+
if (originalWords.length === 0) {
if (noTriggerWords) noTriggerWords.style.display = '';
tagsContainer.style.display = 'none';
return;
}
-
+
// Hide "no trigger words" message
if (noTriggerWords) noTriggerWords.style.display = 'none';
tagsContainer.style.display = 'flex';
-
+
// Recreate original tags
originalWords.forEach(word => {
const tag = document.createElement('div');
tag.className = 'trigger-word-tag';
tag.dataset.word = word;
- tag.onclick = () => copyTriggerWord(word);
+ tag.onclick = () => copyTriggerWord(tag.dataset.word);
+
+ const escapedWord = escapeHtml(word);
tag.innerHTML = `
-
${word}
+
${escapedWord}
@@ -475,10 +484,10 @@ function restoreOriginalTriggerWords(section, originalWords) {
function addNewTriggerWord(word) {
word = word.trim();
if (!word) return;
-
+
const triggerWordsSection = document.querySelector('.trigger-words');
let tagsContainer = document.querySelector('.trigger-words-tags');
-
+
// Ensure tags container exists and is visible
if (tagsContainer) {
tagsContainer.style.display = 'flex';
@@ -491,41 +500,43 @@ function addNewTriggerWord(word) {
contentDiv.appendChild(tagsContainer);
}
}
-
+
if (!tagsContainer) return;
-
+
// Hide "no trigger words" message if it exists
const noTriggerWordsMsg = triggerWordsSection.querySelector('.no-trigger-words');
if (noTriggerWordsMsg) {
noTriggerWordsMsg.style.display = 'none';
}
-
+
// Validation: Check length
if (word.split(/\s+/).length > 100) {
showToast('toast.triggerWords.tooLong', {}, 'error');
return;
}
-
+
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
if (currentTags.length >= 30) {
showToast('toast.triggerWords.tooMany', {}, 'error');
return;
}
-
+
// Validation: Check for duplicates
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
if (existingWords.includes(word)) {
showToast('toast.triggerWords.alreadyExists', {}, 'error');
return;
}
-
+
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'trigger-word-tag';
newTag.dataset.word = word;
+
+ const escapedWord = escapeHtml(word);
newTag.innerHTML = `
-
${word}
+
${escapedWord}
@@ -533,13 +544,13 @@ function addNewTriggerWord(word) {
`;
-
+
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', deleteTriggerWord);
-
+
tagsContainer.appendChild(newTag);
-
+
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
@@ -550,19 +561,19 @@ function addNewTriggerWord(word) {
function updateTrainedWordsDropdown() {
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
if (!dropdown) return;
-
+
// Get all current trigger words
const currentTags = document.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
-
+
// Update status of each item in dropdown
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
const wordText = item.querySelector('.metadata-suggestion-text').textContent;
const isAdded = existingWords.includes(wordText);
-
+
if (isAdded) {
item.classList.add('already-added');
-
+
// Add indicator if it doesn't exist
let indicator = item.querySelector('.added-indicator');
if (!indicator) {
@@ -572,27 +583,27 @@ function updateTrainedWordsDropdown() {
indicator.innerHTML = '
';
meta.appendChild(indicator);
}
-
+
// Remove click event
item.onclick = null;
} else {
// Re-enable items that are no longer in the list
item.classList.remove('already-added');
-
+
// Remove indicator if it exists
const indicator = item.querySelector('.added-indicator');
if (indicator) indicator.remove();
-
+
// Restore click event if not already set
if (!item.onclick) {
item.onclick = () => {
const word = item.querySelector('.metadata-suggestion-text').textContent;
addNewTriggerWord(word);
-
+
// Also populate the input field
const input = document.querySelector('.metadata-input');
if (input) input.value = word;
-
+
// Focus the input
if (input) input.focus();
};
@@ -610,19 +621,19 @@ async function saveTriggerWords() {
const triggerWordsSection = editBtn.closest('.trigger-words');
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const words = Array.from(triggerWordTags).map(tag => tag.dataset.word);
-
+
try {
// Special format for updating nested civitai.trainedWords
await getModelApiClient().saveModelMetadata(filePath, {
civitai: { trainedWords: words }
});
-
+
// Set flag to skip restoring original words when exiting edit mode
editBtn.dataset.skipRestore = "true";
-
+
// Exit edit mode without restoring original trigger words
editBtn.click();
-
+
// If we saved an empty array and there's a no-trigger-words element, show it
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
@@ -630,7 +641,7 @@ async function saveTriggerWords() {
noTriggerWords.style.display = '';
if (tagsContainer) tagsContainer.style.display = 'none';
}
-
+
showToast('toast.triggerWords.updateSuccess', {}, 'success');
} catch (error) {
console.error('Error saving trigger words:', error);
@@ -642,7 +653,7 @@ async function saveTriggerWords() {
* Copy a trigger word to clipboard
* @param {string} word - Word to copy
*/
-window.copyTriggerWord = async function(word) {
+window.copyTriggerWord = async function (word) {
try {
await copyToClipboard(word, 'Trigger word copied');
} catch (err) {
diff --git a/tests/frontend/components/triggerWords.escaping.test.js b/tests/frontend/components/triggerWords.escaping.test.js
new file mode 100644
index 00000000..199304e2
--- /dev/null
+++ b/tests/frontend/components/triggerWords.escaping.test.js
@@ -0,0 +1,55 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+const {
+ TRIGGER_WORDS_MODULE,
+ UTILS_MODULE,
+ I18N_HELPERS_MODULE,
+} = vi.hoisted(() => ({
+ TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
+ UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
+ I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
+}));
+
+vi.mock(I18N_HELPERS_MODULE, () => ({
+ translate: vi.fn((key, params, fallback) => fallback || key),
+}));
+
+vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
+ showToast: vi.fn(),
+ copyToClipboard: vi.fn(),
+}));
+
+vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
+ getModelApiClient: vi.fn(),
+}));
+
+describe("TriggerWords HTML Escaping", () => {
+ let renderTriggerWords;
+
+ beforeEach(async () => {
+ document.body.innerHTML = '';
+ const module = await import(TRIGGER_WORDS_MODULE);
+ renderTriggerWords = module.renderTriggerWords;
+ });
+
+ it("escapes HTML tags in trigger words rendering", () => {
+ const words = ["", "fym
"];
+ const html = renderTriggerWords(words, "test.safetensors");
+
+ expect(html).toContain("<style>guangying</style>");
+ expect(html).toContain("fym <artist>");
+ expect(html).not.toContain("");
+ });
+
+ it("uses dataset for copyTriggerWord to safely handle special characters", () => {
+ const words = ["word'with'quotes", ""];
+ const html = renderTriggerWords(words, "test.safetensors");
+
+ // Check for dataset-word attribute
+ expect(html).toContain('data-word="word'with'quotes"');
+ expect(html).toContain('data-word="<tag>"');
+
+ // Check for the onclick handler
+ expect(html).toContain('onclick="copyTriggerWord(this.dataset.word)"');
+ });
+});