import System from "../System.js"; import { getUserEncryptionKey } from "../keyManager.js"; import Book, { CompleteBookData } from "./Book.js"; import ChapterRepo, { ActChapterQuery, ChapterQueryResult, ChapterStoryQueryResult, LastChapterResult } from "../repositories/chapter.repository.js"; import { ActChapter, ActStory } from "./Act.js"; import ChapterContentRepository, { ChapterContentQueryResult, CompanionContentQueryResult, ContentQueryResult } from "../repositories/chaptercontent.repository.js"; import RemovedItem from "./RemovedItem.js"; export interface ChapterContent { version: number; content: string; wordsCount: number; } export interface ChapterContentData extends ChapterContent { title: string; chapterOrder: number; } export interface ChapterProps { chapterId: string; title: string; chapterOrder: number; chapterContent?: ChapterContent } export interface CompanionContent { version: number; content: string; wordsCount: number; } export interface SyncedChapter { id: string; name: string; lastUpdate: number; contents: SyncedChapterContent[]; info: SyncedChapterInfo | null; } export interface SyncedChapterContent { id: string; lastUpdate: number; } export interface SyncedChapterInfo { id: string; lastUpdate: number; } export interface CompleteChapterContent { id: string; title: string; content: string; order: number; version?: number; } interface TipTapNode { type?: string; text?: string; content?: TipTapNode[]; attrs?: Record; marks?: TipTapMark[]; } interface TipTapMark { type: string; attrs?: Record; } export default class Chapter { /** * Retrieves all chapters from a specific book. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param lang - The language for error messages ('fr' or 'en') * @returns An array of ChapterProps containing chapter details */ public static getAllChaptersFromABook(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): ChapterProps[] { const chapterQueryResults: ChapterQueryResult[] = ChapterRepo.fetchAllChapterFromABook(userId, bookId, lang); const decryptedChapters: ChapterProps[] = []; const userEncryptionKey: string = getUserEncryptionKey(userId); for (const chapterResult of chapterQueryResults) { const decryptedTitle: string = System.decryptDataWithUserKey(chapterResult.title, userEncryptionKey); decryptedChapters.push({ chapterId: chapterResult.chapter_id, title: decryptedTitle, chapterOrder: chapterResult.chapter_order }); } return decryptedChapters; } /** * Retrieves all chapters organized by acts for a specific book. * Caches decrypted titles to avoid redundant decryption operations. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param lang - The language for error messages ('fr' or 'en') * @returns An array of ActChapter containing chapter details with act information */ public static getAllChapterFromActs(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): ActChapter[] { const actChapterQueryResults: ActChapterQuery[] = ChapterRepo.fetchAllChapterForActs(userId, bookId, lang); const actChapters: ActChapter[] = []; const decryptedTitleCache: { id: string; title: string }[] = []; const userEncryptionKey: string = getUserEncryptionKey(userId); if (actChapterQueryResults.length === 0) { return []; } for (const chapterQueryResult of actChapterQueryResults) { let decryptedTitle: string = ''; const cachedTitleIndex: number = decryptedTitleCache.findIndex( (cachedItem: { id: string; title: string }) => cachedItem.id === chapterQueryResult.chapter_id ); if (cachedTitleIndex > -1) { decryptedTitle = decryptedTitleCache[cachedTitleIndex]?.title ?? ''; } else { decryptedTitle = System.decryptDataWithUserKey(chapterQueryResult.title, userEncryptionKey); decryptedTitleCache.push({ id: chapterQueryResult.chapter_id, title: decryptedTitle }); } actChapters.push({ chapterId: chapterQueryResult.chapter_id, title: decryptedTitle, actId: chapterQueryResult.act_id, chapterInfoId: chapterQueryResult.chapter_info_id, chapterOrder: chapterQueryResult.chapter_order, goal: chapterQueryResult.goal ? System.decryptDataWithUserKey(chapterQueryResult.goal, userEncryptionKey) : '', summary: chapterQueryResult.summary ? System.decryptDataWithUserKey(chapterQueryResult.summary, userEncryptionKey) : '', incidentId: chapterQueryResult.incident_id, plotPointId: chapterQueryResult.plot_point_id }); } return actChapters; } /** * Retrieves a complete chapter with its content for a specific version. * Optionally updates the last chapter record for the book. * @param userId - The unique identifier of the user * @param chapterId - The unique identifier of the chapter * @param version - The version number of the chapter content * @param bookId - Optional book identifier to update last chapter record * @param lang - The language for error messages ('fr' or 'en') * @returns ChapterProps containing chapter details and content */ public static getWholeChapter(userId: string, chapterId: string, version: number, bookId?: string, lang: 'fr' | 'en' = 'fr'): ChapterProps { const chapterContentResult: ChapterContentQueryResult = ChapterContentRepository.fetchWholeChapter(userId, chapterId, version, lang); const userEncryptionKey: string = getUserEncryptionKey(userId); if (bookId) { ChapterRepo.updateLastChapterRecord(userId, bookId, chapterId, version, lang); } return { chapterId: chapterContentResult.chapter_id, title: System.decryptDataWithUserKey(chapterContentResult.title, userEncryptionKey), chapterOrder: chapterContentResult.chapter_order, chapterContent: { content: chapterContentResult.content ? System.decryptDataWithUserKey(chapterContentResult.content, userEncryptionKey) : '', version: version, wordsCount: chapterContentResult.words_count } }; } /** * Saves the content of a chapter for a specific version. * Encrypts the content before storing it in the database. * @param userId - The unique identifier of the user * @param chapterId - The unique identifier of the chapter * @param version - The version number of the chapter content * @param content - The JSON content to save * @param wordsCount - The word count of the content * @param currentTime - The current timestamp (unused, actual timestamp is generated) * @param lang - The language for error messages ('fr' or 'en') * @returns True if the content was saved successfully, false otherwise */ public static saveChapterContent(userId: string, chapterId: string, version: number, content: JSON, wordsCount: number, currentTime: number, lang: 'fr' | 'en' = 'fr'): boolean { const userEncryptionKey: string = getUserEncryptionKey(userId); const encryptedContent: string = System.encryptDataWithUserKey(JSON.stringify(content), userEncryptionKey); return ChapterContentRepository.updateChapterContent(userId, chapterId, version, encryptedContent, wordsCount, System.timeStampInSeconds(), lang); } /** * Retrieves the last accessed chapter for a specific book. * Falls back to the first chapter content if no last chapter record exists. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param lang - The language for error messages ('fr' or 'en') * @returns ChapterProps containing chapter details and content, or null if no chapters exist */ public static getLastChapter(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): ChapterProps | null { const lastChapterRecord: LastChapterResult | null = ChapterRepo.fetchLastChapter(userId, bookId, lang); if (lastChapterRecord) { return Chapter.getWholeChapter(userId, lastChapterRecord.chapter_id, lastChapterRecord.version, bookId, lang); } const chapterContentResults: ChapterContentQueryResult[] = ChapterContentRepository.fetchLastChapterContent(userId, bookId, lang); if (chapterContentResults.length === 0) { return null; } const firstChapterContent: ChapterContentQueryResult = chapterContentResults[0]; const userEncryptionKey: string = getUserEncryptionKey(userId); return { chapterId: firstChapterContent.chapter_id, title: firstChapterContent.title ? System.decryptDataWithUserKey(firstChapterContent.title, userEncryptionKey) : '', chapterOrder: firstChapterContent.chapter_order, chapterContent: { content: firstChapterContent.content ? System.decryptDataWithUserKey(firstChapterContent.content, userEncryptionKey) : '', version: firstChapterContent.version, wordsCount: firstChapterContent.words_count } }; } /** * Adds a new chapter to a book. * Validates that the chapter name is unique within the book. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param title - The title of the new chapter * @param wordsCount - The initial word count of the chapter * @param chapterOrder - The order position of the chapter * @param lang - The language for error messages ('fr' or 'en') * @param existingChapterId - Optional existing chapter ID for updates * @returns The unique identifier of the created chapter * @throws Error if a chapter with the same name already exists */ public static addChapter(userId: string, bookId: string, title: string, wordsCount: number, chapterOrder: number, lang: 'fr' | 'en' = 'fr', existingChapterId?: string): string { const hashedTitle: string = System.hashElement(title); const userEncryptionKey: string = getUserEncryptionKey(userId); const encryptedTitle: string = System.encryptDataWithUserKey(title, userEncryptionKey); if (!existingChapterId && ChapterRepo.checkNameDuplication(userId, bookId, hashedTitle, lang)) { throw new Error(lang === 'fr' ? `Ce nom de chapitre existe déjà.` : `This chapter name already exists.`); } const chapterId: string = existingChapterId || System.createUniqueId(); return ChapterRepo.insertChapter(chapterId, userId, bookId, encryptedTitle, hashedTitle, wordsCount, chapterOrder, lang); } /** * Removes a chapter from the database. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param chapterId - The unique identifier of the chapter to remove * @param deletedAt - The timestamp of deletion (from UI via System.timeStampInSeconds()) * @param lang - The language for error messages ('fr' or 'en') * @returns True if the chapter was removed successfully, false otherwise */ public static removeChapter(userId: string, bookId: string, chapterId: string, deletedAt: number, lang: 'fr' | 'en' = 'fr'): boolean { const deleted: boolean = ChapterRepo.deleteChapter(userId, chapterId, lang); if (deleted) { RemovedItem.deleteTracker(userId, bookId, 'book_chapters', chapterId, deletedAt, lang); } return deleted; } /** * Adds chapter information linking a chapter to an act, plot point, and/or incident. * @param userId - The unique identifier of the user * @param chapterId - The unique identifier of the chapter * @param actId - The act number the chapter belongs to * @param bookId - The unique identifier of the book * @param plotId - Optional plot point identifier * @param incidentId - Optional incident identifier * @param lang - The language for error messages ('fr' or 'en') * @param existingChapterInfoId - Optional existing chapter info ID for updates * @returns The unique identifier of the created chapter information */ public static addChapterInformation(userId: string, chapterId: string, actId: number, bookId: string, plotId: string | null, incidentId: string | null, lang: 'fr' | 'en' = 'fr', existingChapterInfoId?: string): string { const chapterInfoId: string = existingChapterInfoId || System.createUniqueId(); return ChapterRepo.insertChapterInformation(chapterInfoId, userId, chapterId, actId, bookId, plotId, incidentId, lang); } /** * Updates a chapter's title and order position. * @param userId - The unique identifier of the user * @param chapterId - The unique identifier of the chapter * @param title - The new title for the chapter * @param chapterOrder - The new order position for the chapter * @param lang - The language for error messages ('fr' or 'en') * @returns True if the chapter was updated successfully, false otherwise */ public static updateChapter(userId: string, chapterId: string, title: string, chapterOrder: number, lang: 'fr' | 'en' = 'fr'): boolean { const hashedTitle: string = System.hashElement(title); const userEncryptionKey: string = getUserEncryptionKey(userId); const encryptedTitle: string = System.encryptDataWithUserKey(title, userEncryptionKey); return ChapterRepo.updateChapter(userId, chapterId, encryptedTitle, hashedTitle, chapterOrder, System.timeStampInSeconds(), lang); } /** * Updates chapter information for multiple chapters including summary and goal. * @param chapters - Array of ActChapter objects containing updated information * @param userId - The unique identifier of the user * @param actId - The act number the chapters belong to * @param bookId - The unique identifier of the book * @param incidentId - Optional incident identifier * @param plotId - Optional plot point identifier * @param lang - The language for error messages ('fr' or 'en') */ static updateChapterInfos(chapters: ActChapter[], userId: string, actId: number, bookId: string, incidentId: string | null, plotId: string | null, lang: 'fr' | 'en' = 'fr'): void { const userEncryptionKey: string = getUserEncryptionKey(userId); for (const chapterData of chapters) { const encryptedSummary: string = chapterData.summary ? System.encryptDataWithUserKey(chapterData.summary, userEncryptionKey) : ''; const encryptedGoal: string = chapterData.goal ? System.encryptDataWithUserKey(chapterData.goal, userEncryptionKey) : ''; const chapterId: string = chapterData.chapterId; ChapterRepo.updateChapterInfos(userId, chapterId, actId, bookId, incidentId, plotId, encryptedSummary, encryptedGoal, System.timeStampInSeconds(), lang); } } /** * Retrieves the companion content for a chapter (previous version content). * @param userId - The unique identifier of the user * @param chapterId - The unique identifier of the chapter * @param version - The current version number (companion is version - 1) * @param lang - The language for error messages ('fr' or 'en') * @returns CompanionContent containing the previous version's content */ static getCompanionContent(userId: string, chapterId: string, version: number, lang: 'fr' | 'en' = 'fr'): CompanionContent { const companionVersion: number = version - 1; const companionContentResults: CompanionContentQueryResult[] = ChapterContentRepository.fetchCompanionContent(userId, chapterId, companionVersion, lang); if (companionContentResults.length === 0) { return { version: version, content: '', wordsCount: 0 }; } const companionContentData: CompanionContentQueryResult = companionContentResults[0]; const userEncryptionKey: string = getUserEncryptionKey(userId); return { version: companionContentData.version, content: companionContentData.content ? System.decryptDataWithUserKey(companionContentData.content, userEncryptionKey) : '', wordsCount: companionContentData.words_count }; } /** * Retrieves the story context for a chapter including act summaries, incidents, and plot points. * @param userId - The unique identifier of the user * @param chapterId - The unique identifier of the chapter * @param lang - The language for error messages ('fr' or 'en') * @returns An array of ActStory containing story context organized by act */ static getChapterStory(userId: string, chapterId: string, lang: 'fr' | 'en' = 'fr'): ActStory[] { const chapterStoryResults: ChapterStoryQueryResult[] = ChapterRepo.fetchChapterStory(userId, chapterId, lang); const actStoriesMap: Record = {}; const userEncryptionKey: string = getUserEncryptionKey(userId); for (const storyResult of chapterStoryResults) { const actId: number = storyResult.act_id; if (!actStoriesMap[actId]) { actStoriesMap[actId] = { actId: actId, summary: storyResult.summary ? System.decryptDataWithUserKey(storyResult.summary, userEncryptionKey) : '', chapterSummary: storyResult.chapter_summary ? System.decryptDataWithUserKey(storyResult.chapter_summary, userEncryptionKey) : '', chapterGoal: storyResult.chapter_goal ? System.decryptDataWithUserKey(storyResult.chapter_goal, userEncryptionKey) : '', incidents: [], plotPoints: [] }; } if (storyResult.incident_id) { const decryptedIncidentTitle: string = storyResult.incident_title ? System.decryptDataWithUserKey(storyResult.incident_title, userEncryptionKey) : ''; const decryptedIncidentSummary: string = storyResult.incident_summary ? System.decryptDataWithUserKey(storyResult.incident_summary, userEncryptionKey) : ''; const incidentAlreadyExists: boolean = actStoriesMap[actId].incidents.some( (existingIncident) => existingIncident.incidentTitle === decryptedIncidentTitle && existingIncident.incidentSummary === decryptedIncidentSummary ); if (!incidentAlreadyExists) { actStoriesMap[actId].incidents.push({ incidentTitle: decryptedIncidentTitle, incidentSummary: decryptedIncidentSummary, chapterSummary: storyResult.chapter_summary ? System.decryptDataWithUserKey(storyResult.chapter_summary, userEncryptionKey) : '', chapterGoal: storyResult.chapter_goal ? System.decryptDataWithUserKey(storyResult.chapter_goal, userEncryptionKey) : '' }); } } if (storyResult.plot_point_id) { const decryptedPlotTitle: string = storyResult.plot_title ? System.decryptDataWithUserKey(storyResult.plot_title, userEncryptionKey) : ''; const decryptedPlotSummary: string = storyResult.plot_summary ? System.decryptDataWithUserKey(storyResult.plot_summary, userEncryptionKey) : ''; const plotPointAlreadyExists: boolean = actStoriesMap[actId].plotPoints.some( (existingPlotPoint) => existingPlotPoint.plotTitle === decryptedPlotTitle && existingPlotPoint.plotSummary === decryptedPlotSummary ); if (!plotPointAlreadyExists) { actStoriesMap[actId].plotPoints.push({ plotTitle: decryptedPlotTitle, plotSummary: decryptedPlotSummary, chapterSummary: storyResult.chapter_summary ? System.decryptDataWithUserKey(storyResult.chapter_summary, userEncryptionKey) : '', chapterGoal: storyResult.chapter_goal ? System.decryptDataWithUserKey(storyResult.chapter_goal, userEncryptionKey) : '' }); } } } return Object.values(actStoriesMap); } /** * Retrieves the content of a specific chapter version. * @param userId - The unique identifier of the user * @param chapterId - The unique identifier of the chapter * @param version - The version number of the content to retrieve * @param lang - The language for error messages ('fr' or 'en') * @returns The decrypted content string, or empty string if not found */ static getChapterContentByVersion(userId: string, chapterId: string, version: number, lang: 'fr' | 'en' = 'fr'): string { const contentResult: ContentQueryResult = ChapterContentRepository.fetchChapterContentByVersion(userId, chapterId, version, lang); const userEncryptionKey: string = getUserEncryptionKey(userId); return contentResult.content ? System.decryptDataWithUserKey(contentResult.content, userEncryptionKey) : ''; } /** * Removes chapter information by its identifier. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param chapterInfoId - The unique identifier of the chapter information to remove * @param deletedAt - The timestamp of deletion (from UI via System.timeStampInSeconds()) * @param lang - The language for error messages ('fr' or 'en') * @returns True if the chapter information was removed successfully, false otherwise */ static removeChapterInformation(userId: string, bookId: string, chapterInfoId: string, deletedAt: number, lang: 'fr' | 'en' = 'fr'): boolean { const deleted: boolean = ChapterRepo.deleteChapterInformation(userId, chapterInfoId, lang); if (deleted) { RemovedItem.deleteTracker(userId, bookId, 'book_chapter_infos', chapterInfoId, deletedAt, lang); } return deleted; } /** * Converts TipTap JSON content to HTML string. * Handles various node types including paragraphs, headings, lists, and text marks. * @param tipTapContent - The TipTap JSON content to convert * @returns The converted HTML string */ static tipTapToHtml(tipTapContent: JSON): string { const escapeHtmlCharacters = (text: string): string => { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; const renderTextWithMarks = (text: string, marks?: TipTapMark[]): string => { if (!marks || marks.length === 0) return escapeHtmlCharacters(text); let renderedText: string = escapeHtmlCharacters(text); marks.forEach((mark: TipTapMark) => { switch (mark.type) { case 'bold': renderedText = `${renderedText}`; break; case 'italic': renderedText = `${renderedText}`; break; case 'underline': renderedText = `${renderedText}`; break; case 'strike': renderedText = `${renderedText}`; break; case 'code': renderedText = `${renderedText}`; break; case 'link': const linkHref: string = (mark.attrs?.href as string) || '#'; renderedText = `${renderedText}`; break; } }); return renderedText; }; const renderTipTapNode = (node: TipTapNode): string => { if (!node) return ''; if (node.type === 'text') { const textContent: string = node.text || '\u00A0'; return renderTextWithMarks(textContent, node.marks); } const childrenHtml: string = node.content?.map(renderTipTapNode).join('') || ''; const textAlignStyle: string = node.attrs?.textAlign ? ` style="text-align: ${node.attrs.textAlign}"` : ''; switch (node.type) { case 'doc': return childrenHtml; case 'paragraph': return `${childrenHtml || '\u00A0'}

`; case 'heading': const headingLevel: number = (node.attrs?.level as number) || 1; return `${childrenHtml}`; case 'bulletList': return `
    ${childrenHtml}
`; case 'orderedList': return `
    ${childrenHtml}
`; case 'listItem': return `
  • ${childrenHtml}
  • `; case 'blockquote': return `
    ${childrenHtml}
    `; case 'codeBlock': return `
    ${childrenHtml}
    `; case 'hardBreak': return '
    '; case 'horizontalRule': return '
    '; default: return childrenHtml; } }; const contentNode: TipTapNode = tipTapContent as unknown as TipTapNode; return renderTipTapNode(contentNode); } /** * Retrieves all chapters with their content data for a specific book. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param lang - The language for error messages ('fr' or 'en') * @returns An array of ChapterContentData containing chapter details with content */ static getAllChapters(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): ChapterContentData[] { try { const completeBookData: CompleteBookData = Book.completeBookData(userId, bookId, lang); return Chapter.getChaptersOrSheet(completeBookData.chapters); } catch (error: unknown) { return []; } } /** * Processes book chapters to return either sheet content or chapter content. * If only a sheet exists (order -1), returns the sheet. Otherwise, returns all positive-order chapters. * @param bookChapters - Array of CompleteChapterContent from the book * @returns An array of ChapterContentData with processed content */ static getChaptersOrSheet(bookChapters: CompleteChapterContent[]): ChapterContentData[] { const processedChapters: ChapterContentData[] = []; const sheetContent: CompleteChapterContent | undefined = bookChapters.find( (chapter: CompleteChapterContent): boolean => chapter.order === -1 ); const regularChapter: CompleteChapterContent | undefined = bookChapters.find( (chapter: CompleteChapterContent): boolean => chapter.order > 0 ); if (sheetContent && !regularChapter) { processedChapters.push({ title: sheetContent.title, chapterOrder: sheetContent.order, content: System.htmlToText(Chapter.tipTapToHtml(JSON.parse(sheetContent.content))), wordsCount: 0, version: sheetContent.version || 0 }); } else if (regularChapter) { for (const chapterData of bookChapters) { if (chapterData.order < 0) continue; processedChapters.push({ title: chapterData.title, chapterOrder: chapterData.order, content: System.htmlToText(Chapter.tipTapToHtml(JSON.parse(chapterData.content))), wordsCount: 0, version: chapterData.version || 0 }); } } return processedChapters; } }