import CharacterRepo, { AttributeResult, CharacterResult, CompleteCharacterResult } from "../repositories/character.repository.js"; import BookRepo, {BookToolsTable} from "../repositories/book.repository.js"; import System from "../System.js"; import {getUserEncryptionKey} from "../keyManager.js"; import RemovedItem from "./RemovedItem.js"; export type CharacterCategory = 'Main' | 'Secondary' | 'Recurring'; export interface CharacterPropsPost { id: string | null; name: string; lastName: string; nickname: string; age: number | null; gender: string; species: string; nationality: string; status: string; category: CharacterCategory; title: string; image: string; physical: { name: string }[]; psychological: { name: string }[]; relations: { name: string }[]; skills: { name: string }[]; weaknesses: { name: string }[]; strengths: { name: string }[]; goals: { name: string }[]; motivations: { name: string }[]; arc: { name: string }[]; secrets: { name: string }[]; fears: { name: string }[]; flaws: { name: string }[]; beliefs: { name: string }[]; conflicts: { name: string }[]; quotes: { name: string }[]; distinguishingMarks: { name: string }[]; items: { name: string }[]; affiliations: { name: string }[]; role: string; biography?: string; history?: string; speechPattern?: string; catchphrase?: string; residence?: string; notes?: string; color?: string; seriesCharacterId?: string | null; } export interface CharacterProps { id: string; name: string; lastName: string; nickname: string; age: number | null; gender: string; species: string; nationality: string; status: string; title: string; category: string; image: string; role: string; biography: string; history: string; speechPattern: string; catchphrase: string; residence: string; notes: string; color: string; seriesCharacterId: string | null; } export interface CharacterListResponse { characters: CharacterProps[]; enabled: boolean; } export interface CompleteCharacterProps { id?: string; name: string; lastName: string; nickname: string; age: number | null; gender: string; species: string; nationality: string; status: string; title: string; category: string; image?: string; role: string; biography: string; history: string; speechPattern: string; catchphrase: string; residence: string; notes: string; color: string; [key: string]: Attribute[] | string | number | null | undefined; } export interface Attribute { id: string; name: string; } export interface CharacterAttribute { type: string; values: Attribute[]; } export interface SyncedCharacter { id: string; name: string; lastUpdate: number; attributes: SyncedCharacterAttribute[]; } export interface SyncedCharacterAttribute { id: string; name: string; lastUpdate: number; } export default class Character { /** * Retrieves a list of all characters for a specific book. * Decrypts character data using the user's encryption key. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param lang - The language code for localization (defaults to 'fr') * @returns An array of decrypted character properties */ public static getCharacterList(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): CharacterListResponse { const bookTools: BookToolsTable | null = BookRepo.fetchBookTools(userId, bookId, lang); const enabled: boolean = bookTools ? bookTools.characters_enabled === 1 : false; const userEncryptionKey: string = getUserEncryptionKey(userId); const encryptedCharacters: CharacterResult[] = CharacterRepo.fetchCharacters(userId, bookId, lang); if (!encryptedCharacters || encryptedCharacters.length === 0) { return { characters: [], enabled }; } const decryptedCharacterList: CharacterProps[] = []; for (const encryptedCharacter of encryptedCharacters) { decryptedCharacterList.push({ id: encryptedCharacter.character_id, name: encryptedCharacter.first_name ? System.decryptDataWithUserKey(encryptedCharacter.first_name, userEncryptionKey) : '', lastName: encryptedCharacter.last_name ? System.decryptDataWithUserKey(encryptedCharacter.last_name, userEncryptionKey) : '', nickname: encryptedCharacter.nickname ? System.decryptDataWithUserKey(encryptedCharacter.nickname, userEncryptionKey) : '', age: encryptedCharacter.age ? parseInt(System.decryptDataWithUserKey(encryptedCharacter.age, userEncryptionKey), 10) : null, gender: encryptedCharacter.gender ? System.decryptDataWithUserKey(encryptedCharacter.gender, userEncryptionKey) : '', species: encryptedCharacter.species ? System.decryptDataWithUserKey(encryptedCharacter.species, userEncryptionKey) : '', nationality: encryptedCharacter.nationality ? System.decryptDataWithUserKey(encryptedCharacter.nationality, userEncryptionKey) : '', status: encryptedCharacter.status ? System.decryptDataWithUserKey(encryptedCharacter.status, userEncryptionKey) : 'alive', title: encryptedCharacter.title ? System.decryptDataWithUserKey(encryptedCharacter.title, userEncryptionKey) : '', category: encryptedCharacter.category ? System.decryptDataWithUserKey(encryptedCharacter.category, userEncryptionKey) : '', image: encryptedCharacter.image ? System.decryptDataWithUserKey(encryptedCharacter.image, userEncryptionKey) : '', role: encryptedCharacter.role ? System.decryptDataWithUserKey(encryptedCharacter.role, userEncryptionKey) : '', biography: encryptedCharacter.biography ? System.decryptDataWithUserKey(encryptedCharacter.biography, userEncryptionKey) : '', history: encryptedCharacter.history ? System.decryptDataWithUserKey(encryptedCharacter.history, userEncryptionKey) : '', speechPattern: encryptedCharacter.speech_pattern ? System.decryptDataWithUserKey(encryptedCharacter.speech_pattern, userEncryptionKey) : '', catchphrase: encryptedCharacter.catchphrase ? System.decryptDataWithUserKey(encryptedCharacter.catchphrase, userEncryptionKey) : '', residence: encryptedCharacter.residence ? System.decryptDataWithUserKey(encryptedCharacter.residence, userEncryptionKey) : '', notes: encryptedCharacter.notes ? System.decryptDataWithUserKey(encryptedCharacter.notes, userEncryptionKey) : '', color: encryptedCharacter.color ? System.decryptDataWithUserKey(encryptedCharacter.color, userEncryptionKey) : '', seriesCharacterId: encryptedCharacter.series_character_id || null, }) } return { characters: decryptedCharacterList, enabled }; } /** * Creates a new character with all its attributes for a specific book. * Encrypts all character data before storing in the database. * @param userId - The unique identifier of the user * @param character - The character data to be created * @param bookId - The unique identifier of the book * @param lang - The language code for localization (defaults to 'fr') * @param existingCharacterId - Optional existing character ID for updates or imports * @returns The unique identifier of the newly created character */ public static addNewCharacter(userId: string, character: CharacterPropsPost, bookId: string, lang: 'fr' | 'en' = 'fr', existingCharacterId?: string): string { const userEncryptionKey: string = getUserEncryptionKey(userId); const characterId: string = existingCharacterId || System.createUniqueId(); const characterData = { firstName: System.encryptDataWithUserKey(character.name, userEncryptionKey), lastName: System.encryptDataWithUserKey(character.lastName, userEncryptionKey), nickname: System.encryptDataWithUserKey(character.nickname || '', userEncryptionKey), age: character.age !== null ? System.encryptDataWithUserKey(String(character.age), userEncryptionKey) : '', gender: System.encryptDataWithUserKey(character.gender || '', userEncryptionKey), species: System.encryptDataWithUserKey(character.species || '', userEncryptionKey), nationality: System.encryptDataWithUserKey(character.nationality || '', userEncryptionKey), status: System.encryptDataWithUserKey(character.status || 'alive', userEncryptionKey), title: System.encryptDataWithUserKey(character.title, userEncryptionKey), category: System.encryptDataWithUserKey(character.category, userEncryptionKey), image: System.encryptDataWithUserKey(character.image, userEncryptionKey), role: System.encryptDataWithUserKey(character.role, userEncryptionKey), biography: System.encryptDataWithUserKey(character.biography || '', userEncryptionKey), history: System.encryptDataWithUserKey(character.history || '', userEncryptionKey), speechPattern: System.encryptDataWithUserKey(character.speechPattern || '', userEncryptionKey), catchphrase: System.encryptDataWithUserKey(character.catchphrase || '', userEncryptionKey), residence: System.encryptDataWithUserKey(character.residence || '', userEncryptionKey), notes: System.encryptDataWithUserKey(character.notes || '', userEncryptionKey), color: System.encryptDataWithUserKey(character.color || '', userEncryptionKey), }; CharacterRepo.addNewCharacter(userId, characterId, characterData, bookId, lang, character.seriesCharacterId || null); const characterPropertyKeys: string[] = Object.keys(character); for (const propertyKey of characterPropertyKeys) { if (Array.isArray(character[propertyKey as keyof CharacterPropsPost])) { const attributeArray = character[propertyKey as keyof CharacterPropsPost] as { name: string }[]; if (attributeArray.length > 0) { for (const attributeItem of attributeArray) { const attributeType: string = propertyKey; const attributeName: string = attributeItem.name; this.addNewAttribute(characterId, userId, attributeType, attributeName, lang); } } } } return characterId; } /** * Updates an existing character's core properties. * Encrypts all updated data before storing in the database. * @param userId - The unique identifier of the user * @param character - The character data with updated values * @param lang - The language code for localization (defaults to 'fr') * @returns True if the update was successful, false otherwise */ static updateCharacter(userId: string, character: CharacterPropsPost, lang: 'fr' | 'en' = 'fr'): boolean { const userEncryptionKey: string = getUserEncryptionKey(userId); if (!character.id) { return false; } const characterData = { firstName: System.encryptDataWithUserKey(character.name, userEncryptionKey), lastName: System.encryptDataWithUserKey(character.lastName, userEncryptionKey), nickname: System.encryptDataWithUserKey(character.nickname || '', userEncryptionKey), age: character.age !== null ? System.encryptDataWithUserKey(String(character.age), userEncryptionKey) : '', gender: System.encryptDataWithUserKey(character.gender || '', userEncryptionKey), species: System.encryptDataWithUserKey(character.species || '', userEncryptionKey), nationality: System.encryptDataWithUserKey(character.nationality || '', userEncryptionKey), status: System.encryptDataWithUserKey(character.status || 'alive', userEncryptionKey), title: System.encryptDataWithUserKey(character.title, userEncryptionKey), category: System.encryptDataWithUserKey(character.category, userEncryptionKey), image: System.encryptDataWithUserKey(character.image, userEncryptionKey), role: System.encryptDataWithUserKey(character.role, userEncryptionKey), biography: System.encryptDataWithUserKey(character.biography || '', userEncryptionKey), history: System.encryptDataWithUserKey(character.history || '', userEncryptionKey), speechPattern: System.encryptDataWithUserKey(character.speechPattern || '', userEncryptionKey), catchphrase: System.encryptDataWithUserKey(character.catchphrase || '', userEncryptionKey), residence: System.encryptDataWithUserKey(character.residence || '', userEncryptionKey), notes: System.encryptDataWithUserKey(character.notes || '', userEncryptionKey), color: System.encryptDataWithUserKey(character.color || '', userEncryptionKey), }; return CharacterRepo.updateCharacter(userId, character.id, characterData, System.timeStampInSeconds(), lang, character.seriesCharacterId || null); } /** * Adds a new attribute to a character. * Attributes are categorized properties like physical traits, skills, or goals. * @param characterId - The unique identifier of the character * @param userId - The unique identifier of the user * @param type - The type/category of the attribute (e.g., 'physical', 'skills') * @param name - The value/name of the attribute * @param lang - The language code for localization (defaults to 'fr') * @param existingAttributeId - Optional existing attribute ID for updates or imports * @returns The unique identifier of the newly created attribute */ static addNewAttribute(characterId: string, userId: string, type: string, name: string, lang: 'fr' | 'en' = 'fr', existingAttributeId?: string): string { const userEncryptionKey: string = getUserEncryptionKey(userId); const attributeId: string = existingAttributeId || System.createUniqueId(); const encryptedType: string = System.encryptDataWithUserKey(type, userEncryptionKey); const encryptedName: string = System.encryptDataWithUserKey(name, userEncryptionKey); return CharacterRepo.insertAttribute(attributeId, characterId, userId, encryptedType, encryptedName, lang); } /** * Deletes an attribute from a character. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param attributeId - The unique identifier of the attribute to delete * @param deletedAt - The timestamp of deletion * @param lang - The language code for localization (defaults to 'fr') * @returns True if the deletion was successful, false otherwise */ static deleteAttribute(userId: string, bookId: string, attributeId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { const deleted: boolean = CharacterRepo.deleteAttribute(userId, attributeId, lang); if (deleted) { RemovedItem.deleteTracker(userId, bookId, 'book_characters_attributes', attributeId, deletedAt, lang); } return deleted; } /** * Deletes a character and all its related data. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param characterId - The unique identifier of the character to delete * @param deletedAt - The timestamp of deletion * @param lang - The language code for localization (defaults to 'fr') * @returns True if the deletion was successful */ static deleteCharacter(userId: string, bookId: string, characterId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { const deleted: boolean = CharacterRepo.deleteCharacter(userId, characterId, lang); if (deleted) { RemovedItem.deleteTracker(userId, bookId, 'book_characters', characterId, deletedAt, lang); } return deleted; } /** * Retrieves all attributes for a specific character, grouped by type. * Decrypts attribute data using the user's encryption key. * @param characterId - The unique identifier of the character * @param userId - The unique identifier of the user * @param lang - The language code for localization (defaults to 'fr') * @returns An array of character attributes grouped by type */ static getAttributes(characterId: string, userId: string, lang: 'fr' | 'en' = 'fr'): CharacterAttribute[] { const userEncryptionKey: string = getUserEncryptionKey(userId); const encryptedAttributes: AttributeResult[] = CharacterRepo.fetchAttributes(characterId, userId, lang); if (!encryptedAttributes?.length) return []; const attributesByType: Map = new Map(); for (const encryptedAttribute of encryptedAttributes) { const decryptedType: string = System.decryptDataWithUserKey(encryptedAttribute.attribute_name, userEncryptionKey); const decryptedValue: string = encryptedAttribute.attribute_value ? System.decryptDataWithUserKey(encryptedAttribute.attribute_value, userEncryptionKey) : ''; if (!attributesByType.has(decryptedType)) { attributesByType.set(decryptedType, []); } attributesByType.get(decryptedType)!.push({ id: encryptedAttribute.attr_id, name: decryptedValue }); } return Array.from<[string, Attribute[]], CharacterAttribute>( attributesByType, ([type, values]: [string, Attribute[]]): CharacterAttribute => ({type, values}) ); } /** * Retrieves complete character data including all attributes for multiple characters. * Used for exporting or displaying full character profiles. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param characters - An array of character IDs to retrieve * @param lang - The language code for localization (defaults to 'fr') * @returns An array of complete character objects with all their attributes */ static getCompleteCharacterList(userId: string, bookId: string, characters: string[], lang: 'fr' | 'en' = 'fr'): CompleteCharacterProps[] { const encryptedCharacterList: CompleteCharacterResult[] = CharacterRepo.fetchCompleteCharacters(userId, bookId, characters, lang); if (!encryptedCharacterList || encryptedCharacterList.length === 0) { return []; } const userEncryptionKey: string = getUserEncryptionKey(userId); const completeCharactersMap = new Map(); for (const encryptedCharacter of encryptedCharacterList) { if (!encryptedCharacter.character_id) { continue; } if (!completeCharactersMap.has(encryptedCharacter.character_id)) { const decryptedCharacter: CompleteCharacterProps = { id: '', name: encryptedCharacter.first_name ? System.decryptDataWithUserKey(encryptedCharacter.first_name, userEncryptionKey) : '', lastName: encryptedCharacter.last_name ? System.decryptDataWithUserKey(encryptedCharacter.last_name, userEncryptionKey) : '', nickname: encryptedCharacter.nickname ? System.decryptDataWithUserKey(encryptedCharacter.nickname as string, userEncryptionKey) : '', age: encryptedCharacter.age ? parseInt(System.decryptDataWithUserKey(encryptedCharacter.age as string, userEncryptionKey), 10) : null, gender: encryptedCharacter.gender ? System.decryptDataWithUserKey(encryptedCharacter.gender as string, userEncryptionKey) : '', species: encryptedCharacter.species ? System.decryptDataWithUserKey(encryptedCharacter.species as string, userEncryptionKey) : '', nationality: encryptedCharacter.nationality ? System.decryptDataWithUserKey(encryptedCharacter.nationality as string, userEncryptionKey) : '', status: encryptedCharacter.status ? System.decryptDataWithUserKey(encryptedCharacter.status as string, userEncryptionKey) : 'alive', title: encryptedCharacter.title ? System.decryptDataWithUserKey(encryptedCharacter.title as string, userEncryptionKey) : '', category: encryptedCharacter.category ? System.decryptDataWithUserKey(encryptedCharacter.category as string, userEncryptionKey) : '', role: encryptedCharacter.role ? System.decryptDataWithUserKey(encryptedCharacter.role as string, userEncryptionKey) : '', biography: encryptedCharacter.biography ? System.decryptDataWithUserKey(encryptedCharacter.biography as string, userEncryptionKey) : '', history: encryptedCharacter.history ? System.decryptDataWithUserKey(encryptedCharacter.history as string, userEncryptionKey) : '', speechPattern: encryptedCharacter.speech_pattern ? System.decryptDataWithUserKey(encryptedCharacter.speech_pattern as string, userEncryptionKey) : '', catchphrase: encryptedCharacter.catchphrase ? System.decryptDataWithUserKey(encryptedCharacter.catchphrase as string, userEncryptionKey) : '', residence: encryptedCharacter.residence ? System.decryptDataWithUserKey(encryptedCharacter.residence as string, userEncryptionKey) : '', notes: encryptedCharacter.notes ? System.decryptDataWithUserKey(encryptedCharacter.notes as string, userEncryptionKey) : '', color: encryptedCharacter.color ? System.decryptDataWithUserKey(encryptedCharacter.color as string, userEncryptionKey) : '', physical: [], psychological: [], relations: [], skills: [], weaknesses: [], strengths: [], goals: [], motivations: [], arc: [], secrets: [], fears: [], flaws: [], beliefs: [], conflicts: [], quotes: [], distinguishingMarks: [], items: [], affiliations: [] }; completeCharactersMap.set(encryptedCharacter.character_id, decryptedCharacter); } const characterEntry: CompleteCharacterProps | undefined = completeCharactersMap.get(encryptedCharacter.character_id); if (!encryptedCharacter.attribute_name || !characterEntry) { continue; } const decryptedAttributeName: string = System.decryptDataWithUserKey(encryptedCharacter.attribute_name, userEncryptionKey); const decryptedAttributeValue: string = encryptedCharacter.attribute_value ? System.decryptDataWithUserKey(encryptedCharacter.attribute_value, userEncryptionKey) : ''; if (Array.isArray(characterEntry[decryptedAttributeName])) { characterEntry[decryptedAttributeName].push({ id: '', name: decryptedAttributeValue }); } } return Array.from(completeCharactersMap.values()); } /** * Generates a formatted vCard-style string representation of characters. * Useful for AI context or text-based exports. * @param characters - An array of complete character objects to format * @returns A formatted string containing all character information */ static characterVCard(characters: CompleteCharacterProps[]): string { const uniqueCharactersMap = new Map(); let formattedCharactersDescription: string = ''; characters.forEach((character: CompleteCharacterProps): void => { const characterIdentifier: string = character.name || character.id || 'unknown'; if (!uniqueCharactersMap.has(characterIdentifier)) { uniqueCharactersMap.set(characterIdentifier, { name: character.name, lastName: character.lastName, nickname: character.nickname, age: character.age, gender: character.gender, species: character.species, nationality: character.nationality, status: character.status, title: character.title, category: character.category, role: character.role, biography: character.biography, history: character.history, speechPattern: character.speechPattern, catchphrase: character.catchphrase, residence: character.residence, notes: character.notes, color: character.color }); } const aggregatedCharacterData: CompleteCharacterProps = uniqueCharactersMap.get(characterIdentifier)!; Object.keys(character).forEach((propertyName: string): void => { if (Array.isArray(character[propertyName])) { if (!aggregatedCharacterData[propertyName]) aggregatedCharacterData[propertyName] = []; (aggregatedCharacterData[propertyName] as Attribute[]).push(...(character[propertyName] as Attribute[])); } }); }); formattedCharactersDescription = Array.from(uniqueCharactersMap.values()).map((character: CompleteCharacterProps): string => { const characterDescriptionLines: string[] = []; const fullName: string = [character.name, character.lastName].filter(Boolean).join(' '); if (fullName) characterDescriptionLines.push(`Nom : ${fullName}`); (['category', 'title', 'role', 'biography', 'history'] as const).forEach((propertyKey) => { if (character[propertyKey]) { characterDescriptionLines.push(`${propertyKey.charAt(0).toUpperCase() + propertyKey.slice(1)} : ${character[propertyKey]}`); } }); Object.keys(character).forEach((propertyKey: string): void => { const propertyValue = character[propertyKey]; if (Array.isArray(propertyValue) && propertyValue.length > 0) { const capitalizedPropertyKey: string = propertyKey.charAt(0).toUpperCase() + propertyKey.slice(1); const formattedAttributeValues: string = propertyValue.map((attributeItem: Attribute) => attributeItem.name).join(', '); characterDescriptionLines.push(`${capitalizedPropertyKey} : ${formattedAttributeValues}`); } }); return characterDescriptionLines.join('\n'); }).join('\n\n'); return formattedCharactersDescription; } }