mirror of
				https://github.com/SillyTavern/SillyTavern.git
				synced 2025-06-05 21:59:27 +02:00 
			
		
		
		
	V3 spec (IMPORT ONLY)
This commit is contained in:
		| @@ -296,6 +296,7 @@ export function evaluateMacros(content, env) { | ||||
|     // Legacy non-macro substitutions | ||||
|     content = content.replace(/<USER>/gi, typeof env.user === 'function' ? env.user() : env.user); | ||||
|     content = content.replace(/<BOT>/gi, typeof env.char === 'function' ? env.char() : env.char); | ||||
|     content = content.replace(/<CHAR>/gi, typeof env.char === 'function' ? env.char() : env.char); | ||||
|     content = content.replace(/<CHARIFNOTGROUP>/gi, typeof env.group === 'function' ? env.group() : env.group); | ||||
|     content = content.replace(/<GROUP>/gi, typeof env.group === 'function' ? env.group() : env.group); | ||||
|  | ||||
|   | ||||
| @@ -633,6 +633,9 @@ function parseTimestamp(timestamp) { | ||||
|  | ||||
|     // Unix time (legacy TAI / tags) | ||||
|     if (typeof timestamp === 'number') { | ||||
|         if (isNaN(timestamp) || !isFinite(timestamp) || timestamp < 0) { | ||||
|             return moment.invalid(); | ||||
|         } | ||||
|         return moment(timestamp); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -6,6 +6,7 @@ const PNGtext = require('png-chunk-text'); | ||||
|  | ||||
| /** | ||||
|  * Writes Character metadata to a PNG image buffer. | ||||
|  * Writes only 'chara', 'ccv3' is not supported and removed not to create a mismatch. | ||||
|  * @param {Buffer} image PNG image buffer | ||||
|  * @param {string} data Character data to write | ||||
|  * @returns {Buffer} PNG image buffer with metadata | ||||
| @@ -14,10 +15,14 @@ const write = (image, data) => { | ||||
|     const chunks = extract(image); | ||||
|     const tEXtChunks = chunks.filter(chunk => chunk.name === 'tEXt'); | ||||
|  | ||||
|     // Remove all existing tEXt chunks | ||||
|     for (let tEXtChunk of tEXtChunks) { | ||||
|     // Remove existing tEXt chunks | ||||
|     for (const tEXtChunk of tEXtChunks) { | ||||
|         const data = PNGtext.decode(tEXtChunk.data); | ||||
|         if (data.keyword.toLowerCase() === 'chara' || data.keyword.toLowerCase() === 'ccv3') { | ||||
|             chunks.splice(chunks.indexOf(tEXtChunk), 1); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Add new chunks before the IEND chunk | ||||
|     const base64EncodedData = Buffer.from(data, 'utf8').toString('base64'); | ||||
|     chunks.splice(-1, 0, PNGtext.encode('chara', base64EncodedData)); | ||||
| @@ -27,31 +32,34 @@ const write = (image, data) => { | ||||
|  | ||||
| /** | ||||
|  * Reads Character metadata from a PNG image buffer. | ||||
|  * Supports both V2 (chara) and V3 (ccv3). V3 (ccv3) takes precedence. | ||||
|  * @param {Buffer} image PNG image buffer | ||||
|  * @returns {string} Character data | ||||
|  */ | ||||
| const read = (image) => { | ||||
|     const chunks = extract(image); | ||||
|  | ||||
|     const textChunks = chunks.filter(function (chunk) { | ||||
|         return chunk.name === 'tEXt'; | ||||
|     }).map(function (chunk) { | ||||
|         return PNGtext.decode(chunk.data); | ||||
|     }); | ||||
|     const textChunks = chunks.filter((chunk) => chunk.name === 'tEXt').map((chunk) => PNGtext.decode(chunk.data)); | ||||
|  | ||||
|     if (textChunks.length === 0) { | ||||
|         console.error('PNG metadata does not contain any text chunks.'); | ||||
|         throw new Error('No PNG metadata.'); | ||||
|     } | ||||
|  | ||||
|     let index = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() == 'chara'); | ||||
|     const ccv3Index = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() === 'ccv3'); | ||||
|  | ||||
|     if (index === -1) { | ||||
|         console.error('PNG metadata does not contain any character data.'); | ||||
|         throw new Error('No PNG metadata.'); | ||||
|     if (ccv3Index > -1) { | ||||
|         return Buffer.from(textChunks[ccv3Index].text, 'base64').toString('utf8'); | ||||
|     } | ||||
|  | ||||
|     return Buffer.from(textChunks[index].text, 'base64').toString('utf8'); | ||||
|     const charaIndex = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() === 'chara'); | ||||
|  | ||||
|     if (charaIndex > -1) { | ||||
|         return Buffer.from(textChunks[charaIndex].text, 'base64').toString('utf8'); | ||||
|     } | ||||
|  | ||||
|     console.error('PNG metadata does not contain any character data.'); | ||||
|     throw new Error('No PNG metadata.'); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -424,6 +424,7 @@ function convertWorldInfoToCharacterBook(name, entries) { | ||||
|             insertion_order: entry.order, | ||||
|             enabled: !entry.disable, | ||||
|             position: entry.position == 0 ? 'before_char' : 'after_char', | ||||
|             use_regex: true, // ST keys are always regex | ||||
|             extensions: { | ||||
|                 position: entry.position, | ||||
|                 exclude_recursion: entry.excludeRecursion, | ||||
| @@ -498,7 +499,7 @@ async function importFromJson(uploadPath, { request }) { | ||||
|     let jsonData = JSON.parse(data); | ||||
|  | ||||
|     if (jsonData.spec !== undefined) { | ||||
|         console.log('Importing from v2 json'); | ||||
|         console.log(`Importing from ${jsonData.spec} json`); | ||||
|         importRisuSprites(request.user.directories, jsonData); | ||||
|         unsetFavFlag(jsonData); | ||||
|         jsonData = readFromV2(jsonData); | ||||
| @@ -581,7 +582,7 @@ async function importFromPng(uploadPath, { request }, preservedFileName) { | ||||
|     const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories); | ||||
|  | ||||
|     if (jsonData.spec !== undefined) { | ||||
|         console.log('Found a v2 character file.'); | ||||
|         console.log(`Found a ${jsonData.spec} character file.`); | ||||
|         importRisuSprites(request.user.directories, jsonData); | ||||
|         unsetFavFlag(jsonData); | ||||
|         jsonData = readFromV2(jsonData); | ||||
|   | ||||
| @@ -6,6 +6,9 @@ | ||||
|  * @link https://github.com/malfoyslastname/character-card-spec-v2 | ||||
|  */ | ||||
| class TavernCardValidator { | ||||
|     /** | ||||
|      * @type {string|null} | ||||
|      */ | ||||
|     #lastValidationError = null; | ||||
|  | ||||
|     constructor(card) { | ||||
| @@ -37,6 +40,10 @@ class TavernCardValidator { | ||||
|             return 2; | ||||
|         } | ||||
|  | ||||
|         if (this.validateV3()) { | ||||
|             return 3; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
| @@ -62,13 +69,23 @@ class TavernCardValidator { | ||||
|      * @returns {false|boolean|*} | ||||
|      */ | ||||
|     validateV2() { | ||||
|         return this.#validateSpec() | ||||
|             && this.#validateSpecVersion() | ||||
|             && this.#validateData() | ||||
|             && this.#validateCharacterBook(); | ||||
|         return this.#validateSpecV2() | ||||
|             && this.#validateSpecVersionV2() | ||||
|             && this.#validateDataV2() | ||||
|             && this.#validateCharacterBookV2(); | ||||
|     } | ||||
|  | ||||
|     #validateSpec() { | ||||
|     /** | ||||
|      * Validate against V3 specification | ||||
|      * @returns {boolean} | ||||
|      */ | ||||
|     validateV3() { | ||||
|         return this.#validateSpecV3() | ||||
|             && this.#validateSpecVersionV3() | ||||
|             && this.#validateDataV3(); | ||||
|     } | ||||
|  | ||||
|     #validateSpecV2() { | ||||
|         if (this.card.spec !== 'chara_card_v2') { | ||||
|             this.#lastValidationError = 'spec'; | ||||
|             return false; | ||||
| @@ -76,7 +93,7 @@ class TavernCardValidator { | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     #validateSpecVersion() { | ||||
|     #validateSpecVersionV2() { | ||||
|         if (this.card.spec_version !== '2.0') { | ||||
|             this.#lastValidationError = 'spec_version'; | ||||
|             return false; | ||||
| @@ -84,7 +101,7 @@ class TavernCardValidator { | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     #validateData() { | ||||
|     #validateDataV2() { | ||||
|         const data = this.card.data; | ||||
|  | ||||
|         if (!data) { | ||||
| @@ -104,7 +121,7 @@ class TavernCardValidator { | ||||
|         return isAllRequiredFieldsPresent && Array.isArray(data.alternate_greetings) && Array.isArray(data.tags) && typeof data.extensions === 'object'; | ||||
|     } | ||||
|  | ||||
|     #validateCharacterBook() { | ||||
|     #validateCharacterBookV2() { | ||||
|         const characterBook = this.card.data.character_book; | ||||
|  | ||||
|         if (!characterBook) { | ||||
| @@ -122,6 +139,33 @@ class TavernCardValidator { | ||||
|  | ||||
|         return isAllRequiredFieldsPresent && Array.isArray(characterBook.entries) && typeof characterBook.extensions === 'object'; | ||||
|     } | ||||
|  | ||||
|     #validateSpecV3() { | ||||
|         if (this.card.spec !== 'chara_card_v3') { | ||||
|             this.#lastValidationError = 'spec'; | ||||
|             return false; | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     #validateSpecVersionV3() { | ||||
|         if (Number(this.card.spec_version) < 3.0 || Number(this.card.spec_version) >= 4.0) { | ||||
|             this.#lastValidationError = 'spec_version'; | ||||
|             return false; | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     #validateDataV3() { | ||||
|         const data = this.card.data; | ||||
|  | ||||
|         if (!data || typeof data !== 'object') { | ||||
|             this.#lastValidationError = 'No tavern card data found'; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
|  | ||||
| module.exports = { TavernCardValidator }; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user