Spaces:
Running
Running
import path from 'node:path'; | |
import fs from 'node:fs'; | |
import { promises as fsPromises } from 'node:fs'; | |
import { Buffer } from 'node:buffer'; | |
import express from 'express'; | |
import sanitize from 'sanitize-filename'; | |
import { sync as writeFileAtomicSync } from 'write-file-atomic'; | |
import yaml from 'yaml'; | |
import _ from 'lodash'; | |
import mime from 'mime-types'; | |
import { Jimp, JimpMime } from '../jimp.js'; | |
import storage from 'node-persist'; | |
import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js'; | |
import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js'; | |
import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue, mutateJsonString } from '../util.js'; | |
import { TavernCardValidator } from '../validator/TavernCardValidator.js'; | |
import { parse, read, write } from '../character-card-parser.js'; | |
import { readWorldInfoFile } from './worldinfo.js'; | |
import { invalidateThumbnail } from './thumbnails.js'; | |
import { importRisuSprites } from './sprites.js'; | |
import { getUserDirectories } from '../users.js'; | |
import { getChatInfo } from './chats.js'; | |
// With 100 MB limit it would take roughly 3000 characters to reach this limit | |
const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb'); | |
const memoryCache = new MemoryLimitedMap(memoryCacheCapacity); | |
// Some Android devices require tighter memory management | |
const isAndroid = process.platform === 'android'; | |
// Use shallow character data for the character list | |
const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters', false, 'boolean'); | |
const useDiskCache = !!getConfigValue('performance.useDiskCache', true, 'boolean'); | |
class DiskCache { | |
/** | |
* @type {string} | |
* @readonly | |
*/ | |
static DIRECTORY = 'characters'; | |
/** | |
* @type {number} | |
* @readonly | |
*/ | |
static SYNC_INTERVAL = 5 * 60 * 1000; | |
/** @type {import('node-persist').LocalStorage} */ | |
#instance; | |
/** @type {NodeJS.Timeout} */ | |
#syncInterval; | |
/** | |
* Queue of user handles to sync. | |
* @type {Set<string>} | |
* @readonly | |
*/ | |
syncQueue = new Set(); | |
/** | |
* Path to the cache directory. | |
* @returns {string} | |
*/ | |
get cachePath() { | |
return path.join(globalThis.DATA_ROOT, '_cache', DiskCache.DIRECTORY); | |
} | |
/** | |
* Returns the list of hashed keys in the cache. | |
* @returns {string[]} | |
*/ | |
get hashedKeys() { | |
return fs.readdirSync(this.cachePath); | |
} | |
/** | |
* Processes the synchronization queue. | |
* @returns {Promise<void>} | |
*/ | |
async #syncCacheEntries() { | |
try { | |
if (!useDiskCache || this.syncQueue.size === 0) { | |
return; | |
} | |
const directories = [...this.syncQueue].map(entry => getUserDirectories(entry)); | |
this.syncQueue.clear(); | |
await this.verify(directories); | |
} catch (error) { | |
console.error('Error while synchronizing cache entries:', error); | |
} | |
} | |
/** | |
* Gets the disk cache instance. | |
* @returns {Promise<import('node-persist').LocalStorage>} | |
*/ | |
async instance() { | |
if (this.#instance) { | |
return this.#instance; | |
} | |
this.#instance = storage.create({ | |
dir: this.cachePath, | |
ttl: false, | |
forgiveParseErrors: true, | |
// @ts-ignore | |
maxFileDescriptors: 100, | |
}); | |
await this.#instance.init(); | |
this.#syncInterval = setInterval(this.#syncCacheEntries.bind(this), DiskCache.SYNC_INTERVAL); | |
return this.#instance; | |
} | |
/** | |
* Verifies disk cache size and prunes it if necessary. | |
* @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories | |
* @returns {Promise<void>} | |
*/ | |
async verify(directoriesList) { | |
try { | |
if (!useDiskCache) { | |
return; | |
} | |
const cache = await this.instance(); | |
const validKeys = new Set(); | |
for (const dir of directoriesList) { | |
const files = fs.readdirSync(dir.characters, { withFileTypes: true }); | |
for (const file of files.filter(f => f.isFile() && path.extname(f.name) === '.png')) { | |
const filePath = path.join(dir.characters, file.name); | |
const cacheKey = getCacheKey(filePath); | |
validKeys.add(path.parse(cache.getDatumPath(cacheKey)).base); | |
} | |
} | |
for (const key of this.hashedKeys) { | |
if (!validKeys.has(key)) { | |
await cache.removeItem(key); | |
} | |
} | |
} catch (error) { | |
console.error('Error while verifying disk cache:', error); | |
} | |
} | |
dispose() { | |
if (this.#syncInterval) { | |
clearInterval(this.#syncInterval); | |
} | |
} | |
} | |
export const diskCache = new DiskCache(); | |
/** | |
* Gets the cache key for the specified image file. | |
* @param {string} inputFile - Path to the image file | |
* @returns {string} - Cache key | |
*/ | |
function getCacheKey(inputFile) { | |
if (fs.existsSync(inputFile)) { | |
const stat = fs.statSync(inputFile); | |
return `${inputFile}-${stat.mtimeMs}`; | |
} | |
return inputFile; | |
} | |
/** | |
* Reads the character card from the specified image file. | |
* @param {string} inputFile - Path to the image file | |
* @param {string} inputFormat - 'png' | |
* @returns {Promise<string | undefined>} - Character card data | |
*/ | |
async function readCharacterData(inputFile, inputFormat = 'png') { | |
const cacheKey = getCacheKey(inputFile); | |
if (memoryCache.has(cacheKey)) { | |
return memoryCache.get(cacheKey); | |
} | |
if (useDiskCache) { | |
try { | |
const cache = await diskCache.instance(); | |
const cachedData = await cache.getItem(cacheKey); | |
if (cachedData) { | |
return cachedData; | |
} | |
} catch (error) { | |
console.warn('Error while reading from disk cache:', error); | |
} | |
} | |
const result = await parse(inputFile, inputFormat); | |
!isAndroid && memoryCache.set(cacheKey, result); | |
if (useDiskCache) { | |
try { | |
const cache = await diskCache.instance(); | |
await cache.setItem(cacheKey, result); | |
} catch (error) { | |
console.warn('Error while writing to disk cache:', error); | |
} | |
} | |
return result; | |
} | |
/** | |
* Writes the character card to the specified image file. | |
* @param {string|Buffer} inputFile - Path to the image file or image buffer | |
* @param {string} data - Character card data | |
* @param {string} outputFile - Target image file name | |
* @param {import('express').Request} request - Express request obejct | |
* @param {Crop|undefined} crop - Crop parameters | |
* @returns {Promise<boolean>} - True if the operation was successful | |
*/ | |
async function writeCharacterData(inputFile, data, outputFile, request, crop = undefined) { | |
try { | |
// Reset the cache | |
for (const key of memoryCache.keys()) { | |
if (Buffer.isBuffer(inputFile)) { | |
break; | |
} | |
if (key.startsWith(inputFile)) { | |
memoryCache.delete(key); | |
break; | |
} | |
} | |
if (useDiskCache && !Buffer.isBuffer(inputFile)) { | |
diskCache.syncQueue.add(request.user.profile.handle); | |
} | |
/** | |
* Read the image, resize, and save it as a PNG into the buffer. | |
* @returns {Promise<Buffer>} Image buffer | |
*/ | |
async function getInputImage() { | |
try { | |
if (Buffer.isBuffer(inputFile)) { | |
return await parseImageBuffer(inputFile, crop); | |
} | |
return await tryReadImage(inputFile, crop); | |
} catch (error) { | |
const message = Buffer.isBuffer(inputFile) ? 'Failed to read image buffer.' : `Failed to read image: ${inputFile}.`; | |
console.warn(message, 'Using a fallback image.', error); | |
return await fs.promises.readFile(DEFAULT_AVATAR_PATH); | |
} | |
} | |
const inputImage = await getInputImage(); | |
// Get the chunks | |
const outputImage = write(inputImage, data); | |
const outputImagePath = path.join(request.user.directories.characters, `${outputFile}.png`); | |
writeFileAtomicSync(outputImagePath, outputImage); | |
return true; | |
} catch (err) { | |
console.error(err); | |
return false; | |
} | |
} | |
/** | |
* @typedef {Object} Crop | |
* @property {number} x X-coordinate | |
* @property {number} y Y-coordinate | |
* @property {number} width Width | |
* @property {number} height Height | |
* @property {boolean} want_resize Resize the image to the standard avatar size | |
*/ | |
/** | |
* Parses an image buffer and applies crop if defined. | |
* @param {Buffer} buffer Buffer of the image | |
* @param {Crop|undefined} [crop] Crop parameters | |
* @returns {Promise<Buffer>} Image buffer | |
*/ | |
async function parseImageBuffer(buffer, crop) { | |
const image = await Jimp.fromBuffer(buffer); | |
let finalWidth = image.bitmap.width, finalHeight = image.bitmap.height; | |
// Apply crop if defined | |
if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) { | |
image.crop({ x: crop.x, y: crop.y, w: crop.width, h: crop.height }); | |
// Apply standard resize if requested | |
if (crop.want_resize) { | |
finalWidth = AVATAR_WIDTH; | |
finalHeight = AVATAR_HEIGHT; | |
} else { | |
finalWidth = crop.width; | |
finalHeight = crop.height; | |
} | |
} | |
image.cover({ w: finalWidth, h: finalHeight }); | |
return await image.getBuffer(JimpMime.png); | |
} | |
/** | |
* Reads an image file and applies crop if defined. | |
* @param {string} imgPath Path to the image file | |
* @param {Crop|undefined} crop Crop parameters | |
* @returns {Promise<Buffer>} Image buffer | |
*/ | |
async function tryReadImage(imgPath, crop) { | |
try { | |
const rawImg = await Jimp.read(imgPath); | |
let finalWidth = rawImg.bitmap.width, finalHeight = rawImg.bitmap.height; | |
// Apply crop if defined | |
if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) { | |
rawImg.crop({ x: crop.x, y: crop.y, w: crop.width, h: crop.height }); | |
// Apply standard resize if requested | |
if (crop.want_resize) { | |
finalWidth = AVATAR_WIDTH; | |
finalHeight = AVATAR_HEIGHT; | |
} else { | |
finalWidth = crop.width; | |
finalHeight = crop.height; | |
} | |
} | |
rawImg.cover({ w: finalWidth, h: finalHeight }); | |
return await rawImg.getBuffer(JimpMime.png); | |
} | |
// If it's an unsupported type of image (APNG) - just read the file as buffer | |
catch (error) { | |
console.error(`Failed to read image: ${imgPath}`, error); | |
return fs.readFileSync(imgPath); | |
} | |
} | |
/** | |
* calculateChatSize - Calculates the total chat size for a given character. | |
* | |
* @param {string} charDir The directory where the chats are stored. | |
* @return { {chatSize: number, dateLastChat: number} } The total chat size. | |
*/ | |
const calculateChatSize = (charDir) => { | |
let chatSize = 0; | |
let dateLastChat = 0; | |
if (fs.existsSync(charDir)) { | |
const chats = fs.readdirSync(charDir); | |
if (Array.isArray(chats) && chats.length) { | |
for (const chat of chats) { | |
const chatStat = fs.statSync(path.join(charDir, chat)); | |
chatSize += chatStat.size; | |
dateLastChat = Math.max(dateLastChat, chatStat.mtimeMs); | |
} | |
} | |
} | |
return { chatSize, dateLastChat }; | |
}; | |
// Calculate the total string length of the data object | |
const calculateDataSize = (data) => { | |
return typeof data === 'object' ? Object.values(data).reduce((acc, val) => acc + String(val).length, 0) : 0; | |
}; | |
/** | |
* Only get fields that are used to display the character list. | |
* @param {object} character Character object | |
* @returns {{shallow: true, [key: string]: any}} Shallow character | |
*/ | |
const toShallow = (character) => { | |
return { | |
shallow: true, | |
name: character.name, | |
avatar: character.avatar, | |
chat: character.chat, | |
fav: character.fav, | |
date_added: character.date_added, | |
create_date: character.create_date, | |
date_last_chat: character.date_last_chat, | |
chat_size: character.chat_size, | |
data_size: character.data_size, | |
tags: character.tags, | |
data: { | |
name: _.get(character, 'data.name', ''), | |
character_version: _.get(character, 'data.character_version', ''), | |
creator: _.get(character, 'data.creator', ''), | |
creator_notes: _.get(character, 'data.creator_notes', ''), | |
tags: _.get(character, 'data.tags', []), | |
extensions: { | |
fav: _.get(character, 'data.extensions.fav', false), | |
}, | |
}, | |
}; | |
}; | |
/** | |
* processCharacter - Process a given character, read its data and calculate its statistics. | |
* | |
* @param {string} item The name of the character. | |
* @param {import('../users.js').UserDirectoryList} directories User directories | |
* @param {object} options Options for the character processing | |
* @param {boolean} options.shallow If true, only return the core character's metadata | |
* @return {Promise<object>} A Promise that resolves when the character processing is done. | |
*/ | |
const processCharacter = async (item, directories, { shallow }) => { | |
try { | |
const imgFile = path.join(directories.characters, item); | |
const imgData = await readCharacterData(imgFile); | |
if (imgData === undefined) throw new Error('Failed to read character file'); | |
let jsonObject = getCharaCardV2(JSON.parse(imgData), directories, false); | |
jsonObject.avatar = item; | |
const character = jsonObject; | |
character['json_data'] = imgData; | |
const charStat = fs.statSync(path.join(directories.characters, item)); | |
character['date_added'] = charStat.ctimeMs; | |
character['create_date'] = jsonObject['create_date'] || humanizedISO8601DateTime(charStat.ctimeMs); | |
const chatsDirectory = path.join(directories.chats, item.replace('.png', '')); | |
const { chatSize, dateLastChat } = calculateChatSize(chatsDirectory); | |
character['chat_size'] = chatSize; | |
character['date_last_chat'] = dateLastChat; | |
character['data_size'] = calculateDataSize(jsonObject?.data); | |
return shallow ? toShallow(character) : character; | |
} | |
catch (err) { | |
console.error(`Could not process character: ${item}`); | |
if (err instanceof SyntaxError) { | |
console.error(`${item} does not contain a valid JSON object.`); | |
} else { | |
console.error('An unexpected error occurred: ', err); | |
} | |
return { | |
date_added: 0, | |
date_last_chat: 0, | |
chat_size: 0, | |
}; | |
} | |
}; | |
/** | |
* Convert a character object to Spec V2 format. | |
* @param {object} jsonObject Character object | |
* @param {import('../users.js').UserDirectoryList} directories User directories | |
* @param {boolean} hoistDate Will set the chat and create_date fields to the current date if they are missing | |
* @returns {object} Character object in Spec V2 format | |
*/ | |
function getCharaCardV2(jsonObject, directories, hoistDate = true) { | |
if (jsonObject.spec === undefined) { | |
jsonObject = convertToV2(jsonObject, directories); | |
if (hoistDate && !jsonObject.create_date) { | |
jsonObject.create_date = humanizedISO8601DateTime(); | |
} | |
} else { | |
jsonObject = readFromV2(jsonObject); | |
} | |
return jsonObject; | |
} | |
/** | |
* Convert a character object to Spec V2 format. | |
* @param {object} char Character object | |
* @param {import('../users.js').UserDirectoryList} directories User directories | |
* @returns {object} Character object in Spec V2 format | |
*/ | |
function convertToV2(char, directories) { | |
// Simulate incoming data from frontend form | |
const result = charaFormatData({ | |
json_data: JSON.stringify(char), | |
ch_name: char.name, | |
description: char.description, | |
personality: char.personality, | |
scenario: char.scenario, | |
first_mes: char.first_mes, | |
mes_example: char.mes_example, | |
creator_notes: char.creatorcomment, | |
talkativeness: char.talkativeness, | |
fav: char.fav, | |
creator: char.creator, | |
tags: char.tags, | |
depth_prompt_prompt: char.depth_prompt_prompt, | |
depth_prompt_depth: char.depth_prompt_depth, | |
depth_prompt_role: char.depth_prompt_role, | |
}, directories); | |
result.chat = char.chat ?? humanizedISO8601DateTime(); | |
result.create_date = char.create_date; | |
return result; | |
} | |
/** | |
* Removes fields that are not meant to be shared. | |
*/ | |
function unsetPrivateFields(char) { | |
_.set(char, 'fav', false); | |
_.set(char, 'data.extensions.fav', false); | |
_.unset(char, 'chat'); | |
} | |
function readFromV2(char) { | |
if (_.isUndefined(char.data)) { | |
console.warn(`Char ${char['name']} has Spec v2 data missing`); | |
return char; | |
} | |
const fieldMappings = { | |
name: 'name', | |
description: 'description', | |
personality: 'personality', | |
scenario: 'scenario', | |
first_mes: 'first_mes', | |
mes_example: 'mes_example', | |
talkativeness: 'extensions.talkativeness', | |
fav: 'extensions.fav', | |
tags: 'tags', | |
}; | |
_.forEach(fieldMappings, (v2Path, charField) => { | |
//console.info(`Migrating field: ${charField} from ${v2Path}`); | |
const v2Value = _.get(char.data, v2Path); | |
if (_.isUndefined(v2Value)) { | |
let defaultValue = undefined; | |
// Backfill default values for missing ST extension fields | |
if (v2Path === 'extensions.talkativeness') { | |
defaultValue = 0.5; | |
} | |
if (v2Path === 'extensions.fav') { | |
defaultValue = false; | |
} | |
if (!_.isUndefined(defaultValue)) { | |
//console.warn(`Spec v2 extension data missing for field: ${charField}, using default value: ${defaultValue}`); | |
char[charField] = defaultValue; | |
} else { | |
console.warn(`Char ${char['name']} has Spec v2 data missing for unknown field: ${charField}`); | |
return; | |
} | |
} | |
if (!_.isUndefined(char[charField]) && !_.isUndefined(v2Value) && String(char[charField]) !== String(v2Value)) { | |
console.warn(`Char ${char['name']} has Spec v2 data mismatch with Spec v1 for field: ${charField}`, char[charField], v2Value); | |
} | |
char[charField] = v2Value; | |
}); | |
char['chat'] = char['chat'] ?? humanizedISO8601DateTime(); | |
return char; | |
} | |
/** | |
* Format character data to Spec V2 format. | |
* @param {object} data Character data | |
* @param {import('../users.js').UserDirectoryList} directories User directories | |
* @returns | |
*/ | |
function charaFormatData(data, directories) { | |
// This is supposed to save all the foreign keys that ST doesn't care about | |
const char = tryParse(data.json_data) || {}; | |
// Checks if data.alternate_greetings is an array, a string, or neither, and acts accordingly. (expected to be an array of strings) | |
const getAlternateGreetings = data => { | |
if (Array.isArray(data.alternate_greetings)) return data.alternate_greetings; | |
if (typeof data.alternate_greetings === 'string') return [data.alternate_greetings]; | |
return []; | |
}; | |
// Spec V1 fields | |
_.set(char, 'name', data.ch_name); | |
_.set(char, 'description', data.description || ''); | |
_.set(char, 'personality', data.personality || ''); | |
_.set(char, 'scenario', data.scenario || ''); | |
_.set(char, 'first_mes', data.first_mes || ''); | |
_.set(char, 'mes_example', data.mes_example || ''); | |
// Old ST extension fields (for backward compatibility, will be deprecated) | |
_.set(char, 'creatorcomment', data.creator_notes || ''); | |
_.set(char, 'avatar', 'none'); | |
_.set(char, 'chat', data.ch_name + ' - ' + humanizedISO8601DateTime()); | |
_.set(char, 'talkativeness', data.talkativeness || 0.5); | |
_.set(char, 'fav', data.fav == 'true'); | |
_.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []); | |
// Spec V2 fields | |
_.set(char, 'spec', 'chara_card_v2'); | |
_.set(char, 'spec_version', '2.0'); | |
_.set(char, 'data.name', data.ch_name); | |
_.set(char, 'data.description', data.description || ''); | |
_.set(char, 'data.personality', data.personality || ''); | |
_.set(char, 'data.scenario', data.scenario || ''); | |
_.set(char, 'data.first_mes', data.first_mes || ''); | |
_.set(char, 'data.mes_example', data.mes_example || ''); | |
// New V2 fields | |
_.set(char, 'data.creator_notes', data.creator_notes || ''); | |
_.set(char, 'data.system_prompt', data.system_prompt || ''); | |
_.set(char, 'data.post_history_instructions', data.post_history_instructions || ''); | |
_.set(char, 'data.tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []); | |
_.set(char, 'data.creator', data.creator || ''); | |
_.set(char, 'data.character_version', data.character_version || ''); | |
_.set(char, 'data.alternate_greetings', getAlternateGreetings(data)); | |
// ST extension fields to V2 object | |
_.set(char, 'data.extensions.talkativeness', data.talkativeness || 0.5); | |
_.set(char, 'data.extensions.fav', data.fav == 'true'); | |
_.set(char, 'data.extensions.world', data.world || ''); | |
// Spec extension: depth prompt | |
const depth_default = 4; | |
const role_default = 'system'; | |
const depth_value = !isNaN(Number(data.depth_prompt_depth)) ? Number(data.depth_prompt_depth) : depth_default; | |
const role_value = data.depth_prompt_role ?? role_default; | |
_.set(char, 'data.extensions.depth_prompt.prompt', data.depth_prompt_prompt ?? ''); | |
_.set(char, 'data.extensions.depth_prompt.depth', depth_value); | |
_.set(char, 'data.extensions.depth_prompt.role', role_value); | |
//_.set(char, 'data.extensions.create_date', humanizedISO8601DateTime()); | |
//_.set(char, 'data.extensions.avatar', 'none'); | |
//_.set(char, 'data.extensions.chat', data.ch_name + ' - ' + humanizedISO8601DateTime()); | |
// V3 fields | |
_.set(char, 'data.group_only_greetings', data.group_only_greetings ?? []); | |
if (data.world) { | |
try { | |
const file = readWorldInfoFile(directories, data.world, false); | |
// File was imported - save it to the character book | |
if (file && file.originalData) { | |
_.set(char, 'data.character_book', file.originalData); | |
} | |
// File was not imported - convert the world info to the character book | |
if (file && file.entries) { | |
_.set(char, 'data.character_book', convertWorldInfoToCharacterBook(data.world, file.entries)); | |
} | |
} catch { | |
console.warn(`Failed to read world info file: ${data.world}. Character book will not be available.`); | |
} | |
} | |
if (data.extensions) { | |
try { | |
const extensions = JSON.parse(data.extensions); | |
// Deep merge the extensions object | |
_.set(char, 'data.extensions', deepMerge(char.data.extensions, extensions)); | |
} catch { | |
console.warn(`Failed to parse extensions JSON: ${data.extensions}`); | |
} | |
} | |
return char; | |
} | |
/** | |
* @param {string} name Name of World Info file | |
* @param {object} entries Entries object | |
*/ | |
function convertWorldInfoToCharacterBook(name, entries) { | |
/** @type {{ entries: object[]; name: string }} */ | |
const result = { entries: [], name }; | |
for (const index in entries) { | |
const entry = entries[index]; | |
const originalEntry = { | |
id: entry.uid, | |
keys: entry.key, | |
secondary_keys: entry.keysecondary, | |
comment: entry.comment, | |
content: entry.content, | |
constant: entry.constant, | |
selective: entry.selective, | |
insertion_order: entry.order, | |
enabled: !entry.disable, | |
position: entry.position == 0 ? 'before_char' : 'after_char', | |
use_regex: true, // ST keys are always regex | |
extensions: { | |
...entry.extensions, | |
position: entry.position, | |
exclude_recursion: entry.excludeRecursion, | |
display_index: entry.displayIndex, | |
probability: entry.probability ?? null, | |
useProbability: entry.useProbability ?? false, | |
depth: entry.depth ?? 4, | |
selectiveLogic: entry.selectiveLogic ?? 0, | |
group: entry.group ?? '', | |
group_override: entry.groupOverride ?? false, | |
group_weight: entry.groupWeight ?? null, | |
prevent_recursion: entry.preventRecursion ?? false, | |
delay_until_recursion: entry.delayUntilRecursion ?? false, | |
scan_depth: entry.scanDepth ?? null, | |
match_whole_words: entry.matchWholeWords ?? null, | |
use_group_scoring: entry.useGroupScoring ?? false, | |
case_sensitive: entry.caseSensitive ?? null, | |
automation_id: entry.automationId ?? '', | |
role: entry.role ?? 0, | |
vectorized: entry.vectorized ?? false, | |
sticky: entry.sticky ?? null, | |
cooldown: entry.cooldown ?? null, | |
delay: entry.delay ?? null, | |
match_persona_description: entry.matchPersonaDescription ?? false, | |
match_character_description: entry.matchCharacterDescription ?? false, | |
match_character_personality: entry.matchCharacterPersonality ?? false, | |
match_character_depth_prompt: entry.matchCharacterDepthPrompt ?? false, | |
match_scenario: entry.matchScenario ?? false, | |
match_creator_notes: entry.matchCreatorNotes ?? false, | |
}, | |
}; | |
result.entries.push(originalEntry); | |
} | |
return result; | |
} | |
/** | |
* Import a character from a YAML file. | |
* @param {string} uploadPath Path to the uploaded file | |
* @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects | |
* @param {string|undefined} preservedFileName Preserved file name | |
* @returns {Promise<string>} Internal name of the character | |
*/ | |
async function importFromYaml(uploadPath, context, preservedFileName) { | |
const fileText = fs.readFileSync(uploadPath, 'utf8'); | |
fs.unlinkSync(uploadPath); | |
const yamlData = yaml.parse(fileText); | |
console.info('Importing from YAML'); | |
yamlData.name = sanitize(yamlData.name); | |
const fileName = preservedFileName || getPngName(yamlData.name, context.request.user.directories); | |
let char = convertToV2({ | |
'name': yamlData.name, | |
'description': yamlData.context ?? '', | |
'first_mes': yamlData.greeting ?? '', | |
'create_date': humanizedISO8601DateTime(), | |
'chat': `${yamlData.name} - ${humanizedISO8601DateTime()}`, | |
'personality': '', | |
'creatorcomment': '', | |
'avatar': 'none', | |
'mes_example': '', | |
'scenario': '', | |
'talkativeness': 0.5, | |
'creator': '', | |
'tags': '', | |
}, context.request.user.directories); | |
const result = await writeCharacterData(DEFAULT_AVATAR_PATH, JSON.stringify(char), fileName, context.request); | |
return result ? fileName : ''; | |
} | |
/** | |
* Imports a character card from CharX (ZIP) file. | |
* @param {string} uploadPath | |
* @param {object} params | |
* @param {import('express').Request} params.request | |
* @param {string|undefined} preservedFileName Preserved file name | |
* @returns {Promise<string>} Internal name of the character | |
*/ | |
async function importFromCharX(uploadPath, { request }, preservedFileName) { | |
const data = fs.readFileSync(uploadPath).buffer; | |
fs.unlinkSync(uploadPath); | |
console.info('Importing from CharX'); | |
const cardBuffer = await extractFileFromZipBuffer(data, 'card.json'); | |
if (!cardBuffer) { | |
throw new Error('Failed to extract card.json from CharX file'); | |
} | |
const card = readFromV2(JSON.parse(cardBuffer.toString())); | |
if (card.spec === undefined) { | |
throw new Error('Invalid CharX card file: missing spec field'); | |
} | |
/** @type {string|Buffer} */ | |
let avatar = DEFAULT_AVATAR_PATH; | |
const assets = _.get(card, 'data.assets'); | |
if (Array.isArray(assets) && assets.length) { | |
for (const asset of assets.filter(x => x.type === 'icon' && typeof x.uri === 'string')) { | |
const pathNoProtocol = String(asset.uri.replace(/^(?:\/\/|[^/]+)*\//, '')); | |
const buffer = await extractFileFromZipBuffer(data, pathNoProtocol); | |
if (buffer) { | |
avatar = buffer; | |
break; | |
} | |
} | |
} | |
unsetPrivateFields(card); | |
card['create_date'] = humanizedISO8601DateTime(); | |
card.name = sanitize(card.name); | |
const fileName = preservedFileName || getPngName(card.name, request.user.directories); | |
const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request); | |
return result ? fileName : ''; | |
} | |
/** | |
* Import a character from a JSON file. | |
* @param {string} uploadPath Path to the uploaded file | |
* @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects | |
* @param {string|undefined} preservedFileName Preserved file name | |
* @returns {Promise<string>} Internal name of the character | |
*/ | |
async function importFromJson(uploadPath, { request }, preservedFileName) { | |
const data = fs.readFileSync(uploadPath, 'utf8'); | |
fs.unlinkSync(uploadPath); | |
let jsonData = JSON.parse(data); | |
if (jsonData.spec !== undefined) { | |
console.info(`Importing from ${jsonData.spec} json`); | |
importRisuSprites(request.user.directories, jsonData); | |
unsetPrivateFields(jsonData); | |
jsonData = readFromV2(jsonData); | |
jsonData['create_date'] = humanizedISO8601DateTime(); | |
const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories); | |
const char = JSON.stringify(jsonData); | |
const result = await writeCharacterData(DEFAULT_AVATAR_PATH, char, pngName, request); | |
return result ? pngName : ''; | |
} else if (jsonData.name !== undefined) { | |
console.info('Importing from v1 json'); | |
jsonData.name = sanitize(jsonData.name); | |
if (jsonData.creator_notes) { | |
jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); | |
} | |
const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories); | |
let char = { | |
'name': jsonData.name, | |
'description': jsonData.description ?? '', | |
'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', | |
'personality': jsonData.personality ?? '', | |
'first_mes': jsonData.first_mes ?? '', | |
'avatar': 'none', | |
'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), | |
'mes_example': jsonData.mes_example ?? '', | |
'scenario': jsonData.scenario ?? '', | |
'create_date': humanizedISO8601DateTime(), | |
'talkativeness': jsonData.talkativeness ?? 0.5, | |
'creator': jsonData.creator ?? '', | |
'tags': jsonData.tags ?? '', | |
}; | |
char = convertToV2(char, request.user.directories); | |
let charJSON = JSON.stringify(char); | |
const result = await writeCharacterData(DEFAULT_AVATAR_PATH, charJSON, pngName, request); | |
return result ? pngName : ''; | |
} else if (jsonData.char_name !== undefined) {//json Pygmalion notepad | |
console.info('Importing from gradio json'); | |
jsonData.char_name = sanitize(jsonData.char_name); | |
if (jsonData.creator_notes) { | |
jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); | |
} | |
const pngName = preservedFileName || getPngName(jsonData.char_name, request.user.directories); | |
let char = { | |
'name': jsonData.char_name, | |
'description': jsonData.char_persona ?? '', | |
'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', | |
'personality': '', | |
'first_mes': jsonData.char_greeting ?? '', | |
'avatar': 'none', | |
'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), | |
'mes_example': jsonData.example_dialogue ?? '', | |
'scenario': jsonData.world_scenario ?? '', | |
'create_date': humanizedISO8601DateTime(), | |
'talkativeness': jsonData.talkativeness ?? 0.5, | |
'creator': jsonData.creator ?? '', | |
'tags': jsonData.tags ?? '', | |
}; | |
char = convertToV2(char, request.user.directories); | |
const charJSON = JSON.stringify(char); | |
const result = await writeCharacterData(DEFAULT_AVATAR_PATH, charJSON, pngName, request); | |
return result ? pngName : ''; | |
} | |
return ''; | |
} | |
/** | |
* Import a character from a PNG file. | |
* @param {string} uploadPath Path to the uploaded file | |
* @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects | |
* @param {string|undefined} preservedFileName Preserved file name | |
* @returns {Promise<string>} Internal name of the character | |
*/ | |
async function importFromPng(uploadPath, { request }, preservedFileName) { | |
const imgData = await readCharacterData(uploadPath); | |
if (imgData === undefined) throw new Error('Failed to read character data'); | |
let jsonData = JSON.parse(imgData); | |
jsonData.name = sanitize(jsonData.data?.name || jsonData.name); | |
const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories); | |
if (jsonData.spec !== undefined) { | |
console.info(`Found a ${jsonData.spec} character file.`); | |
importRisuSprites(request.user.directories, jsonData); | |
unsetPrivateFields(jsonData); | |
jsonData = readFromV2(jsonData); | |
jsonData['create_date'] = humanizedISO8601DateTime(); | |
const char = JSON.stringify(jsonData); | |
const result = await writeCharacterData(uploadPath, char, pngName, request); | |
fs.unlinkSync(uploadPath); | |
return result ? pngName : ''; | |
} else if (jsonData.name !== undefined) { | |
console.info('Found a v1 character file.'); | |
if (jsonData.creator_notes) { | |
jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); | |
} | |
let char = { | |
'name': jsonData.name, | |
'description': jsonData.description ?? '', | |
'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', | |
'personality': jsonData.personality ?? '', | |
'first_mes': jsonData.first_mes ?? '', | |
'avatar': 'none', | |
'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), | |
'mes_example': jsonData.mes_example ?? '', | |
'scenario': jsonData.scenario ?? '', | |
'create_date': humanizedISO8601DateTime(), | |
'talkativeness': jsonData.talkativeness ?? 0.5, | |
'creator': jsonData.creator ?? '', | |
'tags': jsonData.tags ?? '', | |
}; | |
char = convertToV2(char, request.user.directories); | |
const charJSON = JSON.stringify(char); | |
const result = await writeCharacterData(uploadPath, charJSON, pngName, request); | |
fs.unlinkSync(uploadPath); | |
return result ? pngName : ''; | |
} | |
return ''; | |
} | |
export const router = express.Router(); | |
router.post('/create', getFileNameValidationFunction('file_name'), async function (request, response) { | |
try { | |
if (!request.body) return response.sendStatus(400); | |
request.body.ch_name = sanitize(request.body.ch_name); | |
const char = JSON.stringify(charaFormatData(request.body, request.user.directories)); | |
const internalName = request.body.file_name || getPngName(request.body.ch_name, request.user.directories); | |
const avatarName = `${internalName}.png`; | |
const chatsPath = path.join(request.user.directories.chats, internalName); | |
if (!fs.existsSync(chatsPath)) fs.mkdirSync(chatsPath); | |
if (!request.file) { | |
await writeCharacterData(DEFAULT_AVATAR_PATH, char, internalName, request); | |
return response.send(avatarName); | |
} else { | |
const crop = tryParse(request.query.crop); | |
const uploadPath = path.join(request.file.destination, request.file.filename); | |
await writeCharacterData(uploadPath, char, internalName, request, crop); | |
fs.unlinkSync(uploadPath); | |
return response.send(avatarName); | |
} | |
} catch (err) { | |
console.error(err); | |
response.sendStatus(500); | |
} | |
}); | |
router.post('/rename', validateAvatarUrlMiddleware, async function (request, response) { | |
if (!request.body.avatar_url || !request.body.new_name) { | |
return response.sendStatus(400); | |
} | |
const oldAvatarName = request.body.avatar_url; | |
const newName = sanitize(request.body.new_name); | |
const oldInternalName = path.parse(request.body.avatar_url).name; | |
const newInternalName = getPngName(newName, request.user.directories); | |
const newAvatarName = `${newInternalName}.png`; | |
const oldAvatarPath = path.join(request.user.directories.characters, oldAvatarName); | |
const oldChatsPath = path.join(request.user.directories.chats, oldInternalName); | |
const newChatsPath = path.join(request.user.directories.chats, newInternalName); | |
try { | |
// Read old file, replace name int it | |
const rawOldData = await readCharacterData(oldAvatarPath); | |
if (rawOldData === undefined) throw new Error('Failed to read character file'); | |
const oldData = getCharaCardV2(JSON.parse(rawOldData), request.user.directories); | |
_.set(oldData, 'data.name', newName); | |
_.set(oldData, 'name', newName); | |
const newData = JSON.stringify(oldData); | |
// Write data to new location | |
await writeCharacterData(oldAvatarPath, newData, newInternalName, request); | |
// Rename chats folder | |
if (fs.existsSync(oldChatsPath) && !fs.existsSync(newChatsPath)) { | |
fs.cpSync(oldChatsPath, newChatsPath, { recursive: true }); | |
fs.rmSync(oldChatsPath, { recursive: true, force: true }); | |
} | |
// Remove the old character file | |
fs.unlinkSync(oldAvatarPath); | |
// Return new avatar name to ST | |
return response.send({ avatar: newAvatarName }); | |
} | |
catch (err) { | |
console.error(err); | |
return response.sendStatus(500); | |
} | |
}); | |
router.post('/edit', validateAvatarUrlMiddleware, async function (request, response) { | |
if (!request.body) { | |
console.warn('Error: no response body detected'); | |
response.status(400).send('Error: no response body detected'); | |
return; | |
} | |
if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') { | |
console.warn('Error: invalid name.'); | |
response.status(400).send('Error: invalid name.'); | |
return; | |
} | |
let char = charaFormatData(request.body, request.user.directories); | |
char.chat = request.body.chat; | |
char.create_date = request.body.create_date; | |
char = JSON.stringify(char); | |
let targetFile = (request.body.avatar_url).replace('.png', ''); | |
try { | |
if (!request.file) { | |
const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url); | |
await writeCharacterData(avatarPath, char, targetFile, request); | |
} else { | |
const crop = tryParse(request.query.crop); | |
const newAvatarPath = path.join(request.file.destination, request.file.filename); | |
invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url); | |
await writeCharacterData(newAvatarPath, char, targetFile, request, crop); | |
fs.unlinkSync(newAvatarPath); | |
// Bust cache to reload the new avatar | |
response.setHeader('Clear-Site-Data', '"cache"'); | |
} | |
return response.sendStatus(200); | |
} catch (err) { | |
console.error('An error occurred, character edit invalidated.', err); | |
} | |
}); | |
/** | |
* Handle a POST request to edit a character attribute. | |
* | |
* This function reads the character data from a file, updates the specified attribute, | |
* and writes the updated data back to the file. | |
* | |
* @param {Object} request - The HTTP request object. | |
* @param {Object} response - The HTTP response object. | |
* @returns {void} | |
*/ | |
router.post('/edit-attribute', validateAvatarUrlMiddleware, async function (request, response) { | |
console.debug(request.body); | |
if (!request.body) { | |
console.warn('Error: no response body detected'); | |
return response.status(400).send('Error: no response body detected'); | |
} | |
if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') { | |
console.warn('Error: invalid name.'); | |
return response.status(400).send('Error: invalid name.'); | |
} | |
try { | |
const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url); | |
const charJSON = await readCharacterData(avatarPath); | |
if (typeof charJSON !== 'string') throw new Error('Failed to read character file'); | |
const char = JSON.parse(charJSON); | |
//check if the field exists | |
if (char[request.body.field] === undefined && char.data[request.body.field] === undefined) { | |
console.warn('Error: invalid field.'); | |
response.status(400).send('Error: invalid field.'); | |
return; | |
} | |
char[request.body.field] = request.body.value; | |
char.data[request.body.field] = request.body.value; | |
let newCharJSON = JSON.stringify(char); | |
const targetFile = (request.body.avatar_url).replace('.png', ''); | |
await writeCharacterData(avatarPath, newCharJSON, targetFile, request); | |
return response.sendStatus(200); | |
} catch (err) { | |
console.error('An error occurred, character edit invalidated.', err); | |
} | |
}); | |
/** | |
* Handle a POST request to edit character properties. | |
* | |
* Merges the request body with the selected character and | |
* validates the result against TavernCard V2 specification. | |
* | |
* @param {Object} request - The HTTP request object. | |
* @param {Object} response - The HTTP response object. | |
* | |
* @returns {void} | |
* */ | |
router.post('/merge-attributes', getFileNameValidationFunction('avatar'), async function (request, response) { | |
try { | |
const update = request.body; | |
const avatarPath = path.join(request.user.directories.characters, update.avatar); | |
const pngStringData = await readCharacterData(avatarPath); | |
if (!pngStringData) { | |
console.error('Error: invalid character file.'); | |
return response.status(400).send('Error: invalid character file.'); | |
} | |
let character = JSON.parse(pngStringData); | |
character = deepMerge(character, update); | |
const validator = new TavernCardValidator(character); | |
const targetImg = (update.avatar).replace('.png', ''); | |
//Accept either V1 or V2. | |
if (validator.validate()) { | |
await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request); | |
response.sendStatus(200); | |
} else { | |
console.warn(validator.lastValidationError); | |
response.status(400).send({ message: `Validation failed for ${character.name}`, error: validator.lastValidationError }); | |
} | |
} catch (exception) { | |
response.status(500).send({ message: 'Unexpected error while saving character.', error: exception.toString() }); | |
} | |
}); | |
router.post('/delete', validateAvatarUrlMiddleware, async function (request, response) { | |
if (!request.body || !request.body.avatar_url) { | |
return response.sendStatus(400); | |
} | |
if (request.body.avatar_url !== sanitize(request.body.avatar_url)) { | |
console.error('Malicious filename prevented'); | |
return response.sendStatus(403); | |
} | |
const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url); | |
if (!fs.existsSync(avatarPath)) { | |
return response.sendStatus(400); | |
} | |
fs.unlinkSync(avatarPath); | |
invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url); | |
let dir_name = (request.body.avatar_url.replace('.png', '')); | |
if (!dir_name.length) { | |
console.error('Malicious dirname prevented'); | |
return response.sendStatus(403); | |
} | |
if (request.body.delete_chats == true) { | |
try { | |
await fs.promises.rm(path.join(request.user.directories.chats, sanitize(dir_name)), { recursive: true, force: true }); | |
} catch (err) { | |
console.error(err); | |
return response.sendStatus(500); | |
} | |
} | |
return response.sendStatus(200); | |
}); | |
/** | |
* HTTP POST endpoint for the "/api/characters/all" route. | |
* | |
* This endpoint is responsible for reading character files from the `charactersPath` directory, | |
* parsing character data, calculating stats for each character and responding with the data. | |
* Stats are calculated only on the first run, on subsequent runs the stats are fetched from | |
* the `charStats` variable. | |
* The stats are calculated by the `calculateStats` function. | |
* The characters are processed by the `processCharacter` function. | |
* | |
* @param {import("express").Request} request The HTTP request object. | |
* @param {import("express").Response} response The HTTP response object. | |
* @return {void} | |
*/ | |
router.post('/all', async function (request, response) { | |
try { | |
const files = fs.readdirSync(request.user.directories.characters); | |
const pngFiles = files.filter(file => file.endsWith('.png')); | |
const processingPromises = pngFiles.map(file => processCharacter(file, request.user.directories, { shallow: useShallowCharacters })); | |
const data = (await Promise.all(processingPromises)).filter(c => c.name); | |
return response.send(data); | |
} catch (err) { | |
console.error(err); | |
response.sendStatus(500); | |
} | |
}); | |
router.post('/get', validateAvatarUrlMiddleware, async function (request, response) { | |
try { | |
if (!request.body) return response.sendStatus(400); | |
const item = request.body.avatar_url; | |
const filePath = path.join(request.user.directories.characters, item); | |
if (!fs.existsSync(filePath)) { | |
return response.sendStatus(404); | |
} | |
const data = await processCharacter(item, request.user.directories, { shallow: false }); | |
return response.send(data); | |
} catch (err) { | |
console.error(err); | |
response.sendStatus(500); | |
} | |
}); | |
router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) { | |
try { | |
if (!request.body) return response.sendStatus(400); | |
const characterDirectory = (request.body.avatar_url).replace('.png', ''); | |
const chatsDirectory = path.join(request.user.directories.chats, characterDirectory); | |
if (!fs.existsSync(chatsDirectory)) { | |
return response.send({ error: true }); | |
} | |
const files = fs.readdirSync(chatsDirectory); | |
const jsonFiles = files.filter(file => path.extname(file) === '.jsonl'); | |
if (jsonFiles.length === 0) { | |
response.send({ error: true }); | |
return; | |
} | |
if (request.body.simple) { | |
return response.send(jsonFiles.map(file => ({ file_name: file }))); | |
} | |
const jsonFilesPromise = jsonFiles.map((file) => { | |
const pathToFile = path.join(request.user.directories.chats, characterDirectory, file); | |
return getChatInfo(pathToFile); | |
}); | |
const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value); | |
const validFiles = chatData.filter(i => i.file_name); | |
return response.send(validFiles); | |
} catch (error) { | |
console.error(error); | |
return response.send({ error: true }); | |
} | |
}); | |
/** | |
* Gets the name for the uploaded PNG file. | |
* @param {string} file File name | |
* @param {import('../users.js').UserDirectoryList} directories User directories | |
* @returns {string} - The name for the uploaded PNG file | |
*/ | |
function getPngName(file, directories) { | |
let i = 1; | |
const baseName = file; | |
while (fs.existsSync(path.join(directories.characters, `${file}.png`))) { | |
file = baseName + i; | |
i++; | |
} | |
return file; | |
} | |
/** | |
* Gets the preserved name for the uploaded file if the request is valid. | |
* @param {import("express").Request} request - Express request object | |
* @returns {string | undefined} - The preserved name if the request is valid, otherwise undefined | |
*/ | |
function getPreservedName(request) { | |
return typeof request.body.preserved_name === 'string' && request.body.preserved_name.length > 0 | |
? path.parse(request.body.preserved_name).name | |
: undefined; | |
} | |
router.post('/import', async function (request, response) { | |
if (!request.body || !request.file) return response.sendStatus(400); | |
const uploadPath = path.join(request.file.destination, request.file.filename); | |
const format = request.body.file_type; | |
const preservedFileName = getPreservedName(request); | |
const formatImportFunctions = { | |
'yaml': importFromYaml, | |
'yml': importFromYaml, | |
'json': importFromJson, | |
'png': importFromPng, | |
'charx': importFromCharX, | |
}; | |
try { | |
const importFunction = formatImportFunctions[format]; | |
if (!importFunction) { | |
throw new Error(`Unsupported format: ${format}`); | |
} | |
const fileName = await importFunction(uploadPath, { request, response }, preservedFileName); | |
if (!fileName) { | |
console.warn('Failed to import character'); | |
return response.sendStatus(400); | |
} | |
if (preservedFileName) { | |
invalidateThumbnail(request.user.directories, 'avatar', `${preservedFileName}.png`); | |
} | |
response.send({ file_name: fileName }); | |
} catch (err) { | |
console.error(err); | |
response.send({ error: true }); | |
} | |
}); | |
router.post('/duplicate', validateAvatarUrlMiddleware, async function (request, response) { | |
try { | |
if (!request.body.avatar_url) { | |
console.warn('avatar URL not found in request body'); | |
console.debug(request.body); | |
return response.sendStatus(400); | |
} | |
let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url)); | |
if (!fs.existsSync(filename)) { | |
console.error('file for dupe not found', filename); | |
return response.sendStatus(404); | |
} | |
let suffix = 1; | |
let newFilename = filename; | |
// If filename ends with a _number, increment the number | |
const nameParts = path.basename(filename, path.extname(filename)).split('_'); | |
const lastPart = nameParts[nameParts.length - 1]; | |
let baseName; | |
if (!isNaN(Number(lastPart)) && nameParts.length > 1) { | |
suffix = parseInt(lastPart) + 1; | |
baseName = nameParts.slice(0, -1).join('_'); // construct baseName without suffix | |
} else { | |
baseName = nameParts.join('_'); // original filename is completely the baseName | |
} | |
newFilename = path.join(request.user.directories.characters, `${baseName}_${suffix}${path.extname(filename)}`); | |
while (fs.existsSync(newFilename)) { | |
let suffixStr = '_' + suffix; | |
newFilename = path.join(request.user.directories.characters, `${baseName}${suffixStr}${path.extname(filename)}`); | |
suffix++; | |
} | |
fs.copyFileSync(filename, newFilename); | |
console.info(`${filename} was copied to ${newFilename}`); | |
response.send({ path: path.parse(newFilename).base }); | |
} | |
catch (error) { | |
console.error(error); | |
return response.send({ error: true }); | |
} | |
}); | |
router.post('/export', validateAvatarUrlMiddleware, async function (request, response) { | |
try { | |
if (!request.body.format || !request.body.avatar_url) { | |
return response.sendStatus(400); | |
} | |
let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url)); | |
if (!fs.existsSync(filename)) { | |
return response.sendStatus(404); | |
} | |
switch (request.body.format) { | |
case 'png': { | |
const rawBuffer = await fsPromises.readFile(filename); | |
const rawData = read(rawBuffer); | |
const mutatedData = mutateJsonString(rawData, unsetPrivateFields); | |
const mutatedBuffer = write(rawBuffer, mutatedData); | |
const contentType = mime.lookup(filename) || 'image/png'; | |
response.setHeader('Content-Type', contentType); | |
response.setHeader('Content-Disposition', `attachment; filename="${encodeURI(path.basename(filename))}"`); | |
return response.send(mutatedBuffer); | |
} | |
case 'json': { | |
try { | |
const json = await readCharacterData(filename); | |
if (json === undefined) return response.sendStatus(400); | |
const jsonObject = getCharaCardV2(JSON.parse(json), request.user.directories); | |
unsetPrivateFields(jsonObject); | |
return response.type('json').send(JSON.stringify(jsonObject, null, 4)); | |
} | |
catch { | |
return response.sendStatus(400); | |
} | |
} | |
} | |
return response.sendStatus(400); | |
} catch (err) { | |
console.error('Character export failed', err); | |
response.sendStatus(500); | |
} | |
}); | |