diff --git a/public/lib/eventemitter.js b/public/lib/eventemitter.js index 51ac4eca3..abf2b8dbc 100644 --- a/public/lib/eventemitter.js +++ b/public/lib/eventemitter.js @@ -95,13 +95,14 @@ EventEmitter.prototype.removeListener = function (event, listener) { }; EventEmitter.prototype.emit = async function (event) { + let args = [].slice.call(arguments, 1); if (localStorage.getItem('eventTracing') === 'true') { console.trace('Event emitted: ' + event, args); } else { console.debug('Event emitted: ' + event); } - var i, listeners, length, args = [].slice.call(arguments, 1); + let i, listeners, length; if (typeof this.events[event] === 'object') { listeners = this.events[event].slice(); @@ -120,13 +121,14 @@ EventEmitter.prototype.emit = async function (event) { }; EventEmitter.prototype.emitAndWait = function (event) { + let args = [].slice.call(arguments, 1); if (localStorage.getItem('eventTracing') === 'true') { console.trace('Event emitted: ' + event, args); } else { console.debug('Event emitted: ' + event); } - var i, listeners, length, args = [].slice.call(arguments, 1); + let i, listeners, length; if (typeof this.events[event] === 'object') { listeners = this.events[event].slice(); diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index e31807893..e5ba1ed21 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -14,7 +14,6 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from ' import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js'; import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; -import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js'; import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; export { MODULE_NAME }; @@ -986,6 +985,71 @@ async function setSpriteSlashCommand(_, spriteId) { return label; } +/** + * Returns the sprite folder name (including override) for a character. + * @param {object} char Character object + * @param {string} char.avatar Avatar filename with extension + * @returns {string} Sprite folder name + * @throws {Error} If character not found or avatar not set + */ +function spriteFolderNameFromCharacter(char) { + const avatarFileName = char.avatar.replace(/\.[^/.]+$/, ''); + const expressionOverride = extension_settings.expressionOverrides.find(e => e.name === avatarFileName); + return expressionOverride?.path ? expressionOverride.path : avatarFileName; +} + +/** + * Slash command callback for /uploadsprite + * + * label= is required + * if name= is provided, it will be used as a findChar lookup + * if name= is not provided, the last character's name will be used + * if folder= is a full path, it will be used as the folder + * if folder= is a partial path, it will be appended to the character's name + * if folder= is not provided, the character's override folder will be used, if set + * + * @param {object} args + * @param {string} args.name Character name or avatar key, passed through findChar + * @param {string} args.label Expression label + * @param {string} args.folder Sprite folder path, processed using backslash rules + * @param {string} imageUrl Image URI to fetch and upload + * @returns {Promise} + */ +async function uploadSpriteCommand({ name, label, folder }, imageUrl) { + if (!imageUrl) throw new Error('Image URL is required'); + if (!label || typeof label !== 'string') throw new Error('Expression label is required'); + + label = label.replace(/[^a-z]/gi, '').toLowerCase().trim(); + if (!label) throw new Error('Expression label must contain at least one letter'); + + name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name; + const char = findChar({ name }); + + if (!folder) { + folder = spriteFolderNameFromCharacter(char); + } else if (folder.startsWith('/') || folder.startsWith('\\')) { + const subfolder = folder.slice(1); + folder = `${char.name}/${subfolder}`; + } + + try { + const response = await fetch(imageUrl); + const blob = await response.blob(); + const file = new File([blob], 'image.png', { type: 'image/png' }); + + const formData = new FormData(); + formData.append('name', folder); // this is the folder or character name + formData.append('label', label); // this is the expression label + formData.append('avatar', file); // this is the image file + + await handleFileUpload('/api/sprites/upload', formData); + console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`); + } catch (error) { + console.error(`[${MODULE_NAME}] Error uploading file:`, error); + throw error; + } +} + /** * Processes the classification text to reduce the amount of text sent to the API. * Quotes and asterisks are to be removed. If the text is less than 300 characters, it is returned as is. @@ -1283,8 +1347,6 @@ async function drawSpritesList(character, labels, sprites) { * @returns {Promise} Rendered list item template */ async function getListItem(item, imageSrc, textClass, isCustom) { - const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; - imageSrc = isFirefox ? `${imageSrc}?t=${Date.now()}` : imageSrc; return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom }); } @@ -2217,4 +2279,43 @@ function migrateSettings() { `, })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'uploadsprite', + callback: async (args, url) => { + await uploadSpriteCommand(args, url); + return ''; + }, + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'URL of the image to upload', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + }), + ], + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'name', + description: 'Character name or avatar key (default is current character)', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: false, + acceptsMultiple: false, + }), + SlashCommandNamedArgument.fromProps({ + name: 'label', + description: 'Sprite label/expression name', + typeList: [ARGUMENT_TYPE.STRING], + enumProvider: localEnumProviders.expressions, + isRequired: true, + acceptsMultiple: false, + }), + SlashCommandNamedArgument.fromProps({ + name: 'folder', + description: 'Override folder to upload into', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: false, + acceptsMultiple: false, + }), + ], + helpString: '
Upload a sprite from a URL.
Example:
/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png
', + })); })(); diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index 733b12478..6a2e5e6b9 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -124,9 +124,10 @@ router.get('/get', jsonParser, function (request, response) { }) .map((file) => { const pathToSprite = path.join(spritesPath, file); + const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14); return { label: path.parse(pathToSprite).name.toLowerCase(), - path: `/characters/${name}/${file}`, + path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), }; }); }