Merge pull request #3215 from ceruleandeep/feature/uploadSprite /uploadsprite slashcommand
Signed| @@ -95,13 +95,14 @@ EventEmitter.prototype.removeListener = function (event, listener) { | ||
| 95 | 95 | }; |
| 96 | 96 | |
| 97 | 97 | EventEmitter.prototype.emit = async function (event) { |
| 98 | + let args = [].slice.call(arguments, 1); | |
| 98 | 99 | if (localStorage.getItem('eventTracing') === 'true') { |
| 99 | 100 | console.trace('Event emitted: ' + event, args); |
| 100 | 101 | } else { |
| 101 | 102 | console.debug('Event emitted: ' + event); |
| 102 | 103 | } |
| 103 | 104 | |
| 104 | 105 | varlet i, listeners, length, args = [].slice.call(arguments, 1); |
| 105 | 106 | |
| 106 | 107 | if (typeof this.events[event] === 'object') { |
| 107 | 108 | listeners = this.events[event].slice(); |
| @@ -120,13 +121,14 @@ EventEmitter.prototype.emit = async function (event) { | ||
| 120 | 121 | }; |
| 121 | 122 | |
| 122 | 123 | EventEmitter.prototype.emitAndWait = function (event) { |
| 124 | + let args = [].slice.call(arguments, 1); | |
| 123 | 125 | if (localStorage.getItem('eventTracing') === 'true') { |
| 124 | 126 | console.trace('Event emitted: ' + event, args); |
| 125 | 127 | } else { |
| 126 | 128 | console.debug('Event emitted: ' + event); |
| 127 | 129 | } |
| 128 | 130 | |
| 129 | 131 | varlet i, listeners, length, args = [].slice.call(arguments, 1); |
| 130 | 132 | |
| 131 | 133 | if (typeof this.events[event] === 'object') { |
| 132 | 134 | listeners = this.events[event].slice(); |
| @@ -14,7 +14,6 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from ' | ||
| 14 | 14 | import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js'; |
| 15 | 15 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 16 | 16 | import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; |
| 17 | -import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js'; | |
| 18 | 17 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; |
| 19 | 18 | export { MODULE_NAME }; |
| 20 | 19 | |
| @@ -987,6 +986,71 @@ async function setSpriteSlashCommand(_, spriteId) { | ||
| 987 | 986 | } |
| 988 | 987 | |
| 989 | 988 | /** |
| 989 | + * Returns the sprite folder name (including override) for a character. | |
| 990 | + * @param {object} char Character object | |
| 991 | + * @param {string} char.avatar Avatar filename with extension | |
| 992 | + * @returns {string} Sprite folder name | |
| 993 | + * @throws {Error} If character not found or avatar not set | |
| 994 | + */ | |
| 995 | +function spriteFolderNameFromCharacter(char) { | |
| 996 | + const avatarFileName = char.avatar.replace(/\.[^/.]+$/, ''); | |
| 997 | + const expressionOverride = extension_settings.expressionOverrides.find(e => e.name === avatarFileName); | |
| 998 | + return expressionOverride?.path ? expressionOverride.path : avatarFileName; | |
| 999 | +} | |
| 1000 | + | |
| 1001 | +/** | |
| 1002 | + * Slash command callback for /uploadsprite | |
| 1003 | + * | |
| 1004 | + * label= is required | |
| 1005 | + * if name= is provided, it will be used as a findChar lookup | |
| 1006 | + * if name= is not provided, the last character's name will be used | |
| 1007 | + * if folder= is a full path, it will be used as the folder | |
| 1008 | + * if folder= is a partial path, it will be appended to the character's name | |
| 1009 | + * if folder= is not provided, the character's override folder will be used, if set | |
| 1010 | + * | |
| 1011 | + * @param {object} args | |
| 1012 | + * @param {string} args.name Character name or avatar key, passed through findChar | |
| 1013 | + * @param {string} args.label Expression label | |
| 1014 | + * @param {string} args.folder Sprite folder path, processed using backslash rules | |
| 1015 | + * @param {string} imageUrl Image URI to fetch and upload | |
| 1016 | + * @returns {Promise<void>} | |
| 1017 | + */ | |
| 1018 | +async function uploadSpriteCommand({ name, label, folder }, imageUrl) { | |
| 1019 | + if (!imageUrl) throw new Error('Image URL is required'); | |
| 1020 | + if (!label || typeof label !== 'string') throw new Error('Expression label is required'); | |
| 1021 | + | |
| 1022 | + label = label.replace(/[^a-z]/gi, '').toLowerCase().trim(); | |
| 1023 | + if (!label) throw new Error('Expression label must contain at least one letter'); | |
| 1024 | + | |
| 1025 | + name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name; | |
| 1026 | + const char = findChar({ name }); | |
| 1027 | + | |
| 1028 | + if (!folder) { | |
| 1029 | + folder = spriteFolderNameFromCharacter(char); | |
| 1030 | + } else if (folder.startsWith('/') || folder.startsWith('\\')) { | |
| 1031 | + const subfolder = folder.slice(1); | |
| 1032 | + folder = `${char.name}/${subfolder}`; | |
| 1033 | + } | |
| 1034 | + | |
| 1035 | + try { | |
| 1036 | + const response = await fetch(imageUrl); | |
| 1037 | + const blob = await response.blob(); | |
| 1038 | + const file = new File([blob], 'image.png', { type: 'image/png' }); | |
| 1039 | + | |
| 1040 | + const formData = new FormData(); | |
| 1041 | + formData.append('name', folder); // this is the folder or character name | |
| 1042 | + formData.append('label', label); // this is the expression label | |
| 1043 | + formData.append('avatar', file); // this is the image file | |
| 1044 | + | |
| 1045 | + await handleFileUpload('/api/sprites/upload', formData); | |
| 1046 | + console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`); | |
| 1047 | + } catch (error) { | |
| 1048 | + console.error(`[${MODULE_NAME}] Error uploading file:`, error); | |
| 1049 | + throw error; | |
| 1050 | + } | |
| 1051 | +} | |
| 1052 | + | |
| 1053 | +/** | |
| 990 | 1054 | * Processes the classification text to reduce the amount of text sent to the API. |
| 991 | 1055 | * Quotes and asterisks are to be removed. If the text is less than 300 characters, it is returned as is. |
| 992 | 1056 | * If the text is more than 300 characters, the first and last 150 characters are returned. |
| @@ -1283,8 +1347,6 @@ async function drawSpritesList(character, labels, sprites) { | ||
| 1283 | 1347 | * @returns {Promise<string>} Rendered list item template |
| 1284 | 1348 | */ |
| 1285 | 1349 | async function getListItem(item, imageSrc, textClass, isCustom) { |
| 1286 | - const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; | |
| 1287 | - imageSrc = isFirefox ? `${imageSrc}?t=${Date.now()}` : imageSrc; | |
| 1288 | 1350 | return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom }); |
| 1289 | 1351 | } |
| 1290 | 1352 | |
| @@ -2217,4 +2279,43 @@ function migrateSettings() { | ||
| 2217 | 2279 | </div> |
| 2218 | 2280 | `, |
| 2219 | 2281 | })); |
| 2282 | + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| 2283 | + name: 'uploadsprite', | |
| 2284 | + callback: async (args, url) => { | |
| 2285 | + await uploadSpriteCommand(args, url); | |
| 2286 | + return ''; | |
| 2287 | + }, | |
| 2288 | + unnamedArgumentList: [ | |
| 2289 | + SlashCommandArgument.fromProps({ | |
| 2290 | + description: 'URL of the image to upload', | |
| 2291 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 2292 | + isRequired: true, | |
| 2293 | + }), | |
| 2294 | + ], | |
| 2295 | + namedArgumentList: [ | |
| 2296 | + SlashCommandNamedArgument.fromProps({ | |
| 2297 | + name: 'name', | |
| 2298 | + description: 'Character name or avatar key (default is current character)', | |
| 2299 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 2300 | + isRequired: false, | |
| 2301 | + acceptsMultiple: false, | |
| 2302 | + }), | |
| 2303 | + SlashCommandNamedArgument.fromProps({ | |
| 2304 | + name: 'label', | |
| 2305 | + description: 'Sprite label/expression name', | |
| 2306 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 2307 | + enumProvider: localEnumProviders.expressions, | |
| 2308 | + isRequired: true, | |
| 2309 | + acceptsMultiple: false, | |
| 2310 | + }), | |
| 2311 | + SlashCommandNamedArgument.fromProps({ | |
| 2312 | + name: 'folder', | |
| 2313 | + description: 'Override folder to upload into', | |
| 2314 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 2315 | + isRequired: false, | |
| 2316 | + acceptsMultiple: false, | |
| 2317 | + }), | |
| 2318 | + ], | |
| 2319 | + helpString: '<div>Upload a sprite from a URL.</div><div>Example:</div><pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>', | |
| 2320 | + })); | |
| 2220 | 2321 | })(); |
| @@ -124,9 +124,10 @@ router.get('/get', jsonParser, function (request, response) { | ||
| 124 | 124 | }) |
| 125 | 125 | .map((file) => { |
| 126 | 126 | const pathToSprite = path.join(spritesPath, file); |
| 127 | + const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14); | |
| 127 | 128 | return { |
| 128 | 129 | label: path.parse(pathToSprite).name.toLowerCase(), |
| 129 | 130 | path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), |
| 130 | 131 | }; |
| 131 | 132 | }); |
| 132 | 133 | } |