- Refactor `TextEditor` to include book-closing functionality with updated toolbar buttons - Replace `generateHTML` with a lightweight custom TipTap-to-HTML renderer - Update and lock `esbuild` and related dependencies to latest versions
392 lines
17 KiB
TypeScript
392 lines
17 KiB
TypeScript
import ChapterRepo, {
|
|
ActChapterQuery,
|
|
ChapterQueryResult,
|
|
ChapterContentQueryResult,
|
|
LastChapterResult,
|
|
CompanionContentQueryResult,
|
|
ChapterStoryQueryResult,
|
|
ContentQueryResult
|
|
} from "../repositories/chapter.repository.js";
|
|
import System from "../System.js";
|
|
import {getUserEncryptionKey} from "../keyManager.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 ActChapter {
|
|
chapterInfoId: number;
|
|
chapterId: string;
|
|
title: string;
|
|
chapterOrder: number;
|
|
actId: number;
|
|
incidentId: string | null;
|
|
plotPointId: string | null;
|
|
summary: string;
|
|
goal: string;
|
|
}
|
|
|
|
export interface CompanionContent {
|
|
version: number;
|
|
content: string;
|
|
wordsCount: number;
|
|
}
|
|
|
|
export interface ActStory {
|
|
actId: number;
|
|
summary: string;
|
|
chapterSummary: string;
|
|
chapterGoal: string;
|
|
incidents: IncidentStory[];
|
|
plotPoints: PlotPointStory[];
|
|
}
|
|
|
|
export interface IncidentStory {
|
|
incidentTitle: string;
|
|
incidentSummary: string;
|
|
chapterSummary: string;
|
|
chapterGoal: string;
|
|
}
|
|
|
|
export interface PlotPointStory {
|
|
plotTitle: string;
|
|
plotSummary: string;
|
|
chapterSummary: string;
|
|
chapterGoal: string;
|
|
}
|
|
|
|
export default class Chapter {
|
|
public static getAllChaptersFromABook(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): ChapterProps[] {
|
|
const chapters: ChapterQueryResult[] = ChapterRepo.fetchAllChapterFromABook(userId, bookId, lang);
|
|
let returnChapters: ChapterProps[] = [];
|
|
const userKey: string = getUserEncryptionKey(userId);
|
|
for (const chapter of chapters) {
|
|
const title: string = System.decryptDataWithUserKey(chapter.title, userKey);
|
|
returnChapters.push({
|
|
chapterId: chapter.chapter_id,
|
|
title: title,
|
|
chapterOrder: chapter.chapter_order
|
|
});
|
|
}
|
|
return returnChapters;
|
|
}
|
|
|
|
public static getAllChapterFromActs(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): ActChapter[] {
|
|
const query: ActChapterQuery[] = ChapterRepo.fetchAllChapterForActs(userId, bookId, lang);
|
|
let chapters: ActChapter[] = [];
|
|
let tempChapter: { id: string, title: string }[] = []
|
|
const userKey: string = getUserEncryptionKey(userId);
|
|
if (query.length > 0) {
|
|
for (const chapter of query) {
|
|
let decryptTitle: string = '';
|
|
const newTitleId: number = tempChapter.findIndex((temp: { id: string, title: string }) => temp.id === chapter.chapter_id);
|
|
if (newTitleId > -1) {
|
|
decryptTitle = tempChapter[newTitleId]?.title ?? ''
|
|
} else {
|
|
decryptTitle = System.decryptDataWithUserKey(chapter.title, userKey);
|
|
tempChapter.push({id: chapter.chapter_id, title: decryptTitle});
|
|
}
|
|
chapters.push({
|
|
chapterId: chapter.chapter_id,
|
|
title: decryptTitle,
|
|
actId: chapter.act_id,
|
|
chapterInfoId: chapter.chapter_info_id,
|
|
chapterOrder: chapter.chapter_order,
|
|
goal: chapter.goal ? System.decryptDataWithUserKey(chapter.goal, userKey) : '',
|
|
summary: chapter.summary ? System.decryptDataWithUserKey(chapter.summary, userKey) : '',
|
|
incidentId: chapter.incident_id,
|
|
plotPointId: chapter.plot_point_id
|
|
})
|
|
}
|
|
return chapters;
|
|
} else {
|
|
return []
|
|
}
|
|
}
|
|
|
|
public static getWholeChapter(userId: string, chapterId: string, version: number, bookId?: string, lang: 'fr' | 'en' = 'fr'): ChapterProps {
|
|
const chapter: ChapterContentQueryResult = ChapterRepo.fetchWholeChapter(userId, chapterId, version, lang);
|
|
const userKey: string = getUserEncryptionKey(userId);
|
|
|
|
if (bookId) {
|
|
ChapterRepo.updateLastChapterRecord(userId, bookId, chapterId, version, lang);
|
|
}
|
|
return {
|
|
chapterId: chapter.chapter_id,
|
|
title: System.decryptDataWithUserKey(chapter.title, userKey),
|
|
chapterOrder: chapter.chapter_order,
|
|
chapterContent: {
|
|
content: chapter.content ? System.decryptDataWithUserKey(chapter.content, userKey) : '',
|
|
version: version,
|
|
wordsCount: chapter.words_count
|
|
}
|
|
};
|
|
}
|
|
|
|
public static saveChapterContent(userId: string, chapterId: string, version: number, content: JSON, wordsCount: number, currentTime: number, lang: 'fr' | 'en' = 'fr'): boolean {
|
|
const userKey: string = getUserEncryptionKey(userId);
|
|
const encryptContent: string = System.encryptDataWithUserKey(JSON.stringify(content), userKey);
|
|
/*if (version === 2){
|
|
const QS = new AI();
|
|
const prompt:string = System.htmlToText(Chapter.tipTapToHtml(content));
|
|
const response:string = await QS.request(prompt,'summary-chapter');
|
|
console.log(response);
|
|
}*/
|
|
return ChapterRepo.updateChapterContent(userId, chapterId, version, encryptContent, wordsCount, System.timeStampInSeconds(), lang);
|
|
}
|
|
|
|
public static getLastChapter(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): ChapterProps | null {
|
|
const lastChapter: LastChapterResult | null = ChapterRepo.fetchLastChapter(userId, bookId, lang);
|
|
if (lastChapter) {
|
|
return Chapter.getWholeChapter(userId, lastChapter.chapter_id, lastChapter.version, bookId, lang);
|
|
}
|
|
const chapter: ChapterContentQueryResult[] = ChapterRepo.fetchLastChapterContent(userId, bookId, lang);
|
|
if (chapter.length === 0) {
|
|
return null
|
|
}
|
|
const chapterData: ChapterContentQueryResult = chapter[0];
|
|
const userKey: string = getUserEncryptionKey(userId);
|
|
return {
|
|
chapterId: chapterData.chapter_id,
|
|
title: chapterData.title ? System.decryptDataWithUserKey(chapterData.title, userKey) : '',
|
|
chapterOrder: chapterData.chapter_order,
|
|
chapterContent: {
|
|
content: chapterData.content ? System.decryptDataWithUserKey(chapterData.content, userKey) : '',
|
|
version: chapterData.version,
|
|
wordsCount: chapterData.words_count
|
|
}
|
|
};
|
|
}
|
|
|
|
public static addChapter(userId: string, bookId: string, title: string, wordsCount: number, chapterOrder: number, lang: 'fr' | 'en' = 'fr'): string {
|
|
const hashedTitle: string = System.hashElement(title);
|
|
const userKey: string = getUserEncryptionKey(userId);
|
|
const encryptedTitle: string = System.encryptDataWithUserKey(title, userKey);
|
|
|
|
if (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 = System.createUniqueId();
|
|
return ChapterRepo.insertChapter(chapterId, userId, bookId, encryptedTitle, hashedTitle, wordsCount, chapterOrder, lang);
|
|
}
|
|
|
|
public static removeChapter(userId: string, chapterId: string, lang: 'fr' | 'en' = 'fr'): boolean {
|
|
return ChapterRepo.deleteChapter(userId, chapterId, lang);
|
|
}
|
|
|
|
public static addChapterInformation(userId: string, chapterId: string, actId: number, bookId: string, plotId: string | null, incidentId: string | null, lang: 'fr' | 'en' = 'fr'): string {
|
|
const chapterInfoId: string = System.createUniqueId();
|
|
return ChapterRepo.insertChapterInformation(chapterInfoId, userId, chapterId, actId, bookId, plotId, incidentId, lang);
|
|
}
|
|
|
|
public static updateChapter(userId: string, chapterId: string, title: string, chapterOrder: number, lang: 'fr' | 'en' = 'fr'): boolean {
|
|
const hashedTitle: string = System.hashElement(title);
|
|
const userKey: string = getUserEncryptionKey(userId);
|
|
const encryptedTitle: string = System.encryptDataWithUserKey(title, userKey);
|
|
return ChapterRepo.updateChapter(userId, chapterId, encryptedTitle, hashedTitle, chapterOrder, System.timeStampInSeconds(), lang);
|
|
}
|
|
|
|
static updateChapterInfos(chapters: ActChapter[], userId: string, actId: number, bookId: string, incidentId: string | null, plotId: string | null, lang: 'fr' | 'en' = 'fr') {
|
|
const userKey: string = getUserEncryptionKey(userId);
|
|
for (const chapter of chapters) {
|
|
const summary: string = chapter.summary ? System.encryptDataWithUserKey(chapter.summary, userKey) : '';
|
|
const goal: string = chapter.goal ? System.encryptDataWithUserKey(chapter.goal, userKey) : '';
|
|
const chapterId: string = chapter.chapterId;
|
|
ChapterRepo.updateChapterInfos(userId, chapterId, actId, bookId, incidentId, plotId, summary, goal, System.timeStampInSeconds(), lang);
|
|
}
|
|
}
|
|
|
|
static getCompanionContent(userId: string, chapterId: string, version: number, lang: 'fr' | 'en' = 'fr'): CompanionContent {
|
|
const versionNum: number = version - 1;
|
|
const chapterResponse: CompanionContentQueryResult[] = ChapterRepo.fetchCompanionContent(userId, chapterId, versionNum, lang);
|
|
if (chapterResponse.length === 0) {
|
|
return {
|
|
version: version,
|
|
content: '',
|
|
wordsCount: 0
|
|
};
|
|
}
|
|
const chapter: CompanionContentQueryResult = chapterResponse[0];
|
|
const userKey: string = getUserEncryptionKey(userId);
|
|
return {
|
|
version: chapter.version,
|
|
content: chapter.content ? System.decryptDataWithUserKey(chapter.content, userKey) : '',
|
|
wordsCount: chapter.words_count
|
|
};
|
|
}
|
|
|
|
static getChapterStory(userId: string, chapterId: string, lang: 'fr' | 'en' = 'fr'): ActStory[] {
|
|
const stories: ChapterStoryQueryResult[] = ChapterRepo.fetchChapterStory(userId, chapterId, lang);
|
|
const actStories: Record<number, ActStory> = {};
|
|
const userKey: string = getUserEncryptionKey(userId);
|
|
|
|
for (const story of stories) {
|
|
const actId: number = story.act_id;
|
|
|
|
if (!actStories[actId]) {
|
|
actStories[actId] = {
|
|
actId: actId,
|
|
summary: story.summary ? System.decryptDataWithUserKey(story.summary, userKey) : '',
|
|
chapterSummary: story.chapter_summary ? System.decryptDataWithUserKey(story.chapter_summary, userKey) : '',
|
|
chapterGoal: story.chapter_goal ? System.decryptDataWithUserKey(story.chapter_goal, userKey) : '',
|
|
incidents: [],
|
|
plotPoints: []
|
|
};
|
|
}
|
|
|
|
if (story.incident_id) {
|
|
const incidentTitle = story.incident_title ? System.decryptDataWithUserKey(story.incident_title, userKey) : '';
|
|
const incidentSummary = story.incident_summary ? System.decryptDataWithUserKey(story.incident_summary, userKey) : '';
|
|
|
|
const incidentExists = actStories[actId].incidents.some(
|
|
(incident) => incident.incidentTitle === incidentTitle && incident.incidentSummary === incidentSummary
|
|
);
|
|
|
|
if (!incidentExists) {
|
|
actStories[actId].incidents.push({
|
|
incidentTitle: incidentTitle,
|
|
incidentSummary: incidentSummary,
|
|
chapterSummary: story.chapter_summary ? System.decryptDataWithUserKey(story.chapter_summary, userKey) : '',
|
|
chapterGoal: story.chapter_goal ? System.decryptDataWithUserKey(story.chapter_goal, userKey) : ''
|
|
});
|
|
}
|
|
}
|
|
|
|
if (story.plot_point_id) {
|
|
const plotTitle = story.plot_title ? System.decryptDataWithUserKey(story.plot_title, userKey) : '';
|
|
const plotSummary = story.plot_summary ? System.decryptDataWithUserKey(story.plot_summary, userKey) : '';
|
|
|
|
const plotPointExists = actStories[actId].plotPoints.some(
|
|
(plotPoint) => plotPoint.plotTitle === plotTitle && plotPoint.plotSummary === plotSummary
|
|
);
|
|
|
|
if (!plotPointExists) {
|
|
actStories[actId].plotPoints.push({
|
|
plotTitle: plotTitle,
|
|
plotSummary: plotSummary,
|
|
chapterSummary: story.chapter_summary ? System.decryptDataWithUserKey(story.chapter_summary, userKey) : '',
|
|
chapterGoal: story.chapter_goal ? System.decryptDataWithUserKey(story.chapter_goal, userKey) : ''
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return Object.values(actStories);
|
|
}
|
|
|
|
|
|
static getChapterContentByVersion(userId: string, chapterid: string, version: number, lang: 'fr' | 'en' = 'fr'): string {
|
|
const chapter: ContentQueryResult = ChapterRepo.fetchChapterContentByVersion(userId, chapterid, version, lang);
|
|
const userKey: string = getUserEncryptionKey(userId);
|
|
return chapter.content ? System.decryptDataWithUserKey(chapter.content, userKey) : '';
|
|
}
|
|
|
|
static removeChapterInformation(userId: string, chapterInfoId: string, lang: 'fr' | 'en' = 'fr') {
|
|
return ChapterRepo.deleteChapterInformation(userId, chapterInfoId, lang);
|
|
}
|
|
|
|
static tipTapToHtml(tipTapContent: JSON): string {
|
|
interface TipTapNode {
|
|
type?: string;
|
|
text?: string;
|
|
content?: TipTapNode[];
|
|
attrs?: Record<string, unknown>;
|
|
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
|
|
}
|
|
|
|
const escapeHtml = (text: string): string => {
|
|
return text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
};
|
|
|
|
const renderMarks = (text: string, marks?: Array<{ type: string; attrs?: Record<string, unknown> }>): string => {
|
|
if (!marks || marks.length === 0) return escapeHtml(text);
|
|
|
|
let result = escapeHtml(text);
|
|
marks.forEach((mark) => {
|
|
switch (mark.type) {
|
|
case 'bold':
|
|
result = `<strong>${result}</strong>`;
|
|
break;
|
|
case 'italic':
|
|
result = `<em>${result}</em>`;
|
|
break;
|
|
case 'underline':
|
|
result = `<u>${result}</u>`;
|
|
break;
|
|
case 'strike':
|
|
result = `<s>${result}</s>`;
|
|
break;
|
|
case 'code':
|
|
result = `<code>${result}</code>`;
|
|
break;
|
|
case 'link':
|
|
const href = mark.attrs?.href || '#';
|
|
result = `<a href="${escapeHtml(String(href))}">${result}</a>`;
|
|
break;
|
|
}
|
|
});
|
|
return result;
|
|
};
|
|
|
|
const renderNode = (node: TipTapNode): string => {
|
|
if (!node) return '';
|
|
|
|
if (node.type === 'text') {
|
|
const textContent = node.text || '\u00A0';
|
|
return renderMarks(textContent, node.marks);
|
|
}
|
|
|
|
const children = node.content?.map(renderNode).join('') || '';
|
|
const textAlign = node.attrs?.textAlign ? ` style="text-align: ${node.attrs.textAlign}"` : '';
|
|
|
|
switch (node.type) {
|
|
case 'doc':
|
|
return children;
|
|
case 'paragraph':
|
|
return `<p${textAlign}>${children || '\u00A0'}</p>`;
|
|
case 'heading':
|
|
const level = node.attrs?.level || 1;
|
|
return `<h${level}${textAlign}>${children}</h${level}>`;
|
|
case 'bulletList':
|
|
return `<ul>${children}</ul>`;
|
|
case 'orderedList':
|
|
return `<ol>${children}</ol>`;
|
|
case 'listItem':
|
|
return `<li>${children}</li>`;
|
|
case 'blockquote':
|
|
return `<blockquote>${children}</blockquote>`;
|
|
case 'codeBlock':
|
|
return `<pre><code>${children}</code></pre>`;
|
|
case 'hardBreak':
|
|
return '<br />';
|
|
case 'horizontalRule':
|
|
return '<hr />';
|
|
default:
|
|
return children;
|
|
}
|
|
};
|
|
|
|
const contentNode = tipTapContent as unknown as TipTapNode;
|
|
return renderNode(contentNode);
|
|
}
|
|
}
|