From b559f2f55931f3fafecd62a5a48856b8b6e3d007 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 1 Jun 2024 22:07:57 +0300 Subject: [PATCH] V3 spec (IMPORT ONLY) --- public/scripts/macros.js | 1 + public/scripts/utils.js | 3 ++ src/character-card-parser.js | 34 ++++++++++------ src/endpoints/characters.js | 5 ++- src/validator/TavernCardValidator.js | 60 ++++++++++++++++++++++++---- 5 files changed, 80 insertions(+), 23 deletions(-) diff --git a/public/scripts/macros.js b/public/scripts/macros.js index aa683c7f1..11051d80e 100644 --- a/public/scripts/macros.js +++ b/public/scripts/macros.js @@ -296,6 +296,7 @@ export function evaluateMacros(content, env) { // Legacy non-macro substitutions content = content.replace(//gi, typeof env.user === 'function' ? env.user() : env.user); content = content.replace(//gi, typeof env.char === 'function' ? env.char() : env.char); + content = content.replace(//gi, typeof env.char === 'function' ? env.char() : env.char); content = content.replace(//gi, typeof env.group === 'function' ? env.group() : env.group); content = content.replace(//gi, typeof env.group === 'function' ? env.group() : env.group); diff --git a/public/scripts/utils.js b/public/scripts/utils.js index 46c1c767a..377073b09 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -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); } diff --git a/src/character-card-parser.js b/src/character-card-parser.js index 9e9cbd1a7..7b17f1868 100644 --- a/src/character-card-parser.js +++ b/src/character-card-parser.js @@ -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) { - chunks.splice(chunks.indexOf(tEXtChunk), 1); + // 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.'); }; /** diff --git a/src/endpoints/characters.js b/src/endpoints/characters.js index fc7ae3ef8..4ad99029d 100644 --- a/src/endpoints/characters.js +++ b/src/endpoints/characters.js @@ -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); diff --git a/src/validator/TavernCardValidator.js b/src/validator/TavernCardValidator.js index e2364f2bd..dec2fbb89 100644 --- a/src/validator/TavernCardValidator.js +++ b/src/validator/TavernCardValidator.js @@ -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 };