Add Full Support for BYAF Archives (#4701) * + Fix discarding all scenarios aside from the first one, which would cause all chats other than the first one to be lost. + Accumulate alternative starting messages from all scenarios, as different scenarios are how Backyard handles having alternative greetings. A scenario with multiple messages in its "firstMessages" array is invalid as per BYAF spec. + Return scenarios so that they can be used to populate chats, scenario overrides, and in the future, the other character settings overrides as well. * + Added support for importing chats (including swipes) from BYAF. This brings the core functionality of BYAF imports mostly in-line with the standard, without the inclusion of any features that are currently missing from ST. > The only supported feature not yet implemented is scenario overrides per-chat. This will be the the goal of the next commit. * + Implemented scenario overrides, fully bringing ST's BYAF import as in-line with the BYAF standard as possible without adding new feature functionality to ST. + Also added some new chat metadata fields in preparation for example message and system prompt overrides. * + Added support for pulling alternate icons from the BYAF archive. They are not used yet, but they are safely stored. + Added support for pulling backgrounds from the BYAF archive. They are not used yet but they are stored and linked to the chats they originally belonged to. + Saved every remaining field that was previously discarded from BYAF that a user actually needs in a way that could be used in the future, and can be accessed by the user if needed after import. Any fields that are not preserved at this point are specfic to either Backyard AI's desktop application, or to the BYAF manifest itself, and therefore have no relevance to a user. > With this, BYAF import should be fully supported and in-line with the official spec. * + Updated comments and method names to reflect changes appropriately. First round of touch ups. * + More comment updates. * - Removed commented code, that is no longer needed for reference. * > No longer sanitize for filenames in the character card, as there's no need. The sanitization now happens when generating the filename, which is subsequently used for file operations. + Added replacement function for sanitizing filenames which uses visually similar but valid for filenames replacement characters instead of deleting the characters for better data parity. * + Preserve isNSFW flag as a tag, so it's not lost on import. * + preserve labels on character alt avatars * + Deduplicate first messages, so that 5 chats with the same first message don't create 5 identical first messages. * + Adjusted icon naming logic to produce more sensible results. * > User path.basename and path.extname for getting the base name and extension of files, respectively. * > Should use camelCase for local function names. * > Use a set for greetings instead of explicitly checking for duplicates. * > Missed a snake case local function name. * > Updated background import logic to integrate with existing per-chat background system. * + Updated logic to skip import of chats and backgrounds when a character is being updated/replaced. * + Added copy of getUniqueName helper function from frontend utils to backend utils. + Refactored duplicated logic for getting unique filenames for backgrounds and character alt icons to use the newly available function. * > fixed adding an extra dot to the filenames for icons after switching to path.extname for getting the extension * > Updated metadata settings generation in byaf chat so that it doesn't overwrite valid yet 'falsy' values with defaults. Oops * > Remove variable for configuration that will likely never need to exist. * > custom_background should be an empty string if chat_background is falsy * > Make an array empty if chat_background is falsy. * > Prefer camelCase for local variable names. * + Added definitions for ByafParseResult and ByafChatBackground. > Updated overused and unweildy anonymous types to the newly defined types. * + When importing a character with chats, the character will now no longer open to a new empty chat, but instead will pick the first of the imported chats to open up to. * Rename prev_paths => paths * Prettify logs display * Set fallback chat name if scenario is untitled * card.data.name => card.name * Optimize first scenario message access * + Moved custom replacement function from importFromByaf into util.js as sanitizeSafeCharacterReplacements() so that the replacement logic may be reused anywhere for consistency. * > Missed a couple. Updated for consistency * + Preserve character full/display name without it needing to be safe as a filename. > Change logic for naming the folder the alt images live in to match the logic the sprites endpoint uses to get character sprites for consistency * > Only add full_name extension if displayName exists. * full_name => display_name * - No longer use safe lookalike replacements for filename sanitization to be consistent with how web browsers sanitize invalid characters in filenames before saving to disk. * Tiny formatting fix --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

dd758161bdf8b27f7c3c9b12c246ff677e1081fa

docfail <4965997+docfail@users.noreply.github.com>

Signed
5 files changed, +330 -71Ignore whitespace
public/script.js+1 -0
@@ -9322,6 +9322,7 @@ async function importCharacter(file, { preserveFileName = '', importTags = false
93229322 const formData = new FormData();
93239323 formData.append('avatar', file);
93249324 formData.append('file_type', format);
9325+ formData.append('user_name', name1);
93259326 if (preserveFileName) formData.append('preserved_name', preserveFileName);
93269327
93279328 try {
src/byaf.js+226 -68
@@ -1,4 +1,3 @@
1-import sanitize from 'sanitize-filename';
21import { promises as fsPromises } from 'node:fs';
32import path from 'node:path';
43import urlJoin from 'url-join';
@@ -28,7 +27,7 @@ export class ByafParser {
2827 * @returns {string} String with macros replaced
2928 * @private
3029 */
3130 static replaceMacros(str) {
3231 return String(str || '')
3332 .replace(/#{user}:/gi, '{{user}}:')
3433 .replace(/#{character}:/gi, '{{char}}:')
@@ -42,7 +41,7 @@ export class ByafParser {
4241 * @returns {string} Formatted example messages
4342 * @private
4443 */
4544 static formatExampleMessages(examples) {
4645 if (!Array.isArray(examples)) {
4746 return '';
4847 }
@@ -53,7 +52,7 @@ export class ByafParser {
5352 if (!example?.text) {
5453 return;
5554 }
5655 formattedExamples += `<START>\n${thisByafParser.replaceMacros(example.text)}\n`;
5756 });
5857
5958 return formattedExamples.trimEnd();
@@ -61,21 +60,30 @@ export class ByafParser {
6160
6261 /**
6362 * Formats alternate greetings for a character.
6463 * @param {ByafExampleMessagePartial<ByafScenario>[]} [greetingsscenarios] Array of greetingscenario objects
6564 * @returns {string[]} Formatted alternate greetings
6665 * @private
6766 */
6867 formatAlternateGreetings(greetingsscenarios) {
6968 if (!Array.isArray(greetingsscenarios)) {
7069 return [];
7170 }
7271
73- if (greetings.length <= 1) {
72+ // Skip one because it goes into 'first_mes'
73+ if (scenarios.length <= 1) {
7474 return [];
7575 }
76-
76+ const greetings = new Set();
77- // Skip one because it goes into 'first_mes'
77+ const firstScenarioFirstMessage = scenarios?.[0]?.firstMessages?.[0]?.text;
78- return greetings.slice(1).map(g => this.replaceMacros(g?.text));
78+ for (const scenario of scenarios.slice(1).filter(s => Array.isArray(s.firstMessages) && s.firstMessages.length > 0)) {
79+ // As per the BYAF spec, "firstMessages" array MUST contain AT MOST one message.
80+ // So we only consider the first one if it exists.
81+ const firstMessage = scenario?.firstMessages?.[0];
82+ if (firstMessage?.text && firstMessage.text !== firstScenarioFirstMessage) {
83+ greetings.add(ByafParser.replaceMacros(firstMessage.text));
84+ }
85+ }
86+ return Array.from(greetings);
7987 }
8088
8189 /**
@@ -100,8 +108,8 @@ export class ByafParser {
100108 return;
101109 }
102110 book.entries.push({
103111 keys: thisByafParser.replaceMacros(item?.key).split(',').map(key => key.trim()).filter(Boolean),
104112 content: thisByafParser.replaceMacros(item?.value),
105113 extensions: {},
106114 enabled: true,
107115 insertion_order: index,
@@ -152,108 +160,147 @@ export class ByafParser {
152160 }
153161
154162 /**
155163 * Extracts aall scenario objectobjects from BYAF buffer.
156164 * @param {ByafManifest} manifest BYAF manifest
157165 * @returns {Promise<Partial<ByafScenario>[]>} ScenarioScenarios objectarray
158166 * @private
159167 */
160168 async getScenarioFromManifestgetScenariosFromManifest(manifest) {
161169 const scenariosArray = manifest?.scenarios;
162170
163171 if (!Array.isArray(scenariosArray) || scenariosArray.length === 0) {
164172 console.warn('Warning: BYAF manifest contains no scenarios');
165173 return [{}];
166174 }
167175
168- if (scenariosArray.length > 1) {
176+ const scenarios = [];
169- console.warn('Warning: BYAF manifest contains more than one scenario, only the first one will be imported');
170- }
171177
172178 for (const scenarioPath =of scenariosArray[0];) {
173- if (!scenarioPath) {
179+ const scenarioBuffer = await extractFileFromZipBuffer(this.#data, scenarioPath);
174- console.warn('Warning: missing BYAF scenario path');
180+ if (!scenarioBuffer) {
175- return {};
181+ console.warn('Warning: failed to extract BYAF scenario JSON');
182+ }
183+ if (scenarioBuffer) {
184+ try {
185+ scenarios.push(JSON.parse(scenarioBuffer.toString()));
186+ } catch (error) {
187+ console.warn('Warning: BYAF scenario is not a valid JSON', error);
188+ }
189+ }
176190 }
177191
178- const scenarioBuffer = await extractFileFromZipBuffer(this.#data, scenarioPath);
192+ if (scenarios.length === 0) {
179- if (!scenarioBuffer) {
193+ console.warn('Warning: BYAF manifest contains no valid scenarios');
180- console.warn('Warning: failed to extract BYAF scenario JSON');
194+ return [{}];
181- return {};
182195 }
183196
184- try {
197+ return scenarios;
185- return JSON.parse(scenarioBuffer.toString());
186- } catch (error) {
187- console.warn('Warning: BYAF scenario is not a valid JSON', error);
188- return {};
189- }
190198 }
191199
192200 /**
193201 * Extracts anall imagecharacter icon images from BYAF buffer.
194202 * @param {ByafCharacter} character Character object
195203 * @param {string} characterPath Path to the character in the BYAF manifest
196204 * @return {Promise<{filename: string, image: Buffer, label: string}[]>} Image buffer
197205 * @private
198206 */
199207 async getCharacterImagegetCharacterImages(character, characterPath) {
200208 const defaultAvatarBuffer = await fsPromises.readFile(DEFAULT_AVATAR_PATH);
201209 const characterImages = character?.images;
202210
203211 if (!Array.isArray(characterImages) || characterImages.length === 0) {
204212 console.warn('Warning: BYAF character has no images');
205- return defaultAvatarBuffer;
213+ return [{ filename: '', image: defaultAvatarBuffer, label: '' }];
206214 }
207215
208216 const imagePathimageBuffers = characterImages[0]?.path;
209217 iffor (!imagePathconst image of characterImages) {
210- console.warn('Warning: BYAF character image path is empty');
218+ const imagePath = image?.path;
211- return defaultAvatarBuffer;
219+ if (!imagePath) {
212- }
220+ console.warn('Warning: BYAF character image path is empty');
221+ continue;
222+ }
213223
214224 const fullImagePath = urlJoin(path.dirname(characterPath), imagePath);
215225 const imageBuffer = await extractFileFromZipBuffer(this.#data, fullImagePath);
216226 if (!imageBuffer) {
217227 console.warn('Warning: failed to extract BYAF character image');
218- return defaultAvatarBuffer;
228+ continue;
219229 }
220230
221- return imageBuffer;
231+ imageBuffers.push({ filename: path.basename(imagePath), image: imageBuffer, label: image?.label || '' });
232+ }
233+ if (imageBuffers.length === 0) {
234+ console.warn('Warning: BYAF character has no valid images');
235+ return [{ filename: '', image: defaultAvatarBuffer, label: '' }];
236+ }
237+ return imageBuffers;
222238 }
223239
224240 /**
225241 * Formats BYAF data as a character card.
226242 * @param {ByafManifest} manifest BYAF manifest
227243 * @param {ByafCharacter} character Character object
228244 * @param {Partial<ByafScenario>[]} scenarioscenarios ScenarioScenarios objectarray
229245 * @return {TavernCardV2} Character card object
230246 * @private
231247 */
232248 getCharacterCard(manifest, character, scenarioscenarios) {
233249 return {
234250 spec: 'chara_card_v2',
235251 spec_version: '2.0',
236252 data: {
237253 name: sanitize(character?.name || character?.displayName || ''),
238254 description: thisByafParser.replaceMacros(character?.persona),
239255 personality: '',
240256 scenario: thisByafParser.replaceMacros(scenarioscenarios[0]?.narrative),
241257 first_mes: thisByafParser.replaceMacros(scenarioscenarios[0]?.firstMessages?.[0]?.text),
242258 mes_example: thisByafParser.formatExampleMessages(scenarioscenarios[0]?.exampleMessages),
243- creator_notes: '',
259+ creator_notes: manifest?.author?.backyardURL || '', // To preserve the link to the author from BYAF manifest, this is a good place.
244260 system_prompt: thisByafParser.replaceMacros(scenarioscenarios[0]?.formattingInstructions),
245261 post_history_instructions: '',
246262 alternate_greetings: this.formatAlternateGreetings(scenario?.firstMessagesscenarios),
247263 character_book: this.convertCharacterBook(character?.loreItems),
248- tags: [],
264+ tags: character?.isNSFW ? ['nsfw'] : [], // Since there are no tags in BYAF spec, we can use this to preserve the isNSFW flag.
249265 creator: manifest?.author?.name || '',
250266 character_version: '',
251- extensions: {},
267+ extensions: { ...(character?.displayName && { 'display_name': character?.displayName }) }, // Preserve display name unmodified using extensions. "display_name" is not used by SillyTavern currently.
252268 },
253269 // @ts-ignore Non-standard spec extension
254270 create_date: humanizedISO8601DateTime(),
255271 };
256272 }
273+ /**
274+ * Gets chat backgrounds from BYAF data mapped to their respective scenarios.
275+ * @param {ByafCharacter} character Character object
276+ * @param {Partial<ByafScenario>[]} scenarios Scenarios array
277+ * @returns {Promise<Array<ByafChatBackground>>} Chat backgrounds
278+ * @private
279+ */
280+ async getChatBackgrounds(character, scenarios) {
281+ // Implementation for extracting chat backgrounds from BYAF data
282+ const backgrounds = [];
283+ let i = 1;
284+ for (const scenario of scenarios) {
285+ const bgImagePath = scenario?.backgroundImage;
286+ if (bgImagePath) {
287+ const data = await extractFileFromZipBuffer(this.#data, bgImagePath);
288+ if (data) {
289+ const existingIndex = backgrounds.findIndex(bg => bg.data.compare(data) === 0);
290+ if (existingIndex !== -1) {
291+ backgrounds[existingIndex].paths.push(bgImagePath);
292+ continue; // Skip adding a new background since it already exists
293+ }
294+ backgrounds.push({
295+ name: `${character?.name} bg ${i++}` || '',
296+ data: data,
297+ paths: [bgImagePath],
298+ });
299+ }
300+ }
301+ }
302+ return backgrounds;
303+ }
257304
258305 /**
259306 * Gets the manifest from the BYAF data.
@@ -275,17 +322,128 @@ export class ByafParser {
275322 }
276323
277324 /**
325+ * Imports a chat from BYAF format.
326+ * @param {Partial<ByafScenario>} scenario Scenario object
327+ * @param {string} userName User name
328+ * @param {string} characterName Character name
329+ * @param {Array<ByafChatBackground>} chatBackgrounds Chat backgrounds
330+ * @returns {string} Chat data
331+ */
332+ static getChatFromScenario(scenario, userName, characterName, chatBackgrounds) {
333+ const chatStartDate = scenario?.messages?.length == 0 ? humanizedISO8601DateTime() : scenario?.messages?.filter(m => 'createdAt' in m)[0].createdAt;
334+ const chatBackground = chatBackgrounds.find(bg => bg.paths.includes(scenario?.backgroundImage || ''))?.name || '';
335+ /** @type {object[]} */
336+ const chat = [{
337+ user_name: userName,
338+ character_name: characterName,
339+ create_date: chatStartDate,
340+ chat_metadata: {
341+ scenario: scenario?.narrative ?? '',
342+ mes_example: ByafParser.formatExampleMessages(scenario?.exampleMessages),
343+ system_prompt: ByafParser.replaceMacros(scenario?.formattingInstructions),
344+ mes_examples_optional: scenario?.canDeleteExampleMessages ?? false,
345+ byaf_model_settings: {
346+ model: scenario?.model ?? '',
347+ temperature: scenario?.temperature ?? 1.2,
348+ top_k: scenario?.topK ?? 40,
349+ top_p: scenario?.topP ?? 0.9,
350+ min_p: scenario?.minP ?? 0.1,
351+ min_p_enabled: scenario?.minPEnabled ?? true,
352+ repeat_penalty: scenario?.repeatPenalty ?? 1.05,
353+ repeat_penalty_tokens: scenario?.repeatLastN ?? 256,
354+ by_prompt_template: scenario?.promptTemplate ?? 'general',
355+ grammar: scenario?.grammar ?? null,
356+ },
357+ chat_backgrounds: chatBackground ? [chatBackground] : [],
358+ custom_background: chatBackground ? `url("${encodeURI(chatBackground)}")` : '',
359+ },
360+ }];
361+ // Add the first message IF it exists.
362+ if (scenario?.firstMessages?.length && scenario?.firstMessages?.length > 0 && scenario?.firstMessages?.[0]?.text) {
363+ chat.push({
364+ name: characterName,
365+ is_user: false,
366+ send_date: chatStartDate,
367+ mes: scenario?.firstMessages?.[0]?.text || '',
368+ });
369+ }
370+
371+ const sortByTimestamp = (newest, curr) => {
372+ const aTime = new Date(newest.activeTimestamp);
373+ const bTime = new Date(curr.activeTimestamp);
374+ return aTime >= bTime ? newest : curr;
375+ };
376+
377+ const getNewestAiMessage = (message) => {
378+ return message.outputs.reduce(sortByTimestamp);
379+ };
380+ const getSwipesForAiMessage = (aiMessage) => {
381+ return aiMessage.outputs.map(output => output.text);
382+ };
383+
384+ const userMessages = scenario?.messages?.filter(msg => msg.type === 'human');
385+ const characterMessages = scenario?.messages?.filter(msg => msg.type === 'ai');
386+ /**
387+ * Reorders messages by interleaving user and character messages so that they are in correct chronological order.
388+ * This is only needed to import old chats from Backyard AI that were incorrectly imported by an earlier version
389+ * that completely messed up the order of messages. Backyard AI Windows frontend never supported creation of chats
390+ * with which were ordered like this in the first place, so for most users this is desired functionality.
391+ */
392+ if (userMessages && characterMessages && userMessages.length === characterMessages.length) { // Only do the reordering if there are equal numbers of user and character messages, otherwise just import in existing order, because it's probably correct already.
393+ for (let i = 0; i < userMessages.length; i++) {
394+ chat.push({
395+ name: userName,
396+ is_user: true,
397+ send_date: Number(userMessages[i]?.createdAt),
398+ mes: userMessages[i]?.text,
399+ });
400+ const aiMessage = getNewestAiMessage(characterMessages[i]);
401+ const aiSwipes = getSwipesForAiMessage(characterMessages[i]);
402+ chat.push({
403+ name: characterName,
404+ is_user: false,
405+ send_date: Number(aiMessage.createdAt),
406+ mes: aiMessage.text,
407+ swipes: aiSwipes,
408+ swipe_id: aiSwipes.findIndex(s => s === aiMessage.text),
409+ });
410+ }
411+ } else if (scenario?.messages) {
412+ for (const message of scenario.messages) {
413+ const isUser = message.type === 'human';
414+ const aiMessage = !isUser ? getNewestAiMessage(message) : null;
415+ const chatMessage = {
416+ name: isUser ? userName : characterName,
417+ is_user: isUser,
418+ send_date: Number(isUser ? message.createdAt : aiMessage.createdAt),
419+ mes: isUser ? message.text : aiMessage.text,
420+ };
421+ if (!isUser) {
422+ const aiSwipes = getSwipesForAiMessage(message);
423+ chatMessage.swipes = aiSwipes;
424+ chatMessage.swipe_id = aiSwipes.findIndex(s => s === aiMessage.text);
425+ }
426+ chat.push(chatMessage);
427+ }
428+ } else {
429+ console.warn('Warning: BYAF scenario contained no messages property.');
430+ }
431+
432+ return chat.map(obj => JSON.stringify(obj)).join('\n');
433+ }
434+
435+ /**
278436 * Parses the BYAF data.
279437 * @return {Promise<{card: TavernCardV2, image: Buffer}ByafParseResult>} Parsed character card and image buffer
280438 */
281439 async parse() {
282440 const manifest = await this.getManifest();
283441 const { character, characterPath } = await this.getCharacterFromManifest(manifest);
284442 const scenarioscenarios = await this.getScenarioFromManifestgetScenariosFromManifest(manifest);
285443 const imageimages = await this.getCharacterImagegetCharacterImages(character, characterPath);
286444 const card = this.getCharacterCard(manifest, character, scenarioscenarios);
287-
445+ const chatBackgrounds = await this.getChatBackgrounds(character, scenarios);
288446 return { card, imageimages, scenarios, chatBackgrounds, character };
289447 }
290448}
291449
src/endpoints/characters.js+64 -3
@@ -14,7 +14,7 @@ import storage from 'node-persist';
1414
1515import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js';
1616import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
1717import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js';
1818import { TavernCardValidator } from '../validator/TavernCardValidator.js';
1919import { parse, read, write } from '../character-card-parser.js';
2020import { readWorldInfoFile } from './worldinfo.js';
@@ -814,8 +814,69 @@ async function importFromByaf(uploadPath, { request }, preservedFileName) {
814814
815815 const byafData = await new ByafParser(data).parse();
816816 const card = readFromV2(byafData.card);
817817 const fileName = preservedFileName || getPngName(sanitize(byafData.character.displayName || card.name, { replacement: sanitizeSafeCharacterReplacements }), request.user.directories);
818- const result = await writeCharacterData(byafData.image, JSON.stringify(card), fileName, request);
818+
819+ // Don't import chats and images if the character is being replaced or updated, instead of newly imported.
820+ if (!preservedFileName) {
821+ /**
822+ * @param {Partial<ByafScenario>} scenario
823+ */
824+ const createChatAsCurrentPersona = (scenario) => {
825+ const chatName = sanitize(`${scenario.title || card.name} - ${humanizedISO8601DateTime()} imported.jsonl`, { replacement: sanitizeSafeCharacterReplacements });
826+ const filePath = path.join(request.user.directories.chats, path.basename(fileName), chatName);
827+ const dir = path.dirname(filePath);
828+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
829+ writeFileAtomicSync(filePath, ByafParser.getChatFromScenario(scenario, request.body.user_name, card.name, byafData.chatBackgrounds), 'utf8');
830+ console.log(`Created ${chatName} chat from BYAF import`);
831+ return chatName;
832+ };
833+
834+ // Upload backgrounds
835+ for (const bg of byafData.chatBackgrounds) {
836+ const extension = path.extname(bg.paths?.[0]) || '.png';
837+ const baseName = `${path.basename(fileName)}_bg`;
838+ const filePath = path.join(request.user.directories.userImages, fileName);
839+ if (!fs.existsSync(filePath)) fs.mkdirSync(filePath, { recursive: true });
840+ const file = getUniqueName(baseName, (name) => fs.existsSync(path.join(filePath, `${name}${extension}`)));
841+ if (Buffer.isBuffer(bg.data)) {
842+ const newFile = `${file}${extension}`;
843+ writeFileAtomicSync(path.join(filePath, newFile), bg.data);
844+ bg.name = clientRelativePath(request.user.directories.root, path.join(filePath, newFile)); // Update background name to the new file
845+ console.log(`Created ${newFile} background from BYAF import`);
846+ }
847+ }
848+
849+ const chats = [];
850+ // Create chats for each scenario
851+ if (Array.isArray(byafData.scenarios)) {
852+ for (const scenario of byafData.scenarios) {
853+ chats.push(createChatAsCurrentPersona(scenario));
854+ }
855+ }
856+
857+ // Update the default chat if there are any so we open to an existing chat instead of creating a new one and opening that.
858+ if (chats.length > 0) {
859+ card.chat = path.basename(chats[0], path.extname(chats[0]));
860+ }
861+
862+ // Save alternate icons for the character.
863+ for (const icon of byafData.images.slice(1)) {
864+ // BYAF does not support character expressions, so using the same structure will not result in conflicts,
865+ // even if the expression system did not tolerate additional icons that are not mapped to expressions.
866+ // This will not yet allow changing icons within the UI but at least the icons will be available for manual selection, rather than being lost.
867+ const altImagesFolder = path.join(request.user.directories.characters, sanitize(card.name));
868+ if (!fs.existsSync(altImagesFolder)) fs.mkdirSync(altImagesFolder, { recursive: true });
869+ const extension = path.extname(icon.filename) || '.png';
870+ const file = getUniqueName(`${sanitize(icon.label, { replacement: sanitizeSafeCharacterReplacements }) || 'alt'}`, (name) => fs.existsSync(path.join(altImagesFolder, `${name}${extension}`)));
871+ if (Buffer.isBuffer(icon.image)) {
872+ writeFileAtomicSync(path.join(altImagesFolder, `${file}${extension}`), icon.image);
873+ console.log(`Created ${file}${extension} alternate icon from BYAF import`);
874+ }
875+ }
876+ }
877+
878+ const result = await writeCharacterData(byafData.images[0].image, JSON.stringify(card), fileName, request);
879+
819880 return result ? fileName : '';
820881}
821882
src/types/byaf.d.ts+14 -0
@@ -75,3 +75,17 @@ type ByafScenario = {
7575 messages: Array<ByafAiMessage | ByafHumanMessage>;
7676 backgroundImage?: string;
7777};
78+
79+type ByafChatBackground = {
80+ name: string;
81+ data: Buffer;
82+ paths: string[];
83+};
84+
85+type ByafParseResult = {
86+ card: TavernCardV2,
87+ images: { filename: string, image: Buffer, label: string }[],
88+ scenarios: Partial<ByafScenario>[],
89+ chatBackgrounds: Array<ByafChatBackground>,
90+ character: ByafCharacter
91+};
src/util.js+25 -0
@@ -425,6 +425,31 @@ export function clientRelativePath(root, inputPath) {
425425}
426426
427427/**
428+ * Returns a name that is unique among the names that exist.
429+ * @param {string} name The name to check.
430+ * @param {{ (name: string): boolean; }} exists Function to check if name exists.
431+ * @returns {string} A unique name.
432+ */
433+export function getUniqueName(name, exists) {
434+ let i = 1;
435+ let baseName = name;
436+ while (exists(name)) {
437+ name = `${baseName} (${i})`;
438+ i++;
439+ }
440+ return name;
441+}
442+
443+/**
444+ * Provides safe replacements for characters in filenames. Intended for use with sanitize() from the sanitize-filename package.
445+ * @param {string} char Character to sanitize
446+ * @returns {string} Safe replacement character
447+ */
448+export function sanitizeSafeCharacterReplacements(char) {
449+ return '_';
450+}
451+
452+/**
428453 * Strip the last file extension from a given file name. If there are multiple extensions, only the last is removed.
429454 * @param {string} filename The file name to remove the extension from.
430455 * @returns The file name, sans extension