Merge pull request #3215 from ceruleandeep/feature/uploadSprite /uploadsprite slashcommand

352a8e9a6193c03dd2a348c942a40bd1725bc09a

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
3 files changed, +110 -6Showing whitespace changes
public/lib/eventemitter.js+4 -2
@@ -95,13 +95,14 @@ EventEmitter.prototype.removeListener = function (event, listener) {
95};95};
9696
97EventEmitter.prototype.emit = async function (event) {97EventEmitter.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 }
103104
104 var i, listeners, length, args = [].slice.call(arguments, 1);105 let i, listeners, length;
105106
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};
121122
122EventEmitter.prototype.emitAndWait = function (event) {123EventEmitter.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 }
128130
129 var i, listeners, length, args = [].slice.call(arguments, 1);131 let i, listeners, length;
130132
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();
public/scripts/extensions/expressions/index.js+104 -3
@@ -14,7 +14,6 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
14import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';14import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
15import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';15import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
16import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';16import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
17import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
18import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';17import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
19export { MODULE_NAME };18export { MODULE_NAME };
2019
@@ -987,6 +986,71 @@ async function setSpriteSlashCommand(_, spriteId) {
987}986}
988987
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 */
995function 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 */
1018async 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 template1347 * @returns {Promise<string>} Rendered list item template
1284 */1348 */
1285async function getListItem(item, imageSrc, textClass, isCustom) {1349async 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}
12901352
@@ -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})();
src/endpoints/sprites.js+2 -1
@@ -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 }