diff --git a/public/css/character-group-overlay.css b/public/css/character-group-overlay.css new file mode 100644 index 000000000..dbe02014a --- /dev/null +++ b/public/css/character-group-overlay.css @@ -0,0 +1,63 @@ + +#rm_print_characters_block.group_overlay_mode_select .character_select { + background-color: rgba(170, 170, 170, 0.2); +} + +#rm_print_characters_block.group_overlay_mode_select .character_select.character_selected { + background-color: var(--SmartThemeQuoteColor); +} + +#rm_print_characters_block.group_overlay_mode_select .character_select .bulk_select_checkbox { + visibility: hidden; + height: 0 !important; +} + +#character_context_menu.hidden { display: none; } +#character_context_menu { + position: absolute; + padding: 3px; + z-index: 10000; + background-color: var(--black90a); + border: 1px solid var(--black90a); + border-radius: 10px; +} + +#character_context_menu ul li button { + border: 0; + border-bottom-color: currentcolor; + color: var(--SmartThemeQuoteColor); + background-color: transparent; + font-weight: bold; + font-size: 1em; + padding: 0.5em; + border-bottom: 1px dotted var(--SmartThemeQuoteColor); + width: 100%; + cursor: pointer; +} + +#character_context_menu ul li button:hover { + background-color: var(--SmartThemeBlurTintColor); +} + +#character_context_menu ul li:last-child button { + border-bottom: 0; +} + +#character_context_menu ul li #character_context_menu_delete { + color: var(--fullred); +} + +#character_context_menu ul { + list-style-type: none; + padding: 0; + margin: 0; +} + +#character_context_menu .character_context_menu_separator { + height: 1px; + background-color: var(--SmartThemeBotMesBlurTintColor); +} + +#character_context_menu li:hover { + background-color: var(--SmartThemeBotMesBlurTintColor); +} diff --git a/public/index.html b/public/index.html index d36c7a094..8de8769f9 100644 --- a/public/index.html +++ b/public/index.html @@ -100,6 +100,16 @@
+ +
@@ -4303,6 +4313,7 @@ +
diff --git a/public/script.js b/public/script.js index e72823212..3dc4830ed 100644 --- a/public/script.js +++ b/public/script.js @@ -187,6 +187,7 @@ import { getTokenCount, getTokenizerModel, initTokenizers, saveTokenCache } from import { initPersonas, selectCurrentPersona, setPersonaDescription } from "./scripts/personas.js"; import { getBackgrounds, initBackgrounds } from "./scripts/backgrounds.js"; import { hideLoader, showLoader } from "./scripts/loader.js"; +import {CharacterContextMenu, CharacterGroupOverlay} from "./scripts/CharacterGroupOverlay.js"; //exporting functions and vars for mods export { @@ -299,6 +300,9 @@ export const event_types = { OAI_PRESET_CHANGED_AFTER: 'oai_preset_changed_after', WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated', CHARACTER_EDITED: 'character_edited', + CHARACTER_PAGE_LOADED: 'character_page_loaded', + CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE: 'character_group_overlay_state_change_before', + CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER: 'character_group_overlay_state_change_after', USER_MESSAGE_RENDERED: 'user_message_rendered', CHARACTER_MESSAGE_RENDERED: 'character_message_rendered', FORCE_SET_BACKGROUND: 'force_set_background', @@ -316,6 +320,10 @@ eventSource.on(event_types.CHAT_CHANGED, displayOverrideWarnings); eventSource.on(event_types.MESSAGE_RECEIVED, processExtensionHelpers); eventSource.on(event_types.MESSAGE_SENT, processExtensionHelpers); +const characterGroupOverlay = new CharacterGroupOverlay(); +const characterContextMenu = new CharacterContextMenu(characterGroupOverlay); +eventSource.on(event_types.CHARACTER_PAGE_LOADED, characterGroupOverlay.onPageLoad); + hljs.addPlugin({ "before:highlightElement": ({ el }) => { el.textContent = el.innerText } }); // Markdown converter @@ -1048,6 +1056,7 @@ async function printCharacters(fullRefresh = false) { $("#rm_print_characters_block").append(getGroupBlock(i.item)); } } + eventSource.emit(event_types.CHARACTER_PAGE_LOADED); }, afterSizeSelectorChange: function (e) { localStorage.setItem(storageKey, e.target.value); @@ -1076,7 +1085,7 @@ export function getEntitiesList({ doFilter } = {}) { return entities; } -async function getOneCharacter(avatarUrl) { +export async function getOneCharacter(avatarUrl) { const response = await fetch("/getonecharacter", { method: "POST", headers: getRequestHeaders(), diff --git a/public/scripts/CharacterGroupOverlay.js b/public/scripts/CharacterGroupOverlay.js new file mode 100644 index 000000000..f389a1ec5 --- /dev/null +++ b/public/scripts/CharacterGroupOverlay.js @@ -0,0 +1,351 @@ +"use strict"; + +import { + callPopup, + characters, deleteCharacter, + event_types, + eventSource, + getCharacters, + getRequestHeaders, handleDeleteCharacter, this_chid +} from "../script.js"; +import {favsToHotswap} from "./RossAscends-mods.js"; +import {convertCharacterToPersona} from "./personas.js"; + +const popupMessage = { + deleteChat(characterCount) { + return `

Delete ${characterCount} characters?

+ THIS IS PERMANENT!

+
`; + }, + exportCharacters(characterCount) { + return `

Export ${characterCount} characters?

`; + } +} + +const toggleFavoriteHighlight = (characterId) => { + const element = document.getElementById(`CharID${characterId}`); + element.classList.toggle('is_fav'); +} + +/** + * Implement a SingletonPattern, allowing access to the group overlay instance + * from everywhere via (new CharacterGroupOverlay()) + * + * @type CharacterGroupOverlay + */ +let characterGroupOverlayInstance = null; + +class CharacterGroupOverlayState { + static browse = 0; + static select = 1; +} + +class CharacterContextMenu { + /** + * Duplicate a character + * + * @param characterId + * @returns {Promise} + */ + static duplicate = async (characterId) => { + const character = CharacterContextMenu.getCharacter(characterId); + + return fetch('/dupecharacter', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ avatar_url: character.avatar }), + }); + } + + /** + * Favorite a character + * and toggle its ui element. + * + * @param characterId + * @returns {Promise} + */ + static favorite = async (characterId) => { + const character = CharacterContextMenu.getCharacter(characterId); + const data = { + name: character.name, + avatar: character.avatar, + data: { + extensions: { + fav: !character.data.extensions.fav + } + } + }; + + return fetch('/v2/editcharacterattribute', { + method: "POST", + headers: getRequestHeaders(), + body: JSON.stringify(data), + }).then((response) => { + if (response.ok) toggleFavoriteHighlight(characterId) + else toastr.error('Character not saved. Error: ' + response.json()?.message) + }); + } + + static persona = async (characterId) => convertCharacterToPersona(characterId); + + static delete = async (characterId, deleteChats = false) => { + const character = CharacterContextMenu.getCharacter(characterId); + + return fetch('/deletecharacter', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ avatar_url: character.avatar , delete_chats: deleteChats }), + cache: 'no-cache', + }).then(response => { + if (response.ok) { + deleteCharacter(character.name, character.avatar).then(() => { + if (deleteChats) { + fetch("/getallchatsofcharacter", { + method: 'POST', + body: JSON.stringify({ avatar_url: character.avatar }), + headers: getRequestHeaders(), + }).then( (response) => { + let data = response.json(); + data = Object.values(data); + const pastChats = data.sort((a, b) => a["file_name"].localeCompare(b["file_name"])).reverse(); + + for (const chat of pastChats) { + const name = chat.file_name.replace('.jsonl', ''); + eventSource.emit(event_types.CHAT_DELETED, name); + } + }); + } + }) + } + eventSource.emit('characterDeleted', { id: this_chid, character: characters[this_chid] }); + }); + } + + static getCharacter = (characterId) => characters[characterId] ?? null; + + static show = (positionX, positionY) => { + let contextMenu = document.getElementById(CharacterGroupOverlay.contextMenuId); + contextMenu.style.left = `${positionX}px`; + contextMenu.style.top = `${positionY}px`; + + document.getElementById(CharacterGroupOverlay.contextMenuId).classList.remove('hidden'); + } + + static hide = () => document.getElementById(CharacterGroupOverlay.contextMenuId).classList.add('hidden'); + + constructor(characterGroupOverlay) { + const contextMenuItems = [ + {id: 'character_context_menu_favorite', callback: characterGroupOverlay.handleContextMenuFavorite}, + {id: 'character_context_menu_duplicate', callback: characterGroupOverlay.handleContextMenuDuplicate}, + {id: 'character_context_menu_delete', callback: characterGroupOverlay.handleContextMenuDelete}, + {id: 'character_context_menu_persona', callback: characterGroupOverlay.handleContextMenuPersona} + ]; + + contextMenuItems.forEach(contextMenuItem => document.getElementById(contextMenuItem.id).addEventListener('click', contextMenuItem.callback)) + } +} + +class CharacterGroupOverlay { + static containerId = 'rm_print_characters_block'; + static contextMenuId = 'character_context_menu'; + static characterClass = 'character_select'; + static selectModeClass = 'group_overlay_mode_select'; + static selectedClass = 'character_selected'; + + #state = CharacterGroupOverlayState.browse; + #longPress = false; + #stateChangeCallbacks = []; + #selectedCharacters = []; + + /** + * @type HTMLElement + */ + container = null; + + get state() { + return this.#state; + } + + set state(newState) { + if (this.#state === newState) return; + + eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE, newState) + .then(() => { + this.#state = newState; + eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.state) + }); + } + + get isLongPress() { + return this.#longPress; + } + + set isLongPress(longPress) { + this.#longPress = longPress; + } + + get stateChangeCallbacks() { + return this.#stateChangeCallbacks; + } + + get selectedCharacters() { + return this.#selectedCharacters; + } + + constructor() { + if (characterGroupOverlayInstance instanceof CharacterGroupOverlay) + return characterGroupOverlayInstance + + this.container = document.getElementById(CharacterGroupOverlay.containerId); + this.container.addEventListener('click', this.handleCancelClick); + + eventSource.on(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.handleStateChange); + characterGroupOverlayInstance = Object.freeze(this); + } + + browseState = () => this.state = CharacterGroupOverlayState.browse; + selectState = () => this.state = CharacterGroupOverlayState.select; + + /** + * Set up a Sortable grid for the loaded page + */ + onPageLoad = () => { + const elements = [...document.getElementsByClassName(CharacterGroupOverlay.characterClass)]; + + elements.forEach(element => element.addEventListener('touchstart', this.handleHold)); + elements.forEach(element => element.addEventListener('mousedown', this.handleHold)); + + elements.forEach(element => element.addEventListener('touchend', this.handleLongPressEnd)); + elements.forEach(element => element.addEventListener('mouseup', this.handleLongPressEnd)); + elements.forEach(element => element.addEventListener('dragend', this.handleLongPressEnd)); + + const grid = document.getElementById(CharacterGroupOverlay.containerId); + grid.addEventListener('click', this.handleCancelClick); + } + + /** + * Block the browsers native context menu and + * set a click event to hide the custom context menu. + */ + enableContextMenu = () => { + document.addEventListener('contextmenu', this.handleContextMenuShow); + document.addEventListener('click', this.handleContextMenuHide); + } + + /** + * Remove event listeners, allowing the native browser context + * menu to be opened. + */ + disableContextMenu = () => { + document.removeEventListener('contextmenu', this.handleContextMenuShow); + document.removeEventListener('click', this.handleContextMenuHide); + } + + handleHold = () => { + this.isLongPress = true; + setTimeout(() => { + if (this.isLongPress) { + this.state = CharacterGroupOverlayState.select; + } + }, 3000); + } + + handleLongPressEnd = () => { + this.isLongPress = false; + } + + handleCancelClick = () => { + this.state = CharacterGroupOverlayState.browse; + } + + handleStateChange = () => { + switch (this.state) { + case CharacterGroupOverlayState.browse: + this.container.classList.remove(CharacterGroupOverlay.selectModeClass); + [...this.container.getElementsByClassName(CharacterGroupOverlay.characterClass)] + .forEach(element => element.removeEventListener('click', this.toggleCharacterSelected)); + this.clearSelectedCharacters(); + this.disableContextMenu(); + CharacterContextMenu.hide(); + break; + case CharacterGroupOverlayState.select: + this.container.classList.add(CharacterGroupOverlay.selectModeClass); + [...this.container.getElementsByClassName(CharacterGroupOverlay.characterClass)] + .forEach(element => element.addEventListener('click', this.toggleCharacterSelected)); + this.enableContextMenu(); + break; + } + + this.stateChangeCallbacks.forEach(callback => callback(this.state)); + } + + toggleCharacterSelected = event => { + event.stopPropagation(); + + const character = event.currentTarget; + const characterId = character.getAttribute('chid'); + + const alreadySelected = this.selectedCharacters.includes(characterId) + + if (alreadySelected) { + character.classList.remove(CharacterGroupOverlay.selectedClass); + this.dismissCharacter(characterId); + } else { + character.classList.add(CharacterGroupOverlay.selectedClass); + this.selectCharacter(characterId); + } + } + + handleContextMenuShow = (event) => { + event.preventDefault(); + document.getElementById(CharacterGroupOverlay.containerId).style.pointerEvents = 'none'; + CharacterContextMenu.show(event.clientX, event.clientY); + } + + handleContextMenuHide = (event) => { + document.getElementById(CharacterGroupOverlay.containerId).style.pointerEvents = ''; + let contextMenu = document.getElementById(CharacterGroupOverlay.contextMenuId); + if (false === contextMenu.contains(event.target)) { + CharacterContextMenu.hide(); + } + } + + handleContextMenuFavorite = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.favorite(characterId))) + .then(() => getCharacters()) + .then(() => favsToHotswap()) + .then(() => this.browseState()) + + handleContextMenuDuplicate = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.duplicate(characterId))) + .then(() => getCharacters()) + .then(() => this.browseState()) + + handleContextMenuPersona = () => { Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.persona(characterId))) + .then(() => this.browseState()); + } + + handleContextMenuDelete = () => { + callPopup( + popupMessage.deleteChat(this.selectedCharacters.length), null) + .then(deleteChats => + Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.delete(characterId, deleteChats))) + .then(() => getCharacters()) + .then(() => this.browseState()) + ); + } + + addStateChangeCallback = callback => this.stateChangeCallbacks.push(callback); + + selectCharacter = characterId => this.selectedCharacters.push(String(characterId)); + + dismissCharacter = characterId => this.#selectedCharacters = this.selectedCharacters.filter(item => String(characterId) !== item); + + clearSelectedCharacters = () => { + this.selectedCharacters.forEach(characterId => document.querySelector('.character_select[chid="' + characterId + '"]')?.classList.remove(CharacterGroupOverlay.selectedClass)) + this.selectedCharacters.length = 0; + } +} + +export {CharacterGroupOverlayState, CharacterContextMenu, CharacterGroupOverlay}; diff --git a/public/scripts/bulk-edit.js b/public/scripts/bulk-edit.js index 19694262b..83de126bc 100644 --- a/public/scripts/bulk-edit.js +++ b/public/scripts/bulk-edit.js @@ -1,24 +1,44 @@ import { characters, getCharacters, handleDeleteCharacter, callPopup } from "../script.js"; +import {CharacterGroupOverlay, CharacterGroupOverlayState} from "./CharacterGroupOverlay.js"; + let is_bulk_edit = false; +const enableBulkEdit = () => { + enableBulkSelect(); + (new CharacterGroupOverlay()).selectState(); + // show the delete button + $("#bulkDeleteButton").show(); + is_bulk_edit = true; +} + +const disableBulkEdit = () => { + disableBulkSelect(); + (new CharacterGroupOverlay()).browseState(); + // hide the delete button + $("#bulkDeleteButton").hide(); + is_bulk_edit = false; +} + +const toggleBulkEditMode = (isBulkEdit) => { + if (isBulkEdit) { + disableBulkEdit(); + } else { + enableBulkEdit(); + } +} + +(new CharacterGroupOverlay()).addStateChangeCallback((state) => { + if (state === CharacterGroupOverlayState.select) enableBulkEdit(); + if (state === CharacterGroupOverlayState.browse) disableBulkEdit(); +}); + /** * Toggles bulk edit mode on/off when the edit button is clicked. */ function onEditButtonClick() { console.log("Edit button clicked"); - // toggle bulk edit mode - if (is_bulk_edit) { - disableBulkSelect(); - // hide the delete button - $("#bulkDeleteButton").hide(); - is_bulk_edit = false; - } else { - enableBulkSelect(); - // show the delete button - $("#bulkDeleteButton").show(); - is_bulk_edit = true; - } + toggleBulkEditMode(is_bulk_edit); } /** diff --git a/public/scripts/personas.js b/public/scripts/personas.js index c3113b3db..fc91e8008 100644 --- a/public/scripts/personas.js +++ b/public/scripts/personas.js @@ -42,16 +42,18 @@ async function createDummyPersona() { await uploadUserAvatar(default_avatar); } -async function convertCharacterToPersona() { - const avatarUrl = characters[this_chid]?.avatar; +export async function convertCharacterToPersona(characterId = null) { + if (null === characterId) characterId = this_chid; + + const avatarUrl = characters[characterId]?.avatar; if (!avatarUrl) { console.log("No avatar found for this character"); return; } - const name = characters[this_chid]?.name; - let description = characters[this_chid]?.description; + const name = characters[characterId]?.name; + let description = characters[characterId]?.description; const overwriteName = `${name} (Persona).png`; if (overwriteName in power_user.personas) { diff --git a/public/style.css b/public/style.css index 6c09d4359..721b579e9 100644 --- a/public/style.css +++ b/public/style.css @@ -2,6 +2,7 @@ @import url(css/promptmanager.css); @import url(css/loader.css); +@import url(css/character-group-overlay.css); :root { --doc-height: 100%; @@ -3704,4 +3705,4 @@ a { height: 100vh; z-index: 9999; } -} \ No newline at end of file +} diff --git a/server.js b/server.js index f2a84df23..097ef9582 100644 --- a/server.js +++ b/server.js @@ -57,7 +57,7 @@ const characterCardParser = require('./src/character-card-parser.js'); const contentManager = require('./src/content-manager'); const statsHelpers = require('./statsHelpers.js'); const { readSecret, migrateSecrets, SECRET_KEYS } = require('./src/secrets'); -const { delay, getVersion } = require('./src/util'); +const { delay, getVersion, deepMerge} = require('./src/util'); const { invalidateThumbnail, ensureThumbnailCache } = require('./src/thumbnails'); const { getTokenizerModel, getTiktokenTokenizer, loadTokenizers, TEXT_COMPLETION_MODELS } = require('./src/tokenizers'); const { convertClaudePrompt } = require('./src/chat-completion'); @@ -208,6 +208,7 @@ const AVATAR_HEIGHT = 600; const jsonParser = express.json({ limit: '100mb' }); const urlencodedParser = express.urlencoded({ extended: true, limit: '100mb' }); const { DIRECTORIES, UPLOADS_PATH, PALM_SAFETY } = require('./src/constants'); +const {TavernCardValidator} = require("./src/validator/TavernCardValidator"); // CSRF Protection // if (cliArguments.disableCsrf === false) { @@ -1167,6 +1168,42 @@ app.post("/editcharacterattribute", jsonParser, async function (request, respons } }); +/** + * 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} + * */ +app.post("/v2/editcharacterattribute", jsonParser, async function (request, response) { + const update = request.body; + const avatarPath = path.join(charactersPath, update.avatar); + + try { + let character = JSON.parse(await charaRead(avatarPath)); + character = deepMerge(character, update); + + const validator = new TavernCardValidator(character); + if (validator.validateV2()) { + await charaWrite( + avatarPath, + JSON.stringify(character), + (update.avatar).replace('.png', ''), + response, + 'Character saved' + ); + } else { + response.status(400).send({message: `Validation failed for card ${character.name}`, field: validator.getValidationError}); + } + } catch (exception) { + response.status(500).send({message: 'Unexpected error while saving character.', error: exception.toString()}); + } +}); + app.post("/deletecharacter", jsonParser, async function (request, response) { if (!request.body || !request.body.avatar_url) { return response.sendStatus(400); diff --git a/src/util.js b/src/util.js index 553f2f285..78a7f26f0 100644 --- a/src/util.js +++ b/src/util.js @@ -196,6 +196,27 @@ async function readAllChunks(readableStream) { }); } +function isObject(item) { + return (item && typeof item === 'object' && !Array.isArray(item)); +} + +function deepMerge(target, source) { + let output = Object.assign({}, target); + if (isObject(target) && isObject(source)) { + Object.keys(source).forEach(key => { + if (isObject(source[key])) { + if (!(key in target)) + Object.assign(output, { [key]: source[key] }); + else + output[key] = deepMerge(target[key], source[key]); + } else { + Object.assign(output, { [key]: source[key] }); + } + }); + } + return output; +} + module.exports = { getConfig, getConfigValue, @@ -205,4 +226,5 @@ module.exports = { getImageBuffers, readAllChunks, delay, + deepMerge, }; diff --git a/src/validator/TavernCardValidator.js b/src/validator/TavernCardValidator.js new file mode 100644 index 000000000..61c91c8c9 --- /dev/null +++ b/src/validator/TavernCardValidator.js @@ -0,0 +1,129 @@ +const characterBook = require("lodash/object"); + +/** + * Validates the data structure of character cards. + * Supported specs: V1, V2 + * Up to: 8083fb3 + * + * @link https://github.com/malfoyslastname/character-card-spec-v2 + */ +class TavernCardValidator { + #validationError = null; + + constructor(card) { + this.card = card; + } + + /** + * Field that caused the validation to fail + * + * @returns {null|string} + */ + get validationError() { + return this.#validationError; + } + + /** + * Validate against V1 and V2 spec. + * + * @returns {number|boolean} - false when neither V1 nor V2 spec were matched. Specification version number otherwise. + */ + validate() { + this.#validationError = null; + + if (this.validateV1()) { + return 2; + } + + if (this.validateV2()) { + return 1; + } + + return false; + } + + /** + * Validate against V1 specification + * + * @returns {this is string[]} + */ + validateV1() { + const requiredFields = ['name', 'description', 'personality', 'scenario', 'first_mes', 'mes_example']; + return requiredFields.every(field => { + if (!this.card.hasOwnProperty(field)) { + this.#validationError = field; + return false; + } + return true; + }); + } + + /** + * Validate against V2 specification + * + * @returns {false|boolean|*} + */ + validateV2() { + return this.#validateSpec() + && this.#validateSpecVersion() + && this.#validateData() + && this.#validateCharacterBook(); + } + + #validateSpec() { + if (this.card.spec !== 'chara_card_v2') { + this.#validationError = 'spec'; + return false; + } + return true; + } + + #validateSpecVersion() { + if (this.card.spec_version !== '2.0') { + this.#validationError = 'spec_version'; + return false; + } + return true; + } + + #validateData() { + const data = this.card.data; + + if (!data) { + this.#validationError = 'data'; + return false; + } + + const requiredFields = ['name', 'description', 'personality', 'scenario', 'first_mes', 'mes_example', 'creator_notes', 'system_prompt', 'post_history_instructions', 'alternate_greetings', 'tags', 'creator', 'character_version', 'extensions']; + const isAllRequiredFieldsPresent = requiredFields.every(field => { + if (!data.hasOwnProperty(field)) { + this.#validationError = `data.${field}`; + return false; + } + return true; + }); + + return isAllRequiredFieldsPresent && Array.isArray(data.alternate_greetings) && Array.isArray(data.tags) && typeof data.extensions === 'object'; + } + + #validateCharacterBook() { + const characterBook = this.card.data.character_book; + + if (!characterBook) { + return true; + } + + const requiredFields = ['extensions', 'entries']; + const isAllRequiredFieldsPresent = requiredFields.every(field => { + if (!characterBook.hasOwnProperty(field)) { + this.#validationError = `data.character_book.${field}`; + return false; + } + return true; + }); + + return isAllRequiredFieldsPresent && Array.isArray(characterBook.entries) && typeof characterBook.extensions === 'object'; + } +} + +module.exports = {TavernCardValidator}