Blame Raw
Cohee · 51ad27fb · · 2422 lines (85.1 KB)
2 contributors
1// Move chat functions here from script.js (eventually)
2
3import { Popper, css, DOMPurify } from '../lib.js';
4import {
5 addCopyToCodeBlocks,
6 appendMediaToMessage,
7 characters,
8 chat,
9 eventSource,
10 event_types,
11 getCurrentChatId,
12 getRequestHeaders,
13 name2,
14 reloadCurrentChat,
15 saveSettingsDebounced,
16 this_chid,
17 saveChatConditional,
18 chat_metadata,
19 neutralCharacterName,
20 updateChatMetadata,
21 system_message_types,
22 converter,
23 substituteParams,
24 getSystemMessageByType,
25 printMessages,
26 clearChat,
27 refreshSwipeButtons,
28 getMediaIndex,
29 getMediaDisplay,
30 chatElement,
31} from '../script.js';
32import { selected_group } from './group-chats.js';
33import { power_user } from './power-user.js';
34import {
35 extractTextFromHTML,
36 extractTextFromMarkdown,
37 extractTextFromPDF,
38 extractTextFromEpub,
39 getBase64Async,
40 getStringHash,
41 humanFileSize,
42 saveBase64AsFile,
43 extractTextFromOffice,
44 download,
45 getFileText,
46 getFileExtension,
47 convertTextToBase64,
48 isSameFile,
49 clamp,
50} from './utils.js';
51import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';
52import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
53import { ScraperManager } from './scrapers.js';
54import { DragAndDropHandler } from './dragdrop.js';
55import { renderTemplateAsync } from './templates.js';
56import { t } from './i18n.js';
57import { humanizedDateTime } from './RossAscends-mods.js';
58import { accountStorage } from './util/AccountStorage.js';
59import { MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR, SWIPE_DIRECTION } from './constants.js';
60
61/**
62 * @typedef {Object} FileAttachment
63 * @property {string} url File URL
64 * @property {number} size File size
65 * @property {string} name File name
66 * @property {number} created Timestamp
67 * @property {string} [text] File text
68 */
69
70/**
71 * @typedef {function} ConverterFunction
72 * @param {File} file File object
73 * @returns {Promise<string>} Converted file text
74 */
75
76const fileSizeLimit = 1024 * 1024 * 350; // 350 MB
77const ATTACHMENT_SOURCE = {
78 GLOBAL: 'global',
79 CHARACTER: 'character',
80 CHAT: 'chat',
81};
82
83/**
84 * @type {Record<string, ConverterFunction>} File converters
85 */
86const converters = {
87 'application/pdf': extractTextFromPDF,
88 'text/html': extractTextFromHTML,
89 'text/markdown': extractTextFromMarkdown,
90 'application/epub+zip': extractTextFromEpub,
91 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': extractTextFromOffice,
92 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': extractTextFromOffice,
93 'application/vnd.openxmlformats-officedocument.presentationml.presentation': extractTextFromOffice,
94 'application/vnd.oasis.opendocument.text': extractTextFromOffice,
95 'application/vnd.oasis.opendocument.presentation': extractTextFromOffice,
96 'application/vnd.oasis.opendocument.spreadsheet': extractTextFromOffice,
97};
98
99/**
100 * Finds a matching key in the converters object.
101 * @param {string} type MIME type
102 * @returns {string} Matching key
103 */
104function findConverterKey(type) {
105 return Object.keys(converters).find((key) => {
106 // Match exact type
107 if (type === key) {
108 return true;
109 }
110
111 // Match wildcards
112 if (key.endsWith('*')) {
113 return type.startsWith(key.substring(0, key.length - 1));
114 }
115
116 return false;
117 });
118}
119
120/**
121 * Determines if the file type has a converter function.
122 * @param {string} type MIME type
123 * @returns {boolean} True if the file type is convertible, false otherwise.
124 */
125function isConvertible(type) {
126 return Boolean(findConverterKey(type));
127}
128
129/**
130 * Gets the converter function for a file type.
131 * @param {string} type MIME type
132 * @returns {ConverterFunction} Converter function
133 */
134function getConverter(type) {
135 const key = findConverterKey(type);
136 return key && converters[key];
137}
138
139/**
140 * Mark a range of messages as hidden ("is_system") or not.
141 * @param {number} start Starting message ID
142 * @param {number} end Ending message ID (inclusive)
143 * @param {boolean} unhide If true, unhide the messages instead.
144 * @param {string} nameFitler Optional name filter
145 * @returns {Promise<void>}
146 */
147export async function hideChatMessageRange(start, end, unhide, nameFitler = null) {
148 if (isNaN(start)) return;
149 if (!end) end = start;
150 const hide = !unhide;
151
152 for (let messageId = start; messageId <= end; messageId++) {
153 const message = chat[messageId];
154 if (!message) continue;
155 if (nameFitler && message.name !== nameFitler) continue;
156
157 message.is_system = hide;
158
159 // Also toggle "hidden" state for all visible messages
160 const messageBlock = $(`.mes[mesid="${messageId}"]`);
161 if (!messageBlock.length) continue;
162 messageBlock.attr('is_system', String(hide));
163 }
164
165 // Reload swipes. Useful when a last message is hidden.
166 refreshSwipeButtons();
167
168 await saveChatConditional();
169}
170
171/**
172 * Mark message as hidden (system message).
173 * @deprecated Use hideChatMessageRange.
174 * @param {number} messageId Message ID
175 * @param {JQuery<Element>} _messageBlock Unused
176 * @returns {Promise<void>}
177 */
178export async function hideChatMessage(messageId, _messageBlock) {
179 return hideChatMessageRange(messageId, messageId, false);
180}
181
182/**
183 * Mark message as visible (non-system message).
184 * @deprecated Use hideChatMessageRange.
185 * @param {number} messageId Message ID
186 * @param {JQuery<Element>} _messageBlock Unused
187 * @returns {Promise<void>}
188 */
189export async function unhideChatMessage(messageId, _messageBlock) {
190 return hideChatMessageRange(messageId, messageId, true);
191}
192
193/**
194 * Adds a file attachment to the message.
195 * @param {ChatMessage} message Message object
196 * @returns {Promise<void>} A promise that resolves when file is uploaded.
197 */
198export async function populateFileAttachment(message, inputId = 'file_form_input') {
199 try {
200 if (!message) return;
201 if (!message.extra || typeof message.extra !== 'object') message.extra = {};
202 const fileInput = document.getElementById(inputId);
203 if (!(fileInput instanceof HTMLInputElement)) return;
204
205 for (const file of fileInput.files) {
206 const slug = getStringHash(file.name);
207 const fileNamePrefix = `${Date.now()}_${slug}`;
208 const fileBase64 = await getBase64Async(file);
209 let base64Data = fileBase64.split(',')[1];
210 const extension = getFileExtension(file);
211
212 const mediaType = MEDIA_TYPE.getFromMime(file.type);
213 if (mediaType) {
214 const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension);
215 if (!Array.isArray(message.extra.media)) {
216 message.extra.media = [];
217 }
218 /** @type {MediaAttachment} */
219 const mediaAttachment = {
220 url: imageUrl,
221 type: mediaType,
222 title: file.name,
223 source: MEDIA_SOURCE.UPLOAD,
224 };
225 message.extra.media.push(mediaAttachment);
226 message.extra.media_index = message.extra.media.length - 1;
227 message.extra.inline_image = true;
228 } else {
229 const uniqueFileName = `${fileNamePrefix}.txt`;
230
231 if (isConvertible(file.type)) {
232 try {
233 const converter = getConverter(file.type);
234 const fileText = await converter(file);
235 base64Data = convertTextToBase64(fileText);
236 } catch (error) {
237 toastr.error(String(error), t`Could not convert file`);
238 console.error('Could not convert file', error);
239 }
240 }
241
242 const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data);
243
244 if (!fileUrl) {
245 continue;
246 }
247
248 if (!Array.isArray(message.extra.files)) {
249 message.extra.files = [];
250 }
251
252 message.extra.files.push({
253 url: fileUrl,
254 size: file.size,
255 name: file.name,
256 created: Date.now(),
257 });
258 }
259 }
260 } catch (error) {
261 console.error('Could not upload file', error);
262 toastr.error(t`Either the file is corrupted or its format is not supported.`, t`Could not upload the file`);
263 } finally {
264 $('#file_form').trigger('reset');
265 }
266}
267
268/**
269 * Uploads file to the server.
270 * @param {string} fileName
271 * @param {string} base64Data
272 * @returns {Promise<string>} File URL
273 */
274export async function uploadFileAttachment(fileName, base64Data) {
275 try {
276 const result = await fetch('/api/files/upload', {
277 method: 'POST',
278 headers: getRequestHeaders(),
279 body: JSON.stringify({
280 name: fileName,
281 data: base64Data,
282 }),
283 });
284
285 if (!result.ok) {
286 const error = await result.text();
287 throw new Error(error);
288 }
289
290 const responseData = await result.json();
291 return responseData.path;
292 } catch (error) {
293 toastr.error(String(error), t`Could not upload file`);
294 console.error('Could not upload file', error);
295 }
296}
297
298/**
299 * Downloads file from the server.
300 * @param {string} url File URL
301 * @returns {Promise<string>} File text
302 */
303export async function getFileAttachment(url) {
304 try {
305 const result = await fetch(url, {
306 method: 'GET',
307 cache: 'force-cache',
308 headers: getRequestHeaders(),
309 });
310
311 if (!result.ok) {
312 const error = await result.text();
313 throw new Error(error);
314 }
315
316 const text = await result.text();
317 return text;
318 } catch (error) {
319 toastr.error(error, t`Could not download file`);
320 console.error('Could not download file', error);
321 }
322}
323
324/**
325 * Validates file to make sure it is not binary or not image.
326 * @param {File} file File object
327 * @returns {Promise<boolean>} True if file is valid, false otherwise.
328 */
329async function validateFile(file) {
330 const fileText = await file.text();
331 const isMedia = file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/');
332 const isBinary = /^[\x00-\x08\x0E-\x1F\x7F-\xFF]*$/.test(fileText);
333
334 if (!isMedia && file.size > fileSizeLimit) {
335 toastr.error(t`File is too big. Maximum size is ${humanFileSize(fileSizeLimit)}.`);
336 return false;
337 }
338
339 // If file is binary
340 if (isBinary && !isMedia && !isConvertible(file.type)) {
341 toastr.error(t`Binary files are not supported. Select a text file or image.`);
342 return false;
343 }
344
345 return true;
346}
347
348export function hasPendingFileAttachment() {
349 const fileInput = document.getElementById('file_form_input');
350 if (!(fileInput instanceof HTMLInputElement)) return false;
351 return fileInput.files.length > 0;
352}
353
354/**
355 * Displays file information in the message sending form.
356 * @param {FileList} fileList File object
357 * @returns {Promise<void>}
358 */
359async function onFileAttach(fileList) {
360 if (!fileList || fileList.length === 0) return;
361
362 for (const file of fileList) {
363 const isValid = await validateFile(file);
364
365 // If file is binary
366 if (!isValid) {
367 toastr.warning(t`File ${file.name} is not supported.`);
368 $('#file_form').trigger('reset');
369 return;
370 }
371 }
372
373 const name = fileList.length === 1 ? fileList[0].name : t`${fileList.length} files selected`;
374 const size = [...fileList].reduce((acc, file) => acc + file.size, 0);
375 const title = [...fileList].map(x => x.name).join('\n');
376 $('#file_form .file_name').text(name).attr('title', title);
377 $('#file_form .file_size').text(humanFileSize(size)).attr('title', size);
378 $('#file_form').removeClass('displayNone');
379
380 // Reset form on chat change (if not on a welcome screen)
381 const currentChatId = getCurrentChatId();
382 if (currentChatId) {
383 eventSource.once(event_types.CHAT_CHANGED, () => {
384 $('#file_form').trigger('reset');
385 });
386 }
387}
388
389/**
390 * Deletes file from a message.
391 * @param {JQuery<HTMLElement>} messageBlock Message block element
392 * @param {number} messageId Message ID
393 * @param {number} fileIndex File index
394 */
395async function deleteMessageFile(messageBlock, messageId, fileIndex) {
396 if (isNaN(messageId) || isNaN(fileIndex)) {
397 console.warn('Invalid message ID or file index');
398 return;
399 }
400
401 const confirm = await callGenericPopup('Are you sure you want to delete this file?', POPUP_TYPE.CONFIRM);
402
403 if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
404 console.debug('Delete file cancelled');
405 return;
406 }
407
408 const message = chat[messageId];
409
410 if (!Array.isArray(message?.extra?.files)) {
411 console.debug('Message has no files');
412 return;
413 }
414
415 if (fileIndex < 0 || fileIndex >= message.extra.files.length) {
416 console.warn('Invalid file index for message');
417 return;
418 }
419
420 const url = message.extra.files[fileIndex]?.url;
421 message.extra.files.splice(fileIndex, 1);
422
423 await saveChatConditional();
424 await deleteFileFromServer(url);
425
426 appendMediaToMessage(message, messageBlock, SCROLL_BEHAVIOR.KEEP);
427}
428
429/**
430 * Opens file from message in a modal.
431 * @param {number} messageId Message ID
432 * @param {number} fileIndex File index
433 */
434async function viewMessageFile(messageId, fileIndex) {
435 if (isNaN(messageId) || isNaN(fileIndex)) {
436 console.warn('Invalid message ID or file index');
437 return;
438 }
439
440 const message = chat[messageId];
441
442 if (!Array.isArray(message?.extra?.files)) {
443 console.debug('Message has no files');
444 return;
445 }
446
447 if (fileIndex < 0 || fileIndex >= message.extra.files.length) {
448 console.warn('Invalid file index for message');
449 return;
450 }
451
452 const messageFile = message.extra.files[fileIndex];
453
454 if (!messageFile) {
455 console.debug('Message has no file or it is empty');
456 return;
457 }
458
459 await openFilePopup(messageFile);
460}
461
462/**
463 * Inserts a file embed into the message.
464 * @param {number} messageId
465 * @param {JQuery<HTMLElement>} messageBlock
466 * @returns {Promise<void>}
467 */
468function embedMessageFile(messageId, messageBlock) {
469 const message = chat[messageId];
470
471 if (!message) {
472 console.warn('Failed to find message with id', messageId);
473 return;
474 }
475
476 $('#embed_file_input')
477 .off('change')
478 .on('change', parseAndUploadEmbed)
479 .trigger('click');
480
481 async function parseAndUploadEmbed(/** @type {JQuery.ChangeEvent} */ e) {
482 if (!(e.target instanceof HTMLInputElement)) return;
483 if (!e.target.files.length) return;
484
485 for (const file of e.target.files) {
486 const isValid = await validateFile(file);
487
488 if (!isValid) {
489 toastr.warning(t`File ${file.name} is not supported.`);
490 $('#file_form').trigger('reset');
491 return;
492 }
493 }
494
495 await populateFileAttachment(message, 'embed_file_input');
496 await eventSource.emit(event_types.MESSAGE_FILE_EMBEDDED, messageId);
497 appendMediaToMessage(message, messageBlock, SCROLL_BEHAVIOR.KEEP);
498 await saveChatConditional();
499 }
500}
501
502/**
503 * Appends file content to the message text.
504 * @param {ChatMessage} message Message object
505 * @param {string} messageText Message text
506 * @returns {Promise<string>} Message text with file content appended.
507 */
508export async function appendFileContent(message, messageText) {
509 if (!message || !message.extra || typeof message.extra !== 'object') {
510 return messageText;
511 }
512 if (message.extra.fileLength >= 0) {
513 delete message.extra.fileLength;
514 }
515 if (Array.isArray(message.extra?.files) && message.extra.files.length > 0) {
516 const fileTexts = [];
517 for (const file of message.extra.files) {
518 const fileText = file.text || (await getFileAttachment(file.url));
519 if (fileText) {
520 fileTexts.push(fileText);
521 }
522 }
523 const mergedFileTexts = fileTexts.join('\n\n') + '\n\n';
524 message.extra.fileLength = mergedFileTexts.length;
525 return mergedFileTexts + messageText;
526 }
527 return messageText;
528}
529
530/**
531 * Replaces style tags in the message text with custom tags with encoded content.
532 * @param {string} text
533 * @returns {string} Encoded message text
534 * @copyright https://github.com/kwaroran/risuAI
535 */
536export function encodeStyleTags(text) {
537 const styleRegex = /<style>(.+?)<\/style>/gims;
538 return text.replaceAll(styleRegex, (_, match) => {
539 return `<custom-style>${encodeURIComponent(match)}</custom-style>`;
540 });
541}
542
543/**
544 * Sanitizes custom style tags in the message text to prevent DOM pollution.
545 * @param {string} text Message text
546 * @param {object} options Options object
547 * @param {string} options.prefix Prefix the selectors with this value
548 * @returns {string} Sanitized message text
549 * @copyright https://github.com/kwaroran/risuAI
550 */
551export function decodeStyleTags(text, { prefix } = { prefix: '.mes_text ' }) {
552 const styleDecodeRegex = /<custom-style>(.+?)<\/custom-style>/gms;
553 const mediaAllowed = isExternalMediaAllowed();
554
555 function sanitizeRule(rule) {
556 if (Array.isArray(rule.selectors)) {
557 for (let i = 0; i < rule.selectors.length; i++) {
558 const selector = rule.selectors[i];
559 if (selector) {
560 rule.selectors[i] = prefix + sanitizeSelector(selector);
561 }
562 }
563 }
564 if (!mediaAllowed && Array.isArray(rule.declarations) && rule.declarations.length > 0) {
565 rule.declarations = rule.declarations.filter(declaration => !declaration.value.includes('://'));
566 }
567 }
568
569 function sanitizeSelector(selector) {
570 // Handle pseudo-classes that can contain nested selectors
571 const pseudoClasses = ['has', 'not', 'where', 'is', 'matches', 'any'];
572 const pseudoRegex = new RegExp(`:(${pseudoClasses.join('|')})\\(([^)]+)\\)`, 'g');
573
574 // First, sanitize any nested selectors within pseudo-classes
575 selector = selector.replace(pseudoRegex, (match, pseudoClass, content) => {
576 // Recursively sanitize the content within the pseudo-class
577 const sanitizedContent = sanitizeSimpleSelector(content);
578 return `:${pseudoClass}(${sanitizedContent})`;
579 });
580
581 // Then sanitize the main selector parts
582 return sanitizeSimpleSelector(selector);
583 }
584
585 function sanitizeSimpleSelector(selector) {
586 // Split by spaces but preserve complex selectors
587 return selector.split(/\s+/).map((part) => {
588 // Handle class selectors, but preserve pseudo-classes and other complex parts
589 return part.replace(/\.([\w-]+)/g, (match, className) => {
590 // Don't modify if it's already prefixed with 'custom-'
591 if (className.startsWith('custom-')) {
592 return match;
593 }
594 return `.custom-${className}`;
595 });
596 }).join(' ');
597 }
598
599 function sanitizeRuleSet(ruleSet) {
600 if (Array.isArray(ruleSet.selectors) || Array.isArray(ruleSet.declarations)) {
601 sanitizeRule(ruleSet);
602 }
603
604 if (Array.isArray(ruleSet.rules)) {
605 ruleSet.rules = ruleSet.rules.filter(rule => rule.type !== 'import');
606
607 for (const mediaRule of ruleSet.rules) {
608 sanitizeRuleSet(mediaRule);
609 }
610 }
611 }
612
613 return text.replaceAll(styleDecodeRegex, (_, style) => {
614 try {
615 let styleCleaned = decodeURIComponent(style).replaceAll(/<br\/>/g, '');
616 const ast = css.parse(styleCleaned);
617 const sheet = ast?.stylesheet;
618 if (sheet) {
619 sanitizeRuleSet(ast.stylesheet);
620 }
621 return `<style>${css.stringify(ast)}</style>`;
622 } catch (error) {
623 return `CSS ERROR: ${error}`;
624 }
625 });
626}
627
628/**
629 * Class to manage style preferences for characters.
630 */
631class StylesPreference {
632 /**
633 * Creates a new StylesPreference instance.
634 * @param {string|null} avatarId - The avatar ID of the character
635 */
636 constructor(avatarId) {
637 this.avatarId = avatarId;
638 }
639
640 /**
641 * Gets the account storage key for the style preference.
642 */
643 get key() {
644 return `AllowGlobalStyles-${this.avatarId}`;
645 }
646
647 /**
648 * Checks if a preference exists for this character.
649 * @returns {boolean} True if preference exists, false otherwise
650 */
651 exists() {
652 return this.avatarId
653 ? accountStorage.getItem(this.key) !== null
654 : true; // No character == assume preference is set
655 }
656
657 /**
658 * Gets the current style preference.
659 * @returns {boolean} True if global styles are allowed, false otherwise
660 */
661 get() {
662 return this.avatarId
663 ? accountStorage.getItem(this.key) === 'true'
664 : false; // Always disabled when creating a new character
665 }
666
667 /**
668 * Sets the global styles preference.
669 * @param {boolean} allowed - Whether global styles are allowed
670 */
671 set(allowed) {
672 if (this.avatarId) {
673 accountStorage.setItem(this.key, String(allowed));
674 }
675 }
676}
677
678/**
679 * Formats creator notes in the message text.
680 * @param {string} text Raw Markdown text
681 * @param {string} avatarId Avatar ID
682 * @returns {string} Formatted HTML text
683 */
684export function formatCreatorNotes(text, avatarId) {
685 const preference = new StylesPreference(avatarId);
686 const sanitizeStyles = !preference.get();
687 const decodeStyleParam = { prefix: sanitizeStyles ? '#creator_notes_spoiler ' : '' };
688 /** @type {DOMPurify.Config} */
689 const config = {
690 RETURN_DOM: false,
691 RETURN_DOM_FRAGMENT: false,
692 RETURN_TRUSTED_TYPE: false,
693 MESSAGE_SANITIZE: true,
694 ADD_TAGS: ['custom-style'],
695 };
696
697 let html = converter.makeHtml(substituteParams(text));
698 html = encodeStyleTags(html);
699 html = DOMPurify.sanitize(html, config);
700 html = decodeStyleTags(html, decodeStyleParam);
701
702 return html;
703}
704
705async function openGlobalStylesPreferenceDialog() {
706 if (selected_group) {
707 toastr.info(t`To change the global styles preference, please select a character individually.`);
708 return;
709 }
710
711 const entityId = getCurrentEntityId();
712 const preference = new StylesPreference(entityId);
713 const currentValue = preference.get();
714
715 const template = $(await renderTemplateAsync('globalStylesPreference'));
716
717 const allowedRadio = template.find('#global_styles_allowed');
718 const forbiddenRadio = template.find('#global_styles_forbidden');
719
720 allowedRadio.on('change', () => {
721 preference.set(true);
722 allowedRadio.prop('checked', true);
723 forbiddenRadio.prop('checked', false);
724 });
725
726 forbiddenRadio.on('change', () => {
727 preference.set(false);
728 allowedRadio.prop('checked', false);
729 forbiddenRadio.prop('checked', true);
730 });
731
732 const currentPreferenceRadio = currentValue ? allowedRadio : forbiddenRadio;
733 template.find(currentPreferenceRadio).prop('checked', true);
734
735 await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: false, large: false });
736
737 // Re-render the notes if the preference changed
738 const newValue = preference.get();
739 if (newValue !== currentValue) {
740 $('#rm_button_selected_ch').trigger('click');
741 setGlobalStylesButtonClass(newValue);
742 }
743}
744
745async function checkForCreatorNotesStyles() {
746 // Don't do anything if in group chat or not in a chat
747 if (selected_group || this_chid === undefined) {
748 return;
749 }
750
751 const notes = characters[this_chid].data?.creator_notes || characters[this_chid].creatorcomment;
752 const avatarId = characters[this_chid].avatar;
753 const styleContents = getStyleContentsFromMarkdown(notes);
754
755 if (!styleContents) {
756 setGlobalStylesButtonClass(null);
757 return;
758 }
759
760 const preference = new StylesPreference(avatarId);
761 const hasPreference = preference.exists();
762 if (!hasPreference) {
763 const template = $(await renderTemplateAsync('globalStylesPopup'));
764 template.find('textarea').val(styleContents);
765 const confirmResult = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', {
766 wide: false,
767 large: false,
768 okButton: t`Just to Creator's Notes`,
769 cancelButton: t`Apply to the entire app`,
770 });
771
772 switch (confirmResult) {
773 case POPUP_RESULT.AFFIRMATIVE:
774 preference.set(false);
775 break;
776 case POPUP_RESULT.NEGATIVE:
777 preference.set(true);
778 break;
779 case POPUP_RESULT.CANCELLED:
780 preference.set(false);
781 break;
782 }
783
784 $('#rm_button_selected_ch').trigger('click');
785 }
786
787 const currentPreference = preference.get();
788 setGlobalStylesButtonClass(currentPreference);
789}
790
791/**
792 * Sets the class of the global styles button based on the state.
793 * @param {boolean|null} state State of the button
794 */
795function setGlobalStylesButtonClass(state) {
796 const button = $('#creators_note_styles_button');
797 button.toggleClass('empty', state === null);
798 button.toggleClass('allowed', state === true);
799 button.toggleClass('forbidden', state === false);
800}
801
802/**
803 * Extracts the contents of all style elements from the Markdown text.
804 * @param {string} text Markdown text
805 * @returns {string} The joined contents of all style elements
806 */
807function getStyleContentsFromMarkdown(text) {
808 if (!text) {
809 return '';
810 }
811
812 const html = converter.makeHtml(substituteParams(text));
813 const parsedDocument = new DOMParser().parseFromString(html, 'text/html');
814 const styleElements = Array.from(parsedDocument.querySelectorAll('style'));
815 return styleElements
816 .filter(s => s.textContent.trim().length > 0)
817 .map(s => s.textContent.trim())
818 .join('\n\n');
819}
820
821async function openExternalMediaOverridesDialog() {
822 const entityId = getCurrentEntityId();
823
824 if (!entityId) {
825 toastr.info(t`No character or group selected`);
826 return;
827 }
828
829 const template = $(await renderTemplateAsync('forbidMedia'));
830 template.find('.forbid_media_global_state_forbidden').toggle(power_user.forbid_external_media);
831 template.find('.forbid_media_global_state_allowed').toggle(!power_user.forbid_external_media);
832
833 if (power_user.external_media_allowed_overrides.includes(entityId)) {
834 template.find('#forbid_media_override_allowed').prop('checked', true);
835 } else if (power_user.external_media_forbidden_overrides.includes(entityId)) {
836 template.find('#forbid_media_override_forbidden').prop('checked', true);
837 } else {
838 template.find('#forbid_media_override_global').prop('checked', true);
839 }
840
841 callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: false, large: false });
842}
843
844export function getCurrentEntityId() {
845 if (selected_group) {
846 return String(selected_group);
847 }
848
849 return characters[this_chid]?.avatar ?? null;
850}
851
852export function isExternalMediaAllowed() {
853 const entityId = getCurrentEntityId();
854 if (!entityId) {
855 return !power_user.forbid_external_media;
856 }
857
858 if (power_user.external_media_allowed_overrides.includes(entityId)) {
859 return true;
860 }
861
862 if (power_user.external_media_forbidden_overrides.includes(entityId)) {
863 return false;
864 }
865
866 return !power_user.forbid_external_media;
867}
868
869/**
870 * Expands the message media attachment.
871 * @param {number} messageId Message ID
872 * @param {number} mediaIndex Media index
873 * @returns {HTMLElement} Enlarged media element
874 */
875function expandMessageMedia(messageId, mediaIndex) {
876 if (isNaN(messageId) || isNaN(mediaIndex)) {
877 console.warn('Invalid message ID or media index');
878 return;
879 }
880
881 /** @type {ChatMessage} */
882 const message = chat[messageId];
883
884 if (!Array.isArray(message?.extra?.media) || message.extra.media.length === 0) {
885 console.warn('Message has no media to expand');
886 return;
887 }
888
889 const mediaAttachment = message.extra.media[mediaIndex];
890 const title = mediaAttachment.title || message.extra.title || '';
891
892 if (!mediaAttachment) {
893 return;
894 }
895
896 if (mediaAttachment.type === MEDIA_TYPE.AUDIO) {
897 console.warn('Audio media cannot be expanded');
898 return;
899 }
900
901 /**
902 * Gets the media element based on its type.
903 * @returns {HTMLElement} Media element
904 */
905 function getMediaElement() {
906 function getImageElement() {
907 const img = document.createElement('img');
908 img.src = mediaAttachment.url;
909 img.classList.add('img_enlarged');
910 return img;
911 }
912
913 function getVideoElement() {
914 const video = document.createElement('video');
915 video.src = mediaAttachment.url;
916 video.classList.add('img_enlarged');
917 video.controls = true;
918 video.autoplay = true;
919 return video;
920 }
921
922 switch (mediaAttachment.type) {
923 case MEDIA_TYPE.IMAGE:
924 return getImageElement();
925 case MEDIA_TYPE.VIDEO:
926 return getVideoElement();
927 }
928
929 console.warn('Unsupported media type for enlargement:', mediaAttachment.type);
930 return getImageElement();
931 }
932
933 const mediaElement = getMediaElement();
934 const mediaHolder = document.createElement('div');
935 mediaHolder.classList.add('img_enlarged_holder');
936 mediaHolder.append(mediaElement);
937 const mediaContainer = document.createElement('div');
938 mediaContainer.classList.add('img_enlarged_container');
939 mediaContainer.append(mediaHolder);
940
941 mediaElement.addEventListener('click', event => {
942 const shouldZoom = !mediaElement.classList.contains('zoomed') && mediaElement.nodeName === 'IMG';
943 mediaElement.classList.toggle('zoomed', shouldZoom);
944 event.stopPropagation();
945 });
946
947 if (title.trim().length > 0) {
948 const mediaTitlePre = document.createElement('pre');
949 const mediaTitleCode = document.createElement('code');
950 mediaTitleCode.classList.add('img_enlarged_title', 'txt');
951 mediaTitleCode.textContent = title;
952 mediaTitlePre.append(mediaTitleCode);
953 mediaTitleCode.addEventListener('click', event => {
954 event.stopPropagation();
955 });
956 mediaContainer.append(mediaTitlePre);
957 addCopyToCodeBlocks(mediaContainer);
958 }
959
960 const popup = new Popup(mediaContainer, POPUP_TYPE.DISPLAY, '', { large: true, transparent: true });
961
962 popup.dlg.style.width = 'unset';
963 popup.dlg.style.height = 'unset';
964 popup.dlg.addEventListener('click', () => {
965 popup.completeCancelled();
966 });
967
968 popup.show();
969 return mediaElement;
970}
971
972/**
973 * Deletes an image from a message.
974 * @param {number} messageId Message ID
975 * @param {number} mediaIndex Image index
976 * @param {JQuery<HTMLElement>} messageBlock Message block element
977 */
978async function deleteMessageMedia(messageId, mediaIndex, messageBlock) {
979 if (isNaN(messageId) || isNaN(mediaIndex)) {
980 console.warn('Invalid message ID or media index');
981 return;
982 }
983
984 const deleteUrls = [];
985 const deleteFromServerId = 'delete_media_files_checkbox';
986 let deleteFromServer = true;
987
988 const value = await Popup.show.confirm(t`Delete media from message?`, t`This action can't be undone.`, {
989 okButton: t`Delete one`,
990 cancelButton: false,
991 customButtons: [
992 {
993 text: t`Delete all`,
994 appendAtEnd: true,
995 result: POPUP_RESULT.CUSTOM1,
996 },
997 {
998 text: t`Cancel`,
999 appendAtEnd: true,
1000 result: POPUP_RESULT.CANCELLED,
1001 },
1002 ],
1003 customInputs: [
1004 {
1005 type: 'checkbox',
1006 label: t`Also delete files from server`,
1007 id: deleteFromServerId,
1008 defaultState: true,
1009 },
1010 ],
1011 onClose: (popup) => {
1012 deleteFromServer = Boolean(popup.inputResults.get(deleteFromServerId) ?? false);
1013 },
1014 });
1015
1016 if (!value) {
1017 return;
1018 }
1019
1020 /** @type {ChatMessage} */
1021 const message = chat[messageId];
1022
1023 if (!Array.isArray(message?.extra?.media)) {
1024 console.debug('Message has no media');
1025 return;
1026 }
1027
1028 if (mediaIndex < 0 || mediaIndex >= message.extra.media.length) {
1029 console.warn('Invalid media index for message');
1030 return;
1031 }
1032
1033 deleteUrls.push(message.extra.media[mediaIndex].url);
1034 message.extra.media.splice(mediaIndex, 1);
1035
1036 if (message.extra.media_index === mediaIndex) {
1037 const newIndex = mediaIndex > 0 ? mediaIndex - 1 : 0;
1038 message.extra.media_index = clamp(newIndex, 0, message.extra.media.length - 1);
1039 }
1040
1041 if (value === POPUP_RESULT.CUSTOM1) {
1042 for (const media of message.extra.media) {
1043 deleteUrls.push(media.url);
1044 }
1045 delete message.extra.media;
1046 delete message.extra.inline_image;
1047 delete message.extra.title;
1048 delete message.extra.append_title;
1049 }
1050
1051 if (deleteFromServer) {
1052 for (const url of deleteUrls) {
1053 if (!url) continue;
1054 await deleteMediaFromServer(url, true);
1055 }
1056 }
1057
1058 await saveChatConditional();
1059 appendMediaToMessage(message, messageBlock, SCROLL_BEHAVIOR.KEEP);
1060}
1061
1062/**
1063 * Switches the media display mode for a message.
1064 * @param {number} messageId Message ID
1065 * @param {JQuery<HTMLElement>} messageBlock Message block element
1066 * @param {MEDIA_DISPLAY} targetDisplay Target display mode
1067 */
1068async function switchMessageMediaDisplay(messageId, messageBlock, targetDisplay) {
1069 if (isNaN(messageId)) {
1070 console.warn('Invalid message ID');
1071 return;
1072 }
1073
1074 /** @type {ChatMessage} */
1075 const message = chat[messageId];
1076
1077 if (!message) {
1078 console.warn('Message not found for ID', messageId);
1079 return;
1080 }
1081
1082 if (!message.extra || typeof message.extra !== 'object') {
1083 message.extra = {};
1084 }
1085
1086 message.extra.media_display = targetDisplay;
1087 await saveChatConditional();
1088 appendMediaToMessage(message, messageBlock, SCROLL_BEHAVIOR.KEEP);
1089}
1090
1091/**
1092 * Deletes media file from the server.
1093 * @param {string} url Path to the media file on the server
1094 * @param {boolean} [silent=false] If true, do not show error messages
1095 * @returns {Promise<boolean>} True if media file was deleted, false otherwise.
1096 */
1097export async function deleteMediaFromServer(url, silent = false) {
1098 try {
1099 const result = await fetch('/api/images/delete', {
1100 method: 'POST',
1101 headers: getRequestHeaders(),
1102 body: JSON.stringify({ path: url }),
1103 });
1104
1105 if (!result.ok) {
1106 if (!silent) {
1107 const error = await result.text();
1108 throw new Error(error);
1109 }
1110 return false;
1111 }
1112
1113 await eventSource.emit(event_types.MEDIA_ATTACHMENT_DELETED, url);
1114 return true;
1115 } catch (error) {
1116 toastr.error(String(error), t`Could not delete image`);
1117 console.error('Could not delete image', error);
1118 return false;
1119 }
1120}
1121
1122/**
1123 * Deletes file from the server.
1124 * @param {string} url Path to the file on the server
1125 * @param {boolean} [silent=false] If true, do not show error messages
1126 * @returns {Promise<boolean>} True if file was deleted, false otherwise.
1127 */
1128export async function deleteFileFromServer(url, silent = false) {
1129 try {
1130 const result = await fetch('/api/files/delete', {
1131 method: 'POST',
1132 headers: getRequestHeaders(),
1133 body: JSON.stringify({ path: url }),
1134 });
1135
1136 if (!result.ok) {
1137 if (!silent) {
1138 const error = await result.text();
1139 throw new Error(error);
1140 }
1141 return false;
1142 }
1143
1144 await eventSource.emit(event_types.FILE_ATTACHMENT_DELETED, url);
1145 return true;
1146 } catch (error) {
1147 toastr.error(String(error), t`Could not delete file`);
1148 console.error('Could not delete file', error);
1149 return false;
1150 }
1151}
1152
1153/**
1154 * Opens file attachment in a modal.
1155 * @param {FileAttachment} attachment File attachment
1156 */
1157async function openFilePopup(attachment) {
1158 const fileText = attachment.text || (await getFileAttachment(attachment.url));
1159
1160 const modalTemplate = $('<div><pre><code></code></pre></div>');
1161 modalTemplate.find('code').addClass('txt').text(fileText);
1162 modalTemplate.addClass('file_modal').addClass('textarea_compact').addClass('fontsize90p');
1163 addCopyToCodeBlocks(modalTemplate);
1164
1165 callGenericPopup(modalTemplate, POPUP_TYPE.TEXT, '', { wide: true, large: true });
1166}
1167
1168/**
1169 * Edit a file attachment in a notepad-like modal.
1170 * @param {FileAttachment} attachment Attachment to edit
1171 * @param {string} source Attachment source
1172 * @param {function} callback Callback function
1173 */
1174async function editAttachment(attachment, source, callback) {
1175 const originalFileText = attachment.text || (await getFileAttachment(attachment.url));
1176 const template = $(await renderExtensionTemplateAsync('attachments', 'notepad'));
1177
1178 let editedFileText = originalFileText;
1179 template.find('[name="notepadFileContent"]').val(editedFileText).on('input', function () {
1180 editedFileText = String($(this).val());
1181 });
1182
1183 let editedFileName = attachment.name;
1184 template.find('[name="notepadFileName"]').val(editedFileName).on('input', function () {
1185 editedFileName = String($(this).val());
1186 });
1187
1188 const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { wide: true, large: true, okButton: 'Save', cancelButton: 'Cancel' });
1189
1190 if (result !== POPUP_RESULT.AFFIRMATIVE) {
1191 return;
1192 }
1193
1194 if (editedFileText === originalFileText && editedFileName === attachment.name) {
1195 return;
1196 }
1197
1198 const nullCallback = () => { };
1199 await deleteAttachment(attachment, source, nullCallback, false);
1200 const file = new File([editedFileText], editedFileName, { type: 'text/plain' });
1201 await uploadFileAttachmentToServer(file, source);
1202
1203 callback();
1204}
1205
1206/**
1207 * Downloads an attachment to the user's device.
1208 * @param {FileAttachment} attachment Attachment to download
1209 */
1210async function downloadAttachment(attachment) {
1211 const fileText = attachment.text || (await getFileAttachment(attachment.url));
1212 const blob = new Blob([fileText], { type: 'text/plain' });
1213 const url = URL.createObjectURL(blob);
1214 const a = document.createElement('a');
1215 a.href = url;
1216 a.download = attachment.name;
1217 a.click();
1218 URL.revokeObjectURL(url);
1219}
1220
1221/**
1222 * Removes an attachment from the disabled list.
1223 * @param {FileAttachment} attachment Attachment to enable
1224 * @param {function} callback Success callback
1225 */
1226function enableAttachment(attachment, callback) {
1227 ensureAttachmentsExist();
1228 extension_settings.disabled_attachments = extension_settings.disabled_attachments.filter(url => url !== attachment.url);
1229 saveSettingsDebounced();
1230 callback();
1231}
1232
1233/**
1234 * Adds an attachment to the disabled list.
1235 * @param {FileAttachment} attachment Attachment to disable
1236 * @param {function} callback Success callback
1237 */
1238function disableAttachment(attachment, callback) {
1239 ensureAttachmentsExist();
1240 extension_settings.disabled_attachments.push(attachment.url);
1241 saveSettingsDebounced();
1242 callback();
1243}
1244
1245/**
1246 * Moves a file attachment to a different source.
1247 * @param {FileAttachment} attachment Attachment to moves
1248 * @param {string} source Source of the attachment
1249 * @param {function} callback Success callback
1250 * @returns {Promise<void>} A promise that resolves when the attachment is moved.
1251 */
1252async function moveAttachment(attachment, source, callback) {
1253 let selectedTarget = source;
1254 const targets = getAvailableTargets();
1255 const template = $(await renderExtensionTemplateAsync('attachments', 'move-attachment', { name: attachment.name, targets }));
1256 template.find('.moveAttachmentTarget').val(source).on('input', function () {
1257 selectedTarget = String($(this).val());
1258 });
1259
1260 const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { wide: false, large: false, okButton: 'Move', cancelButton: 'Cancel' });
1261
1262 if (result !== POPUP_RESULT.AFFIRMATIVE) {
1263 console.debug('Move attachment cancelled');
1264 return;
1265 }
1266
1267 if (selectedTarget === source) {
1268 console.debug('Move attachment cancelled: same source and target');
1269 return;
1270 }
1271
1272 const content = await getFileAttachment(attachment.url);
1273 const file = new File([content], attachment.name, { type: 'text/plain' });
1274 await deleteAttachment(attachment, source, () => { }, false);
1275 await uploadFileAttachmentToServer(file, selectedTarget);
1276 callback();
1277}
1278
1279/**
1280 * Deletes an attachment from the server and the chat.
1281 * @param {FileAttachment} attachment Attachment to delete
1282 * @param {string} source Source of the attachment
1283 * @param {function} callback Callback function
1284 * @param {boolean} [confirm=true] If true, show a confirmation dialog
1285 * @returns {Promise<void>} A promise that resolves when the attachment is deleted.
1286 */
1287export async function deleteAttachment(attachment, source, callback, confirm = true) {
1288 if (confirm) {
1289 const result = await callGenericPopup('Are you sure you want to delete this attachment?', POPUP_TYPE.CONFIRM);
1290
1291 if (result !== POPUP_RESULT.AFFIRMATIVE) {
1292 return;
1293 }
1294 }
1295
1296 ensureAttachmentsExist();
1297
1298 switch (source) {
1299 case 'global':
1300 extension_settings.attachments = extension_settings.attachments.filter((a) => a.url !== attachment.url);
1301 saveSettingsDebounced();
1302 break;
1303 case 'chat':
1304 chat_metadata.attachments = chat_metadata.attachments.filter((a) => a.url !== attachment.url);
1305 saveMetadataDebounced();
1306 break;
1307 case 'character':
1308 extension_settings.character_attachments[characters[this_chid]?.avatar] = extension_settings.character_attachments[characters[this_chid]?.avatar].filter((a) => a.url !== attachment.url);
1309 break;
1310 }
1311
1312 if (Array.isArray(extension_settings.disabled_attachments) && extension_settings.disabled_attachments.includes(attachment.url)) {
1313 extension_settings.disabled_attachments = extension_settings.disabled_attachments.filter(url => url !== attachment.url);
1314 saveSettingsDebounced();
1315 }
1316
1317 const silent = confirm === false;
1318 await deleteFileFromServer(attachment.url, silent);
1319 callback();
1320}
1321
1322/**
1323 * Determines if the attachment is disabled.
1324 * @param {FileAttachment} attachment Attachment to check
1325 * @returns {boolean} True if attachment is disabled, false otherwise.
1326 */
1327function isAttachmentDisabled(attachment) {
1328 return extension_settings.disabled_attachments.some(url => url === attachment?.url);
1329}
1330
1331/**
1332 * Opens the attachment manager.
1333 */
1334async function openAttachmentManager() {
1335 /**
1336 * Renders a list of attachments.
1337 * @param {FileAttachment[]} attachments List of attachments
1338 * @param {string} source Source of the attachments
1339 */
1340 async function renderList(attachments, source) {
1341 /**
1342 * Sorts attachments by sortField and sortOrder.
1343 * @param {FileAttachment} a First attachment
1344 * @param {FileAttachment} b Second attachment
1345 * @returns {number} Sort order
1346 */
1347 function sortFn(a, b) {
1348 const sortValueA = a[sortField];
1349 const sortValueB = b[sortField];
1350 if (typeof sortValueA === 'string' && typeof sortValueB === 'string') {
1351 return sortValueA.localeCompare(sortValueB) * (sortOrder === 'asc' ? 1 : -1);
1352 }
1353 return (sortValueA - sortValueB) * (sortOrder === 'asc' ? 1 : -1);
1354 }
1355
1356 /**
1357 * Filters attachments by name.
1358 * @param {FileAttachment} a Attachment
1359 * @returns {boolean} True if attachment matches the filter, false otherwise.
1360 */
1361 function filterFn(a) {
1362 if (!filterString) {
1363 return true;
1364 }
1365
1366 return a.name.toLowerCase().includes(filterString.toLowerCase());
1367 }
1368 const sources = {
1369 [ATTACHMENT_SOURCE.GLOBAL]: '.globalAttachmentsList',
1370 [ATTACHMENT_SOURCE.CHARACTER]: '.characterAttachmentsList',
1371 [ATTACHMENT_SOURCE.CHAT]: '.chatAttachmentsList',
1372 };
1373
1374 const selected = template
1375 .find(sources[source])
1376 .find('.attachmentListItemCheckbox:checked')
1377 .map((_, el) => $(el).closest('.attachmentListItem').attr('data-attachment-url'))
1378 .get();
1379
1380 template.find(sources[source]).empty();
1381
1382 // Sort attachments by sortField and sortOrder, and apply filter
1383 const sortedAttachmentList = attachments.slice().filter(filterFn).sort(sortFn);
1384
1385 for (const attachment of sortedAttachmentList) {
1386 const isDisabled = isAttachmentDisabled(attachment);
1387 const attachmentTemplate = template.find('.attachmentListItemTemplate .attachmentListItem').clone();
1388 attachmentTemplate.toggleClass('disabled', isDisabled);
1389 attachmentTemplate.attr('data-attachment-url', attachment.url);
1390 attachmentTemplate.attr('data-attachment-source', source);
1391 attachmentTemplate.find('.attachmentFileIcon').attr('title', attachment.url);
1392 attachmentTemplate.find('.attachmentListItemName').text(attachment.name);
1393 attachmentTemplate.find('.attachmentListItemSize').text(humanFileSize(attachment.size));
1394 attachmentTemplate.find('.attachmentListItemCreated').text(new Date(attachment.created).toLocaleString());
1395 attachmentTemplate.find('.viewAttachmentButton').on('click', () => openFilePopup(attachment));
1396 attachmentTemplate.find('.editAttachmentButton').on('click', () => editAttachment(attachment, source, renderAttachments));
1397 attachmentTemplate.find('.deleteAttachmentButton').on('click', () => deleteAttachment(attachment, source, renderAttachments));
1398 attachmentTemplate.find('.downloadAttachmentButton').on('click', () => downloadAttachment(attachment));
1399 attachmentTemplate.find('.moveAttachmentButton').on('click', () => moveAttachment(attachment, source, renderAttachments));
1400 attachmentTemplate.find('.enableAttachmentButton').toggle(isDisabled).on('click', () => enableAttachment(attachment, renderAttachments));
1401 attachmentTemplate.find('.disableAttachmentButton').toggle(!isDisabled).on('click', () => disableAttachment(attachment, renderAttachments));
1402 template.find(sources[source]).append(attachmentTemplate);
1403
1404 if (selected.includes(attachment.url)) {
1405 attachmentTemplate.find('.attachmentListItemCheckbox').prop('checked', true);
1406 }
1407 }
1408 }
1409
1410 /**
1411 * Renders buttons for the attachment manager.
1412 */
1413 async function renderButtons() {
1414 const sources = {
1415 [ATTACHMENT_SOURCE.GLOBAL]: '.globalAttachmentsTitle',
1416 [ATTACHMENT_SOURCE.CHARACTER]: '.characterAttachmentsTitle',
1417 [ATTACHMENT_SOURCE.CHAT]: '.chatAttachmentsTitle',
1418 };
1419
1420 const modal = template.find('.actionButtonsModal').hide();
1421 const scrapers = ScraperManager.getDataBankScrapers();
1422
1423 for (const scraper of scrapers) {
1424 const isAvailable = await ScraperManager.isScraperAvailable(scraper.id);
1425 if (!isAvailable) {
1426 continue;
1427 }
1428
1429 const buttonTemplate = template.find('.actionButtonTemplate .actionButton').clone();
1430 if (scraper.iconAvailable) {
1431 buttonTemplate.find('.actionButtonIcon').addClass(scraper.iconClass);
1432 buttonTemplate.find('.actionButtonImg').remove();
1433 } else {
1434 buttonTemplate.find('.actionButtonImg').attr('src', scraper.iconClass);
1435 buttonTemplate.find('.actionButtonIcon').remove();
1436 }
1437 buttonTemplate.find('.actionButtonText').text(scraper.name);
1438 buttonTemplate.attr('title', scraper.description);
1439 buttonTemplate.on('click', () => {
1440 const target = modal.attr('data-attachment-manager-target');
1441 runScraper(scraper.id, target, renderAttachments);
1442 });
1443 modal.append(buttonTemplate);
1444 }
1445
1446 const modalButtonData = Object.entries(sources).map(entry => {
1447 const [source, selector] = entry;
1448 const button = template.find(selector).find('.openActionModalButton').get(0);
1449
1450 if (!button) {
1451 return;
1452 }
1453
1454 const bodyListener = (e) => {
1455 if (modal.is(':visible') && (!$(e.target).closest('.openActionModalButton').length)) {
1456 modal.hide();
1457 }
1458
1459 // Replay a click if the modal was already open by another button
1460 if ($(e.target).closest('.openActionModalButton').length && !modal.is(':visible')) {
1461 modal.show();
1462 }
1463 };
1464 document.body.addEventListener('click', bodyListener);
1465
1466 const popper = Popper.createPopper(button, modal.get(0), { placement: 'bottom-end' });
1467 button.addEventListener('click', () => {
1468 modal.attr('data-attachment-manager-target', source);
1469 modal.toggle();
1470 popper.update();
1471 });
1472
1473 return { popper, bodyListener };
1474 }).filter(Boolean);
1475
1476 return () => {
1477 modalButtonData.forEach(p => {
1478 const { popper, bodyListener } = p;
1479 popper.destroy();
1480 document.body.removeEventListener('click', bodyListener);
1481 });
1482 modal.remove();
1483 };
1484 }
1485
1486 async function renderAttachments() {
1487 /** @type {FileAttachment[]} */
1488 const globalAttachments = extension_settings.attachments ?? [];
1489 /** @type {FileAttachment[]} */
1490 const chatAttachments = chat_metadata.attachments ?? [];
1491 /** @type {FileAttachment[]} */
1492 const characterAttachments = extension_settings.character_attachments?.[characters[this_chid]?.avatar] ?? [];
1493
1494 await renderList(globalAttachments, ATTACHMENT_SOURCE.GLOBAL);
1495 await renderList(chatAttachments, ATTACHMENT_SOURCE.CHAT);
1496 await renderList(characterAttachments, ATTACHMENT_SOURCE.CHARACTER);
1497
1498 const isNotCharacter = this_chid === undefined || selected_group;
1499 const isNotInChat = getCurrentChatId() === undefined;
1500 template.find('.characterAttachmentsBlock').toggle(!isNotCharacter);
1501 template.find('.chatAttachmentsBlock').toggle(!isNotInChat);
1502
1503 const characterName = characters[this_chid]?.name || 'Anonymous';
1504 template.find('.characterAttachmentsName').text(characterName);
1505
1506 const chatName = getCurrentChatId() || 'Unnamed chat';
1507 template.find('.chatAttachmentsName').text(chatName);
1508 }
1509
1510 const dragDropHandler = new DragAndDropHandler('.popup', async (files, event) => {
1511 let selectedTarget = ATTACHMENT_SOURCE.GLOBAL;
1512 const targets = getAvailableTargets();
1513
1514 const targetSelectTemplate = $(await renderExtensionTemplateAsync('attachments', 'files-dropped', { count: files.length, targets: targets }));
1515 targetSelectTemplate.find('.droppedFilesTarget').on('input', function () {
1516 selectedTarget = String($(this).val());
1517 });
1518 const result = await callGenericPopup(targetSelectTemplate, POPUP_TYPE.CONFIRM, '', { wide: false, large: false, okButton: 'Upload', cancelButton: 'Cancel' });
1519 if (result !== POPUP_RESULT.AFFIRMATIVE) {
1520 console.log('File upload cancelled');
1521 return;
1522 }
1523 for (const file of files) {
1524 await uploadFileAttachmentToServer(file, selectedTarget);
1525 }
1526 renderAttachments();
1527 });
1528
1529 let sortField = accountStorage.getItem('DataBank_sortField') || 'created';
1530 let sortOrder = accountStorage.getItem('DataBank_sortOrder') || 'desc';
1531 let filterString = '';
1532
1533 const template = $(await renderExtensionTemplateAsync('attachments', 'manager', {}));
1534
1535 template.find('.attachmentSearch').on('input', function () {
1536 filterString = String($(this).val());
1537 renderAttachments();
1538 });
1539 template.find('.attachmentSort').on('change', function () {
1540 if (!(this instanceof HTMLSelectElement) || this.selectedOptions.length === 0) {
1541 return;
1542 }
1543
1544 sortField = this.selectedOptions[0].dataset.sortField;
1545 sortOrder = this.selectedOptions[0].dataset.sortOrder;
1546 accountStorage.setItem('DataBank_sortField', sortField);
1547 accountStorage.setItem('DataBank_sortOrder', sortOrder);
1548 renderAttachments();
1549 });
1550 function handleBulkAction(action) {
1551 return async () => {
1552 const selectedAttachments = document.querySelectorAll('.attachmentListItemCheckboxContainer .attachmentListItemCheckbox:checked');
1553
1554 if (selectedAttachments.length === 0) {
1555 toastr.info(t`No attachments selected.`, t`Data Bank`);
1556 return;
1557 }
1558
1559 if (action.confirmMessage) {
1560 const confirm = await callGenericPopup(action.confirmMessage, POPUP_TYPE.CONFIRM);
1561 if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
1562 return;
1563 }
1564 }
1565
1566 const includeDisabled = true;
1567 const attachments = getDataBankAttachments(includeDisabled);
1568 selectedAttachments.forEach(async (checkbox) => {
1569 const listItem = checkbox.closest('.attachmentListItem');
1570 if (!(listItem instanceof HTMLElement)) {
1571 return;
1572 }
1573 const url = listItem.dataset.attachmentUrl;
1574 const source = listItem.dataset.attachmentSource;
1575 const attachment = attachments.find(a => a.url === url);
1576 if (!attachment) {
1577 return;
1578 }
1579 await action.perform(attachment, source);
1580 });
1581
1582 document.querySelectorAll('.attachmentListItemCheckbox, .attachmentsBulkEditCheckbox').forEach(checkbox => {
1583 if (checkbox instanceof HTMLInputElement) {
1584 checkbox.checked = false;
1585 }
1586 });
1587
1588 await renderAttachments();
1589 };
1590 }
1591
1592 template.find('.bulkActionDisable').on('click', handleBulkAction({
1593 perform: (attachment) => disableAttachment(attachment, () => { }),
1594 }));
1595
1596 template.find('.bulkActionEnable').on('click', handleBulkAction({
1597 perform: (attachment) => enableAttachment(attachment, () => { }),
1598 }));
1599
1600 template.find('.bulkActionDelete').on('click', handleBulkAction({
1601 confirmMessage: 'Are you sure you want to delete the selected attachments?',
1602 perform: async (attachment, source) => await deleteAttachment(attachment, source, () => { }, false),
1603 }));
1604
1605 template.find('.bulkActionSelectAll').on('click', () => {
1606 $('.attachmentListItemCheckbox:visible').each((_, checkbox) => {
1607 if (checkbox instanceof HTMLInputElement) {
1608 checkbox.checked = true;
1609 }
1610 });
1611 });
1612 template.find('.bulkActionSelectNone').on('click', () => {
1613 $('.attachmentListItemCheckbox:visible').each((_, checkbox) => {
1614 if (checkbox instanceof HTMLInputElement) {
1615 checkbox.checked = false;
1616 }
1617 });
1618 });
1619
1620 const cleanupFn = await renderButtons();
1621 await verifyAttachments();
1622 await renderAttachments();
1623 await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, okButton: 'Close', allowVerticalScrolling: true });
1624
1625 cleanupFn();
1626 dragDropHandler.destroy();
1627}
1628
1629/**
1630 * Gets a list of available targets for attachments.
1631 * @returns {string[]} List of available targets
1632 */
1633function getAvailableTargets() {
1634 const targets = Object.values(ATTACHMENT_SOURCE);
1635
1636 const isNotCharacter = this_chid === undefined || selected_group;
1637 const isNotInChat = getCurrentChatId() === undefined;
1638
1639 if (isNotCharacter) {
1640 targets.splice(targets.indexOf(ATTACHMENT_SOURCE.CHARACTER), 1);
1641 }
1642
1643 if (isNotInChat) {
1644 targets.splice(targets.indexOf(ATTACHMENT_SOURCE.CHAT), 1);
1645 }
1646
1647 return targets;
1648}
1649
1650/**
1651 * Runs a known scraper on a source and saves the result as an attachment.
1652 * @param {string} scraperId Id of the scraper
1653 * @param {string} target Target for the attachment
1654 * @param {function} callback Callback function
1655 * @returns {Promise<void>} A promise that resolves when the source is scraped.
1656 */
1657async function runScraper(scraperId, target, callback) {
1658 try {
1659 console.log(`Running scraper ${scraperId} for ${target}`);
1660 const files = await ScraperManager.runDataBankScraper(scraperId);
1661
1662 if (!Array.isArray(files)) {
1663 console.warn('Scraping returned nothing');
1664 return;
1665 }
1666
1667 if (files.length === 0) {
1668 console.warn('Scraping returned no files');
1669 toastr.info(t`No files were scraped.`, t`Data Bank`);
1670 return;
1671 }
1672
1673 for (const file of files) {
1674 await uploadFileAttachmentToServer(file, target);
1675 }
1676
1677 toastr.success(t`Scraped ${files.length} files from ${scraperId} to ${target}.`, t`Data Bank`);
1678 callback();
1679 } catch (error) {
1680 console.error('Scraping failed', error);
1681 toastr.error(t`Check browser console for details.`, t`Scraping failed`);
1682 }
1683}
1684
1685/**
1686 * Uploads a file attachment to the server.
1687 * @param {File} file File to upload
1688 * @param {string} target Target for the attachment
1689 * @returns {Promise<string>} Path to the uploaded file
1690 */
1691export async function uploadFileAttachmentToServer(file, target) {
1692 const isValid = await validateFile(file);
1693
1694 if (!isValid) {
1695 return;
1696 }
1697
1698 let base64Data = await getBase64Async(file);
1699 const slug = getStringHash(file.name);
1700 const uniqueFileName = `${Date.now()}_${slug}.txt`;
1701
1702 if (isConvertible(file.type)) {
1703 try {
1704 const converter = getConverter(file.type);
1705 const fileText = await converter(file);
1706 base64Data = convertTextToBase64(fileText);
1707 } catch (error) {
1708 toastr.error(String(error), t`Could not convert file`);
1709 console.error('Could not convert file', error);
1710 }
1711 } else {
1712 const fileText = await file.text();
1713 base64Data = convertTextToBase64(fileText);
1714 }
1715
1716 const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data);
1717 const convertedSize = Math.round(base64Data.length * 0.75);
1718
1719 if (!fileUrl) {
1720 return;
1721 }
1722
1723 const attachment = {
1724 url: fileUrl,
1725 size: convertedSize,
1726 name: file.name,
1727 created: Date.now(),
1728 };
1729
1730 ensureAttachmentsExist();
1731
1732 switch (target) {
1733 case ATTACHMENT_SOURCE.GLOBAL:
1734 extension_settings.attachments.push(attachment);
1735 saveSettingsDebounced();
1736 break;
1737 case ATTACHMENT_SOURCE.CHAT:
1738 chat_metadata.attachments.push(attachment);
1739 saveMetadataDebounced();
1740 break;
1741 case ATTACHMENT_SOURCE.CHARACTER:
1742 extension_settings.character_attachments[characters[this_chid]?.avatar].push(attachment);
1743 saveSettingsDebounced();
1744 break;
1745 }
1746
1747 return fileUrl;
1748}
1749
1750function ensureAttachmentsExist() {
1751 if (!Array.isArray(extension_settings.disabled_attachments)) {
1752 extension_settings.disabled_attachments = [];
1753 }
1754
1755 if (!Array.isArray(extension_settings.attachments)) {
1756 extension_settings.attachments = [];
1757 }
1758
1759 if (!Array.isArray(chat_metadata.attachments)) {
1760 chat_metadata.attachments = [];
1761 }
1762
1763 if (this_chid !== undefined && characters[this_chid]) {
1764 if (!extension_settings.character_attachments) {
1765 extension_settings.character_attachments = {};
1766 }
1767
1768 if (!Array.isArray(extension_settings.character_attachments[characters[this_chid].avatar])) {
1769 extension_settings.character_attachments[characters[this_chid].avatar] = [];
1770 }
1771 }
1772}
1773
1774/**
1775 * Gets all currently available attachments. Ignores disabled attachments by default.
1776 * @param {boolean} [includeDisabled=false] If true, include disabled attachments
1777 * @returns {FileAttachment[]} List of attachments
1778 */
1779export function getDataBankAttachments(includeDisabled = false) {
1780 ensureAttachmentsExist();
1781 const globalAttachments = extension_settings.attachments ?? [];
1782 const chatAttachments = chat_metadata.attachments ?? [];
1783 const characterAttachments = extension_settings.character_attachments?.[characters[this_chid]?.avatar] ?? [];
1784
1785 return [...globalAttachments, ...chatAttachments, ...characterAttachments].filter(x => includeDisabled || !isAttachmentDisabled(x));
1786}
1787
1788/**
1789 * Gets all attachments for a specific source. Includes disabled attachments by default.
1790 * @param {string} source Attachment source
1791 * @param {boolean} [includeDisabled=true] If true, include disabled attachments
1792 * @returns {FileAttachment[]} List of attachments
1793 */
1794export function getDataBankAttachmentsForSource(source, includeDisabled = true) {
1795 ensureAttachmentsExist();
1796
1797 function getBySource() {
1798 switch (source) {
1799 case ATTACHMENT_SOURCE.GLOBAL:
1800 return extension_settings.attachments ?? [];
1801 case ATTACHMENT_SOURCE.CHAT:
1802 return chat_metadata.attachments ?? [];
1803 case ATTACHMENT_SOURCE.CHARACTER:
1804 return extension_settings.character_attachments?.[characters[this_chid]?.avatar] ?? [];
1805 }
1806
1807 return [];
1808 }
1809
1810 return getBySource().filter(x => includeDisabled || !isAttachmentDisabled(x));
1811}
1812
1813/**
1814 * Verifies all attachments in the Data Bank.
1815 * @returns {Promise<void>} A promise that resolves when attachments are verified.
1816 */
1817async function verifyAttachments() {
1818 for (const source of Object.values(ATTACHMENT_SOURCE)) {
1819 await verifyAttachmentsForSource(source);
1820 }
1821}
1822
1823/**
1824 * Verifies all attachments for a specific source.
1825 * @param {string} source Attachment source
1826 * @returns {Promise<void>} A promise that resolves when attachments are verified.
1827 */
1828async function verifyAttachmentsForSource(source) {
1829 try {
1830 const attachments = getDataBankAttachmentsForSource(source);
1831 const urls = attachments.map(a => a.url);
1832 const response = await fetch('/api/files/verify', {
1833 method: 'POST',
1834 headers: getRequestHeaders(),
1835 body: JSON.stringify({ urls }),
1836 });
1837
1838 if (!response.ok) {
1839 const error = await response.text();
1840 throw new Error(error);
1841 }
1842
1843 const verifiedUrls = await response.json();
1844 for (const attachment of attachments) {
1845 if (verifiedUrls[attachment.url] === false) {
1846 console.log('Deleting orphaned attachment', attachment);
1847 await deleteAttachment(attachment, source, () => { }, false);
1848 }
1849 }
1850 } catch (error) {
1851 console.error('Attachment verification failed', error);
1852 }
1853}
1854
1855const NEUTRAL_CHAT_KEY = 'neutralChat';
1856
1857export function preserveNeutralChat() {
1858 if (this_chid !== undefined || selected_group || name2 !== neutralCharacterName) {
1859 return;
1860 }
1861
1862 sessionStorage.setItem(NEUTRAL_CHAT_KEY, JSON.stringify({ chat, chat_metadata }));
1863}
1864
1865export function restoreNeutralChat() {
1866 if (this_chid !== undefined || selected_group || name2 !== neutralCharacterName) {
1867 return;
1868 }
1869
1870 const neutralChat = sessionStorage.getItem(NEUTRAL_CHAT_KEY);
1871 if (!neutralChat) {
1872 return;
1873 }
1874
1875 const { chat: neutralChatData, chat_metadata: neutralChatMetadata } = JSON.parse(neutralChat);
1876 chat.splice(0, chat.length, ...neutralChatData);
1877 updateChatMetadata(neutralChatMetadata, true);
1878 sessionStorage.removeItem(NEUTRAL_CHAT_KEY);
1879}
1880
1881/**
1882 * Registers a file converter function.
1883 * @param {string} mimeType MIME type
1884 * @param {ConverterFunction} converter Function to convert file
1885 * @returns {void}
1886 */
1887export function registerFileConverter(mimeType, converter) {
1888 if (typeof mimeType !== 'string' || typeof converter !== 'function') {
1889 console.error('Invalid converter registration');
1890 return;
1891 }
1892
1893 if (Object.keys(converters).includes(mimeType)) {
1894 console.error('Converter already registered');
1895 return;
1896 }
1897
1898 converters[mimeType] = converter;
1899}
1900
1901export function addDOMPurifyHooks() {
1902 // Allow target="_blank" in links
1903 DOMPurify.addHook('afterSanitizeAttributes', function (node) {
1904 if ('target' in node) {
1905 node.setAttribute('target', '_blank');
1906 node.setAttribute('rel', 'noopener');
1907 }
1908 });
1909
1910 DOMPurify.addHook('uponSanitizeAttribute', (node, data, config) => {
1911 if (!config.MESSAGE_SANITIZE) {
1912 return;
1913 }
1914
1915 /* Retain the classes on UI elements of messages that interact with the main UI */
1916 const permittedNodeTypes = ['BUTTON', 'DIV'];
1917 if (config.MESSAGE_ALLOW_SYSTEM_UI && node.classList.contains('menu_button') && permittedNodeTypes.includes(node.nodeName)) {
1918 return;
1919 }
1920
1921 switch (data.attrName) {
1922 case 'class': {
1923 if (data.attrValue) {
1924 data.attrValue = data.attrValue.split(' ').map((v) => {
1925 if (v.startsWith('fa-') || v.startsWith('note-') || v === 'monospace') {
1926 return v;
1927 }
1928
1929 return 'custom-' + v;
1930 }).join(' ');
1931 }
1932 break;
1933 }
1934 }
1935 });
1936
1937 DOMPurify.addHook('uponSanitizeElement', (node, _, config) => {
1938 if (!config.MESSAGE_SANITIZE) {
1939 return;
1940 }
1941
1942 // Replace line breaks with <br> in unknown elements
1943 if (node instanceof HTMLUnknownElement) {
1944 node.innerHTML = node.innerHTML.trim();
1945
1946 /** @type {Text[]} */
1947 const candidates = [];
1948 const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
1949 while (walker.nextNode()) {
1950 const textNode = /** @type {Text} */ (walker.currentNode);
1951 if (!textNode.data.includes('\n')) continue;
1952
1953 // Skip if this text node is within a <pre> (any ancestor)
1954 if (textNode.parentElement && textNode.parentElement.closest('pre')) continue;
1955
1956 candidates.push(textNode);
1957 }
1958
1959 for (const textNode of candidates) {
1960 const parts = textNode.data.split('\n');
1961 const frag = document.createDocumentFragment();
1962 parts.forEach((part, idx) => {
1963 if (part.length) {
1964 frag.appendChild(document.createTextNode(part));
1965 }
1966 if (idx < parts.length - 1) {
1967 frag.appendChild(document.createElement('br'));
1968 }
1969 });
1970 textNode.replaceWith(frag);
1971 }
1972 }
1973
1974 const isMediaAllowed = isExternalMediaAllowed();
1975 if (isMediaAllowed) {
1976 return;
1977 }
1978
1979 if (!(node instanceof Element)) {
1980 return;
1981 }
1982
1983 let mediaBlocked = false;
1984
1985 switch (node.tagName) {
1986 case 'AUDIO':
1987 case 'VIDEO':
1988 case 'SOURCE':
1989 case 'TRACK':
1990 case 'EMBED':
1991 case 'OBJECT':
1992 case 'IMG': {
1993 const isExternalUrl = (url) => (url.indexOf('://') > 0 || url.indexOf('//') === 0) && !url.startsWith(window.location.origin);
1994 const src = node.getAttribute('src');
1995 const data = node.getAttribute('data');
1996 const srcset = node.getAttribute('srcset');
1997
1998 if (srcset) {
1999 const srcsetUrls = srcset.split(',');
2000
2001 for (const srcsetUrl of srcsetUrls) {
2002 const [url] = srcsetUrl.trim().split(' ');
2003
2004 if (isExternalUrl(url)) {
2005 console.warn('External media blocked', url);
2006 node.remove();
2007 mediaBlocked = true;
2008 break;
2009 }
2010 }
2011 }
2012
2013 if (src && isExternalUrl(src)) {
2014 console.warn('External media blocked', src);
2015 mediaBlocked = true;
2016 node.remove();
2017 }
2018
2019 if (data && isExternalUrl(data)) {
2020 console.warn('External media blocked', data);
2021 mediaBlocked = true;
2022 node.remove();
2023 }
2024
2025 if (mediaBlocked && (node instanceof HTMLMediaElement)) {
2026 node.autoplay = false;
2027 node.pause();
2028 }
2029 }
2030 break;
2031 }
2032
2033 if (mediaBlocked) {
2034 const entityId = getCurrentEntityId();
2035 const warningShownKey = `mediaWarningShown:${entityId}`;
2036
2037 if (accountStorage.getItem(warningShownKey) === null) {
2038 const warningToast = toastr.warning(
2039 t`Use the 'Ext. Media' button to allow it. Click on this message to dismiss.`,
2040 t`External media has been blocked`,
2041 {
2042 timeOut: 0,
2043 preventDuplicates: true,
2044 onclick: () => toastr.clear(warningToast),
2045 },
2046 );
2047
2048 accountStorage.setItem(warningShownKey, 'true');
2049 }
2050 }
2051 });
2052}
2053
2054/**
2055 * Switches an image to the next or previous one in the swipe list.
2056 * @param {number} messageId Message ID
2057 * @param {JQuery<HTMLElement>} element Message element
2058 * @param {string} direction Swipe direction
2059 * @returns {Promise<void>}
2060 */
2061async function onImageSwiped(messageId, element, direction) {
2062 const animationClass = 'fa-fade';
2063 const messageMedia = element.find('.mes_img, .mes_video');
2064
2065 // Current image is already animating
2066 if (messageMedia.hasClass(animationClass)) {
2067 return;
2068 }
2069
2070 const message = chat[messageId];
2071 const media = message?.extra?.media;
2072
2073 if (!message || !Array.isArray(media) || media.length === 0) {
2074 console.warn('No media found in the message');
2075 return;
2076 }
2077
2078 const currentIndex = getMediaIndex(message);
2079 const mediaDisplay = getMediaDisplay(message);
2080
2081 if (mediaDisplay !== MEDIA_DISPLAY.GALLERY) {
2082 console.warn('Image swiping is only supported for gallery media display');
2083 return;
2084 }
2085
2086 await eventSource.emit(event_types.IMAGE_SWIPED, { message, element, direction });
2087
2088 if (media.length === 1) {
2089 console.warn('Only one media item in the message, swiping is not applicable');
2090 return;
2091 }
2092
2093 // Switch to previous image or wrap around if at the beginning
2094 if (direction === SWIPE_DIRECTION.LEFT) {
2095 const newIndex = currentIndex === 0 ? media.length - 1 : currentIndex - 1;
2096 message.extra.media_index = newIndex;
2097 }
2098
2099 // Switch to next image or generate a new one if at the end
2100 if (direction === SWIPE_DIRECTION.RIGHT) {
2101 const newIndex = currentIndex === media.length - 1 ? 0 : currentIndex + 1;
2102 message.extra.media_index = newIndex >= media.length ? 0 : newIndex;
2103 }
2104
2105 await saveChatConditional();
2106 appendMediaToMessage(message, element);
2107}
2108
2109export function initChatUtilities() {
2110 $(document).on('click', '.mes_hide', async function () {
2111 const messageBlock = $(this).closest('.mes');
2112 const messageId = Number(messageBlock.attr('mesid'));
2113 await hideChatMessageRange(messageId, messageId, false);
2114 });
2115
2116 $(document).on('click', '.mes_unhide', async function () {
2117 const messageBlock = $(this).closest('.mes');
2118 const messageId = Number(messageBlock.attr('mesid'));
2119 await hideChatMessageRange(messageId, messageId, true);
2120 });
2121
2122 $(document).on('click', '.mes_file_delete', async function () {
2123 const messageBlock = $(this).closest('.mes');
2124 const messageId = Number(messageBlock.attr('mesid'));
2125 const fileBlock = $(this).closest('.mes_file_container');
2126 const fileIndex = Number(fileBlock.attr('data-index'));
2127 await deleteMessageFile(messageBlock, messageId, fileIndex);
2128 });
2129
2130 $(document).on('click', '.mes_file_open', async function () {
2131 const messageBlock = $(this).closest('.mes');
2132 const messageId = Number(messageBlock.attr('mesid'));
2133 const fileBlock = $(this).closest('.mes_file_container');
2134 const fileIndex = Number(fileBlock.attr('data-index'));
2135 await viewMessageFile(messageId, fileIndex);
2136 });
2137
2138 $(document).on('click', '.assistant_note_export', async function (_e) {
2139 /** @type {ChatHeader} */
2140 const chatHeader = {
2141 chat_metadata: chat_metadata,
2142 user_name: 'unused',
2143 character_name: 'unused',
2144 };
2145 const chatToSave = [
2146 chatHeader,
2147 ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE),
2148 ];
2149
2150 download(chatToSave.map((m) => JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonl`, 'application/json');
2151 });
2152
2153 $(document).on('click', '.assistant_note_import', async function () {
2154 const importFile = async () => {
2155 const file = fileInput.files[0];
2156 if (!file) {
2157 return;
2158 }
2159
2160 try {
2161 const text = await getFileText(file);
2162 const lines = text.split('\n').filter(line => line.trim() !== '');
2163 const messages = lines.map(line => JSON.parse(line));
2164 const metadata = messages.shift()?.chat_metadata || {};
2165 messages.unshift(getSystemMessageByType(system_message_types.ASSISTANT_NOTE));
2166 await clearChat();
2167 chat.splice(0, chat.length, ...messages);
2168 updateChatMetadata(metadata, true);
2169 await printMessages();
2170 } catch (error) {
2171 console.error('Error importing assistant chat:', error);
2172 toastr.error(t`It's either corrupted or not a valid JSONL file.`, t`Failed to import chat`);
2173 }
2174 };
2175 const fileInput = document.createElement('input');
2176 fileInput.type = 'file';
2177 fileInput.accept = '.jsonl';
2178 fileInput.addEventListener('change', importFile);
2179 fileInput.click();
2180 });
2181
2182 const fileInput = document.getElementById('file_form_input');
2183
2184 // Do not change. #attachFile is added by extension.
2185 $(document).on('click', '#attachFile', function () {
2186 if (!(fileInput instanceof HTMLInputElement)) return;
2187 const $fileInput = $(fileInput);
2188
2189 // Preserve existing files in DataTransfer
2190 const dataTransfer = new DataTransfer();
2191 for (const file of fileInput.files) {
2192 dataTransfer.items.add(file);
2193 }
2194
2195 $fileInput.off('change').on('change', async () => {
2196 for (const file of fileInput.files) {
2197 if (!Array.from(dataTransfer.files).some(f => isSameFile(f, file))) {
2198 dataTransfer.items.add(file);
2199 }
2200 }
2201
2202 fileInput.files = dataTransfer.files;
2203 await onFileAttach(fileInput.files);
2204 });
2205
2206 $fileInput.trigger('click');
2207 });
2208
2209 // Do not change. #manageAttachments is added by extension.
2210 $(document).on('click', '#manageAttachments', function () {
2211 openAttachmentManager();
2212 });
2213
2214 $(document).on('click', '.mes_embed', function () {
2215 const messageBlock = $(this).closest('.mes');
2216 const messageId = Number(messageBlock.attr('mesid'));
2217 embedMessageFile(messageId, messageBlock);
2218 });
2219
2220 $(document).on('click', '.editor_maximize', async function (e) {
2221 e.preventDefault();
2222 e.stopPropagation();
2223
2224 const broId = $(this).attr('data-for');
2225 const bro = $(`#${broId}`);
2226 const contentEditable = bro.is('[contenteditable]');
2227 const withTab = $(this).attr('data-tab');
2228
2229 if (!bro.length) {
2230 console.error('Could not find editor with id', broId);
2231 return;
2232 }
2233
2234 const wrapper = document.createElement('div');
2235 wrapper.classList.add('height100p', 'wide100p', 'flex-container');
2236 wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter');
2237 const textarea = document.createElement('textarea');
2238 textarea.dataset.for = broId;
2239 if (bro[0].dataset.macros !== undefined) {
2240 textarea.dataset.macros = bro[0].dataset.macros;
2241 textarea.dataset.macrosAutocomplete = 'always'; // Always show autocomplete in expanded editor
2242 textarea.dataset.macrosAutocompleteStyle = 'expanded'; // Use expanded autocomplete style
2243 }
2244 textarea.value = String(contentEditable ? bro[0].innerText : bro.val());
2245 textarea.classList.add('height100p', 'wide100p', 'maximized_textarea');
2246 bro.hasClass('monospace') && textarea.classList.add('monospace');
2247 bro.hasClass('mdHotkeys') && textarea.classList.add('mdHotkeys');
2248 textarea.addEventListener('input', function () {
2249 if (contentEditable) {
2250 bro[0].innerText = textarea.value;
2251 bro.trigger('input');
2252 } else {
2253 bro.val(textarea.value).trigger('input');
2254 }
2255 });
2256 wrapper.appendChild(textarea);
2257
2258 if (withTab) {
2259 textarea.addEventListener('keydown', (evt) => {
2260 if (evt.key == 'Tab' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
2261 evt.preventDefault();
2262 const start = textarea.selectionStart;
2263 const end = textarea.selectionEnd;
2264 if (end - start > 0 && textarea.value.substring(start, end).includes('\n')) {
2265 const lineStart = textarea.value.lastIndexOf('\n', start);
2266 const count = textarea.value.substring(lineStart, end).split('\n').length - 1;
2267 textarea.value = `${textarea.value.substring(0, lineStart)}${textarea.value.substring(lineStart, end).replace(/\n/g, '\n\t')}${textarea.value.substring(end)}`;
2268 textarea.selectionStart = start + 1;
2269 textarea.selectionEnd = end + count;
2270 } else {
2271 textarea.value = `${textarea.value.substring(0, start)}\t${textarea.value.substring(end)}`;
2272 textarea.selectionStart = start + 1;
2273 textarea.selectionEnd = end + 1;
2274 }
2275 } else if (evt.key == 'Tab' && evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
2276 evt.preventDefault();
2277 const start = textarea.selectionStart;
2278 const end = textarea.selectionEnd;
2279 const lineStart = textarea.value.lastIndexOf('\n', start);
2280 const count = textarea.value.substring(lineStart, end).split('\n\t').length - 1;
2281 textarea.value = `${textarea.value.substring(0, lineStart)}${textarea.value.substring(lineStart, end).replace(/\n\t/g, '\n')}${textarea.value.substring(end)}`;
2282 textarea.selectionStart = start - 1;
2283 textarea.selectionEnd = end - count;
2284 }
2285 });
2286 }
2287
2288 await callGenericPopup(wrapper, POPUP_TYPE.TEXT, '', { wide: true, large: true });
2289 });
2290
2291 $(document).on('click', 'body .mes .mes_text, body .mes .mes_reasoning', function (event) {
2292 if (!power_user.click_to_edit) return;
2293 if (window.getSelection().toString()) return;
2294 if ($('.edit_textarea').length) return;
2295 $(this).closest('.mes').find('.mes_edit').trigger('click');
2296 if ($(event.target).closest('.mes_reasoning').length) {
2297 $('.reasoning_edit_textarea').trigger('focus');
2298 }
2299 });
2300
2301 $(document).on('click', '.open_media_overrides', openExternalMediaOverridesDialog);
2302 $(document).on('input', '#forbid_media_override_allowed', function () {
2303 const entityId = getCurrentEntityId();
2304 if (!entityId) return;
2305 power_user.external_media_allowed_overrides.push(entityId);
2306 power_user.external_media_forbidden_overrides = power_user.external_media_forbidden_overrides.filter((v) => v !== entityId);
2307 saveSettingsDebounced();
2308 reloadCurrentChat();
2309 });
2310 $(document).on('input', '#forbid_media_override_forbidden', function () {
2311 const entityId = getCurrentEntityId();
2312 if (!entityId) return;
2313 power_user.external_media_forbidden_overrides.push(entityId);
2314 power_user.external_media_allowed_overrides = power_user.external_media_allowed_overrides.filter((v) => v !== entityId);
2315 saveSettingsDebounced();
2316 reloadCurrentChat();
2317 });
2318 $(document).on('input', '#forbid_media_override_global', function () {
2319 const entityId = getCurrentEntityId();
2320 if (!entityId) return;
2321 power_user.external_media_allowed_overrides = power_user.external_media_allowed_overrides.filter((v) => v !== entityId);
2322 power_user.external_media_forbidden_overrides = power_user.external_media_forbidden_overrides.filter((v) => v !== entityId);
2323 saveSettingsDebounced();
2324 reloadCurrentChat();
2325 });
2326
2327 $('#creators_note_styles_button').on('click', function (e) {
2328 e.stopPropagation();
2329 openGlobalStylesPreferenceDialog();
2330 });
2331
2332 /**
2333 * Returns information about the closest media container.
2334 * @returns {MediaContainerInfo} Information about the media container
2335 * @typedef {object} MediaContainerInfo
2336 * @property {JQuery<HTMLElement>} messageBlock The closest message block
2337 * @property {number} messageId The message ID
2338 * @property {JQuery<HTMLElement>} mediaBlock The closest media container block
2339 * @property {number} mediaIndex The media index within the message
2340 */
2341 function getMediaContainerInfo(containerClass = '.mes_media_container') {
2342 const messageBlock = $(this).closest('.mes');
2343 const messageId = Number(messageBlock.attr('mesid'));
2344 const mediaBlock = $(this).closest(containerClass);
2345 const mediaIndex = Number(mediaBlock.attr('data-index'));
2346 return { messageBlock, messageId, mediaBlock, mediaIndex };
2347 }
2348 chatElement.on('click', '.mes_img', async function () {
2349 const { messageId, mediaIndex } = getMediaContainerInfo.call(this);
2350 expandMessageMedia(messageId, mediaIndex);
2351 });
2352 chatElement.on('click', '.mes_media_enlarge', async function () {
2353 const { messageId, mediaIndex } = getMediaContainerInfo.call(this);
2354 expandMessageMedia(messageId, mediaIndex).click();
2355 });
2356 chatElement.on('click', '.mes_media_delete', async function () {
2357 const { messageId, mediaIndex, messageBlock } = getMediaContainerInfo.call(this);
2358 await deleteMessageMedia(messageId, mediaIndex, messageBlock);
2359 });
2360 chatElement.on('click', '.mes_media_list', async function () {
2361 const { messageId, messageBlock } = getMediaContainerInfo.call(this);
2362 await switchMessageMediaDisplay(messageId, messageBlock, MEDIA_DISPLAY.GALLERY);
2363 });
2364 chatElement.on('click', '.mes_media_gallery', async function () {
2365 const { messageId, messageBlock } = getMediaContainerInfo.call(this);
2366 await switchMessageMediaDisplay(messageId, messageBlock, MEDIA_DISPLAY.LIST);
2367 });
2368 chatElement.on('click', '.mes_img_swipe_left', async function () {
2369 const { messageId, messageBlock } = getMediaContainerInfo.call(this);
2370 await onImageSwiped(messageId, messageBlock, SWIPE_DIRECTION.LEFT);
2371 });
2372 chatElement.on('click', '.mes_img_swipe_right', async function () {
2373 const { messageId, messageBlock } = getMediaContainerInfo.call(this);
2374 await onImageSwiped(messageId, messageBlock, SWIPE_DIRECTION.RIGHT);
2375 });
2376
2377 $('#file_form').on('reset', function () {
2378 $('#file_form').addClass('displayNone');
2379 });
2380
2381 document.getElementById('send_textarea').addEventListener('paste', async function (event) {
2382 if (event.clipboardData.files.length === 0) {
2383 return;
2384 }
2385
2386 event.preventDefault();
2387 event.stopPropagation();
2388
2389 await handleFileAttach(Array.from(event.clipboardData.files));
2390 });
2391
2392 new DragAndDropHandler('#form_sheld', async (files) => {
2393 await handleFileAttach(files);
2394 });
2395
2396 /**
2397 * Common handler for file attachments.
2398 * @param {File[]} files Files to attach
2399 * @returns {Promise<void>}
2400 */
2401 async function handleFileAttach(files) {
2402 if (!(fileInput instanceof HTMLInputElement)) return;
2403
2404 // Workaround for Firefox: Use a DataTransfer object to indirectly set fileInput.files
2405 const dataTransfer = new DataTransfer();
2406 for (const file of fileInput.files) {
2407 dataTransfer.items.add(file);
2408 }
2409
2410 // Preserve existing non-duplicate files in the input
2411 for (const file of files) {
2412 if (!Array.from(dataTransfer.files).some(f => isSameFile(f, file))) {
2413 dataTransfer.items.add(file);
2414 }
2415 }
2416
2417 fileInput.files = dataTransfer.files;
2418 await onFileAttach(fileInput.files);
2419 }
2420
2421 eventSource.on(event_types.CHAT_CHANGED, checkForCreatorNotesStyles);
2422}