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 | EventEmitter.prototype.emit = async function (event) { | 97 | EventEmitter.prototype.emit = async function (event) { |
| 98 | let args = [].slice.call(arguments, 1); | ||
| 98 | if (localStorage.getItem('eventTracing') === 'true') { | 99 | if (localStorage.getItem('eventTracing') === 'true') { |
| 99 | console.trace('Event emitted: ' + event, args); | 100 | console.trace('Event emitted: ' + event, args); |
| 100 | } else { | 101 | } else { |
| 101 | console.debug('Event emitted: ' + event); | 102 | console.debug('Event emitted: ' + event); |
| 102 | } | 103 | } |
| 103 | 104 | ||
| 104 | var i, listeners, length, args = [].slice.call(arguments, 1); | 105 | let i, listeners, length; |
| 105 | 106 | ||
| 106 | if (typeof this.events[event] === 'object') { | 107 | if (typeof this.events[event] === 'object') { |
| 107 | listeners = this.events[event].slice(); | 108 | listeners = this.events[event].slice(); |
| @@ -120,13 +121,14 @@ EventEmitter.prototype.emit = async function (event) { | |||
| 120 | }; | 121 | }; |
| 121 | 122 | ||
| 122 | EventEmitter.prototype.emitAndWait = function (event) { | 123 | EventEmitter.prototype.emitAndWait = function (event) { |
| 124 | let args = [].slice.call(arguments, 1); | ||
| 123 | if (localStorage.getItem('eventTracing') === 'true') { | 125 | if (localStorage.getItem('eventTracing') === 'true') { |
| 124 | console.trace('Event emitted: ' + event, args); | 126 | console.trace('Event emitted: ' + event, args); |
| 125 | } else { | 127 | } else { |
| 126 | console.debug('Event emitted: ' + event); | 128 | console.debug('Event emitted: ' + event); |
| 127 | } | 129 | } |
| 128 | 130 | ||
| 129 | var i, listeners, length, args = [].slice.call(arguments, 1); | 131 | let i, listeners, length; |
| 130 | 132 | ||
| 131 | if (typeof this.events[event] === 'object') { | 133 | if (typeof this.events[event] === 'object') { |
| 132 | listeners = this.events[event].slice(); | 134 | listeners = this.events[event].slice(); |
| @@ -14,7 +14,6 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from ' | |||
| 14 | import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js'; | 14 | import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js'; |
| 15 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; | 15 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 16 | import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; | 16 | import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; |
| 17 | import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js'; | ||
| 18 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; | 17 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; |
| 19 | export { MODULE_NAME }; | 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 | * Processes the classification text to reduce the amount of text sent to the API. | 1054 | * Processes the classification text to reduce the amount of text sent to the API. |
| 991 | * Quotes and asterisks are to be removed. If the text is less than 300 characters, it is returned as is. | 1055 | * Quotes and asterisks are to be removed. If the text is less than 300 characters, it is returned as is. |
| 992 | * If the text is more than 300 characters, the first and last 150 characters are returned. | 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 | * @returns {Promise<string>} Rendered list item template | 1347 | * @returns {Promise<string>} Rendered list item template |
| 1284 | */ | 1348 | */ |
| 1285 | async function getListItem(item, imageSrc, textClass, isCustom) { | 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 | return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom }); | 1350 | return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom }); |
| 1289 | } | 1351 | } |
| 1290 | 1352 | ||
| @@ -2217,4 +2279,43 @@ function migrateSettings() { | |||
| 2217 | </div> | 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 | .map((file) => { | 125 | .map((file) => { |
| 126 | const pathToSprite = path.join(spritesPath, file); | 126 | const pathToSprite = path.join(spritesPath, file); |
| 127 | const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14); | ||
| 127 | return { | 128 | return { |
| 128 | label: path.parse(pathToSprite).name.toLowerCase(), | 129 | label: path.parse(pathToSprite).name.toLowerCase(), |
| 129 | path: `/characters/${name}/${file}`, | 130 | path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), |
| 130 | }; | 131 | }; |
| 131 | }); | 132 | }); |
| 132 | } | 133 | } |