From 7f34421212097cbdd39993a56c1be389a5a3166e Mon Sep 17 00:00:00 2001 From: natreex Date: Sat, 10 Jan 2026 15:50:03 -0500 Subject: [PATCH] Add error handling, enhance syncing, and refactor deletion logic - Introduce new error messages for syncing and book deletion in `en.json`. - Update `DeleteBook` to support local-only deletion and synced book management. - Refine offline/online behavior with `deleteLocalToo` checkbox and update related state handling. - Extend repository and IPC methods to handle optional IDs for updates. - Add `SyncQueueContext` for queueing offline changes and improving synchronization workflows. - Enhance refined text generation logic in `DraftCompanion` and `GhostWriter` components. - Replace PUT with PATCH for world updates to align with API expectations. - Streamline `AlertBox` by integrating dynamic translation keys for deletion prompts. --- app/page.tsx | 3 + components/AlertBox.tsx | 10 +- components/ShortStoryGenerator.tsx | 16 +- components/SyncBook.tsx | 2 +- components/book/BookList.tsx | 2 +- .../book/settings/BasicInformationSetting.tsx | 2 +- components/book/settings/DeleteBook.tsx | 60 ++++- .../book/settings/world/WorldSetting.tsx | 2 +- components/editor/DraftCompanion.tsx | 16 +- components/ghostwriter/GhostWriter.tsx | 17 +- components/offline/OfflineToggle.tsx | 2 +- context/SyncQueueContext.ts | 22 ++ electron/database/models/Book.ts | 24 +- electron/database/models/Chapter.ts | 10 +- electron/database/models/Character.ts | 8 +- electron/database/models/Location.ts | 12 +- electron/ipc/book.ipc.ts | 17 +- electron/ipc/chapter.ipc.ts | 7 +- electron/ipc/character.ipc.ts | 6 +- electron/ipc/location.ipc.ts | 9 +- electron/main.ts | 92 +++++++- hooks/useSyncBooks.ts | 206 ++++++++++++++++++ lib/locales/en.json | 22 +- lib/locales/fr.json | 22 +- lib/models/System.ts | 14 +- lib/models/User.ts | 3 +- 26 files changed, 506 insertions(+), 100 deletions(-) create mode 100644 context/SyncQueueContext.ts create mode 100644 hooks/useSyncBooks.ts diff --git a/app/page.tsx b/app/page.tsx index b92c12d..ec71dd3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -446,6 +446,9 @@ function ScribeContent() { } catch (syncError) { errorMessage(t("homePage.errors.syncError")); } + } else { + console.error('[Page] Database initialization failed'); + errorMessage(t("homePage.errors.dbInitError")); } } catch (error) { errorMessage(t("homePage.errors.syncError")); diff --git a/components/AlertBox.tsx b/components/AlertBox.tsx index 7bfcf90..dae6978 100644 --- a/components/AlertBox.tsx +++ b/components/AlertBox.tsx @@ -15,6 +15,7 @@ interface AlertBoxProps { cancelText?: string; onConfirm: () => Promise; onCancel: () => void; + children?: React.ReactNode; } export default function AlertBox( @@ -25,7 +26,8 @@ export default function AlertBox( confirmText = 'Confirmer', cancelText = 'Annuler', onConfirm, - onCancel + onCancel, + children }: AlertBoxProps) { const [mounted, setMounted] = useState(false); @@ -86,9 +88,9 @@ export default function AlertBox(
-

{message}

- -
+

{message}

+ {children} +
diff --git a/components/ShortStoryGenerator.tsx b/components/ShortStoryGenerator.tsx index d6f538a..e523dbd 100644 --- a/components/ShortStoryGenerator.tsx +++ b/components/ShortStoryGenerator.tsx @@ -147,7 +147,7 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) infoMessage(t("shortStoryGenerator.result.abortSuccess")); } } - + async function handleGeneration(): Promise { setIsGenerating(true); setGeneratedText(''); @@ -213,17 +213,14 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) totalPrice?: number; totalCost?: number; } = JSON.parse(line.slice(6)); - + if (data.content && data.content !== 'starting') { accumulatedText += data.content; setGeneratedText(accumulatedText); } - - if (data.title) { - setGeneratedStoryTitle(data.title); - } - if (data.useYourKey !== undefined && data.totalPrice !== undefined) { + if (data.title && data.useYourKey !== undefined && data.totalPrice !== undefined) { + setGeneratedStoryTitle(data.title); if (data.useYourKey) { setTotalPrice((prev: number): number => prev + data.totalPrice!); } else { @@ -231,16 +228,15 @@ export default function ShortStoryGenerator({onClose}: ShortStoryGeneratorProps) } } } catch (e: unknown) { - console.error('Error parsing SSE data:', e); + errorMessage(t("shortStoryGenerator.result.parsingError")); } } } } catch (e: unknown) { - // Si le reader est annulé ou une erreur survient, sortir break; } } - + setIsGenerating(false); setHasGenerated(true); setAbortController(null); diff --git a/components/SyncBook.tsx b/components/SyncBook.tsx index 6f98c49..c9f4d7f 100644 --- a/components/SyncBook.tsx +++ b/components/SyncBook.tsx @@ -23,7 +23,7 @@ export default function SyncBook({bookId, status}: SyncBookProps) { async function upload(): Promise { if (isOffline) return; setIsLoading(true); - const success = await hookUpload(bookId); + const success:boolean = await hookUpload(bookId); if (success) setCurrentStatus('synced'); setIsLoading(false); } diff --git a/components/book/BookList.tsx b/components/book/BookList.tsx index 96101f1..f9b0112 100644 --- a/components/book/BookList.tsx +++ b/components/book/BookList.tsx @@ -111,7 +111,7 @@ export default function BookList() { if (!isCurrentlyOffline()) { const response: boolean = await System.authPostToServer( 'logs/tour', - {plateforme: 'web', tour: 'new-first-book'}, + {plateforme: 'desktop', tour: 'new-first-book'}, session.accessToken, lang ); if (response) { diff --git a/components/book/settings/BasicInformationSetting.tsx b/components/book/settings/BasicInformationSetting.tsx index 7b9adc2..c2fcc2c 100644 --- a/components/book/settings/BasicInformationSetting.tsx +++ b/components/book/settings/BasicInformationSetting.tsx @@ -68,7 +68,7 @@ function BasicInformationSetting(props: any, ref: any) { }, params: { lang: lang, - plateforme: 'web', + plateforme: 'desktop', }, data: formData, responseType: 'arraybuffer' diff --git a/components/book/settings/DeleteBook.tsx b/components/book/settings/DeleteBook.tsx index 73f3484..9ebf33a 100644 --- a/components/book/settings/DeleteBook.tsx +++ b/components/book/settings/DeleteBook.tsx @@ -9,6 +9,7 @@ import AlertBox from "@/components/AlertBox"; import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; import {SyncedBook} from "@/lib/models/SyncedBook"; +import {useTranslations} from "next-intl"; interface DeleteBookProps { bookId: string; @@ -19,10 +20,16 @@ export default function DeleteBook({bookId}: DeleteBookProps) { const {lang} = useContext(LangContext) const {isCurrentlyOffline} = useContext(OfflineContext); const [showConfirmBox, setShowConfirmBox] = useState(false); + const [deleteLocalToo, setDeleteLocalToo] = useState(false); const {errorMessage} = useContext(AlertContext) - const {serverOnlyBooks,setServerOnlyBooks,localOnlyBooks,setLocalOnlyBooks} = useContext(BooksSyncContext); - + const {serverOnlyBooks,setServerOnlyBooks,localOnlyBooks,setLocalOnlyBooks,localSyncedBooks,setLocalSyncedBooks,setServerSyncedBooks} = useContext(BooksSyncContext); + const t = useTranslations('deleteBook'); + + const ifLocalOnlyBook: SyncedBook | undefined = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId); + const ifSyncedBook: SyncedBook | undefined = localSyncedBooks.find((book: SyncedBook): boolean => book.id === bookId); + function handleConfirmation(): void { + setDeleteLocalToo(false); setShowConfirmBox(true); } @@ -30,9 +37,8 @@ export default function DeleteBook({bookId}: DeleteBookProps) { try { let response: boolean; const deleteData = { id: bookId }; - const ifLocalBook: SyncedBook | undefined = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId); - if (isCurrentlyOffline() || ifLocalBook) { + if (isCurrentlyOffline() || ifLocalOnlyBook) { response = await window.electron.invoke('db:book:delete', deleteData); } else { response = await System.authDeleteToServer( @@ -41,11 +47,23 @@ export default function DeleteBook({bookId}: DeleteBookProps) { session.accessToken, lang ); + // If synced book and user wants to delete local too + if (response && ifSyncedBook && deleteLocalToo) { + await window.electron.invoke('db:book:delete', deleteData); + } } if (response) { setShowConfirmBox(false); - if (ifLocalBook) { + if (ifLocalOnlyBook) { setLocalOnlyBooks(localOnlyBooks.filter((b: SyncedBook): boolean => b.id !== bookId)); + } else if (ifSyncedBook) { + // Remove from synced lists + setLocalSyncedBooks(localSyncedBooks.filter((b: SyncedBook): boolean => b.id !== bookId)); + setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => prev.filter((b: SyncedBook): boolean => b.id !== bookId)); + // If not deleting local, move to localOnlyBooks + if (!deleteLocalToo) { + setLocalOnlyBooks([...localOnlyBooks, ifSyncedBook]); + } } else { setServerOnlyBooks(serverOnlyBooks.filter((b: SyncedBook): boolean => b.id !== bookId)); } @@ -54,7 +72,7 @@ export default function DeleteBook({bookId}: DeleteBookProps) { if (e instanceof Error) { errorMessage(e.message) } else { - errorMessage("Une erreur inconnue est survenue lors de la suppression du livre."); + errorMessage(t('errorUnknown')); } } } @@ -67,10 +85,32 @@ export default function DeleteBook({bookId}: DeleteBookProps) { { showConfirmBox && ( - setShowConfirmBox(false)} - confirmText={'Supprimer'} cancelText={'Annuler'}/> + setShowConfirmBox(false)} + confirmText={t('confirm')} + cancelText={t('cancel')}> + {ifSyncedBook && !isCurrentlyOffline() && ( +
+ +

+ {t('deleteLocalWarning')} +

+
+ )} +
) } diff --git a/components/book/settings/world/WorldSetting.tsx b/components/book/settings/world/WorldSetting.tsx index 16e9094..51c2a0c 100644 --- a/components/book/settings/world/WorldSetting.tsx +++ b/components/book/settings/world/WorldSetting.tsx @@ -165,7 +165,7 @@ export function WorldSetting(props: any, ref: any) { if (isCurrentlyOffline() || book?.localBook) { response = await window.electron.invoke('db:book:world:update', worldData); } else { - response = await System.authPutToServer('book/world/update', worldData, session.accessToken, lang); + response = await System.authPatchToServer('book/world/update', worldData, session.accessToken, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { addToQueue('db:book:world:update', worldData); diff --git a/components/editor/DraftCompanion.tsx b/components/editor/DraftCompanion.tsx index 39a8647..a350241 100644 --- a/components/editor/DraftCompanion.tsx +++ b/components/editor/DraftCompanion.tsx @@ -198,13 +198,13 @@ export default function DraftCompanion() { infoMessage(t("draftCompanion.abortSuccess")); } } - + async function handleQuillSenseRefined(): Promise { if (chapter && session?.accessToken) { setIsRefining(true); setShowRefinedText(false); setRefinedText(''); - + try { const response: Response = await fetch(`${configs.apiUrl}quillsense/refine`, { method: 'POST', @@ -260,21 +260,21 @@ export default function DraftCompanion() { const dataStr: string = line.slice(6); const data: { content?: string; - totalCost?: number; totalPrice?: number; useYourKey?: boolean; - aborted?: boolean; } = JSON.parse(dataStr); - if ('totalCost' in data && 'useYourKey' in data && 'totalPrice' in data) { + if ('content' in data && data.content) { + accumulatedText += data.content; + setRefinedText(accumulatedText); + } + + else if ('useYourKey' in data && 'totalPrice' in data) { if (data.useYourKey) { setTotalPrice((prev: number): number => prev + data.totalPrice!); } else { setTotalCredits(data.totalPrice!); } - } else if ('content' in data && data.content && data.content !== 'starting') { - accumulatedText += data.content; - setRefinedText(accumulatedText); } } catch (e: unknown) { console.error('Error parsing SSE data:', e); diff --git a/components/ghostwriter/GhostWriter.tsx b/components/ghostwriter/GhostWriter.tsx index fad815d..9d62d62 100644 --- a/components/ghostwriter/GhostWriter.tsx +++ b/components/ghostwriter/GhostWriter.tsx @@ -107,7 +107,7 @@ export default function GhostWriter() { infoMessage(t("ghostWriter.abortSuccess")); } } - + async function handleGenerateGhostWriter(): Promise { setIsGenerating(true); setIsTextGenerated(false); @@ -187,21 +187,20 @@ export default function GhostWriter() { totalCost?: number; totalPrice?: number; useYourKey?: boolean; - aborted?: boolean; } = JSON.parse(dataStr); - - if ('totalCost' in data && 'useYourKey' in data && 'totalPrice' in data) { + if ('content' in data && data.content) { + accumulatedText += data.content; + setTextGenerated(accumulatedText); + } + else if ('useYourKey' in data && 'totalPrice' in data) { if (data.useYourKey) { setTotalPrice((prev: number): number => prev + data.totalPrice!); } else { setTotalCredits(data.totalPrice!); } - } else if ('content' in data && data.content && data.content !== 'starting') { - accumulatedText += data.content; - setTextGenerated(accumulatedText); } } catch (e: unknown) { - console.error('Error parsing SSE data:', e); + errorMessage(t('ghostWriter.errorProcessingData')); } } } @@ -209,7 +208,7 @@ export default function GhostWriter() { break; } } - + setIsGenerating(false); setIsTextGenerated(true); setAbortController(null); diff --git a/components/offline/OfflineToggle.tsx b/components/offline/OfflineToggle.tsx index 47545bc..a4d7fa3 100644 --- a/components/offline/OfflineToggle.tsx +++ b/components/offline/OfflineToggle.tsx @@ -8,7 +8,7 @@ import { faWifi, faCircle } from '@fortawesome/free-solid-svg-icons'; export default function OfflineToggle() { const { offlineMode, toggleOfflineMode } = useContext(OfflineContext); - if (!window.electron) { + if (!window.electron || !offlineMode.isDatabaseInitialized) { return null; } diff --git a/context/SyncQueueContext.ts b/context/SyncQueueContext.ts new file mode 100644 index 0000000..9861e33 --- /dev/null +++ b/context/SyncQueueContext.ts @@ -0,0 +1,22 @@ +import {Context, createContext, Dispatch, SetStateAction} from "react"; + +export interface LocalSyncOperation { + id: string; + channel: string; + data: Record; + timestamp: number; +} + +export interface LocalSyncQueueContextProps { + queue: LocalSyncOperation[]; + setQueue: Dispatch>; + addToQueue: (channel: string, data: Record) => void; + isProcessing: boolean; +} + +export const LocalSyncQueueContext: Context = createContext({ + queue: [], + setQueue: (): void => {}, + addToQueue: (): void => {}, + isProcessing: false, +}); diff --git a/electron/database/models/Book.ts b/electron/database/models/Book.ts index cad7d20..28dffd5 100644 --- a/electron/database/models/Book.ts +++ b/electron/database/models/Book.ts @@ -487,11 +487,11 @@ export default class Book { return BookRepo.updateGuideLine(userId, bookId, encryptedTone, encryptedAtmosphere, encryptedWritingStyle, encryptedThemes, encryptedSymbolism, encryptedMotifs, encryptedNarrativeVoice, encryptedPacing, encryptedKeyMessages, encryptedIntendedAudience, lang); } - public static addNewIncident(userId: string, bookId: string, name: string, lang: 'fr' | 'en' = 'fr'): string { + public static addNewIncident(userId: string, bookId: string, name: string, lang: 'fr' | 'en' = 'fr', existingIncidentId?: string): string { const userKey: string = getUserEncryptionKey(userId); const encryptedName:string = System.encryptDataWithUserKey(name,userKey); const hashedName:string = System.hashElement(name); - const incidentId: string = System.createUniqueId(); + const incidentId: string = existingIncidentId || System.createUniqueId(); return BookRepo.insertNewIncident(incidentId, userId, bookId, encryptedName, hashedName, lang); } public static async getPlotPoints(userId:string, bookId: string,actChapters:ActChapter[], lang: 'fr' | 'en' = 'fr'):Promise{ @@ -608,11 +608,11 @@ export default class Book { return BookRepo.updateBookBasicInformation(userId, encryptedTitle, hashedTitle, encryptedSubTitle, hashedSubTitle, encryptedSummary, publicationDate, wordCount, bookId, lang); } - static addNewPlotPoint(userId: string, bookId: string, incidentId: string, name: string, lang: 'fr' | 'en' = 'fr'): string { + static addNewPlotPoint(userId: string, bookId: string, incidentId: string, name: string, lang: 'fr' | 'en' = 'fr', existingPlotPointId?: string): string { const userKey:string = getUserEncryptionKey(userId); const encryptedName:string = System.encryptDataWithUserKey(name, userKey); const hashedName:string = System.hashElement(name); - const plotPointId: string = System.createUniqueId(); + const plotPointId: string = existingPlotPointId || System.createUniqueId(); return BookRepo.insertNewPlotPoint(plotPointId, userId, bookId, encryptedName, hashedName, incidentId, lang); } @@ -620,11 +620,11 @@ export default class Book { return BookRepo.deletePlotPoint(userId, plotId, lang); } - public static addNewIssue(userId: string, bookId: string, name: string, lang: 'fr' | 'en' = 'fr'): string { + public static addNewIssue(userId: string, bookId: string, name: string, lang: 'fr' | 'en' = 'fr', existingIssueId?: string): string { const userKey:string = getUserEncryptionKey(userId); const encryptedName:string = System.encryptDataWithUserKey(name,userKey); const hashedName:string = System.hashElement(name); - const issueId: string = System.createUniqueId(); + const issueId: string = existingIssueId || System.createUniqueId(); return BookRepo.insertNewIssue(issueId, userId, bookId, encryptedName, hashedName,lang); } @@ -691,14 +691,14 @@ export default class Book { return true; } - public static addNewWorld(userId: string, bookId: string, worldName: string, lang: 'fr' | 'en' = 'fr'): string { + public static addNewWorld(userId: string, bookId: string, worldName: string, lang: 'fr' | 'en' = 'fr', existingWorldId?: string): string { const userKey: string = getUserEncryptionKey(userId); const hashedName: string = System.hashElement(worldName); - if (BookRepo.checkWorldExist(userId, bookId, hashedName, lang)) { + if (!existingWorldId && BookRepo.checkWorldExist(userId, bookId, hashedName, lang)) { throw new Error(lang === "fr" ? `Tu as déjà un monde ${worldName}.` : `You already have a world named ${worldName}.`); } const encryptedName: string = System.encryptDataWithUserKey(worldName, userKey); - const worldId: string = System.createUniqueId(); + const worldId: string = existingWorldId || System.createUniqueId(); return BookRepo.insertNewWorld(worldId, userId, bookId, encryptedName, hashedName, lang); } @@ -875,15 +875,15 @@ export default class Book { return BookRepo.updateWorldElements(userId, elements, lang); } - public static addNewElementToWorld(userId: string, worldId: string, elementName: string, elementType: string, lang: 'fr' | 'en' = 'fr'): string { + public static addNewElementToWorld(userId: string, worldId: string, elementName: string, elementType: string, lang: 'fr' | 'en' = 'fr', existingElementId?: string): string { const userKey: string = getUserEncryptionKey(userId); const hashedName: string = System.hashElement(elementName); - if (BookRepo.checkElementExist(worldId, hashedName, lang)) { + if (!existingElementId && BookRepo.checkElementExist(worldId, hashedName, lang)) { throw new Error(lang === "fr" ? `Vous avez déjà un élément avec ce nom ${elementName}.` : `You already have an element named ${elementName}.`); } const elementTypeId: number = Book.getElementTypes(elementType); const encryptedName: string = System.encryptDataWithUserKey(elementName, userKey); - const elementId: string = System.createUniqueId(); + const elementId: string = existingElementId || System.createUniqueId(); return BookRepo.insertNewElement(userId, elementId, elementTypeId, worldId, encryptedName, hashedName, lang); } public static getElementTypes(elementType:string):number{ diff --git a/electron/database/models/Chapter.ts b/electron/database/models/Chapter.ts index 2fb6f55..7c64bd3 100644 --- a/electron/database/models/Chapter.ts +++ b/electron/database/models/Chapter.ts @@ -172,15 +172,15 @@ export default class Chapter { }; } - public static addChapter(userId: string, bookId: string, title: string, wordsCount: number, chapterOrder: number, lang: 'fr' | 'en' = 'fr'): string { + 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 userKey: string = getUserEncryptionKey(userId); const encryptedTitle: string = System.encryptDataWithUserKey(title, userKey); - if (ChapterRepo.checkNameDuplication(userId, bookId, hashedTitle, lang)) { + 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 = System.createUniqueId(); + const chapterId: string = existingChapterId || System.createUniqueId(); return ChapterRepo.insertChapter(chapterId, userId, bookId, encryptedTitle, hashedTitle, wordsCount, chapterOrder, lang); } @@ -188,8 +188,8 @@ export default class Chapter { 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(); + 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); } diff --git a/electron/database/models/Character.ts b/electron/database/models/Character.ts index 598efcb..0cf9eb0 100644 --- a/electron/database/models/Character.ts +++ b/electron/database/models/Character.ts @@ -88,9 +88,9 @@ export default class Character { return characterList; } - public static addNewCharacter(userId: string, character: CharacterPropsPost, bookId: string, lang: 'fr' | 'en' = 'fr'): string { + public static addNewCharacter(userId: string, character: CharacterPropsPost, bookId: string, lang: 'fr' | 'en' = 'fr', existingCharacterId?: string): string { const userKey: string = getUserEncryptionKey(userId); - const characterId: string = System.createUniqueId(); + const characterId: string = existingCharacterId || System.createUniqueId(); const encryptedName: string = System.encryptDataWithUserKey(character.name, userKey); const encryptedLastName: string = System.encryptDataWithUserKey(character.lastName, userKey); const encryptedTitle: string = System.encryptDataWithUserKey(character.title, userKey); @@ -132,9 +132,9 @@ export default class Character { return CharacterRepo.updateCharacter(userId, character.id, encryptedName, encryptedLastName, encryptedTitle, encryptedCategory, encryptedImage, encryptedRole, encryptedBiography, encryptedHistory, System.timeStampInSeconds(), lang); } - static addNewAttribute(characterId: string, userId: string, type: string, name: string, lang: 'fr' | 'en' = 'fr'): string { + static addNewAttribute(characterId: string, userId: string, type: string, name: string, lang: 'fr' | 'en' = 'fr', existingAttributeId?: string): string { const userKey: string = getUserEncryptionKey(userId); - const attributeId: string = System.createUniqueId(); + const attributeId: string = existingAttributeId || System.createUniqueId(); const encryptedType: string = System.encryptDataWithUserKey(type, userKey); const encryptedName: string = System.encryptDataWithUserKey(name, userKey); return CharacterRepo.insertAttribute(attributeId, characterId, userId, encryptedType, encryptedName, lang); diff --git a/electron/database/models/Location.ts b/electron/database/models/Location.ts index 226884a..4388df5 100644 --- a/electron/database/models/Location.ts +++ b/electron/database/models/Location.ts @@ -88,27 +88,27 @@ export default class Location { return locationArray; } - static addLocationSection(userId: string, locationName: string, bookId: string, lang: 'fr' | 'en' = 'fr'): string { + static addLocationSection(userId: string, locationName: string, bookId: string, lang: 'fr' | 'en' = 'fr', existingLocationId?: string): string { const userKey: string = getUserEncryptionKey(userId); const originalName: string = System.hashElement(locationName); const encryptedName: string = System.encryptDataWithUserKey(locationName, userKey); - const locationId: string = System.createUniqueId(); + const locationId: string = existingLocationId || System.createUniqueId(); return LocationRepo.insertLocation(userId, locationId, bookId, encryptedName, originalName, lang); } - static addLocationElement(userId: string, locationId: string, elementName: string, lang: 'fr' | 'en' = 'fr') { + static addLocationElement(userId: string, locationId: string, elementName: string, lang: 'fr' | 'en' = 'fr', existingElementId?: string) { const userKey: string = getUserEncryptionKey(userId); const originalName: string = System.hashElement(elementName); const encryptedName: string = System.encryptDataWithUserKey(elementName, userKey); - const elementId: string = System.createUniqueId(); + const elementId: string = existingElementId || System.createUniqueId(); return LocationRepo.insertLocationElement(userId, elementId, locationId, encryptedName, originalName, lang) } - static addLocationSubElement(userId: string, elementId: string, subElementName: string, lang: 'fr' | 'en' = 'fr') { + static addLocationSubElement(userId: string, elementId: string, subElementName: string, lang: 'fr' | 'en' = 'fr', existingSubElementId?: string) { const userKey: string = getUserEncryptionKey(userId); const originalName: string = System.hashElement(subElementName); const encryptedName: string = System.encryptDataWithUserKey(subElementName, userKey); - const subElementId: string = System.createUniqueId(); + const subElementId: string = existingSubElementId || System.createUniqueId(); return LocationRepo.insertLocationSubElement(userId, subElementId, elementId, encryptedName, originalName, lang) } diff --git a/electron/ipc/book.ipc.ts b/electron/ipc/book.ipc.ts index 40cfaff..ba8a191 100644 --- a/electron/ipc/book.ipc.ts +++ b/electron/ipc/book.ipc.ts @@ -53,28 +53,33 @@ interface CreateBookData { interface AddIncidentData { bookId: string; name: string; + incidentId?: string; } interface AddPlotPointData { bookId: string; name: string; incidentId: string; + plotPointId?: string; } interface AddIssueData { bookId: string; name: string; + issueId?: string; } interface AddWorldData { bookId: string; worldName: string; + id?: string; } interface AddWorldElementData { worldId: string; elementName: string; elementType: number; + id?: string; } interface SetAIGuideLineData { @@ -235,7 +240,7 @@ ipcMain.handle( 'db:book:incident:add', createHandler( function(userId: string, data: AddIncidentData, lang: 'fr' | 'en') { - return Book.addNewIncident(userId, data.bookId, data.name, lang); + return Book.addNewIncident(userId, data.bookId, data.name, lang, data.incidentId); } ) ); @@ -261,7 +266,8 @@ ipcMain.handle('db:book:plot:add', createHandler( data.bookId, data.incidentId, data.name, - lang + lang, + data.plotPointId ); } ) @@ -283,7 +289,7 @@ ipcMain.handle( // POST /book/issue/add - Add issue ipcMain.handle('db:book:issue:add', createHandler( function(userId: string, data: AddIssueData, lang: 'fr' | 'en') { - return Book.addNewIssue(userId, data.bookId, data.name, lang); + return Book.addNewIssue(userId, data.bookId, data.name, lang, data.issueId); } ) ); @@ -314,7 +320,7 @@ ipcMain.handle('db:book:worlds:get', createHandler( // POST /book/world/add - Add world ipcMain.handle('db:book:world:add', createHandler( function(userId: string, data: AddWorldData, lang: 'fr' | 'en') { - return Book.addNewWorld(userId, data.bookId, data.worldName, lang); + return Book.addNewWorld(userId, data.bookId, data.worldName, lang, data.id); } ) ); @@ -327,7 +333,8 @@ ipcMain.handle('db:book:world:element:add', createHandler( // POST /chapter/add - Add new chapter ipcMain.handle('db:chapter:add', createHandler( function(userId: string, data: AddChapterData, lang: 'fr' | 'en'): string { - return Chapter.addChapter(userId, data.bookId, data.title, 0, data.chapterOrder, lang); + return Chapter.addChapter(userId, data.bookId, data.title, 0, data.chapterOrder, lang, data.chapterId); } ) ); @@ -144,7 +146,8 @@ ipcMain.handle('db:chapter:information:add', createHandler( function(userId: string, data: AddCharacterData, lang: 'fr' | 'en'): string { - return Character.addNewCharacter(userId, data.character, data.bookId, lang); + return Character.addNewCharacter(userId, data.character, data.bookId, lang, data.id); } ) ); @@ -47,7 +49,7 @@ ipcMain.handle('db:character:create', createHandler( // POST /character/attribute/add - Add attribute to character ipcMain.handle('db:character:attribute:add', createHandler( function(userId: string, data: AddAttributeData, lang: 'fr' | 'en'): string { - return Character.addNewAttribute(data.characterId, userId, data.type, data.name, lang); + return Character.addNewAttribute(data.characterId, userId, data.type, data.name, lang, data.id); } ) ); diff --git a/electron/ipc/location.ipc.ts b/electron/ipc/location.ipc.ts index fdfae4d..682b8f5 100644 --- a/electron/ipc/location.ipc.ts +++ b/electron/ipc/location.ipc.ts @@ -11,16 +11,19 @@ interface UpdateLocationResponse { interface AddLocationSectionData { locationName: string; bookId: string; + id?: string; } interface AddLocationElementData { locationId: string; elementName: string; + id?: string; } interface AddLocationSubElementData { elementId: string; subElementName: string; + id?: string; } interface UpdateLocationData { @@ -41,7 +44,7 @@ ipcMain.handle('db:location:all', createHandler( function(userId: string, data: AddLocationSectionData, lang: 'fr' | 'en'): string { - return Location.addLocationSection(userId, data.locationName, data.bookId, lang); + return Location.addLocationSection(userId, data.locationName, data.bookId, lang, data.id); } ) ); @@ -49,7 +52,7 @@ ipcMain.handle('db:location:section:add', createHandler( function(userId: string, data: AddLocationElementData, lang: 'fr' | 'en'): string { - return Location.addLocationElement(userId, data.locationId, data.elementName, lang); + return Location.addLocationElement(userId, data.locationId, data.elementName, lang, data.id); } ) ); @@ -57,7 +60,7 @@ ipcMain.handle('db:location:element:add', createHandler( function(userId: string, data: AddLocationSubElementData, lang: 'fr' | 'en'): string { - return Location.addLocationSubElement(userId, data.elementId, data.subElementName, lang); + return Location.addLocationSubElement(userId, data.elementId, data.subElementName, lang, data.id); } ) ); diff --git a/electron/main.ts b/electron/main.ts index 391ca0d..431dd1f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import {app, BrowserWindow, ipcMain, IpcMainInvokeEvent, Menu, nativeImage, protocol, safeStorage, shell} from 'electron'; +import {app, BrowserWindow, dialog, ipcMain, IpcMainInvokeEvent, Menu, nativeImage, protocol, safeStorage, shell} from 'electron'; import * as path from 'path'; import {fileURLToPath} from 'url'; import * as fs from 'fs'; @@ -519,6 +519,73 @@ ipcMain.handle('db-initialize', (_event, userId: string, encryptionKey: string) } }); +/** + * Emergency restore - Clean up ALL local data + */ +function performEmergencyRestore(): void { + try { + // Close database connection + const db: DatabaseService = getDatabaseService(); + db.close(); + + // Get storage and userId before clearing + const storage: SecureStorage = getSecureStorage(); + const userId = storage.get('userId'); + const lastUserId = storage.get('lastUserId'); + + // Delete user-specific data + if (userId) { + storage.delete(`pin-${userId}`); + storage.delete(`encryptionKey-${userId}`); + } + if (lastUserId && lastUserId !== userId) { + storage.delete(`pin-${lastUserId}`); + storage.delete(`encryptionKey-${lastUserId}`); + } + + // Delete all general data + storage.delete('authToken'); + storage.delete('userId'); + storage.delete('lastUserId'); + storage.delete('userLang'); + storage.delete('offlineMode'); + storage.delete('syncInterval'); + + // Save cleared storage + storage.save(); + + // Delete database file + const userDataPath: string = app.getPath('userData'); + const dbPath: string = path.join(userDataPath, 'eritors-local.db'); + if (fs.existsSync(dbPath)) { + fs.unlinkSync(dbPath); + } + + // Delete secure config file to ensure complete reset + const secureConfigPath: string = path.join(userDataPath, 'secure-config.json'); + if (fs.existsSync(secureConfigPath)) { + fs.unlinkSync(secureConfigPath); + } + + console.log('[Emergency Restore] All local data cleared successfully'); + } catch (error) { + console.error('[Emergency Restore] Error:', error); + } + + // Restart app + if (mainWindow) { + mainWindow.close(); + mainWindow = null; + } + if (loginWindow) { + loginWindow.close(); + loginWindow = null; + } + + app.relaunch(); + app.exit(0); +} + app.whenReady().then(():void => { // Security: Disable web cache in production if (!isDev) { @@ -558,6 +625,29 @@ app.whenReady().then(():void => { submenu: [ { role: 'toggleDevTools' } ] + }, + { + label: 'Help', + submenu: [ + { + label: 'Restore App', + click: () => { + dialog.showMessageBox({ + type: 'warning', + buttons: ['Cancel', 'Restore'], + defaultId: 0, + cancelId: 0, + title: 'Restore App', + message: 'Are you sure you want to restore the app?', + detail: 'This will delete all local data including: PIN codes, encryption keys, local database, and authentication tokens. The app will restart after restoration.' + }).then((result) => { + if (result.response === 1) { + performEmergencyRestore(); + } + }); + } + } + ] } ]; diff --git a/hooks/useSyncBooks.ts b/hooks/useSyncBooks.ts new file mode 100644 index 0000000..c6caf68 --- /dev/null +++ b/hooks/useSyncBooks.ts @@ -0,0 +1,206 @@ +import {useContext} from 'react'; +import System from '@/lib/models/System'; +import {SessionContext} from '@/context/SessionContext'; +import {LangContext} from '@/context/LangContext'; +import {AlertContext} from '@/context/AlertContext'; +import OfflineContext from '@/context/OfflineContext'; +import {BooksSyncContext} from '@/context/BooksSyncContext'; +import {CompleteBook} from '@/lib/models/Book'; +import {BookSyncCompare, SyncedBook} from '@/lib/models/SyncedBook'; +import {useTranslations} from 'next-intl'; + +export default function useSyncBooks() { + const t = useTranslations(); + const {session} = useContext(SessionContext); + const {lang} = useContext(LangContext); + const {errorMessage} = useContext(AlertContext); + const {isCurrentlyOffline, offlineMode} = useContext(OfflineContext); + const { + booksToSyncToServer, + booksToSyncFromServer, + localOnlyBooks, + serverOnlyBooks, + setLocalOnlyBooks, + setServerOnlyBooks, + setServerSyncedBooks, + setLocalSyncedBooks + } = useContext(BooksSyncContext); + + async function upload(bookId: string): Promise { + if (isCurrentlyOffline()) return false; + + try { + const bookToSync: CompleteBook = await window.electron.invoke('db:book:uploadToServer', bookId); + if (!bookToSync) { + errorMessage(t('bookCard.uploadError')); + return false; + } + const response: boolean = await System.authPostToServer('book/sync/upload', { + book: bookToSync + }, session.accessToken, lang); + if (!response) { + errorMessage(t('bookCard.uploadError')); + return false; + } + const uploadedBook: SyncedBook | undefined = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId); + setLocalOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => { + return prevBooks.filter((book: SyncedBook): boolean => book.id !== bookId); + }); + if (uploadedBook) { + setLocalSyncedBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, uploadedBook]); + setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, uploadedBook]); + } + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('bookCard.uploadError')); + } + return false; + } + } + + async function download(bookId: string): Promise { + if (isCurrentlyOffline()) return false; + + try { + const response: CompleteBook = await System.authGetQueryToServer('book/sync/download', session.accessToken, lang, {bookId}); + if (!response) { + errorMessage(t('bookCard.downloadError')); + return false; + } + const syncStatus: boolean = await window.electron.invoke('db:book:syncSave', response); + if (!syncStatus) { + errorMessage(t('bookCard.downloadError')); + return false; + } + const downloadedBook: SyncedBook | undefined = serverOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId); + setServerOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => { + return prevBooks.filter((book: SyncedBook): boolean => book.id !== bookId); + }); + if (downloadedBook) { + setLocalSyncedBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, downloadedBook]); + setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, downloadedBook]); + } + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('bookCard.downloadError')); + } + return false; + } + } + + async function syncFromServer(bookId: string): Promise { + if (isCurrentlyOffline()) return false; + + try { + const bookToFetch: BookSyncCompare | undefined = booksToSyncFromServer.find((book: BookSyncCompare): boolean => book.id === bookId); + if (!bookToFetch) { + errorMessage(t('bookCard.syncFromServerError')); + return false; + } + const response: CompleteBook = await System.authPostToServer('book/sync/server-to-client', { + bookToSync: bookToFetch + }, session.accessToken, lang); + if (!response) { + errorMessage(t('bookCard.syncFromServerError')); + return false; + } + const syncStatus: boolean = await window.electron.invoke('db:book:sync:toClient', response); + if (!syncStatus) { + errorMessage(t('bookCard.syncFromServerError')); + return false; + } + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('bookCard.syncFromServerError')); + } + return false; + } + } + + async function syncToServer(bookId: string): Promise { + if (isCurrentlyOffline()) return false; + + try { + const bookToFetch: BookSyncCompare | undefined = booksToSyncToServer.find((book: BookSyncCompare): boolean => book.id === bookId); + if (!bookToFetch) { + errorMessage(t('bookCard.syncToServerError')); + return false; + } + const bookToSync: CompleteBook = await window.electron.invoke('db:book:sync:toServer', bookToFetch); + if (!bookToSync) { + errorMessage(t('bookCard.syncToServerError')); + return false; + } + const response: boolean = await System.authPatchToServer('book/sync/client-to-server', { + book: bookToSync + }, session.accessToken, lang); + if (!response) { + errorMessage(t('bookCard.syncToServerError')); + return false; + } + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('bookCard.syncToServerError')); + } + return false; + } + } + + async function syncAllToServer(): Promise { + for (const diff of booksToSyncToServer) { + await syncToServer(diff.id); + } + } + + async function refreshBooks(): Promise { + try { + let localBooksResponse: SyncedBook[] = []; + let serverBooksResponse: SyncedBook[] = []; + + if (!isCurrentlyOffline()) { + if (offlineMode.isDatabaseInitialized) { + localBooksResponse = await window.electron.invoke('db:books:synced'); + } + serverBooksResponse = await System.authGetQueryToServer('books/synced', session.accessToken, lang); + } else { + if (offlineMode.isDatabaseInitialized) { + localBooksResponse = await window.electron.invoke('db:books:synced'); + } + } + + setServerSyncedBooks(serverBooksResponse); + setLocalSyncedBooks(localBooksResponse); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('bookCard.refreshError')); + } + } + } + + return { + upload, + download, + syncFromServer, + syncToServer, + syncAllToServer, + refreshBooks, + localOnlyBooks, + serverOnlyBooks, + booksToSyncToServer, + booksToSyncFromServer + }; +} diff --git a/lib/locales/en.json b/lib/locales/en.json index 96c1fe4..63a057b 100644 --- a/lib/locales/en.json +++ b/lib/locales/en.json @@ -151,7 +151,12 @@ "serverOnly": "Server only", "toSyncFromServer": "Download from server", "toSyncToServer": "Upload to server", - "sync": "Sync" + "sync": "Sync", + "uploadError": "Error uploading book.", + "downloadError": "Error downloading book.", + "syncFromServerError": "Error syncing from server.", + "syncToServerError": "Error syncing to server.", + "refreshError": "Error refreshing books." }, "scribeTopBar": { "logoAlt": "Logo", @@ -873,7 +878,11 @@ "lastChapterError": "Error retrieving last chapter", "localDataError": "Unable to load local data", "encryptionKeyError": "Encryption key not found", - "offlineModeError": "Error initializing offline mode" + "offlineModeError": "Error initializing offline mode", + "offlineInitError": "Error initializing offline mode", + "syncError": "Error syncing data", + "dbInitError": "Error initializing local database", + "offlineError": "Error checking offline mode" } }, "shortStoryGenerator": { @@ -975,5 +984,14 @@ "setupFailed": "Error configuring PIN" } } + }, + "deleteBook": { + "title": "Delete book", + "message": "You are about to permanently delete your book.", + "confirm": "Delete", + "cancel": "Cancel", + "deleteLocalToo": "Also delete local version", + "deleteLocalWarning": "Warning: This action will delete the book from the server AND your device. This action is irreversible.", + "errorUnknown": "An unknown error occurred while deleting the book." } } \ No newline at end of file diff --git a/lib/locales/fr.json b/lib/locales/fr.json index 6533262..af4c6c4 100644 --- a/lib/locales/fr.json +++ b/lib/locales/fr.json @@ -151,7 +151,12 @@ "serverOnly": "Sur le serveur uniquement", "toSyncFromServer": "Télécharger depuis le serveur", "toSyncToServer": "Envoyer vers le serveur", - "sync": "Synchroniser" + "sync": "Synchroniser", + "uploadError": "Erreur lors du téléversement du livre.", + "downloadError": "Erreur lors du téléchargement du livre.", + "syncFromServerError": "Erreur lors de la synchronisation depuis le serveur.", + "syncToServerError": "Erreur lors de la synchronisation vers le serveur.", + "refreshError": "Erreur lors du rafraîchissement des livres." }, "scribeTopBar": { "logoAlt": "Logo", @@ -874,7 +879,11 @@ "lastChapterError": "Erreur lors de la récupération du dernier chapitre", "localDataError": "Impossible de charger les données locales", "encryptionKeyError": "Clé de chiffrement non trouvée", - "offlineModeError": "Erreur lors de l'initialisation du mode hors ligne" + "offlineModeError": "Erreur lors de l'initialisation du mode hors ligne", + "offlineInitError": "Erreur lors de l'initialisation du mode hors ligne", + "syncError": "Erreur lors de la synchronisation des données", + "dbInitError": "Erreur lors de l'initialisation de la base de données locale", + "offlineError": "Erreur lors de la vérification du mode hors ligne" } }, "shortStoryGenerator": { @@ -976,5 +985,14 @@ "setupFailed": "Erreur lors de la configuration du PIN" } } + }, + "deleteBook": { + "title": "Suppression du livre", + "message": "Vous êtes sur le point de supprimer votre livre définitivement.", + "confirm": "Supprimer", + "cancel": "Annuler", + "deleteLocalToo": "Supprimer également la version locale", + "deleteLocalWarning": "Attention : Cette action supprimera le livre du serveur ET de votre appareil. Cette action est irréversible.", + "errorUnknown": "Une erreur inconnue est survenue lors de la suppression du livre." } } \ No newline at end of file diff --git a/lib/models/System.ts b/lib/models/System.ts index 8c9cb51..0dfdd6f 100644 --- a/lib/models/System.ts +++ b/lib/models/System.ts @@ -1,7 +1,5 @@ import axios, {AxiosResponse} from "axios"; import {configs} from "@/lib/configs"; -import * as electron from "electron"; -import * as os from "node:os"; export default class System{ static verifyInput(input: string): boolean { @@ -36,7 +34,7 @@ export default class System{ }, params: { lang: lang, - plateforme: os.platform(), + plateforme: window.electron.platform, ...params }, url: configs.apiUrl + url, @@ -77,7 +75,7 @@ export default class System{ }, params: { lang: lang, - plateforme: os.platform(), + plateforme: window.electron.platform, }, url: configs.apiUrl + url, data: data @@ -105,7 +103,7 @@ export default class System{ }, params: { lang: lang, - plateforme: os.platform(), + plateforme: window.electron.platform, }, url: configs.apiUrl + url, data: data @@ -133,7 +131,7 @@ export default class System{ url: configs.apiUrl + url, params: { lang: lang, - plateforme: os.platform(), + plateforme: window.electron.platform, }, data: data }) @@ -161,7 +159,7 @@ export default class System{ url: configs.apiUrl + url, params: { lang: lang, - plateforme: os.platform(), + plateforme: window.electron.platform, }, data: data }) @@ -220,7 +218,7 @@ export default class System{ url: configs.apiUrl + url, params: { lang: lang, - plateforme: os.platform(), + plateforme: window.electron.platform, }, data: data }) diff --git a/lib/models/User.ts b/lib/models/User.ts index 98e7528..5cd08a5 100644 --- a/lib/models/User.ts +++ b/lib/models/User.ts @@ -72,8 +72,7 @@ export default class User { static guideTourDone(guide: GuideTour[], tour: string): boolean { if (!tour) return false; - - // Vérifier d'abord dans le guide du serveur + if (guide && guide.find((guide: GuideTour): boolean => guide[tour]) !== undefined) { return false; }