|
|
|
|
|
|
|
|
import { Popper, css, DOMPurify } from '../lib.js'; |
|
|
import { |
|
|
addCopyToCodeBlocks, |
|
|
appendMediaToMessage, |
|
|
characters, |
|
|
chat, |
|
|
eventSource, |
|
|
event_types, |
|
|
getCurrentChatId, |
|
|
getRequestHeaders, |
|
|
name1, |
|
|
name2, |
|
|
reloadCurrentChat, |
|
|
saveSettingsDebounced, |
|
|
this_chid, |
|
|
saveChatConditional, |
|
|
chat_metadata, |
|
|
neutralCharacterName, |
|
|
updateChatMetadata, |
|
|
system_message_types, |
|
|
converter, |
|
|
substituteParams, |
|
|
getSystemMessageByType, |
|
|
printMessages, |
|
|
clearChat, |
|
|
refreshSwipeButtons, |
|
|
getMediaIndex, |
|
|
getMediaDisplay, |
|
|
chatElement, |
|
|
} from '../script.js'; |
|
|
import { selected_group } from './group-chats.js'; |
|
|
import { power_user } from './power-user.js'; |
|
|
import { |
|
|
extractTextFromHTML, |
|
|
extractTextFromMarkdown, |
|
|
extractTextFromPDF, |
|
|
extractTextFromEpub, |
|
|
getBase64Async, |
|
|
getStringHash, |
|
|
humanFileSize, |
|
|
saveBase64AsFile, |
|
|
extractTextFromOffice, |
|
|
download, |
|
|
getFileText, |
|
|
getFileExtension, |
|
|
convertTextToBase64, |
|
|
isSameFile, |
|
|
clamp, |
|
|
} from './utils.js'; |
|
|
import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js'; |
|
|
import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
|
|
import { ScraperManager } from './scrapers.js'; |
|
|
import { DragAndDropHandler } from './dragdrop.js'; |
|
|
import { renderTemplateAsync } from './templates.js'; |
|
|
import { t } from './i18n.js'; |
|
|
import { humanizedDateTime } from './RossAscends-mods.js'; |
|
|
import { accountStorage } from './util/AccountStorage.js'; |
|
|
import { MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR, SWIPE_DIRECTION } from './constants.js'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const fileSizeLimit = 1024 * 1024 * 350; |
|
|
const ATTACHMENT_SOURCE = { |
|
|
GLOBAL: 'global', |
|
|
CHARACTER: 'character', |
|
|
CHAT: 'chat', |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const converters = { |
|
|
'application/pdf': extractTextFromPDF, |
|
|
'text/html': extractTextFromHTML, |
|
|
'text/markdown': extractTextFromMarkdown, |
|
|
'application/epub+zip': extractTextFromEpub, |
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': extractTextFromOffice, |
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': extractTextFromOffice, |
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': extractTextFromOffice, |
|
|
'application/vnd.oasis.opendocument.text': extractTextFromOffice, |
|
|
'application/vnd.oasis.opendocument.presentation': extractTextFromOffice, |
|
|
'application/vnd.oasis.opendocument.spreadsheet': extractTextFromOffice, |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function findConverterKey(type) { |
|
|
return Object.keys(converters).find((key) => { |
|
|
|
|
|
if (type === key) { |
|
|
return true; |
|
|
} |
|
|
|
|
|
|
|
|
if (key.endsWith('*')) { |
|
|
return type.startsWith(key.substring(0, key.length - 1)); |
|
|
} |
|
|
|
|
|
return false; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function isConvertible(type) { |
|
|
return Boolean(findConverterKey(type)); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getConverter(type) { |
|
|
const key = findConverterKey(type); |
|
|
return key && converters[key]; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function hideChatMessageRange(start, end, unhide, nameFitler = null) { |
|
|
if (isNaN(start)) return; |
|
|
if (!end) end = start; |
|
|
const hide = !unhide; |
|
|
|
|
|
for (let messageId = start; messageId <= end; messageId++) { |
|
|
const message = chat[messageId]; |
|
|
if (!message) continue; |
|
|
if (nameFitler && message.name !== nameFitler) continue; |
|
|
|
|
|
message.is_system = hide; |
|
|
|
|
|
|
|
|
const messageBlock = $(`.mes[mesid="${messageId}"]`); |
|
|
if (!messageBlock.length) continue; |
|
|
messageBlock.attr('is_system', String(hide)); |
|
|
} |
|
|
|
|
|
|
|
|
refreshSwipeButtons(); |
|
|
|
|
|
await saveChatConditional(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function hideChatMessage(messageId, _messageBlock) { |
|
|
return hideChatMessageRange(messageId, messageId, false); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function unhideChatMessage(messageId, _messageBlock) { |
|
|
return hideChatMessageRange(messageId, messageId, true); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function populateFileAttachment(message, inputId = 'file_form_input') { |
|
|
try { |
|
|
if (!message) return; |
|
|
if (!message.extra || typeof message.extra !== 'object') message.extra = {}; |
|
|
const fileInput = document.getElementById(inputId); |
|
|
if (!(fileInput instanceof HTMLInputElement)) return; |
|
|
|
|
|
for (const file of fileInput.files) { |
|
|
const slug = getStringHash(file.name); |
|
|
const fileNamePrefix = `${Date.now()}_${slug}`; |
|
|
const fileBase64 = await getBase64Async(file); |
|
|
let base64Data = fileBase64.split(',')[1]; |
|
|
const extension = getFileExtension(file); |
|
|
|
|
|
const mediaType = MEDIA_TYPE.getFromMime(file.type); |
|
|
if (mediaType) { |
|
|
const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension); |
|
|
if (!Array.isArray(message.extra.media)) { |
|
|
message.extra.media = []; |
|
|
} |
|
|
|
|
|
const mediaAttachment = { |
|
|
url: imageUrl, |
|
|
type: mediaType, |
|
|
title: file.name, |
|
|
source: MEDIA_SOURCE.UPLOAD, |
|
|
}; |
|
|
message.extra.media.push(mediaAttachment); |
|
|
message.extra.media_index = message.extra.media.length - 1; |
|
|
message.extra.inline_image = true; |
|
|
} else { |
|
|
const uniqueFileName = `${fileNamePrefix}.txt`; |
|
|
|
|
|
if (isConvertible(file.type)) { |
|
|
try { |
|
|
const converter = getConverter(file.type); |
|
|
const fileText = await converter(file); |
|
|
base64Data = convertTextToBase64(fileText); |
|
|
} catch (error) { |
|
|
toastr.error(String(error), t`Could not convert file`); |
|
|
console.error('Could not convert file', error); |
|
|
} |
|
|
} |
|
|
|
|
|
const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data); |
|
|
|
|
|
if (!fileUrl) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
if (!Array.isArray(message.extra.files)) { |
|
|
message.extra.files = []; |
|
|
} |
|
|
|
|
|
message.extra.files.push({ |
|
|
url: fileUrl, |
|
|
size: file.size, |
|
|
name: file.name, |
|
|
created: Date.now(), |
|
|
}); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Could not upload file', error); |
|
|
toastr.error(t`Either the file is corrupted or its format is not supported.`, t`Could not upload the file`); |
|
|
} finally { |
|
|
$('#file_form').trigger('reset'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function uploadFileAttachment(fileName, base64Data) { |
|
|
try { |
|
|
const result = await fetch('/api/files/upload', { |
|
|
method: 'POST', |
|
|
headers: getRequestHeaders(), |
|
|
body: JSON.stringify({ |
|
|
name: fileName, |
|
|
data: base64Data, |
|
|
}), |
|
|
}); |
|
|
|
|
|
if (!result.ok) { |
|
|
const error = await result.text(); |
|
|
throw new Error(error); |
|
|
} |
|
|
|
|
|
const responseData = await result.json(); |
|
|
return responseData.path; |
|
|
} catch (error) { |
|
|
toastr.error(String(error), t`Could not upload file`); |
|
|
console.error('Could not upload file', error); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function getFileAttachment(url) { |
|
|
try { |
|
|
const result = await fetch(url, { |
|
|
method: 'GET', |
|
|
cache: 'force-cache', |
|
|
headers: getRequestHeaders(), |
|
|
}); |
|
|
|
|
|
if (!result.ok) { |
|
|
const error = await result.text(); |
|
|
throw new Error(error); |
|
|
} |
|
|
|
|
|
const text = await result.text(); |
|
|
return text; |
|
|
} catch (error) { |
|
|
toastr.error(error, t`Could not download file`); |
|
|
console.error('Could not download file', error); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function validateFile(file) { |
|
|
const fileText = await file.text(); |
|
|
const isMedia = file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/'); |
|
|
const isBinary = /^[\x00-\x08\x0E-\x1F\x7F-\xFF]*$/.test(fileText); |
|
|
|
|
|
if (!isMedia && file.size > fileSizeLimit) { |
|
|
toastr.error(t`File is too big. Maximum size is ${humanFileSize(fileSizeLimit)}.`); |
|
|
return false; |
|
|
} |
|
|
|
|
|
|
|
|
if (isBinary && !isMedia && !isConvertible(file.type)) { |
|
|
toastr.error(t`Binary files are not supported. Select a text file or image.`); |
|
|
return false; |
|
|
} |
|
|
|
|
|
return true; |
|
|
} |
|
|
|
|
|
export function hasPendingFileAttachment() { |
|
|
const fileInput = document.getElementById('file_form_input'); |
|
|
if (!(fileInput instanceof HTMLInputElement)) return false; |
|
|
const file = fileInput.files[0]; |
|
|
return !!file; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function onFileAttach(fileList) { |
|
|
if (!fileList || fileList.length === 0) return; |
|
|
|
|
|
for (const file of fileList) { |
|
|
const isValid = await validateFile(file); |
|
|
|
|
|
|
|
|
if (!isValid) { |
|
|
toastr.warning(t`File ${file.name} is not supported.`); |
|
|
$('#file_form').trigger('reset'); |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
const name = fileList.length === 1 ? fileList[0].name : t`${fileList.length} files selected`; |
|
|
const size = [...fileList].reduce((acc, file) => acc + file.size, 0); |
|
|
const title = [...fileList].map(x => x.name).join('\n'); |
|
|
$('#file_form .file_name').text(name).attr('title', title); |
|
|
$('#file_form .file_size').text(humanFileSize(size)).attr('title', size); |
|
|
$('#file_form').removeClass('displayNone'); |
|
|
|
|
|
|
|
|
const currentChatId = getCurrentChatId(); |
|
|
if (currentChatId) { |
|
|
eventSource.once(event_types.CHAT_CHANGED, () => { |
|
|
$('#file_form').trigger('reset'); |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function deleteMessageFile(messageBlock, messageId, fileIndex) { |
|
|
if (isNaN(messageId) || isNaN(fileIndex)) { |
|
|
console.warn('Invalid message ID or file index'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const confirm = await callGenericPopup('Are you sure you want to delete this file?', POPUP_TYPE.CONFIRM); |
|
|
|
|
|
if (confirm !== POPUP_RESULT.AFFIRMATIVE) { |
|
|
console.debug('Delete file cancelled'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const message = chat[messageId]; |
|
|
|
|
|
if (!Array.isArray(message?.extra?.files)) { |
|
|
console.debug('Message has no files'); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (fileIndex < 0 || fileIndex >= message.extra.files.length) { |
|
|
console.warn('Invalid file index for message'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const url = message.extra.files[fileIndex]?.url; |
|
|
message.extra.files.splice(fileIndex, 1); |
|
|
|
|
|
await saveChatConditional(); |
|
|
await deleteFileFromServer(url); |
|
|
|
|
|
appendMediaToMessage(message, messageBlock, SCROLL_BEHAVIOR.KEEP); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function viewMessageFile(messageId, fileIndex) { |
|
|
if (isNaN(messageId) || isNaN(fileIndex)) { |
|
|
console.warn('Invalid message ID or file index'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const message = chat[messageId]; |
|
|
|
|
|
if (!Array.isArray(message?.extra?.files)) { |
|
|
console.debug('Message has no files'); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (fileIndex < 0 || fileIndex >= message.extra.files.length) { |
|
|
console.warn('Invalid file index for message'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const messageFile = message.extra.files[fileIndex]; |
|
|
|
|
|
if (!messageFile) { |
|
|
console.debug('Message has no file or it is empty'); |
|
|
return; |
|
|
} |
|
|
|
|
|
await openFilePopup(messageFile); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function embedMessageFile(messageId, messageBlock) { |
|
|
const message = chat[messageId]; |
|
|
|
|
|
if (!message) { |
|
|
console.warn('Failed to find message with id', messageId); |
|
|
return; |
|
|
} |
|
|
|
|
|
$('#embed_file_input') |
|
|
.off('change') |
|
|
.on('change', parseAndUploadEmbed) |
|
|
.trigger('click'); |
|
|
|
|
|
async function parseAndUploadEmbed( e) { |
|
|
if (!(e.target instanceof HTMLInputElement)) return; |
|
|
if (!e.target.files.length) return; |
|
|
|
|
|
for (const file of e.target.files) { |
|
|
const isValid = await validateFile(file); |
|
|
|
|
|
if (!isValid) { |
|
|
toastr.warning(t`File ${file.name} is not supported.`); |
|
|
$('#file_form').trigger('reset'); |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
await populateFileAttachment(message, 'embed_file_input'); |
|
|
await eventSource.emit(event_types.MESSAGE_FILE_EMBEDDED, messageId); |
|
|
appendMediaToMessage(message, messageBlock, SCROLL_BEHAVIOR.KEEP); |
|
|
await saveChatConditional(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function appendFileContent(message, messageText) { |
|
|
if (!message || !message.extra || typeof message.extra !== 'object') { |
|
|
return messageText; |
|
|
} |
|
|
if (message.extra.fileLength >= 0) { |
|
|
delete message.extra.fileLength; |
|
|
} |
|
|
if (Array.isArray(message.extra?.files) && message.extra.files.length > 0) { |
|
|
const fileTexts = []; |
|
|
for (const file of message.extra.files) { |
|
|
const fileText = file.text || (await getFileAttachment(file.url)); |
|
|
if (fileText) { |
|
|
fileTexts.push(fileText); |
|
|
} |
|
|
} |
|
|
const mergedFileTexts = fileTexts.join('\n\n') + '\n\n'; |
|
|
message.extra.fileLength = mergedFileTexts.length; |
|
|
return mergedFileTexts + messageText; |
|
|
} |
|
|
return messageText; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function encodeStyleTags(text) { |
|
|
const styleRegex = /<style>(.+?)<\/style>/gims; |
|
|
return text.replaceAll(styleRegex, (_, match) => { |
|
|
return `<custom-style>${encodeURIComponent(match)}</custom-style>`; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function decodeStyleTags(text, { prefix } = { prefix: '.mes_text ' }) { |
|
|
const styleDecodeRegex = /<custom-style>(.+?)<\/custom-style>/gms; |
|
|
const mediaAllowed = isExternalMediaAllowed(); |
|
|
|
|
|
function sanitizeRule(rule) { |
|
|
if (Array.isArray(rule.selectors)) { |
|
|
for (let i = 0; i < rule.selectors.length; i++) { |
|
|
const selector = rule.selectors[i]; |
|
|
if (selector) { |
|
|
rule.selectors[i] = prefix + sanitizeSelector(selector); |
|
|
} |
|
|
} |
|
|
} |
|
|
if (!mediaAllowed && Array.isArray(rule.declarations) && rule.declarations.length > 0) { |
|
|
rule.declarations = rule.declarations.filter(declaration => !declaration.value.includes('://')); |
|
|
} |
|
|
} |
|
|
|
|
|
function sanitizeSelector(selector) { |
|
|
|
|
|
const pseudoClasses = ['has', 'not', 'where', 'is', 'matches', 'any']; |
|
|
const pseudoRegex = new RegExp(`:(${pseudoClasses.join('|')})\\(([^)]+)\\)`, 'g'); |
|
|
|
|
|
|
|
|
selector = selector.replace(pseudoRegex, (match, pseudoClass, content) => { |
|
|
|
|
|
const sanitizedContent = sanitizeSimpleSelector(content); |
|
|
return `:${pseudoClass}(${sanitizedContent})`; |
|
|
}); |
|
|
|
|
|
|
|
|
return sanitizeSimpleSelector(selector); |
|
|
} |
|
|
|
|
|
function sanitizeSimpleSelector(selector) { |
|
|
|
|
|
return selector.split(/\s+/).map((part) => { |
|
|
|
|
|
return part.replace(/\.([\w-]+)/g, (match, className) => { |
|
|
|
|
|
if (className.startsWith('custom-')) { |
|
|
return match; |
|
|
} |
|
|
return `.custom-${className}`; |
|
|
}); |
|
|
}).join(' '); |
|
|
} |
|
|
|
|
|
function sanitizeRuleSet(ruleSet) { |
|
|
if (Array.isArray(ruleSet.selectors) || Array.isArray(ruleSet.declarations)) { |
|
|
sanitizeRule(ruleSet); |
|
|
} |
|
|
|
|
|
if (Array.isArray(ruleSet.rules)) { |
|
|
ruleSet.rules = ruleSet.rules.filter(rule => rule.type !== 'import'); |
|
|
|
|
|
for (const mediaRule of ruleSet.rules) { |
|
|
sanitizeRuleSet(mediaRule); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return text.replaceAll(styleDecodeRegex, (_, style) => { |
|
|
try { |
|
|
let styleCleaned = decodeURIComponent(style).replaceAll(/<br\/>/g, ''); |
|
|
const ast = css.parse(styleCleaned); |
|
|
const sheet = ast?.stylesheet; |
|
|
if (sheet) { |
|
|
sanitizeRuleSet(ast.stylesheet); |
|
|
} |
|
|
return `<style>${css.stringify(ast)}</style>`; |
|
|
} catch (error) { |
|
|
return `CSS ERROR: ${error}`; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class StylesPreference { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
constructor(avatarId) { |
|
|
this.avatarId = avatarId; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
get key() { |
|
|
return `AllowGlobalStyles-${this.avatarId}`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
exists() { |
|
|
return this.avatarId |
|
|
? accountStorage.getItem(this.key) !== null |
|
|
: true; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
get() { |
|
|
return this.avatarId |
|
|
? accountStorage.getItem(this.key) === 'true' |
|
|
: false; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
set(allowed) { |
|
|
if (this.avatarId) { |
|
|
accountStorage.setItem(this.key, String(allowed)); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function formatCreatorNotes(text, avatarId) { |
|
|
const preference = new StylesPreference(avatarId); |
|
|
const sanitizeStyles = !preference.get(); |
|
|
const decodeStyleParam = { prefix: sanitizeStyles ? '#creator_notes_spoiler ' : '' }; |
|
|
|
|
|
const config = { |
|
|
RETURN_DOM: false, |
|
|
RETURN_DOM_FRAGMENT: false, |
|
|
RETURN_TRUSTED_TYPE: false, |
|
|
MESSAGE_SANITIZE: true, |
|
|
ADD_TAGS: ['custom-style'], |
|
|
}; |
|
|
|
|
|
let html = converter.makeHtml(substituteParams(text)); |
|
|
html = encodeStyleTags(html); |
|
|
html = DOMPurify.sanitize(html, config); |
|
|
html = decodeStyleTags(html, decodeStyleParam); |
|
|
|
|
|
return html; |
|
|
} |
|
|
|
|
|
async function openGlobalStylesPreferenceDialog() { |
|
|
if (selected_group) { |
|
|
toastr.info(t`To change the global styles preference, please select a character individually.`); |
|
|
return; |
|
|
} |
|
|
|
|
|
const entityId = getCurrentEntityId(); |
|
|
const preference = new StylesPreference(entityId); |
|
|
const currentValue = preference.get(); |
|
|
|
|
|
const template = $(await renderTemplateAsync('globalStylesPreference')); |
|
|
|
|
|
const allowedRadio = template.find('#global_styles_allowed'); |
|
|
const forbiddenRadio = template.find('#global_styles_forbidden'); |
|
|
|
|
|
allowedRadio.on('change', () => { |
|
|
preference.set(true); |
|
|
allowedRadio.prop('checked', true); |
|
|
forbiddenRadio.prop('checked', false); |
|
|
}); |
|
|
|
|
|
forbiddenRadio.on('change', () => { |
|
|
preference.set(false); |
|
|
allowedRadio.prop('checked', false); |
|
|
forbiddenRadio.prop('checked', true); |
|
|
}); |
|
|
|
|
|
const currentPreferenceRadio = currentValue ? allowedRadio : forbiddenRadio; |
|
|
template.find(currentPreferenceRadio).prop('checked', true); |
|
|
|
|
|
await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: false, large: false }); |
|
|
|
|
|
|
|
|
const newValue = preference.get(); |
|
|
if (newValue !== currentValue) { |
|
|
$('#rm_button_selected_ch').trigger('click'); |
|
|
setGlobalStylesButtonClass(newValue); |
|
|
} |
|
|
} |
|
|
|
|
|
async function checkForCreatorNotesStyles() { |
|
|
|
|
|
if (selected_group || this_chid === undefined) { |
|
|
return; |
|
|
} |
|
|
|
|
|
const notes = characters[this_chid].data?.creator_notes || characters[this_chid].creatorcomment; |
|
|
const avatarId = characters[this_chid].avatar; |
|
|
const styleContents = getStyleContentsFromMarkdown(notes); |
|
|
|
|
|
if (!styleContents) { |
|
|
setGlobalStylesButtonClass(null); |
|
|
return; |
|
|
} |
|
|
|
|
|
const preference = new StylesPreference(avatarId); |
|
|
const hasPreference = preference.exists(); |
|
|
if (!hasPreference) { |
|
|
const template = $(await renderTemplateAsync('globalStylesPopup')); |
|
|
template.find('textarea').val(styleContents); |
|
|
const confirmResult = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { |
|
|
wide: false, |
|
|
large: false, |
|
|
okButton: t`Just to Creator's Notes`, |
|
|
cancelButton: t`Apply to the entire app`, |
|
|
}); |
|
|
|
|
|
switch (confirmResult) { |
|
|
case POPUP_RESULT.AFFIRMATIVE: |
|
|
preference.set(false); |
|
|
break; |
|
|
case POPUP_RESULT.NEGATIVE: |
|
|
preference.set(true); |
|
|
break; |
|
|
case POPUP_RESULT.CANCELLED: |
|
|
preference.set(false); |
|
|
break; |
|
|
} |
|
|
|
|
|
$('#rm_button_selected_ch').trigger('click'); |
|
|
} |
|
|
|
|
|
const currentPreference = preference.get(); |
|
|
setGlobalStylesButtonClass(currentPreference); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function setGlobalStylesButtonClass(state) { |
|
|
const button = $('#creators_note_styles_button'); |
|
|
button.toggleClass('empty', state === null); |
|
|
button.toggleClass('allowed', state === true); |
|
|
button.toggleClass('forbidden', state === false); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getStyleContentsFromMarkdown(text) { |
|
|
if (!text) { |
|
|
return ''; |
|
|
} |
|
|
|
|
|
const html = converter.makeHtml(substituteParams(text)); |
|
|
const parsedDocument = new DOMParser().parseFromString(html, 'text/html'); |
|
|
const styleElements = Array.from(parsedDocument.querySelectorAll('style')); |
|
|
return styleElements |
|
|
.filter(s => s.textContent.trim().length > 0) |
|
|
.map(s => s.textContent.trim()) |
|
|
.join('\n\n'); |
|
|
} |
|
|
|
|
|
async function openExternalMediaOverridesDialog() { |
|
|
const entityId = getCurrentEntityId(); |
|
|
|
|
|
if (!entityId) { |
|
|
toastr.info(t`No character or group selected`); |
|
|
return; |
|
|
} |
|
|
|
|
|
const template = $(await renderTemplateAsync('forbidMedia')); |
|
|
template.find('.forbid_media_global_state_forbidden').toggle(power_user.forbid_external_media); |
|
|
template.find('.forbid_media_global_state_allowed').toggle(!power_user.forbid_external_media); |
|
|
|
|
|
if (power_user.external_media_allowed_overrides.includes(entityId)) { |
|
|
template.find('#forbid_media_override_allowed').prop('checked', true); |
|
|
} |
|
|
else if (power_user.external_media_forbidden_overrides.includes(entityId)) { |
|
|
template.find('#forbid_media_override_forbidden').prop('checked', true); |
|
|
} |
|
|
else { |
|
|
template.find('#forbid_media_override_global').prop('checked', true); |
|
|
} |
|
|
|
|
|
callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: false, large: false }); |
|
|
} |
|
|
|
|
|
export function getCurrentEntityId() { |
|
|
if (selected_group) { |
|
|
return String(selected_group); |
|
|
} |
|
|
|
|
|
return characters[this_chid]?.avatar ?? null; |
|
|
} |
|
|
|
|
|
export function isExternalMediaAllowed() { |
|
|
const entityId = getCurrentEntityId(); |
|
|
if (!entityId) { |
|
|
return !power_user.forbid_external_media; |
|
|
} |
|
|
|
|
|
if (power_user.external_media_allowed_overrides.includes(entityId)) { |
|
|
return true; |
|
|
} |
|
|
|
|
|
if (power_user.external_media_forbidden_overrides.includes(entityId)) { |
|
|
return false; |
|
|
} |
|
|
|
|
|
return !power_user.forbid_external_media; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function expandMessageMedia(messageId, mediaIndex) { |
|
|
if (isNaN(messageId) || isNaN(mediaIndex)) { |
|
|
console.warn('Invalid message ID or media index'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const message = chat[messageId]; |
|
|
|
|
|
if (!Array.isArray(message?.extra?.media) || message.extra.media.length === 0) { |
|
|
console.warn('Message has no media to expand'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const mediaAttachment = message.extra.media[mediaIndex]; |
|
|
const title = mediaAttachment.title || message.extra.title || ''; |
|
|
|
|
|
if (!mediaAttachment) { |
|
|
return; |
|
|
} |
|
|
|
|
|
if (mediaAttachment.type === MEDIA_TYPE.AUDIO) { |
|
|
console.warn('Audio media cannot be expanded'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getMediaElement() { |
|
|
function getImageElement() { |
|
|
const img = document.createElement('img'); |
|
|
img.src = mediaAttachment.url; |
|
|
img.classList.add('img_enlarged'); |
|
|
return img; |
|
|
} |
|
|
|
|
|
function getVideoElement() { |
|
|
const video = document.createElement('video'); |
|
|
video.src = mediaAttachment.url; |
|
|
video.classList.add('img_enlarged'); |
|
|
video.controls = true; |
|
|
video.autoplay = true; |
|
|
return video; |
|
|
} |
|
|
|
|
|
switch (mediaAttachment.type) { |
|
|
case MEDIA_TYPE.IMAGE: |
|
|
return getImageElement(); |
|
|
case MEDIA_TYPE.VIDEO: |
|
|
return getVideoElement(); |
|
|
} |
|
|
|
|
|
console.warn('Unsupported media type for enlargement:', mediaAttachment.type); |
|
|
return getImageElement(); |
|
|
} |
|
|
|
|
|
const mediaElement = getMediaElement(); |
|
|
const mediaHolder = document.createElement('div'); |
|
|
mediaHolder.classList.add('img_enlarged_holder'); |
|
|
mediaHolder.append(mediaElement); |
|
|
const mediaContainer = document.createElement('div'); |
|
|
mediaContainer.classList.add('img_enlarged_container'); |
|
|
mediaContainer.append(mediaHolder); |
|
|
|
|
|
mediaElement.addEventListener('click', event => { |
|
|
const shouldZoom = !mediaElement.classList.contains('zoomed') && mediaElement.nodeName === 'IMG'; |
|
|
mediaElement.classList.toggle('zoomed', shouldZoom); |
|
|
event.stopPropagation(); |
|
|
}); |
|
|
|
|
|
if (title.trim().length > 0) { |
|
|
const mediaTitlePre = document.createElement('pre'); |
|
|
const mediaTitleCode = document.createElement('code'); |
|
|
mediaTitleCode.classList.add('img_enlarged_title', 'txt'); |
|
|
mediaTitleCode.textContent = title; |
|
|
mediaTitlePre.append(mediaTitleCode); |
|
|
mediaTitleCode.addEventListener('click', event => { |
|
|
event.stopPropagation(); |
|
|
}); |
|
|
mediaContainer.append(mediaTitlePre); |
|
|
addCopyToCodeBlocks(mediaContainer); |
|
|
} |
|
|
|
|
|
const popup = new Popup(mediaContainer, POPUP_TYPE.DISPLAY, '', { large: true, transparent: true }); |
|
|
|
|
|
popup.dlg.style.width = 'unset'; |
|
|
popup.dlg.style.height = 'unset'; |
|
|
popup.dlg.addEventListener('click', () => { |
|
|
popup.completeCancelled(); |
|
|
}); |
|
|
|
|
|
popup.show(); |
|
|
return mediaElement; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function deleteMessageMedia(messageId, mediaIndex, messageBlock) { |
|
|
if (isNaN(messageId) || isNaN(mediaIndex)) { |
|
|
console.warn('Invalid message ID or media index'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const deleteUrls = []; |
|
|
const deleteFromServerId = 'delete_media_files_checkbox'; |
|
|
let deleteFromServer = true; |
|
|
|
|
|
const value = await Popup.show.confirm(t`Delete media from message?`, t`This action can't be undone.`, { |
|
|
okButton: t`Delete one`, |
|
|
cancelButton: false, |
|
|
customButtons: [ |
|
|
{ |
|
|
text: t`Delete all`, |
|
|
appendAtEnd: true, |
|
|
result: POPUP_RESULT.CUSTOM1, |
|
|
}, |
|
|
{ |
|
|
text: t`Cancel`, |
|
|
appendAtEnd: true, |
|
|
result: POPUP_RESULT.CANCELLED, |
|
|
}, |
|
|
], |
|
|
customInputs: [ |
|
|
{ |
|
|
type: 'checkbox', |
|
|
label: t`Also delete files from server`, |
|
|
id: deleteFromServerId, |
|
|
defaultState: true, |
|
|
}, |
|
|
], |
|
|
onClose: (popup) => { |
|
|
deleteFromServer = Boolean(popup.inputResults.get(deleteFromServerId) ?? false); |
|
|
}, |
|
|
}); |
|
|
|
|
|
if (!value) { |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const message = chat[messageId]; |
|
|
|
|
|
if (!Array.isArray(message?.extra?.media)) { |
|
|
console.debug('Message has no media'); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (mediaIndex < 0 || mediaIndex >= message.extra.media.length) { |
|
|
console.warn('Invalid media index for message'); |
|
|
return; |
|
|
} |
|
|
|
|
|
deleteUrls.push(message.extra.media[mediaIndex].url); |
|
|
message.extra.media.splice(mediaIndex, 1); |
|
|
|
|
|
if (message.extra.media_index === mediaIndex) { |
|
|
const newIndex = mediaIndex > 0 ? mediaIndex - 1 : 0; |
|
|
message.extra.media_index = clamp(newIndex, 0, message.extra.media.length - 1); |
|
|
} |
|
|
|
|
|
if (value === POPUP_RESULT.CUSTOM1) { |
|
|
for (const media of message.extra.media) { |
|
|
deleteUrls.push(media.url); |
|
|
} |
|
|
delete message.extra.media; |
|
|
delete message.extra.inline_image; |
|
|
delete message.extra.title; |
|
|
delete message.extra.append_title; |
|
|
} |
|
|
|
|
|
if (deleteFromServer) { |
|
|
for (const url of deleteUrls) { |
|
|
if (!url) continue; |
|
|
await deleteMediaFromServer(url, true); |
|
|
} |
|
|
} |
|
|
|
|
|
await saveChatConditional(); |
|
|
appendMediaToMessage(message, messageBlock, SCROLL_BEHAVIOR.KEEP); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function switchMessageMediaDisplay(messageId, messageBlock, targetDisplay) { |
|
|
if (isNaN(messageId)) { |
|
|
console.warn('Invalid message ID'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const message = chat[messageId]; |
|
|
|
|
|
if (!message) { |
|
|
console.warn('Message not found for ID', messageId); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!message.extra || typeof message.extra !== 'object') { |
|
|
message.extra = {}; |
|
|
} |
|
|
|
|
|
message.extra.media_display = targetDisplay; |
|
|
await saveChatConditional(); |
|
|
appendMediaToMessage(message, messageBlock, SCROLL_BEHAVIOR.KEEP); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function deleteMediaFromServer(url, silent = false) { |
|
|
try { |
|
|
const result = await fetch('/api/images/delete', { |
|
|
method: 'POST', |
|
|
headers: getRequestHeaders(), |
|
|
body: JSON.stringify({ path: url }), |
|
|
}); |
|
|
|
|
|
if (!result.ok) { |
|
|
if (!silent) { |
|
|
const error = await result.text(); |
|
|
throw new Error(error); |
|
|
} |
|
|
return false; |
|
|
} |
|
|
|
|
|
await eventSource.emit(event_types.MEDIA_ATTACHMENT_DELETED, url); |
|
|
return true; |
|
|
} catch (error) { |
|
|
toastr.error(String(error), t`Could not delete image`); |
|
|
console.error('Could not delete image', error); |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function deleteFileFromServer(url, silent = false) { |
|
|
try { |
|
|
const result = await fetch('/api/files/delete', { |
|
|
method: 'POST', |
|
|
headers: getRequestHeaders(), |
|
|
body: JSON.stringify({ path: url }), |
|
|
}); |
|
|
|
|
|
if (!result.ok) { |
|
|
if (!silent) { |
|
|
const error = await result.text(); |
|
|
throw new Error(error); |
|
|
} |
|
|
return false; |
|
|
} |
|
|
|
|
|
await eventSource.emit(event_types.FILE_ATTACHMENT_DELETED, url); |
|
|
return true; |
|
|
} catch (error) { |
|
|
toastr.error(String(error), t`Could not delete file`); |
|
|
console.error('Could not delete file', error); |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function openFilePopup(attachment) { |
|
|
const fileText = attachment.text || (await getFileAttachment(attachment.url)); |
|
|
|
|
|
const modalTemplate = $('<div><pre><code></code></pre></div>'); |
|
|
modalTemplate.find('code').addClass('txt').text(fileText); |
|
|
modalTemplate.addClass('file_modal').addClass('textarea_compact').addClass('fontsize90p'); |
|
|
addCopyToCodeBlocks(modalTemplate); |
|
|
|
|
|
callGenericPopup(modalTemplate, POPUP_TYPE.TEXT, '', { wide: true, large: true }); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function editAttachment(attachment, source, callback) { |
|
|
const originalFileText = attachment.text || (await getFileAttachment(attachment.url)); |
|
|
const template = $(await renderExtensionTemplateAsync('attachments', 'notepad')); |
|
|
|
|
|
let editedFileText = originalFileText; |
|
|
template.find('[name="notepadFileContent"]').val(editedFileText).on('input', function () { |
|
|
editedFileText = String($(this).val()); |
|
|
}); |
|
|
|
|
|
let editedFileName = attachment.name; |
|
|
template.find('[name="notepadFileName"]').val(editedFileName).on('input', function () { |
|
|
editedFileName = String($(this).val()); |
|
|
}); |
|
|
|
|
|
const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { wide: true, large: true, okButton: 'Save', cancelButton: 'Cancel' }); |
|
|
|
|
|
if (result !== POPUP_RESULT.AFFIRMATIVE) { |
|
|
return; |
|
|
} |
|
|
|
|
|
if (editedFileText === originalFileText && editedFileName === attachment.name) { |
|
|
return; |
|
|
} |
|
|
|
|
|
const nullCallback = () => { }; |
|
|
await deleteAttachment(attachment, source, nullCallback, false); |
|
|
const file = new File([editedFileText], editedFileName, { type: 'text/plain' }); |
|
|
await uploadFileAttachmentToServer(file, source); |
|
|
|
|
|
callback(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function downloadAttachment(attachment) { |
|
|
const fileText = attachment.text || (await getFileAttachment(attachment.url)); |
|
|
const blob = new Blob([fileText], { type: 'text/plain' }); |
|
|
const url = URL.createObjectURL(blob); |
|
|
const a = document.createElement('a'); |
|
|
a.href = url; |
|
|
a.download = attachment.name; |
|
|
a.click(); |
|
|
URL.revokeObjectURL(url); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function enableAttachment(attachment, callback) { |
|
|
ensureAttachmentsExist(); |
|
|
extension_settings.disabled_attachments = extension_settings.disabled_attachments.filter(url => url !== attachment.url); |
|
|
saveSettingsDebounced(); |
|
|
callback(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function disableAttachment(attachment, callback) { |
|
|
ensureAttachmentsExist(); |
|
|
extension_settings.disabled_attachments.push(attachment.url); |
|
|
saveSettingsDebounced(); |
|
|
callback(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function moveAttachment(attachment, source, callback) { |
|
|
let selectedTarget = source; |
|
|
const targets = getAvailableTargets(); |
|
|
const template = $(await renderExtensionTemplateAsync('attachments', 'move-attachment', { name: attachment.name, targets })); |
|
|
template.find('.moveAttachmentTarget').val(source).on('input', function () { |
|
|
selectedTarget = String($(this).val()); |
|
|
}); |
|
|
|
|
|
const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { wide: false, large: false, okButton: 'Move', cancelButton: 'Cancel' }); |
|
|
|
|
|
if (result !== POPUP_RESULT.AFFIRMATIVE) { |
|
|
console.debug('Move attachment cancelled'); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (selectedTarget === source) { |
|
|
console.debug('Move attachment cancelled: same source and target'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const content = await getFileAttachment(attachment.url); |
|
|
const file = new File([content], attachment.name, { type: 'text/plain' }); |
|
|
await deleteAttachment(attachment, source, () => { }, false); |
|
|
await uploadFileAttachmentToServer(file, selectedTarget); |
|
|
callback(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function deleteAttachment(attachment, source, callback, confirm = true) { |
|
|
if (confirm) { |
|
|
const result = await callGenericPopup('Are you sure you want to delete this attachment?', POPUP_TYPE.CONFIRM); |
|
|
|
|
|
if (result !== POPUP_RESULT.AFFIRMATIVE) { |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
ensureAttachmentsExist(); |
|
|
|
|
|
switch (source) { |
|
|
case 'global': |
|
|
extension_settings.attachments = extension_settings.attachments.filter((a) => a.url !== attachment.url); |
|
|
saveSettingsDebounced(); |
|
|
break; |
|
|
case 'chat': |
|
|
chat_metadata.attachments = chat_metadata.attachments.filter((a) => a.url !== attachment.url); |
|
|
saveMetadataDebounced(); |
|
|
break; |
|
|
case 'character': |
|
|
extension_settings.character_attachments[characters[this_chid]?.avatar] = extension_settings.character_attachments[characters[this_chid]?.avatar].filter((a) => a.url !== attachment.url); |
|
|
break; |
|
|
} |
|
|
|
|
|
if (Array.isArray(extension_settings.disabled_attachments) && extension_settings.disabled_attachments.includes(attachment.url)) { |
|
|
extension_settings.disabled_attachments = extension_settings.disabled_attachments.filter(url => url !== attachment.url); |
|
|
saveSettingsDebounced(); |
|
|
} |
|
|
|
|
|
const silent = confirm === false; |
|
|
await deleteFileFromServer(attachment.url, silent); |
|
|
callback(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function isAttachmentDisabled(attachment) { |
|
|
return extension_settings.disabled_attachments.some(url => url === attachment?.url); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function openAttachmentManager() { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function renderList(attachments, source) { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function sortFn(a, b) { |
|
|
const sortValueA = a[sortField]; |
|
|
const sortValueB = b[sortField]; |
|
|
if (typeof sortValueA === 'string' && typeof sortValueB === 'string') { |
|
|
return sortValueA.localeCompare(sortValueB) * (sortOrder === 'asc' ? 1 : -1); |
|
|
} |
|
|
return (sortValueA - sortValueB) * (sortOrder === 'asc' ? 1 : -1); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function filterFn(a) { |
|
|
if (!filterString) { |
|
|
return true; |
|
|
} |
|
|
|
|
|
return a.name.toLowerCase().includes(filterString.toLowerCase()); |
|
|
} |
|
|
const sources = { |
|
|
[ATTACHMENT_SOURCE.GLOBAL]: '.globalAttachmentsList', |
|
|
[ATTACHMENT_SOURCE.CHARACTER]: '.characterAttachmentsList', |
|
|
[ATTACHMENT_SOURCE.CHAT]: '.chatAttachmentsList', |
|
|
}; |
|
|
|
|
|
const selected = template |
|
|
.find(sources[source]) |
|
|
.find('.attachmentListItemCheckbox:checked') |
|
|
.map((_, el) => $(el).closest('.attachmentListItem').attr('data-attachment-url')) |
|
|
.get(); |
|
|
|
|
|
template.find(sources[source]).empty(); |
|
|
|
|
|
|
|
|
const sortedAttachmentList = attachments.slice().filter(filterFn).sort(sortFn); |
|
|
|
|
|
for (const attachment of sortedAttachmentList) { |
|
|
const isDisabled = isAttachmentDisabled(attachment); |
|
|
const attachmentTemplate = template.find('.attachmentListItemTemplate .attachmentListItem').clone(); |
|
|
attachmentTemplate.toggleClass('disabled', isDisabled); |
|
|
attachmentTemplate.attr('data-attachment-url', attachment.url); |
|
|
attachmentTemplate.attr('data-attachment-source', source); |
|
|
attachmentTemplate.find('.attachmentFileIcon').attr('title', attachment.url); |
|
|
attachmentTemplate.find('.attachmentListItemName').text(attachment.name); |
|
|
attachmentTemplate.find('.attachmentListItemSize').text(humanFileSize(attachment.size)); |
|
|
attachmentTemplate.find('.attachmentListItemCreated').text(new Date(attachment.created).toLocaleString()); |
|
|
attachmentTemplate.find('.viewAttachmentButton').on('click', () => openFilePopup(attachment)); |
|
|
attachmentTemplate.find('.editAttachmentButton').on('click', () => editAttachment(attachment, source, renderAttachments)); |
|
|
attachmentTemplate.find('.deleteAttachmentButton').on('click', () => deleteAttachment(attachment, source, renderAttachments)); |
|
|
attachmentTemplate.find('.downloadAttachmentButton').on('click', () => downloadAttachment(attachment)); |
|
|
attachmentTemplate.find('.moveAttachmentButton').on('click', () => moveAttachment(attachment, source, renderAttachments)); |
|
|
attachmentTemplate.find('.enableAttachmentButton').toggle(isDisabled).on('click', () => enableAttachment(attachment, renderAttachments)); |
|
|
attachmentTemplate.find('.disableAttachmentButton').toggle(!isDisabled).on('click', () => disableAttachment(attachment, renderAttachments)); |
|
|
template.find(sources[source]).append(attachmentTemplate); |
|
|
|
|
|
if (selected.includes(attachment.url)) { |
|
|
attachmentTemplate.find('.attachmentListItemCheckbox').prop('checked', true); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function renderButtons() { |
|
|
const sources = { |
|
|
[ATTACHMENT_SOURCE.GLOBAL]: '.globalAttachmentsTitle', |
|
|
[ATTACHMENT_SOURCE.CHARACTER]: '.characterAttachmentsTitle', |
|
|
[ATTACHMENT_SOURCE.CHAT]: '.chatAttachmentsTitle', |
|
|
}; |
|
|
|
|
|
const modal = template.find('.actionButtonsModal').hide(); |
|
|
const scrapers = ScraperManager.getDataBankScrapers(); |
|
|
|
|
|
for (const scraper of scrapers) { |
|
|
const isAvailable = await ScraperManager.isScraperAvailable(scraper.id); |
|
|
if (!isAvailable) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
const buttonTemplate = template.find('.actionButtonTemplate .actionButton').clone(); |
|
|
if (scraper.iconAvailable) { |
|
|
buttonTemplate.find('.actionButtonIcon').addClass(scraper.iconClass); |
|
|
buttonTemplate.find('.actionButtonImg').remove(); |
|
|
} else { |
|
|
buttonTemplate.find('.actionButtonImg').attr('src', scraper.iconClass); |
|
|
buttonTemplate.find('.actionButtonIcon').remove(); |
|
|
} |
|
|
buttonTemplate.find('.actionButtonText').text(scraper.name); |
|
|
buttonTemplate.attr('title', scraper.description); |
|
|
buttonTemplate.on('click', () => { |
|
|
const target = modal.attr('data-attachment-manager-target'); |
|
|
runScraper(scraper.id, target, renderAttachments); |
|
|
}); |
|
|
modal.append(buttonTemplate); |
|
|
} |
|
|
|
|
|
const modalButtonData = Object.entries(sources).map(entry => { |
|
|
const [source, selector] = entry; |
|
|
const button = template.find(selector).find('.openActionModalButton').get(0); |
|
|
|
|
|
if (!button) { |
|
|
return; |
|
|
} |
|
|
|
|
|
const bodyListener = (e) => { |
|
|
if (modal.is(':visible') && (!$(e.target).closest('.openActionModalButton').length)) { |
|
|
modal.hide(); |
|
|
} |
|
|
|
|
|
|
|
|
if ($(e.target).closest('.openActionModalButton').length && !modal.is(':visible')) { |
|
|
modal.show(); |
|
|
} |
|
|
}; |
|
|
document.body.addEventListener('click', bodyListener); |
|
|
|
|
|
const popper = Popper.createPopper(button, modal.get(0), { placement: 'bottom-end' }); |
|
|
button.addEventListener('click', () => { |
|
|
modal.attr('data-attachment-manager-target', source); |
|
|
modal.toggle(); |
|
|
popper.update(); |
|
|
}); |
|
|
|
|
|
return { popper, bodyListener }; |
|
|
}).filter(Boolean); |
|
|
|
|
|
return () => { |
|
|
modalButtonData.forEach(p => { |
|
|
const { popper, bodyListener } = p; |
|
|
popper.destroy(); |
|
|
document.body.removeEventListener('click', bodyListener); |
|
|
}); |
|
|
modal.remove(); |
|
|
}; |
|
|
} |
|
|
|
|
|
async function renderAttachments() { |
|
|
|
|
|
const globalAttachments = extension_settings.attachments ?? []; |
|
|
|
|
|
const chatAttachments = chat_metadata.attachments ?? []; |
|
|
|
|
|
const characterAttachments = extension_settings.character_attachments?.[characters[this_chid]?.avatar] ?? []; |
|
|
|
|
|
await renderList(globalAttachments, ATTACHMENT_SOURCE.GLOBAL); |
|
|
await renderList(chatAttachments, ATTACHMENT_SOURCE.CHAT); |
|
|
await renderList(characterAttachments, ATTACHMENT_SOURCE.CHARACTER); |
|
|
|
|
|
const isNotCharacter = this_chid === undefined || selected_group; |
|
|
const isNotInChat = getCurrentChatId() === undefined; |
|
|
template.find('.characterAttachmentsBlock').toggle(!isNotCharacter); |
|
|
template.find('.chatAttachmentsBlock').toggle(!isNotInChat); |
|
|
|
|
|
const characterName = characters[this_chid]?.name || 'Anonymous'; |
|
|
template.find('.characterAttachmentsName').text(characterName); |
|
|
|
|
|
const chatName = getCurrentChatId() || 'Unnamed chat'; |
|
|
template.find('.chatAttachmentsName').text(chatName); |
|
|
} |
|
|
|
|
|
const dragDropHandler = new DragAndDropHandler('.popup', async (files, event) => { |
|
|
let selectedTarget = ATTACHMENT_SOURCE.GLOBAL; |
|
|
const targets = getAvailableTargets(); |
|
|
|
|
|
const targetSelectTemplate = $(await renderExtensionTemplateAsync('attachments', 'files-dropped', { count: files.length, targets: targets })); |
|
|
targetSelectTemplate.find('.droppedFilesTarget').on('input', function () { |
|
|
selectedTarget = String($(this).val()); |
|
|
}); |
|
|
const result = await callGenericPopup(targetSelectTemplate, POPUP_TYPE.CONFIRM, '', { wide: false, large: false, okButton: 'Upload', cancelButton: 'Cancel' }); |
|
|
if (result !== POPUP_RESULT.AFFIRMATIVE) { |
|
|
console.log('File upload cancelled'); |
|
|
return; |
|
|
} |
|
|
for (const file of files) { |
|
|
await uploadFileAttachmentToServer(file, selectedTarget); |
|
|
} |
|
|
renderAttachments(); |
|
|
}); |
|
|
|
|
|
let sortField = accountStorage.getItem('DataBank_sortField') || 'created'; |
|
|
let sortOrder = accountStorage.getItem('DataBank_sortOrder') || 'desc'; |
|
|
let filterString = ''; |
|
|
|
|
|
const template = $(await renderExtensionTemplateAsync('attachments', 'manager', {})); |
|
|
|
|
|
template.find('.attachmentSearch').on('input', function () { |
|
|
filterString = String($(this).val()); |
|
|
renderAttachments(); |
|
|
}); |
|
|
template.find('.attachmentSort').on('change', function () { |
|
|
if (!(this instanceof HTMLSelectElement) || this.selectedOptions.length === 0) { |
|
|
return; |
|
|
} |
|
|
|
|
|
sortField = this.selectedOptions[0].dataset.sortField; |
|
|
sortOrder = this.selectedOptions[0].dataset.sortOrder; |
|
|
accountStorage.setItem('DataBank_sortField', sortField); |
|
|
accountStorage.setItem('DataBank_sortOrder', sortOrder); |
|
|
renderAttachments(); |
|
|
}); |
|
|
function handleBulkAction(action) { |
|
|
return async () => { |
|
|
const selectedAttachments = document.querySelectorAll('.attachmentListItemCheckboxContainer .attachmentListItemCheckbox:checked'); |
|
|
|
|
|
if (selectedAttachments.length === 0) { |
|
|
toastr.info(t`No attachments selected.`, t`Data Bank`); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (action.confirmMessage) { |
|
|
const confirm = await callGenericPopup(action.confirmMessage, POPUP_TYPE.CONFIRM); |
|
|
if (confirm !== POPUP_RESULT.AFFIRMATIVE) { |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
const includeDisabled = true; |
|
|
const attachments = getDataBankAttachments(includeDisabled); |
|
|
selectedAttachments.forEach(async (checkbox) => { |
|
|
const listItem = checkbox.closest('.attachmentListItem'); |
|
|
if (!(listItem instanceof HTMLElement)) { |
|
|
return; |
|
|
} |
|
|
const url = listItem.dataset.attachmentUrl; |
|
|
const source = listItem.dataset.attachmentSource; |
|
|
const attachment = attachments.find(a => a.url === url); |
|
|
if (!attachment) { |
|
|
return; |
|
|
} |
|
|
await action.perform(attachment, source); |
|
|
}); |
|
|
|
|
|
document.querySelectorAll('.attachmentListItemCheckbox, .attachmentsBulkEditCheckbox').forEach(checkbox => { |
|
|
if (checkbox instanceof HTMLInputElement) { |
|
|
checkbox.checked = false; |
|
|
} |
|
|
}); |
|
|
|
|
|
await renderAttachments(); |
|
|
}; |
|
|
} |
|
|
|
|
|
template.find('.bulkActionDisable').on('click', handleBulkAction({ |
|
|
perform: (attachment) => disableAttachment(attachment, () => { }), |
|
|
})); |
|
|
|
|
|
template.find('.bulkActionEnable').on('click', handleBulkAction({ |
|
|
perform: (attachment) => enableAttachment(attachment, () => { }), |
|
|
})); |
|
|
|
|
|
template.find('.bulkActionDelete').on('click', handleBulkAction({ |
|
|
confirmMessage: 'Are you sure you want to delete the selected attachments?', |
|
|
perform: async (attachment, source) => await deleteAttachment(attachment, source, () => { }, false), |
|
|
})); |
|
|
|
|
|
template.find('.bulkActionSelectAll').on('click', () => { |
|
|
$('.attachmentListItemCheckbox:visible').each((_, checkbox) => { |
|
|
if (checkbox instanceof HTMLInputElement) { |
|
|
checkbox.checked = true; |
|
|
} |
|
|
}); |
|
|
}); |
|
|
template.find('.bulkActionSelectNone').on('click', () => { |
|
|
$('.attachmentListItemCheckbox:visible').each((_, checkbox) => { |
|
|
if (checkbox instanceof HTMLInputElement) { |
|
|
checkbox.checked = false; |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
const cleanupFn = await renderButtons(); |
|
|
await verifyAttachments(); |
|
|
await renderAttachments(); |
|
|
await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, okButton: 'Close', allowVerticalScrolling: true }); |
|
|
|
|
|
cleanupFn(); |
|
|
dragDropHandler.destroy(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getAvailableTargets() { |
|
|
const targets = Object.values(ATTACHMENT_SOURCE); |
|
|
|
|
|
const isNotCharacter = this_chid === undefined || selected_group; |
|
|
const isNotInChat = getCurrentChatId() === undefined; |
|
|
|
|
|
if (isNotCharacter) { |
|
|
targets.splice(targets.indexOf(ATTACHMENT_SOURCE.CHARACTER), 1); |
|
|
} |
|
|
|
|
|
if (isNotInChat) { |
|
|
targets.splice(targets.indexOf(ATTACHMENT_SOURCE.CHAT), 1); |
|
|
} |
|
|
|
|
|
return targets; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function runScraper(scraperId, target, callback) { |
|
|
try { |
|
|
console.log(`Running scraper ${scraperId} for ${target}`); |
|
|
const files = await ScraperManager.runDataBankScraper(scraperId); |
|
|
|
|
|
if (!Array.isArray(files)) { |
|
|
console.warn('Scraping returned nothing'); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (files.length === 0) { |
|
|
console.warn('Scraping returned no files'); |
|
|
toastr.info(t`No files were scraped.`, t`Data Bank`); |
|
|
return; |
|
|
} |
|
|
|
|
|
for (const file of files) { |
|
|
await uploadFileAttachmentToServer(file, target); |
|
|
} |
|
|
|
|
|
toastr.success(t`Scraped ${files.length} files from ${scraperId} to ${target}.`, t`Data Bank`); |
|
|
callback(); |
|
|
} |
|
|
catch (error) { |
|
|
console.error('Scraping failed', error); |
|
|
toastr.error(t`Check browser console for details.`, t`Scraping failed`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function uploadFileAttachmentToServer(file, target) { |
|
|
const isValid = await validateFile(file); |
|
|
|
|
|
if (!isValid) { |
|
|
return; |
|
|
} |
|
|
|
|
|
let base64Data = await getBase64Async(file); |
|
|
const slug = getStringHash(file.name); |
|
|
const uniqueFileName = `${Date.now()}_${slug}.txt`; |
|
|
|
|
|
if (isConvertible(file.type)) { |
|
|
try { |
|
|
const converter = getConverter(file.type); |
|
|
const fileText = await converter(file); |
|
|
base64Data = convertTextToBase64(fileText); |
|
|
} catch (error) { |
|
|
toastr.error(String(error), t`Could not convert file`); |
|
|
console.error('Could not convert file', error); |
|
|
} |
|
|
} else { |
|
|
const fileText = await file.text(); |
|
|
base64Data = convertTextToBase64(fileText); |
|
|
} |
|
|
|
|
|
const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data); |
|
|
const convertedSize = Math.round(base64Data.length * 0.75); |
|
|
|
|
|
if (!fileUrl) { |
|
|
return; |
|
|
} |
|
|
|
|
|
const attachment = { |
|
|
url: fileUrl, |
|
|
size: convertedSize, |
|
|
name: file.name, |
|
|
created: Date.now(), |
|
|
}; |
|
|
|
|
|
ensureAttachmentsExist(); |
|
|
|
|
|
switch (target) { |
|
|
case ATTACHMENT_SOURCE.GLOBAL: |
|
|
extension_settings.attachments.push(attachment); |
|
|
saveSettingsDebounced(); |
|
|
break; |
|
|
case ATTACHMENT_SOURCE.CHAT: |
|
|
chat_metadata.attachments.push(attachment); |
|
|
saveMetadataDebounced(); |
|
|
break; |
|
|
case ATTACHMENT_SOURCE.CHARACTER: |
|
|
extension_settings.character_attachments[characters[this_chid]?.avatar].push(attachment); |
|
|
saveSettingsDebounced(); |
|
|
break; |
|
|
} |
|
|
|
|
|
return fileUrl; |
|
|
} |
|
|
|
|
|
function ensureAttachmentsExist() { |
|
|
if (!Array.isArray(extension_settings.disabled_attachments)) { |
|
|
extension_settings.disabled_attachments = []; |
|
|
} |
|
|
|
|
|
if (!Array.isArray(extension_settings.attachments)) { |
|
|
extension_settings.attachments = []; |
|
|
} |
|
|
|
|
|
if (!Array.isArray(chat_metadata.attachments)) { |
|
|
chat_metadata.attachments = []; |
|
|
} |
|
|
|
|
|
if (this_chid !== undefined && characters[this_chid]) { |
|
|
if (!extension_settings.character_attachments) { |
|
|
extension_settings.character_attachments = {}; |
|
|
} |
|
|
|
|
|
if (!Array.isArray(extension_settings.character_attachments[characters[this_chid].avatar])) { |
|
|
extension_settings.character_attachments[characters[this_chid].avatar] = []; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getDataBankAttachments(includeDisabled = false) { |
|
|
ensureAttachmentsExist(); |
|
|
const globalAttachments = extension_settings.attachments ?? []; |
|
|
const chatAttachments = chat_metadata.attachments ?? []; |
|
|
const characterAttachments = extension_settings.character_attachments?.[characters[this_chid]?.avatar] ?? []; |
|
|
|
|
|
return [...globalAttachments, ...chatAttachments, ...characterAttachments].filter(x => includeDisabled || !isAttachmentDisabled(x)); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getDataBankAttachmentsForSource(source, includeDisabled = true) { |
|
|
ensureAttachmentsExist(); |
|
|
|
|
|
function getBySource() { |
|
|
switch (source) { |
|
|
case ATTACHMENT_SOURCE.GLOBAL: |
|
|
return extension_settings.attachments ?? []; |
|
|
case ATTACHMENT_SOURCE.CHAT: |
|
|
return chat_metadata.attachments ?? []; |
|
|
case ATTACHMENT_SOURCE.CHARACTER: |
|
|
return extension_settings.character_attachments?.[characters[this_chid]?.avatar] ?? []; |
|
|
} |
|
|
|
|
|
return []; |
|
|
} |
|
|
|
|
|
return getBySource().filter(x => includeDisabled || !isAttachmentDisabled(x)); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function verifyAttachments() { |
|
|
for (const source of Object.values(ATTACHMENT_SOURCE)) { |
|
|
await verifyAttachmentsForSource(source); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function verifyAttachmentsForSource(source) { |
|
|
try { |
|
|
const attachments = getDataBankAttachmentsForSource(source); |
|
|
const urls = attachments.map(a => a.url); |
|
|
const response = await fetch('/api/files/verify', { |
|
|
method: 'POST', |
|
|
headers: getRequestHeaders(), |
|
|
body: JSON.stringify({ urls }), |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const error = await response.text(); |
|
|
throw new Error(error); |
|
|
} |
|
|
|
|
|
const verifiedUrls = await response.json(); |
|
|
for (const attachment of attachments) { |
|
|
if (verifiedUrls[attachment.url] === false) { |
|
|
console.log('Deleting orphaned attachment', attachment); |
|
|
await deleteAttachment(attachment, source, () => { }, false); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Attachment verification failed', error); |
|
|
} |
|
|
} |
|
|
|
|
|
const NEUTRAL_CHAT_KEY = 'neutralChat'; |
|
|
|
|
|
export function preserveNeutralChat() { |
|
|
if (this_chid !== undefined || selected_group || name2 !== neutralCharacterName) { |
|
|
return; |
|
|
} |
|
|
|
|
|
sessionStorage.setItem(NEUTRAL_CHAT_KEY, JSON.stringify({ chat, chat_metadata })); |
|
|
} |
|
|
|
|
|
export function restoreNeutralChat() { |
|
|
if (this_chid !== undefined || selected_group || name2 !== neutralCharacterName) { |
|
|
return; |
|
|
} |
|
|
|
|
|
const neutralChat = sessionStorage.getItem(NEUTRAL_CHAT_KEY); |
|
|
if (!neutralChat) { |
|
|
return; |
|
|
} |
|
|
|
|
|
const { chat: neutralChatData, chat_metadata: neutralChatMetadata } = JSON.parse(neutralChat); |
|
|
chat.splice(0, chat.length, ...neutralChatData); |
|
|
updateChatMetadata(neutralChatMetadata, true); |
|
|
sessionStorage.removeItem(NEUTRAL_CHAT_KEY); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function registerFileConverter(mimeType, converter) { |
|
|
if (typeof mimeType !== 'string' || typeof converter !== 'function') { |
|
|
console.error('Invalid converter registration'); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (Object.keys(converters).includes(mimeType)) { |
|
|
console.error('Converter already registered'); |
|
|
return; |
|
|
} |
|
|
|
|
|
converters[mimeType] = converter; |
|
|
} |
|
|
|
|
|
export function addDOMPurifyHooks() { |
|
|
|
|
|
DOMPurify.addHook('afterSanitizeAttributes', function (node) { |
|
|
if ('target' in node) { |
|
|
node.setAttribute('target', '_blank'); |
|
|
node.setAttribute('rel', 'noopener'); |
|
|
} |
|
|
}); |
|
|
|
|
|
DOMPurify.addHook('uponSanitizeAttribute', (node, data, config) => { |
|
|
if (!config['MESSAGE_SANITIZE']) { |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const permittedNodeTypes = ['BUTTON', 'DIV']; |
|
|
if (config['MESSAGE_ALLOW_SYSTEM_UI'] && node.classList.contains('menu_button') && permittedNodeTypes.includes(node.nodeName)) { |
|
|
return; |
|
|
} |
|
|
|
|
|
switch (data.attrName) { |
|
|
case 'class': { |
|
|
if (data.attrValue) { |
|
|
data.attrValue = data.attrValue.split(' ').map((v) => { |
|
|
if (v.startsWith('fa-') || v.startsWith('note-') || v === 'monospace') { |
|
|
return v; |
|
|
} |
|
|
|
|
|
return 'custom-' + v; |
|
|
}).join(' '); |
|
|
} |
|
|
break; |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
DOMPurify.addHook('uponSanitizeElement', (node, _, config) => { |
|
|
if (!config['MESSAGE_SANITIZE']) { |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (node instanceof HTMLUnknownElement) { |
|
|
node.innerHTML = node.innerHTML.trim(); |
|
|
|
|
|
|
|
|
const candidates = []; |
|
|
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT); |
|
|
while (walker.nextNode()) { |
|
|
const textNode = (walker.currentNode); |
|
|
if (!textNode.data.includes('\n')) continue; |
|
|
|
|
|
|
|
|
if (textNode.parentElement && textNode.parentElement.closest('pre')) continue; |
|
|
|
|
|
candidates.push(textNode); |
|
|
} |
|
|
|
|
|
for (const textNode of candidates) { |
|
|
const parts = textNode.data.split('\n'); |
|
|
const frag = document.createDocumentFragment(); |
|
|
parts.forEach((part, idx) => { |
|
|
if (part.length) { |
|
|
frag.appendChild(document.createTextNode(part)); |
|
|
} |
|
|
if (idx < parts.length - 1) { |
|
|
frag.appendChild(document.createElement('br')); |
|
|
} |
|
|
}); |
|
|
textNode.replaceWith(frag); |
|
|
} |
|
|
} |
|
|
|
|
|
const isMediaAllowed = isExternalMediaAllowed(); |
|
|
if (isMediaAllowed) { |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!(node instanceof Element)) { |
|
|
return; |
|
|
} |
|
|
|
|
|
let mediaBlocked = false; |
|
|
|
|
|
switch (node.tagName) { |
|
|
case 'AUDIO': |
|
|
case 'VIDEO': |
|
|
case 'SOURCE': |
|
|
case 'TRACK': |
|
|
case 'EMBED': |
|
|
case 'OBJECT': |
|
|
case 'IMG': { |
|
|
const isExternalUrl = (url) => (url.indexOf('://') > 0 || url.indexOf('//') === 0) && !url.startsWith(window.location.origin); |
|
|
const src = node.getAttribute('src'); |
|
|
const data = node.getAttribute('data'); |
|
|
const srcset = node.getAttribute('srcset'); |
|
|
|
|
|
if (srcset) { |
|
|
const srcsetUrls = srcset.split(','); |
|
|
|
|
|
for (const srcsetUrl of srcsetUrls) { |
|
|
const [url] = srcsetUrl.trim().split(' '); |
|
|
|
|
|
if (isExternalUrl(url)) { |
|
|
console.warn('External media blocked', url); |
|
|
node.remove(); |
|
|
mediaBlocked = true; |
|
|
break; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
if (src && isExternalUrl(src)) { |
|
|
console.warn('External media blocked', src); |
|
|
mediaBlocked = true; |
|
|
node.remove(); |
|
|
} |
|
|
|
|
|
if (data && isExternalUrl(data)) { |
|
|
console.warn('External media blocked', data); |
|
|
mediaBlocked = true; |
|
|
node.remove(); |
|
|
} |
|
|
|
|
|
if (mediaBlocked && (node instanceof HTMLMediaElement)) { |
|
|
node.autoplay = false; |
|
|
node.pause(); |
|
|
} |
|
|
} |
|
|
break; |
|
|
} |
|
|
|
|
|
if (mediaBlocked) { |
|
|
const entityId = getCurrentEntityId(); |
|
|
const warningShownKey = `mediaWarningShown:${entityId}`; |
|
|
|
|
|
if (accountStorage.getItem(warningShownKey) === null) { |
|
|
const warningToast = toastr.warning( |
|
|
t`Use the 'Ext. Media' button to allow it. Click on this message to dismiss.`, |
|
|
t`External media has been blocked`, |
|
|
{ |
|
|
timeOut: 0, |
|
|
preventDuplicates: true, |
|
|
onclick: () => toastr.clear(warningToast), |
|
|
}, |
|
|
); |
|
|
|
|
|
accountStorage.setItem(warningShownKey, 'true'); |
|
|
} |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function onImageSwiped(messageId, element, direction) { |
|
|
const animationClass = 'fa-fade'; |
|
|
const messageMedia = element.find('.mes_img, .mes_video'); |
|
|
|
|
|
|
|
|
if (messageMedia.hasClass(animationClass)) { |
|
|
return; |
|
|
} |
|
|
|
|
|
const message = chat[messageId]; |
|
|
const media = message?.extra?.media; |
|
|
|
|
|
if (!message || !Array.isArray(media) || media.length === 0) { |
|
|
console.warn('No media found in the message'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const currentIndex = getMediaIndex(message); |
|
|
const mediaDisplay = getMediaDisplay(message); |
|
|
|
|
|
if (mediaDisplay !== MEDIA_DISPLAY.GALLERY) { |
|
|
console.warn('Image swiping is only supported for gallery media display'); |
|
|
return; |
|
|
} |
|
|
|
|
|
await eventSource.emit(event_types.IMAGE_SWIPED, { message, element, direction }); |
|
|
|
|
|
if (media.length === 1) { |
|
|
console.warn('Only one media item in the message, swiping is not applicable'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (direction === SWIPE_DIRECTION.LEFT) { |
|
|
const newIndex = currentIndex === 0 ? media.length - 1 : currentIndex - 1; |
|
|
message.extra.media_index = newIndex; |
|
|
} |
|
|
|
|
|
|
|
|
if (direction === SWIPE_DIRECTION.RIGHT) { |
|
|
const newIndex = currentIndex === media.length - 1 ? 0 : currentIndex + 1; |
|
|
message.extra.media_index = newIndex >= media.length ? 0 : newIndex; |
|
|
} |
|
|
|
|
|
await saveChatConditional(); |
|
|
appendMediaToMessage(message, element); |
|
|
} |
|
|
|
|
|
export function initChatUtilities() { |
|
|
$(document).on('click', '.mes_hide', async function () { |
|
|
const messageBlock = $(this).closest('.mes'); |
|
|
const messageId = Number(messageBlock.attr('mesid')); |
|
|
await hideChatMessageRange(messageId, messageId, false); |
|
|
}); |
|
|
|
|
|
$(document).on('click', '.mes_unhide', async function () { |
|
|
const messageBlock = $(this).closest('.mes'); |
|
|
const messageId = Number(messageBlock.attr('mesid')); |
|
|
await hideChatMessageRange(messageId, messageId, true); |
|
|
}); |
|
|
|
|
|
$(document).on('click', '.mes_file_delete', async function () { |
|
|
const messageBlock = $(this).closest('.mes'); |
|
|
const messageId = Number(messageBlock.attr('mesid')); |
|
|
const fileBlock = $(this).closest('.mes_file_container'); |
|
|
const fileIndex = Number(fileBlock.attr('data-index')); |
|
|
await deleteMessageFile(messageBlock, messageId, fileIndex); |
|
|
}); |
|
|
|
|
|
$(document).on('click', '.mes_file_open', async function () { |
|
|
const messageBlock = $(this).closest('.mes'); |
|
|
const messageId = Number(messageBlock.attr('mesid')); |
|
|
const fileBlock = $(this).closest('.mes_file_container'); |
|
|
const fileIndex = Number(fileBlock.attr('data-index')); |
|
|
await viewMessageFile(messageId, fileIndex); |
|
|
}); |
|
|
|
|
|
$(document).on('click', '.assistant_note_export', async function () { |
|
|
const chatToSave = [ |
|
|
{ |
|
|
user_name: name1, |
|
|
character_name: name2, |
|
|
chat_metadata: chat_metadata, |
|
|
}, |
|
|
...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE), |
|
|
]; |
|
|
|
|
|
download(chatToSave.map((m) => JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonl`, 'application/json'); |
|
|
}); |
|
|
|
|
|
$(document).on('click', '.assistant_note_import', async function () { |
|
|
const importFile = async () => { |
|
|
const file = fileInput.files[0]; |
|
|
if (!file) { |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
const text = await getFileText(file); |
|
|
const lines = text.split('\n').filter(line => line.trim() !== ''); |
|
|
const messages = lines.map(line => JSON.parse(line)); |
|
|
const metadata = messages.shift()?.chat_metadata || {}; |
|
|
messages.unshift(getSystemMessageByType(system_message_types.ASSISTANT_NOTE)); |
|
|
await clearChat(); |
|
|
chat.splice(0, chat.length, ...messages); |
|
|
updateChatMetadata(metadata, true); |
|
|
await printMessages(); |
|
|
} catch (error) { |
|
|
console.error('Error importing assistant chat:', error); |
|
|
toastr.error(t`It's either corrupted or not a valid JSONL file.`, t`Failed to import chat`); |
|
|
} |
|
|
}; |
|
|
const fileInput = document.createElement('input'); |
|
|
fileInput.type = 'file'; |
|
|
fileInput.accept = '.jsonl'; |
|
|
fileInput.addEventListener('change', importFile); |
|
|
fileInput.click(); |
|
|
}); |
|
|
|
|
|
|
|
|
$(document).on('click', '#attachFile', function () { |
|
|
$('#file_form_input').trigger('click'); |
|
|
}); |
|
|
|
|
|
|
|
|
$(document).on('click', '#manageAttachments', function () { |
|
|
openAttachmentManager(); |
|
|
}); |
|
|
|
|
|
$(document).on('click', '.mes_embed', function () { |
|
|
const messageBlock = $(this).closest('.mes'); |
|
|
const messageId = Number(messageBlock.attr('mesid')); |
|
|
embedMessageFile(messageId, messageBlock); |
|
|
}); |
|
|
|
|
|
$(document).on('click', '.editor_maximize', async function (e) { |
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
|
|
|
const broId = $(this).attr('data-for'); |
|
|
const bro = $(`#${broId}`); |
|
|
const contentEditable = bro.is('[contenteditable]'); |
|
|
const withTab = $(this).attr('data-tab'); |
|
|
|
|
|
if (!bro.length) { |
|
|
console.error('Could not find editor with id', broId); |
|
|
return; |
|
|
} |
|
|
|
|
|
const wrapper = document.createElement('div'); |
|
|
wrapper.classList.add('height100p', 'wide100p', 'flex-container'); |
|
|
wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter'); |
|
|
const textarea = document.createElement('textarea'); |
|
|
textarea.dataset.for = broId; |
|
|
textarea.value = String(contentEditable ? bro[0].innerText : bro.val()); |
|
|
textarea.classList.add('height100p', 'wide100p', 'maximized_textarea'); |
|
|
bro.hasClass('monospace') && textarea.classList.add('monospace'); |
|
|
bro.hasClass('mdHotkeys') && textarea.classList.add('mdHotkeys'); |
|
|
textarea.addEventListener('input', function () { |
|
|
if (contentEditable) { |
|
|
bro[0].innerText = textarea.value; |
|
|
bro.trigger('input'); |
|
|
} else { |
|
|
bro.val(textarea.value).trigger('input'); |
|
|
} |
|
|
}); |
|
|
wrapper.appendChild(textarea); |
|
|
|
|
|
if (withTab) { |
|
|
textarea.addEventListener('keydown', (evt) => { |
|
|
if (evt.key == 'Tab' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey) { |
|
|
evt.preventDefault(); |
|
|
const start = textarea.selectionStart; |
|
|
const end = textarea.selectionEnd; |
|
|
if (end - start > 0 && textarea.value.substring(start, end).includes('\n')) { |
|
|
const lineStart = textarea.value.lastIndexOf('\n', start); |
|
|
const count = textarea.value.substring(lineStart, end).split('\n').length - 1; |
|
|
textarea.value = `${textarea.value.substring(0, lineStart)}${textarea.value.substring(lineStart, end).replace(/\n/g, '\n\t')}${textarea.value.substring(end)}`; |
|
|
textarea.selectionStart = start + 1; |
|
|
textarea.selectionEnd = end + count; |
|
|
} else { |
|
|
textarea.value = `${textarea.value.substring(0, start)}\t${textarea.value.substring(end)}`; |
|
|
textarea.selectionStart = start + 1; |
|
|
textarea.selectionEnd = end + 1; |
|
|
} |
|
|
} else if (evt.key == 'Tab' && evt.shiftKey && !evt.ctrlKey && !evt.altKey) { |
|
|
evt.preventDefault(); |
|
|
const start = textarea.selectionStart; |
|
|
const end = textarea.selectionEnd; |
|
|
const lineStart = textarea.value.lastIndexOf('\n', start); |
|
|
const count = textarea.value.substring(lineStart, end).split('\n\t').length - 1; |
|
|
textarea.value = `${textarea.value.substring(0, lineStart)}${textarea.value.substring(lineStart, end).replace(/\n\t/g, '\n')}${textarea.value.substring(end)}`; |
|
|
textarea.selectionStart = start - 1; |
|
|
textarea.selectionEnd = end - count; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
await callGenericPopup(wrapper, POPUP_TYPE.TEXT, '', { wide: true, large: true }); |
|
|
}); |
|
|
|
|
|
$(document).on('click', 'body .mes .mes_text', function () { |
|
|
if (!power_user.click_to_edit) return; |
|
|
if (window.getSelection().toString()) return; |
|
|
if ($('.edit_textarea').length) return; |
|
|
$(this).closest('.mes').find('.mes_edit').trigger('click'); |
|
|
}); |
|
|
|
|
|
$(document).on('click', '.open_media_overrides', openExternalMediaOverridesDialog); |
|
|
$(document).on('input', '#forbid_media_override_allowed', function () { |
|
|
const entityId = getCurrentEntityId(); |
|
|
if (!entityId) return; |
|
|
power_user.external_media_allowed_overrides.push(entityId); |
|
|
power_user.external_media_forbidden_overrides = power_user.external_media_forbidden_overrides.filter((v) => v !== entityId); |
|
|
saveSettingsDebounced(); |
|
|
reloadCurrentChat(); |
|
|
}); |
|
|
$(document).on('input', '#forbid_media_override_forbidden', function () { |
|
|
const entityId = getCurrentEntityId(); |
|
|
if (!entityId) return; |
|
|
power_user.external_media_forbidden_overrides.push(entityId); |
|
|
power_user.external_media_allowed_overrides = power_user.external_media_allowed_overrides.filter((v) => v !== entityId); |
|
|
saveSettingsDebounced(); |
|
|
reloadCurrentChat(); |
|
|
}); |
|
|
$(document).on('input', '#forbid_media_override_global', function () { |
|
|
const entityId = getCurrentEntityId(); |
|
|
if (!entityId) return; |
|
|
power_user.external_media_allowed_overrides = power_user.external_media_allowed_overrides.filter((v) => v !== entityId); |
|
|
power_user.external_media_forbidden_overrides = power_user.external_media_forbidden_overrides.filter((v) => v !== entityId); |
|
|
saveSettingsDebounced(); |
|
|
reloadCurrentChat(); |
|
|
}); |
|
|
|
|
|
$('#creators_note_styles_button').on('click', function (e) { |
|
|
e.stopPropagation(); |
|
|
openGlobalStylesPreferenceDialog(); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getMediaContainerInfo(containerClass = '.mes_media_container') { |
|
|
const messageBlock = $(this).closest('.mes'); |
|
|
const messageId = Number(messageBlock.attr('mesid')); |
|
|
const mediaBlock = $(this).closest(containerClass); |
|
|
const mediaIndex = Number(mediaBlock.attr('data-index')); |
|
|
return { messageBlock, messageId, mediaBlock, mediaIndex }; |
|
|
} |
|
|
chatElement.on('click', '.mes_img', async function () { |
|
|
const { messageId, mediaIndex } = getMediaContainerInfo.call(this); |
|
|
expandMessageMedia(messageId, mediaIndex); |
|
|
}); |
|
|
chatElement.on('click', '.mes_media_enlarge', async function () { |
|
|
const { messageId, mediaIndex } = getMediaContainerInfo.call(this); |
|
|
expandMessageMedia(messageId, mediaIndex).click(); |
|
|
}); |
|
|
chatElement.on('click', '.mes_media_delete', async function () { |
|
|
const { messageId, mediaIndex, messageBlock } = getMediaContainerInfo.call(this); |
|
|
await deleteMessageMedia(messageId, mediaIndex, messageBlock); |
|
|
}); |
|
|
chatElement.on('click', '.mes_media_list', async function () { |
|
|
const { messageId, messageBlock } = getMediaContainerInfo.call(this); |
|
|
await switchMessageMediaDisplay(messageId, messageBlock, MEDIA_DISPLAY.GALLERY); |
|
|
}); |
|
|
chatElement.on('click', '.mes_media_gallery', async function () { |
|
|
const { messageId, messageBlock } = getMediaContainerInfo.call(this); |
|
|
await switchMessageMediaDisplay(messageId, messageBlock, MEDIA_DISPLAY.LIST); |
|
|
}); |
|
|
chatElement.on('click', '.mes_img_swipe_left', async function () { |
|
|
const { messageId, messageBlock } = getMediaContainerInfo.call(this); |
|
|
await onImageSwiped(messageId, messageBlock, SWIPE_DIRECTION.LEFT); |
|
|
}); |
|
|
chatElement.on('click', '.mes_img_swipe_right', async function () { |
|
|
const { messageId, messageBlock } = getMediaContainerInfo.call(this); |
|
|
await onImageSwiped(messageId, messageBlock, SWIPE_DIRECTION.RIGHT); |
|
|
}); |
|
|
|
|
|
$('#file_form_input').on('change', async () => { |
|
|
const fileInput = document.getElementById('file_form_input'); |
|
|
if (!(fileInput instanceof HTMLInputElement)) return; |
|
|
await onFileAttach(fileInput.files); |
|
|
}); |
|
|
$('#file_form').on('reset', function () { |
|
|
$('#file_form').addClass('displayNone'); |
|
|
}); |
|
|
|
|
|
document.getElementById('send_textarea').addEventListener('paste', async function (event) { |
|
|
if (event.clipboardData.files.length === 0) { |
|
|
return; |
|
|
} |
|
|
|
|
|
event.preventDefault(); |
|
|
event.stopPropagation(); |
|
|
|
|
|
await handleFileAttach(Array.from(event.clipboardData.files)); |
|
|
}); |
|
|
|
|
|
new DragAndDropHandler('#form_sheld', async (files) => { |
|
|
await handleFileAttach(files); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function handleFileAttach(files) { |
|
|
const fileInput = document.getElementById('file_form_input'); |
|
|
if (!(fileInput instanceof HTMLInputElement)) return; |
|
|
|
|
|
|
|
|
const dataTransfer = new DataTransfer(); |
|
|
for (let i = 0; i < files.length; i++) { |
|
|
dataTransfer.items.add(files[i]); |
|
|
} |
|
|
|
|
|
|
|
|
for (const file of fileInput.files) { |
|
|
if (!Array.from(dataTransfer.files).some(f => isSameFile(f, file))) { |
|
|
dataTransfer.items.add(file); |
|
|
} |
|
|
} |
|
|
|
|
|
fileInput.files = dataTransfer.files; |
|
|
await onFileAttach(fileInput.files); |
|
|
} |
|
|
|
|
|
eventSource.on(event_types.CHAT_CHANGED, checkForCreatorNotesStyles); |
|
|
} |
|
|
|