diff --git a/app/login/LoginWrapper.tsx b/app/login/LoginWrapper.tsx index 6d19dd9..abb7dc6 100644 --- a/app/login/LoginWrapper.tsx +++ b/app/login/LoginWrapper.tsx @@ -10,6 +10,7 @@ import {SessionProps} from "@/lib/models/Session"; import System from "@/lib/models/System"; import {SessionContext} from "@/context/SessionContext"; import {AlertContext} from "@/context/AlertContext"; +import * as tauri from '@/lib/tauri'; const messagesMap = { fr: frMessages, @@ -36,10 +37,7 @@ export default function LoginWrapper({children}: { children: React.ReactNode }) useEffect((): void => { if (session.isConnected) { - // Pas de router.push dans Electron, le main process gère - if (!window.electron) { - window.location.href = '/'; - } + tauri.loginSuccess(); } }, [session]); diff --git a/app/login/login/LoginForm.tsx b/app/login/login/LoginForm.tsx index 7a442f5..6fdaad1 100755 --- a/app/login/login/LoginForm.tsx +++ b/app/login/login/LoginForm.tsx @@ -2,17 +2,16 @@ import {useContext, useState} from "react"; import System from "@/lib/models/System"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faEnvelope, faLock} from "@fortawesome/free-solid-svg-icons"; -import {SessionContext, SessionContextProps} from "@/context/SessionContext"; import {AlertContext} from "@/context/AlertContext"; import {useTranslations} from "next-intl"; import {LangContext, LangContextProps} from "@/context/LangContext"; +import * as tauri from '@/lib/tauri'; export default function LoginForm() { const {errorMessage} = useContext(AlertContext); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); - const {setSession} = useContext(SessionContext); const t = useTranslations(); const {lang} = useContext(LangContext) @@ -48,21 +47,8 @@ export default function LoginForm() { setIsLoading(false); return; } - // Stocker le token dans electron-store via IPC - if (window.electron) { - await window.electron.setToken(response); - window.electron.loginSuccess(response); - } else { - // Fallback pour le mode dev web - System.setCookie('token', response, 30); - const token: string | null = System.getCookie('token'); - if (!token) { - errorMessage(t('loginForm.error.connection')); - setIsLoading(false); - return; - } - setSession({isConnected: true, user: null, accessToken: token}) - } + await tauri.setToken(response); + await tauri.loginSuccess(); } catch (e: unknown) { if (e instanceof Error) { errorMessage(t('loginForm.error.server')); diff --git a/app/login/login/SocialForm.tsx b/app/login/login/SocialForm.tsx index 18df140..1e78be5 100755 --- a/app/login/login/SocialForm.tsx +++ b/app/login/login/SocialForm.tsx @@ -4,60 +4,44 @@ import React, {useContext, useEffect} from "react"; import System from "@/lib/models/System"; import {AlertContext} from "@/context/AlertContext"; import {configs} from "@/lib/configs"; -import {SessionContext, SessionContextProps} from "@/context/SessionContext"; import {useTranslations} from "next-intl"; import {LangContext, LangContextProps} from "@/context/LangContext"; +import * as tauri from '@/lib/tauri'; export default function SocialForm() { const {errorMessage} = useContext(AlertContext); - const {setSession} = useContext(SessionContext) const t = useTranslations(); const {lang} = useContext(LangContext) - const isElectron = typeof window !== 'undefined' && !!window.electron; useEffect((): void => { - // Skip URL parsing in Electron (OAuth is handled via BrowserWindow) - if (isElectron) return; - const params = new URLSearchParams(window.location.search); const provider: string | null = params.get('provider'); - if (!provider) { - return; - } + if (!provider) return; + const code: string | null = params.get('code'); - if (!code) { - return; - } + if (!code) return; + if (provider === 'google') { handleGoogleLogin(code).then(); return; } if (provider === 'facebook') { const state: string | null = params.get('state'); - if (!state) { - return; - } + if (!state) return; handleFacebookLogin(code, state).then(); return; } if (provider === 'apple') { const state: string | null = params.get('state'); - if (!state) { - return; - } + if (!state) return; handleAppleLogin(code, state).then(); return; } }, []); async function handleLoginSuccess(token: string): Promise { - if (window.electron) { - await window.electron.setToken(token); - window.electron.loginSuccess(token); - } else { - System.setCookie('token', token, 30); - setSession({isConnected: true, user: null, accessToken: token}); - } + await tauri.setToken(token); + await tauri.loginSuccess(); } async function handleFacebookLogin(code: string, state: string): Promise { @@ -102,28 +86,10 @@ export default function SocialForm() { } async function handleOAuthClick(provider: 'google' | 'facebook' | 'apple'): Promise { - if (!window.electron) return; - try { - const result = await window.electron.oauthLogin(provider, configs.baseUrl); - - if (!result.success) { - if (result.error !== 'Window closed by user') { - errorMessage(t('socialForm.error.connection')); - } - return; - } - - if (result.code) { - if (provider === 'google') { - await handleGoogleLogin(result.code); - } else if (provider === 'facebook' && result.state) { - await handleFacebookLogin(result.code, result.state); - } else if (provider === 'apple' && result.state) { - await handleAppleLogin(result.code, result.state); - } - } - } catch (error) { + const oauthUrl = `${configs.baseUrl}auth/${provider}/desktop`; + await tauri.openExternal(oauthUrl); + } catch { errorMessage(t('socialForm.error.connection')); } } diff --git a/app/login/login/page.tsx b/app/login/login/page.tsx index 20d5faa..d8202e3 100755 --- a/app/login/login/page.tsx +++ b/app/login/login/page.tsx @@ -7,6 +7,7 @@ import SocialForm from "@/app/login/login/SocialForm"; import {useTranslations} from "next-intl"; import {LangContext} from "@/context/LangContext"; import System from "@/lib/models/System"; +import * as tauri from '@/lib/tauri'; export default function LoginPage() { const t = useTranslations(); @@ -22,21 +23,12 @@ export default function LoginPage() { useEffect(() => { async function checkFirstConnectionAndNetwork() { - // Check if we're in Electron - if (!window.electron) { - return; - } - try { - // Check if token exists (first connection) - const token = await window.electron.getToken(); + const token = await tauri.getToken(); const hasToken = !!token; - - // Check network status const online = navigator.onLine; setIsOnline(online); - // Show warning if first connection AND offline if (!hasToken && !online) { setShowOfflineWarning(true); } @@ -47,19 +39,19 @@ export default function LoginPage() { checkFirstConnectionAndNetwork(); - // Listen for online/offline events const handleOnline = () => { setIsOnline(true); setShowOfflineWarning(false); }; const handleOffline = async () => { setIsOnline(false); - // Check if token exists - if (window.electron) { - const token = await window.electron.getToken(); + try { + const token = await tauri.getToken(); if (!token) { setShowOfflineWarning(true); } + } catch { + setShowOfflineWarning(true); } }; diff --git a/app/login/offline/page.tsx b/app/login/offline/page.tsx index f9e71fd..9edba72 100644 --- a/app/login/offline/page.tsx +++ b/app/login/offline/page.tsx @@ -5,44 +5,32 @@ import OfflinePinVerify from '@/components/offline/OfflinePinVerify'; import { useTranslations } from 'next-intl'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faWifi, faArrowLeft } from '@fortawesome/free-solid-svg-icons'; +import * as tauri from '@/lib/tauri'; export default function OfflineLoginPage() { const t = useTranslations(); - async function handlePinSuccess(userId: string):Promise { - - // Initialize database with user's encryption key - if (window.electron) { - try { - // Get encryption key - const encryptionKey = await window.electron.getUserEncryptionKey(userId); - if (encryptionKey) { - // Initialize database - await window.electron.dbInitialize(userId, encryptionKey); - - // Navigate to main page - window.location.href = '/'; - } - } catch (error) { - console.error('[OfflineLogin] Error initializing database:', error); + async function handlePinSuccess(userId: string): Promise { + try { + const encryptionKey = await tauri.getUserEncryptionKey(userId); + if (encryptionKey) { + await tauri.dbInitialize(userId, encryptionKey); + await tauri.loginSuccess(); } + } catch (error) { + console.error('[OfflineLogin] Error initializing database:', error); } } - function handleBackToOnline():void { - if (window.electron) { - window.electron.logout(); - } + function handleBackToOnline(): void { + tauri.logout(); } - useEffect(():void => { - // Check if we have offline capability + useEffect((): void => { async function checkOfflineCapability() { - if (window.electron) { - const offlineStatus = await window.electron.offlineModeGet(); - if (!offlineStatus.hasPin) { - window.location.href = '/login/login'; - } + const offlineStatus = await tauri.offlineModeGet(); + if (!offlineStatus.hasPin) { + window.location.href = '/login/login'; } } diff --git a/app/page.tsx b/app/page.tsx index 0373c15..fabcaf2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -40,6 +40,7 @@ import {SeriesSyncContext} from "@/context/SeriesSyncContext"; import useSyncBooks from "@/hooks/useSyncBooks"; import useSyncSeries from "@/hooks/useSyncSeries"; import {LocalSyncQueueContext, LocalSyncOperation} from "@/context/SyncQueueContext"; +import * as tauri from '@/lib/tauri'; interface RemovedItemRecord { removal_id: string; @@ -194,7 +195,7 @@ function ScribeContent() { for (const operation of queueCopy) { try { - await window.electron.invoke(operation.channel, operation.data); + await tauri.invoke(operation.channel, operation.data); setLocalSyncQueue((prev: LocalSyncOperation[]): LocalSyncOperation[] => prev.filter((op: LocalSyncOperation): boolean => op.id !== operation.id) ); @@ -277,7 +278,20 @@ function ScribeContent() { ]; useEffect((): void => { - checkAuthentification().then() + checkAuthentification().then(); + + let unlisten: (() => void) | undefined; + import('@tauri-apps/api/event').then(function ({listen}) { + listen('auth-success', function () { + checkAuthentification().then(); + }).then(function (fn) { + unlisten = fn; + }); + }); + + return (): void => { + if (unlisten) unlisten(); + }; }, []); useEffect((): void => { @@ -373,20 +387,20 @@ function ScribeContent() { if (!isCurrentlyOffline()) { if (offlineMode.isDatabaseInitialized) { - localBooksResponse = await window.electron.invoke('db:books:synced'); + localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[]; const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; - const localTombstones: RemovedItemRecord[] = await window.electron.invoke('db:tombstones:since', lastOnlineTimestamp); + const localTombstones: RemovedItemRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as RemovedItemRecord[]; const serverResponse: SyncedBooksResponse = await System.authPostToServer('books/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); serverBooksResponse = serverResponse.books; - await window.electron.invoke('db:tombstones:apply:books', serverResponse.tombstones); + await tauri.applyBookTombstones(serverResponse.tombstones); } else { const serverResponse: SyncedBooksResponse = await System.authPostToServer('books/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale); serverBooksResponse = serverResponse.books; } } else { if (offlineMode.isDatabaseInitialized) { - localBooksResponse = await window.electron.invoke('db:books:synced'); + localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[]; } } @@ -408,20 +422,20 @@ function ScribeContent() { if (!isCurrentlyOffline()) { if (offlineMode.isDatabaseInitialized) { - localSeriesResponse = await window.electron.invoke('db:series:synced'); + localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[]; const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; - const localTombstones: RemovedItemRecord[] = await window.electron.invoke('db:tombstones:since', lastOnlineTimestamp); + const localTombstones: RemovedItemRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as RemovedItemRecord[]; const serverResponse: SyncedSeriesResponse = await System.authPostToServer('series/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); serverSeriesResponse = serverResponse.series; - await window.electron.invoke('db:tombstones:apply:series', serverResponse.tombstones); + await tauri.applySeriesTombstones(serverResponse.tombstones); } else { const serverResponse: SyncedSeriesResponse = await System.authPostToServer('series/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale); serverSeriesResponse = serverResponse.series; } } else { if (offlineMode.isDatabaseInitialized) { - localSeriesResponse = await window.electron.invoke('db:series:synced'); + localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[]; } } @@ -437,57 +451,30 @@ function ScribeContent() { } - useEffect(():void => { - async function checkPinSetup() { - if (session.isConnected && window.electron) { - try { - const offlineStatus = await window.electron.offlineModeGet(); - - if (!offlineStatus.hasPin) { - setTimeout(():void => { - setShowPinSetup(true); - }, 2000); - } - } catch (e:unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } else { - errorMessage('Unknown error occurred while checking offline mode') - } - } - } - } - - checkPinSetup().then(); - - }, [session.isConnected]); - async function handlePinVerifySuccess(userId: string): Promise { try { - if (window.electron) { - const storedToken: string | null = await window.electron.getToken(); - const encryptionKey:string|null = await window.electron.getUserEncryptionKey(userId); + const storedToken: string | null = await tauri.getToken(); + const encryptionKey: string | null = await tauri.getUserEncryptionKey(userId); - if (encryptionKey) { - await window.electron.dbInitialize(userId, encryptionKey); - setOfflineMode(prev => ({...prev, isDatabaseInitialized: true})); + if (encryptionKey) { + await tauri.dbInitialize(userId, encryptionKey); + setOfflineMode(prev => ({...prev, isDatabaseInitialized: true})); - const localUser:UserProps = await window.electron.invoke('db:user:info'); - if (localUser && localUser.id) { - setSession({ - isConnected: true, - user: localUser, - accessToken: storedToken || '', - }); - setShowPinVerify(false); - setCurrentCredits(localUser.creditsBalance || 0); - setAmountSpent(localUser.aiUsage || 0); - } else { - errorMessage(t("homePage.errors.localDataError")); - } + const localUser: UserProps = await tauri.getUserInfo(); + if (localUser && localUser.id) { + setSession({ + isConnected: true, + user: localUser, + accessToken: storedToken || '', + }); + setShowPinVerify(false); + setCurrentCredits(localUser.creditsBalance || 0); + setAmountSpent(localUser.aiUsage || 0); } else { - errorMessage(t("homePage.errors.encryptionKeyError")); + errorMessage(t("homePage.errors.localDataError")); } + } else { + errorMessage(t("homePage.errors.encryptionKeyError")); } } catch (error) { console.error('[OfflinePin] Error initializing offline mode:', error); @@ -530,12 +517,10 @@ function ScribeContent() { async function checkAuthentification(): Promise { let token: string | null = null; - if (typeof window !== 'undefined' && window.electron) { - try { - token = await window.electron.getToken(); - } catch (e) { - console.error('Error getting token from electron:', e); - } + try { + token = await tauri.getToken(); + } catch (e) { + console.error('Error getting token:', e); } if (token) { @@ -543,22 +528,20 @@ function ScribeContent() { const user: UserProps = await System.authGetQueryToServer('user/infos', token, locale); if (!user) { errorMessage(t("homePage.errors.userNotFound")); - if (window.electron) { - await window.electron.removeToken(); - window.electron.logout(); - } + await tauri.removeToken(); + tauri.logout(); return; } - - if (window.electron && user.id) { + + if (user.id) { try { - const initResult = await window.electron.initUser(user.id); + const initResult = await tauri.initUser(user.id); if (!initResult.success) { errorMessage(initResult.error || t("homePage.errors.offlineInitError")); return; } try { - const offlineStatus = await window.electron.offlineModeGet(); + const offlineStatus = await tauri.offlineModeGet(); if (!offlineStatus.hasPin) { setTimeout(():void => { setShowPinSetup(true); @@ -571,12 +554,12 @@ function ScribeContent() { console.error('[Page] Error initializing user:', error); } } - if (window.electron && user.id) { + if (user.id) { try { - const dbInitialized:boolean = await initializeDatabase(user.id); + const dbInitialized: boolean = await initializeDatabase(user.id); if (dbInitialized) { try { - await window.electron.invoke('db:user:sync', { + await tauri.syncUser({ userId: user.id, firstName: user.name, lastName: user.lastName, @@ -587,7 +570,6 @@ function ScribeContent() { errorMessage(t("homePage.errors.syncError")); } } else { - console.error('[Page] Database initialization failed'); errorMessage(t("homePage.errors.dbInitError")); } } catch (error) { @@ -599,30 +581,25 @@ function ScribeContent() { user: user, accessToken: token, }); - console.log(user) setCurrentCredits(user.creditsBalance) setAmountSpent(user.aiUsage) } catch (e: unknown) { - if (window.electron) { - try { - const offlineStatus = await window.electron.offlineModeGet(); - - if (offlineStatus.hasPin && offlineStatus.lastUserId) { - setOfflineMode((prev:OfflineMode):OfflineMode => ({...prev, isOffline: true, isNetworkOnline: false})); - setShowPinVerify(true); - setIsLoading(false); - return; - } else { - if (window.electron) { - await window.electron.removeToken(); - window.electron.logout(); - } - } - } catch (offlineError) { - errorMessage(t("homePage.errors.offlineError")); + try { + const offlineStatus = await tauri.offlineModeGet(); + + if (offlineStatus.hasPin && offlineStatus.lastUserId) { + setOfflineMode((prev:OfflineMode):OfflineMode => ({...prev, isOffline: true, isNetworkOnline: false})); + setShowPinVerify(true); + setIsLoading(false); + return; + } else { + await tauri.removeToken(); + tauri.logout(); } + } catch (offlineError) { + errorMessage(t("homePage.errors.offlineError")); } - + if (e instanceof Error) { errorMessage(e.message); } else { @@ -630,21 +607,19 @@ function ScribeContent() { } } } else { - if (window.electron) { - try { - const offlineStatus = await window.electron.offlineModeGet(); + try { + const offlineStatus = await tauri.offlineModeGet(); - if (offlineStatus.hasPin && offlineStatus.lastUserId) { - setOfflineMode(prev => ({...prev, isOffline: true, isNetworkOnline: false})); - setShowPinVerify(true); - setIsLoading(false); - return; - } - } catch (error) { - errorMessage(t("homePage.errors.authenticationError")); + if (offlineStatus.hasPin && offlineStatus.lastUserId) { + setOfflineMode(prev => ({...prev, isOffline: true, isNetworkOnline: false})); + setShowPinVerify(true); + setIsLoading(false); + return; } - window.electron.logout(); + } catch (error) { + errorMessage(t("homePage.errors.authenticationError")); } + tauri.logout(); } } @@ -685,14 +660,14 @@ function ScribeContent() { setCurrentChapter(undefined); return; } - response = await window.electron.invoke('db:chapter:last', currentBook?.bookId) + response = await tauri.getLastChapter(currentBook?.bookId ?? '') } else { if (currentBook?.localBook) { if (!offlineMode.isDatabaseInitialized) { setCurrentChapter(undefined); return; } - response = await window.electron.invoke('db:chapter:last', currentBook?.bookId) + response = await tauri.getLastChapter(currentBook?.bookId ?? '') } else { response = await System.authGetQueryToServer(`chapter/last-chapter`, session.accessToken, locale, {bookid: currentBook?.bookId}); } @@ -768,7 +743,7 @@ function ScribeContent() { !isTermsAccepted && !isCurrentlyOffline() && } { - showPinSetup && window.electron && ( + showPinSetup && ( setShowPinSetup(false)} @@ -779,12 +754,10 @@ function ScribeContent() { ) } { - showPinVerify && window.electron && ( + showPinVerify && ( { - //window.electron.logout(); - }} + onCancel={():void => {}} /> ) } diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx index c7fe70f..8e39af4 100644 --- a/components/UserMenu.tsx +++ b/components/UserMenu.tsx @@ -3,6 +3,7 @@ import {SessionContext} from "@/context/SessionContext"; import NoPicture from "@/components/NoPicture"; import System from "@/lib/models/System"; import {useTranslations} from "next-intl"; +import * as tauri from '@/lib/tauri'; export default function UserMenu() { const {session} = useContext(SessionContext); @@ -34,8 +35,8 @@ export default function UserMenu() { async function handleLogout(): Promise { System.removeCookie("token"); - await window.electron.removeToken(); - window.electron.logout(); + await tauri.removeToken(); + tauri.logout(); } return ( diff --git a/components/book/AddNewBookForm.tsx b/components/book/AddNewBookForm.tsx index e496558..7d86e98 100644 --- a/components/book/AddNewBookForm.tsx +++ b/components/book/AddNewBookForm.tsx @@ -1,4 +1,5 @@ 'use client' +import * as tauri from '@/lib/tauri'; import {ChangeEvent, Dispatch, RefObject, SetStateAction, useContext, useEffect, useRef, useState} from "react"; import {AlertContext} from "@/context/AlertContext"; import System from "@/lib/models/System"; @@ -139,7 +140,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch< let bookId: string; if (isCurrentlyOffline()) { - bookId = await window.electron.invoke('db:book:create', bookData); + bookId = await tauri.createBook(bookData); } else { bookId = await System.authPostToServer('book/add', bookData, token, lang); } diff --git a/components/book/BookList.tsx b/components/book/BookList.tsx index 6398ce9..8d0ff0e 100644 --- a/components/book/BookList.tsx +++ b/components/book/BookList.tsx @@ -1,3 +1,4 @@ +import * as tauri from '@/lib/tauri'; import {useContext, useEffect, useRef, useState} from "react"; import System from "@/lib/models/System"; import {AlertContext} from "@/context/AlertContext"; @@ -178,11 +179,11 @@ export default function BookList() { const [onlineBooks, localBooks, onlineSeries, localSeries] = await Promise.all([ System.authGetQueryToServer('books', accessToken, lang), offlineMode.isDatabaseInitialized - ? window.electron.invoke('db:book:books') + ? tauri.getBooks() : Promise.resolve([]), System.authGetQueryToServer('series/list', accessToken, lang), offlineMode.isDatabaseInitialized - ? window.electron.invoke('db:series:list') + ? tauri.getSeriesList() as Promise : Promise.resolve([]) ]); @@ -221,8 +222,8 @@ export default function BookList() { return; } const [localBooks, localSeries] = await Promise.all([ - window.electron.invoke('db:book:books'), - window.electron.invoke('db:series:list') + tauri.getBooks(), + tauri.getSeriesList() as Promise ]); booksResponse = localBooks.map(b => ({...b, itIsLocal: true})); seriesResponse = localSeries; @@ -396,24 +397,19 @@ export default function BookList() { let bookResponse: BookProps | null = null; // DUAL LOGIC - if (isCurrentlyOffline()) { - if (!offlineMode.isDatabaseInitialized) { + const isOfflineBook = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId); + if (isCurrentlyOffline() || isOfflineBook) { + if (isCurrentlyOffline() && !offlineMode.isDatabaseInitialized) { errorMessage(t("bookList.errorBookDetails")); return; } - bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId); + bookResponse = await tauri.getBookBasicInformation(bookId); if (bookResponse) localBookOnly = true; - } else { - const isOfflineBook = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId); - if (isOfflineBook) { - bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId); - localBookOnly = true; - } - if (!bookResponse) { - bookResponse = await System.authGetQueryToServer( - 'book/basic-information', accessToken, lang, {id: bookId} - ); - } + } + if (!bookResponse) { + bookResponse = await System.authGetQueryToServer( + 'book/basic-information', accessToken, lang, {id: bookId} + ); } if (!bookResponse) { diff --git a/components/book/settings/BasicInformationSetting.tsx b/components/book/settings/BasicInformationSetting.tsx index c2fcc2c..dc3e504 100644 --- a/components/book/settings/BasicInformationSetting.tsx +++ b/components/book/settings/BasicInformationSetting.tsx @@ -1,4 +1,5 @@ 'use client' +import * as tauri from '@/lib/tauri'; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faFeather, faTimes} from "@fortawesome/free-solid-svg-icons"; import {ChangeEvent, forwardRef, useContext, useImperativeHandle, useState} from "react"; @@ -131,12 +132,12 @@ function BasicInformationSetting(props: any, ref: any) { bookId: bookId }; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:updateBasicInformation', basicInfoData); + response = await tauri.updateBookBasicInfo(basicInfoData); } else { response = await System.authPostToServer('book/basic-information', basicInfoData, userToken, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:updateBasicInformation', basicInfoData); + addToQueue('update_book_basic_info', {data: basicInfoData}); } } if (!response) { diff --git a/components/book/settings/DeleteBook.tsx b/components/book/settings/DeleteBook.tsx index 1015dfb..c3db68b 100644 --- a/components/book/settings/DeleteBook.tsx +++ b/components/book/settings/DeleteBook.tsx @@ -1,3 +1,4 @@ +import * as tauri from '@/lib/tauri'; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faTrash} from "@fortawesome/free-solid-svg-icons"; import {useContext, useState} from "react"; @@ -40,7 +41,7 @@ export default function DeleteBook({bookId}: DeleteBookProps) { const deleteData = { id: bookId, deletedAt }; if (isCurrentlyOffline() || ifLocalOnlyBook) { - response = await window.electron.invoke('db:book:delete', deleteData); + response = await tauri.deleteBook(deleteData.id, deleteData.deletedAt); } else { response = await System.authDeleteToServer( `book/delete`, @@ -50,7 +51,7 @@ export default function DeleteBook({bookId}: DeleteBookProps) { ); // If synced book and user wants to delete local too if (response && ifSyncedBook && deleteLocalToo) { - await window.electron.invoke('db:book:delete', deleteData); + await tauri.deleteBook(deleteData.id, deleteData.deletedAt); } } if (response) { diff --git a/components/book/settings/ExportSetting.tsx b/components/book/settings/ExportSetting.tsx index 2439cde..fb51421 100644 --- a/components/book/settings/ExportSetting.tsx +++ b/components/book/settings/ExportSetting.tsx @@ -1,4 +1,5 @@ 'use client' +import * as tauri from '@/lib/tauri'; import React, {useCallback, useContext, useEffect, useState} from 'react'; import {useTranslations} from 'next-intl'; import {BookContext} from '@/context/BookContext'; @@ -33,10 +34,7 @@ export default function ExportSetting(): React.JSX.Element { if (!book) return; setIsLoading(true); try { - const chaptersInfo: ChapterExportInfo[] = await window.electron.invoke( - 'db:book:export:info', - {bookId: book.bookId} - ); + const chaptersInfo: ChapterExportInfo[] = await tauri.getBookExportInfo(book.bookId) as ChapterExportInfo[]; setChapters(chaptersInfo); const initialSelections: ChapterExportSelection[] = chaptersInfo.map( (ch: ChapterExportInfo): ChapterExportSelection => ({ @@ -92,7 +90,7 @@ export default function ExportSetting(): React.JSX.Element { .filter((s: ChapterExportSelection): boolean => s.selected) .map((s: ChapterExportSelection) => ({chapterId: s.chapterId, version: s.version})); - const result: boolean = await window.electron.invoke('db:book:export', { + const result: boolean = await tauri.exportBook({ bookId: book.bookId, format, selections: selectedChapters.length === chapters.length ? null : selectedChapters diff --git a/components/book/settings/characters/editor/CharacterEditorDetail.tsx b/components/book/settings/characters/editor/CharacterEditorDetail.tsx index 23cf46a..9fb6681 100644 --- a/components/book/settings/characters/editor/CharacterEditorDetail.tsx +++ b/components/book/settings/characters/editor/CharacterEditorDetail.tsx @@ -14,6 +14,7 @@ import {LangContext} from '@/context/LangContext'; import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; import {BookContext} from '@/context/BookContext'; import System from '@/lib/models/System'; +import * as tauri from '@/lib/tauri'; type AttributeResponse = { type: string; values: Attribute[] }[]; @@ -49,10 +50,8 @@ export default function CharacterEditorDetail({ async function getAttributes(): Promise { try { let response: AttributeResponse; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); - } else if (book?.localBook) { - response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + if (isCurrentlyOffline() || book?.localBook) { + response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse; } else { response = await System.authGetQueryToServer( 'character/attribute', diff --git a/components/book/settings/characters/editor/CharacterEditorEdit.tsx b/components/book/settings/characters/editor/CharacterEditorEdit.tsx index 3558040..b9a6978 100644 --- a/components/book/settings/characters/editor/CharacterEditorEdit.tsx +++ b/components/book/settings/characters/editor/CharacterEditorEdit.tsx @@ -28,6 +28,7 @@ import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; import {BookContext} from '@/context/BookContext'; import System from '@/lib/models/System'; import {Dispatch, SetStateAction} from 'react'; +import * as tauri from '@/lib/tauri'; type AttributeResponse = { type: string; values: Attribute[] }[]; @@ -84,10 +85,8 @@ export default function CharacterEditorEdit({ async function getAttributes(): Promise { try { let response: AttributeResponse; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); - } else if (book?.localBook) { - response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + if (isCurrentlyOffline() || book?.localBook) { + response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse; } else { response = await System.authGetQueryToServer( 'character/attribute', diff --git a/components/book/settings/characters/settings/CharacterSettingsDetail.tsx b/components/book/settings/characters/settings/CharacterSettingsDetail.tsx index 164f740..eede8bc 100644 --- a/components/book/settings/characters/settings/CharacterSettingsDetail.tsx +++ b/components/book/settings/characters/settings/CharacterSettingsDetail.tsx @@ -39,6 +39,7 @@ import {LangContext} from '@/context/LangContext'; import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; import {BookContext} from '@/context/BookContext'; import System from '@/lib/models/System'; +import * as tauri from '@/lib/tauri'; type AttributeResponse = { type: string; values: Attribute[] }[]; @@ -70,10 +71,8 @@ export default function CharacterSettingsDetail({ async function getAttributes(): Promise { try { let response: AttributeResponse; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); - } else if (book?.localBook) { - response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + if (isCurrentlyOffline() || book?.localBook) { + response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse; } else { response = await System.authGetQueryToServer( 'character/attribute', diff --git a/components/book/settings/characters/settings/CharacterSettingsEdit.tsx b/components/book/settings/characters/settings/CharacterSettingsEdit.tsx index 3fd7674..a169d4f 100644 --- a/components/book/settings/characters/settings/CharacterSettingsEdit.tsx +++ b/components/book/settings/characters/settings/CharacterSettingsEdit.tsx @@ -37,6 +37,7 @@ import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; import {BookContext} from '@/context/BookContext'; import System from '@/lib/models/System'; import {Dispatch, SetStateAction} from 'react'; +import * as tauri from '@/lib/tauri'; type AttributeResponse = { type: string; values: Attribute[] }[]; @@ -94,10 +95,8 @@ export default function CharacterSettingsEdit({ async function getAttributes(): Promise { try { let response: AttributeResponse; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); - } else if (book?.localBook) { - response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + if (isCurrentlyOffline() || book?.localBook) { + response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse; } else { response = await System.authGetQueryToServer( 'character/attribute', diff --git a/components/book/settings/guide-line/GuideLineSetting.tsx b/components/book/settings/guide-line/GuideLineSetting.tsx index d670878..ef1e9e5 100644 --- a/components/book/settings/guide-line/GuideLineSetting.tsx +++ b/components/book/settings/guide-line/GuideLineSetting.tsx @@ -1,4 +1,5 @@ 'use client' +import * as tauri from '@/lib/tauri'; import {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; import System from '@/lib/models/System'; import {AlertContext} from "@/context/AlertContext"; @@ -83,14 +84,10 @@ function GuideLineSetting(props: any, ref: any) { async function getAIGuideLine(): Promise { try { let response: GuideLineAI; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:book:guideline:ai:get', {id: bookId}); + if (isCurrentlyOffline() || book?.localBook) { + response = await tauri.getAIGuideLine(bookId); } else { - if (book?.localBook) { - response = await window.electron.invoke('db:book:guideline:ai:get', {id: bookId}); - } else { - response = await System.authGetQueryToServer(`book/ai/guideline`, userToken, lang, {id: bookId}); - } + response = await System.authGetQueryToServer(`book/ai/guideline`, userToken, lang, {id: bookId}); } if (response) { setPlotSummary(response.globalResume || ''); @@ -113,19 +110,15 @@ function GuideLineSetting(props: any, ref: any) { async function getGuideLine(): Promise { try { let response: GuideLine; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:book:guideline:get', {id: bookId}); + if (isCurrentlyOffline() || book?.localBook) { + response = await tauri.getGuideLine(bookId); } else { - if (book?.localBook) { - response = await window.electron.invoke('db:book:guideline:get', {id: bookId}); - } else { - response = await System.authGetQueryToServer( - `book/guide-line`, - userToken, - lang, - {id: bookId}, - ); - } + response = await System.authGetQueryToServer( + `book/guide-line`, + userToken, + lang, + {id: bookId}, + ); } if (response) { setTone(response.tone); @@ -165,7 +158,7 @@ function GuideLineSetting(props: any, ref: any) { keyMessages: keyMessages, }; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:guideline:update', guidelineData); + response = await tauri.updateGuideLine(guidelineData); } else { response = await System.authPostToServer( 'book/guide-line', @@ -175,7 +168,7 @@ function GuideLineSetting(props: any, ref: any) { ); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:guideline:update', guidelineData); + addToQueue('update_guideline', {data: guidelineData}); } } if (!response) { @@ -206,7 +199,7 @@ function GuideLineSetting(props: any, ref: any) { themes: themes, }; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:guideline:ai:update', aiGuidelineData); + response = await tauri.updateAIGuideLine(aiGuidelineData); } else { response = await System.authPostToServer( 'quillsense/book/guide-line', @@ -216,7 +209,7 @@ function GuideLineSetting(props: any, ref: any) { ); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:guideline:ai:update', aiGuidelineData); + addToQueue('update_ai_guideline', {data: aiGuidelineData}); } } if (response) { diff --git a/components/book/settings/locations/LocationComponent.tsx b/components/book/settings/locations/LocationComponent.tsx index 9eb689d..066749e 100644 --- a/components/book/settings/locations/LocationComponent.tsx +++ b/components/book/settings/locations/LocationComponent.tsx @@ -21,6 +21,7 @@ import {SyncedSeries} from "@/lib/models/SyncedSeries"; import ToggleSwitch from "@/components/form/ToggleSwitch"; import {SeriesLocationElement, SeriesLocationItem, SeriesLocationSubElement} from "@/lib/models/Series"; import SeriesImportSelector from "@/components/form/SeriesImportSelector"; +import * as tauri from '@/lib/tauri'; interface SubElement { id: string; @@ -120,11 +121,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< try { let response: boolean; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:tool:update', { - bookId: currentEntityId, - toolName: 'locations', - enabled: enabled - }); + response = await tauri.updateBookToolSetting(currentEntityId, 'locations', enabled); } else { response = await System.authPatchToServer('book/tool-setting', { bookId: currentEntityId, @@ -132,11 +129,11 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< enabled: enabled }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:book:tool:update', { + addToQueue('update_book_tool_setting', {data: { bookId: currentEntityId, toolName: 'locations', enabled: enabled - }); + }}); } } if (response && setBook && book) { @@ -162,7 +159,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< if (isSeriesMode) { let response: SeriesLocationItem[]; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:location:list', {seriesId: currentEntityId}); + response = await tauri.getSeriesLocationList(currentEntityId) as SeriesLocationItem[]; } else { response = await System.authGetQueryToServer( 'series/location/list', @@ -190,16 +187,12 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< } } else { let response: LocationListResponse; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:location:all', {bookid: currentEntityId}); + if (isCurrentlyOffline() || book?.localBook) { + response = await tauri.getAllLocations(currentEntityId, true) as LocationListResponse; } else { - if (book?.localBook) { - response = await window.electron.invoke('db:location:all', {bookid: currentEntityId}); - } else { - response = await System.authGetQueryToServer(`location/all`, token, lang, { - bookid: currentEntityId, - }); - } + response = await System.authGetQueryToServer(`location/all`, token, lang, { + bookid: currentEntityId, + }); } if (response) { setSections(response.locations); @@ -238,7 +231,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< name: newSectionName, }; if (isCurrentlyOffline() || localSeries) { - sectionId = await window.electron.invoke('db:series:location:section:add', addData); + sectionId = await tauri.addSeriesLocationSection(addData); } else { sectionId = await System.authPostToServer( 'series/location/section/add', @@ -247,7 +240,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:location:section:add', addData); + addToQueue('add_series_location_section', {data: addData}); } } if (!sectionId) { @@ -255,10 +248,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< return; } } else if (isCurrentlyOffline() || book?.localBook) { - sectionId = await window.electron.invoke('db:location:section:add', { - bookId: currentEntityId, - locationName: newSectionName, - }); + sectionId = await tauri.addLocationSection(newSectionName, currentEntityId); } else { sectionId = await System.authPostToServer(`location/section/add`, { bookId: currentEntityId, @@ -266,11 +256,11 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:location:section:add', { + addToQueue('add_location_section', {data: { bookId: currentEntityId, sectionId, locationName: newSectionName, - }); + }}); } } if (!sectionId) { @@ -306,7 +296,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< name: newElementNames[sectionId], }; if (isCurrentlyOffline() || localSeries) { - elementId = await window.electron.invoke('db:series:location:element:add', addData); + elementId = await tauri.addSeriesLocationElement(addData); } else { elementId = await System.authPostToServer( 'series/location/element/add', @@ -315,7 +305,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:location:element:add', addData); + addToQueue('add_series_location_element', {data: addData}); } } if (!elementId) { @@ -323,11 +313,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< return; } } else if (isCurrentlyOffline() || book?.localBook) { - elementId = await window.electron.invoke('db:location:element:add', { - bookId: currentEntityId, - locationId: sectionId, - elementName: newElementNames[sectionId], - }); + elementId = await tauri.addLocationElement(sectionId, newElementNames[sectionId]); } else { elementId = await System.authPostToServer(`location/element/add`, { bookId: currentEntityId, @@ -337,12 +323,12 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:location:element:add', { + addToQueue('add_location_element', {data: { bookId: currentEntityId, locationId: sectionId, elementId, elementName: newElementNames[sectionId], - }); + }}); } } if (!elementId) { @@ -405,7 +391,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< name: newSubElementNames[elementIndex], }; if (isCurrentlyOffline() || localSeries) { - subElementId = await window.electron.invoke('db:series:location:subelement:add', addData); + subElementId = await tauri.addSeriesLocationSubElement(addData); } else { subElementId = await System.authPostToServer( 'series/location/sub-element/add', @@ -414,7 +400,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:location:subelement:add', addData); + addToQueue('add_series_location_sub_element', {data: addData}); } } if (!subElementId) { @@ -422,10 +408,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< return; } } else if (isCurrentlyOffline() || book?.localBook) { - subElementId = await window.electron.invoke('db:location:subelement:add', { - elementId: elementId, - subElementName: newSubElementNames[elementIndex], - }); + subElementId = await tauri.addLocationSubElement(elementId, newSubElementNames[elementIndex]); } else { subElementId = await System.authPostToServer(`location/sub-element/add`, { elementId: elementId, @@ -433,11 +416,11 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:location:subelement:add', { + addToQueue('add_location_sub_element', {data: { elementId: elementId, subElementId, subElementName: newSubElementNames[elementIndex], - }); + }}); } } if (!subElementId) { @@ -490,26 +473,24 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< if (isSeriesMode) { const deleteData = {elementId: elementId, deletedAt}; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:location:element:delete', deleteData); + response = await tauri.deleteSeriesLocationElement(deleteData.elementId!, deleteData.deletedAt); } else { response = await System.authDeleteToServer('series/location/element/delete', deleteData, token, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:location:element:delete', deleteData); + addToQueue('delete_series_location_element', {data: deleteData}); } } } else if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:location:element:delete', { - elementId: elementId, bookId: currentEntityId, deletedAt, - }); + response = await tauri.deleteLocationElement(elementId!, currentEntityId, deletedAt); } else { response = await System.authDeleteToServer(`location/element/delete`, { elementId: elementId, bookId: currentEntityId, deletedAt, }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:location:element:delete', { + addToQueue('delete_location_element', {data: { elementId: elementId, bookId: currentEntityId, deletedAt, - }); + }}); } } if (!response) { @@ -541,26 +522,24 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< if (isSeriesMode) { const deleteData = {subElementId: subElementId, deletedAt}; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:location:subelement:delete', deleteData); + response = await tauri.deleteSeriesLocationSubElement(deleteData.subElementId!, deleteData.deletedAt); } else { response = await System.authDeleteToServer('series/location/sub-element/delete', deleteData, token, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:location:subelement:delete', deleteData); + addToQueue('delete_series_location_sub_element', {data: deleteData}); } } } else if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:location:subelement:delete', { - subElementId: subElementId, bookId: currentEntityId, deletedAt, - }); + response = await tauri.deleteLocationSubElement(subElementId!, currentEntityId, deletedAt); } else { response = await System.authDeleteToServer(`location/sub-element/delete`, { subElementId: subElementId, bookId: currentEntityId, deletedAt, }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:location:subelement:delete', { + addToQueue('delete_location_sub_element', {data: { subElementId: subElementId, bookId: currentEntityId, deletedAt, - }); + }}); } } if (!response) { @@ -587,26 +566,24 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< if (isSeriesMode) { const deleteData = {locationId: sectionId, deletedAt}; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:location:delete', deleteData); + response = await tauri.deleteSeriesLocation(deleteData.locationId, deleteData.deletedAt); } else { response = await System.authDeleteToServer('series/location/delete', deleteData, token, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:location:delete', deleteData); + addToQueue('delete_series_location', {data: deleteData}); } } } else if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:location:delete', { - locationId: sectionId, bookId: currentEntityId, deletedAt, - }); + response = await tauri.deleteLocationSection(sectionId, currentEntityId, deletedAt); } else { response = await System.authDeleteToServer(`location/delete`, { locationId: sectionId, bookId: currentEntityId, deletedAt, }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:location:delete', { + addToQueue('delete_location_section', {data: { locationId: sectionId, bookId: currentEntityId, deletedAt, - }); + }}); } } if (!response) { @@ -628,18 +605,16 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref< try { let response: boolean; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:location:update', { - locations: sections, - }); + response = await tauri.updateLocations(sections) as boolean; } else { response = await System.authPostToServer(`location/update`, { locations: sections, }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:location:update', { + addToQueue('update_locations', {data: { locations: sections, - }); + }}); } } if (!response) { diff --git a/components/book/settings/story/Act.tsx b/components/book/settings/story/Act.tsx index 50544bf..9054c3d 100644 --- a/components/book/settings/story/Act.tsx +++ b/components/book/settings/story/Act.tsx @@ -24,6 +24,7 @@ import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; import {SyncedBook} from "@/lib/models/SyncedBook"; +import * as tauri from '@/lib/tauri'; interface ActProps { acts: ActType[]; @@ -80,10 +81,7 @@ export default function Act({acts, setActs, mainChapters}: ActProps) { try { let incidentId: string; if (isCurrentlyOffline() || book?.localBook) { - incidentId = await window.electron.invoke('db:book:incident:add', { - bookId, - name: newIncidentTitle, - }); + incidentId = await tauri.addIncident(bookId!, newIncidentTitle); } else { incidentId = await System.authPostToServer('book/incident/new', { bookId, @@ -91,11 +89,11 @@ export default function Act({acts, setActs, mainChapters}: ActProps) { }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:incident:add', { + addToQueue('add_incident', {data: { bookId, incidentId, name: newIncidentTitle, - }); + }}); } } if (!incidentId) { @@ -134,12 +132,12 @@ export default function Act({acts, setActs, mainChapters}: ActProps) { let response: boolean; const deleteData = { bookId, incidentId, deletedAt: System.timeStampInSeconds() }; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:incident:remove', deleteData); + response = await tauri.removeIncident(deleteData.bookId!, deleteData.incidentId, deleteData.deletedAt); } else { response = await System.authDeleteToServer('book/incident/remove', deleteData, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:incident:remove', deleteData); + addToQueue('remove_incident', {data: deleteData}); } } if (!response) { @@ -177,15 +175,15 @@ export default function Act({acts, setActs, mainChapters}: ActProps) { incidentId: selectedIncidentId, }; if (isCurrentlyOffline() || book?.localBook) { - plotId = await window.electron.invoke('db:book:plot:add', plotData); + plotId = await tauri.addPlotPoint(plotData.bookId!, plotData.name, plotData.incidentId); } else { plotId = await System.authPostToServer('book/plot/new', plotData, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:plot:add', { + addToQueue('add_plot_point', {data: { ...plotData, plotId, - }); + }}); } } if (!plotId) { @@ -225,12 +223,12 @@ export default function Act({acts, setActs, mainChapters}: ActProps) { let response: boolean; const deleteData = { plotId: plotPointId, bookId, deletedAt: System.timeStampInSeconds() }; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:plot:remove', deleteData); + response = await tauri.removePlotPoint(deleteData.plotId, deleteData.bookId!, deleteData.deletedAt); } else { response = await System.authDeleteToServer('book/plot/remove', deleteData, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:plot:remove', deleteData); + addToQueue('remove_plot_point', {data: deleteData}); } } if (!response) { @@ -279,15 +277,15 @@ export default function Act({acts, setActs, mainChapters}: ActProps) { incidentId: destination === 'incident' ? itemId : null, }; if (isCurrentlyOffline() || book?.localBook) { - linkId = await window.electron.invoke('db:chapter:information:add', linkData); + linkId = await tauri.addChapterInformation(linkData as any); } else { linkId = await System.authPostToServer('chapter/resume/add', linkData, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:chapter:information:add', { + addToQueue('add_chapter_information', {data: { ...linkData, chapterInfoId: linkId, - }); + }}); } } if (!linkId) { @@ -367,12 +365,12 @@ export default function Act({acts, setActs, mainChapters}: ActProps) { let response: boolean; const unlinkData = { chapterInfoId, bookId, deletedAt: System.timeStampInSeconds() }; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:chapter:information:remove', unlinkData); + response = await tauri.removeChapterInformation(unlinkData.chapterInfoId, unlinkData.bookId!, unlinkData.deletedAt); } else { response = await System.authDeleteToServer('chapter/resume/remove', unlinkData, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:chapter:information:remove', unlinkData); + addToQueue('remove_chapter_information', {data: unlinkData}); } } if (!response) { diff --git a/components/book/settings/story/Issue.tsx b/components/book/settings/story/Issue.tsx index f55bca4..2fb3336 100644 --- a/components/book/settings/story/Issue.tsx +++ b/components/book/settings/story/Issue.tsx @@ -13,6 +13,7 @@ import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; import {SyncedBook} from "@/lib/models/SyncedBook"; +import * as tauri from '@/lib/tauri'; interface IssuesProps { issues: Issue[]; @@ -42,10 +43,7 @@ export default function Issues({issues, setIssues}: IssuesProps) { try { let issueId: string; if (isCurrentlyOffline() || book?.localBook) { - issueId = await window.electron.invoke('db:book:issue:add', { - bookId, - name: newIssueName, - }); + issueId = await tauri.addIssue(bookId!, newIssueName); } else { issueId = await System.authPostToServer('book/issue/add', { bookId, @@ -53,11 +51,11 @@ export default function Issues({issues, setIssues}: IssuesProps) { }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:issue:add', { + addToQueue('add_issue', {data: { bookId, issueId, name: newIssueName, - }); + }}); } } if (!issueId) { @@ -90,11 +88,7 @@ export default function Issues({issues, setIssues}: IssuesProps) { let response: boolean; const deletedAt: number = System.timeStampInSeconds(); if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:issue:remove', { - bookId, - issueId, - deletedAt, - }); + response = await tauri.removeIssue(bookId!, issueId, deletedAt); } else { response = await System.authDeleteToServer( 'book/issue/remove', @@ -108,11 +102,11 @@ export default function Issues({issues, setIssues}: IssuesProps) { ); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:issue:remove', { + addToQueue('remove_issue', {data: { bookId, issueId, deletedAt, - }); + }}); } } if (response) { diff --git a/components/book/settings/story/MainChapter.tsx b/components/book/settings/story/MainChapter.tsx index d2063b7..b8614f6 100644 --- a/components/book/settings/story/MainChapter.tsx +++ b/components/book/settings/story/MainChapter.tsx @@ -16,6 +16,7 @@ import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; import {SyncedBook} from "@/lib/models/SyncedBook"; +import * as tauri from '@/lib/tauri'; interface MainChapterProps { chapters: ChapterListProps[]; @@ -93,12 +94,12 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) { deletedAt, }; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:chapter:remove', deleteData); + response = await tauri.removeChapter(deleteData.chapterId, deleteData.bookId!, deleteData.deletedAt); } else { response = await System.authDeleteToServer('chapter/remove', deleteData, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:chapter:remove', deleteData); + addToQueue('remove_chapter', {data: deleteData}); } } if (!response) { @@ -129,15 +130,15 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) { title: newChapterTitle, }; if (isCurrentlyOffline() || book?.localBook) { - responseId = await window.electron.invoke('db:chapter:add', chapterData); + responseId = await tauri.addChapter(chapterData); } else { responseId = await System.authPostToServer('chapter/add', chapterData, token); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:chapter:add', { + addToQueue('add_chapter', {data: { ...chapterData, chapterId: responseId, - }); + }}); } } if (!responseId) { diff --git a/components/book/settings/story/StorySetting.tsx b/components/book/settings/story/StorySetting.tsx index 22748cc..d314416 100644 --- a/components/book/settings/story/StorySetting.tsx +++ b/components/book/settings/story/StorySetting.tsx @@ -16,6 +16,7 @@ import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; import {SyncedBook} from "@/lib/models/SyncedBook"; +import * as tauri from '@/lib/tauri'; export const StoryContext = createContext<{ acts: ActType[]; @@ -76,16 +77,12 @@ export function Story(props: any, ref: any) { async function getStoryData(): Promise { try { let response: StoryFetchData; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:book:story:get', {bookid: bookId}); + if (isCurrentlyOffline() || book?.localBook) { + response = await tauri.getBookStory(bookId) as StoryFetchData; } else { - if (book?.localBook) { - response = await window.electron.invoke('db:book:story:get', {bookid: bookId}); - } else { - response = await System.authGetQueryToServer(`book/story`, userToken, lang, { - bookid: bookId, - }); - } + response = await System.authGetQueryToServer(`book/story`, userToken, lang, { + bookid: bookId, + }); } if (response) { setActs(response.acts); @@ -143,12 +140,12 @@ export function Story(props: any, ref: any) { issues, }; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:story:update', storyData); + response = await tauri.updateBookStory(storyData); } else { response = await System.authPostToServer('book/story', storyData, userToken, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:story:update', storyData); + addToQueue('update_book_story', {data: storyData}); } } if (!response) { diff --git a/components/book/settings/world/WorldElement.tsx b/components/book/settings/world/WorldElement.tsx index 50ebc20..ce9bba1 100644 --- a/components/book/settings/world/WorldElement.tsx +++ b/components/book/settings/world/WorldElement.tsx @@ -19,6 +19,7 @@ import {SyncedBook} from "@/lib/models/SyncedBook"; import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext"; import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; import {SyncedSeries} from "@/lib/models/SyncedSeries"; +import * as tauri from '@/lib/tauri'; interface WorldElementInputProps { sectionLabel: string; @@ -69,26 +70,24 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World if (isSeriesMode) { const deleteData = {elementId, deletedAt}; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:world:element:delete', deleteData); + response = await tauri.deleteSeriesWorldElement(elementId, deletedAt); } else { response = await System.authDeleteToServer('series/world/element/delete', deleteData, session.accessToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:world:element:delete', deleteData); + addToQueue('delete_series_world_element', {data: deleteData}); } } } else if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:world:element:remove', { - elementId, bookId: book?.bookId, deletedAt, - }); + response = await tauri.removeWorldElement(elementId, book?.bookId || '', deletedAt); } else { response = await System.authDeleteToServer('book/world/element/delete', { elementId, bookId: book?.bookId, deletedAt, }, session.accessToken, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) { - addToQueue('db:book:world:element:remove', { + addToQueue('remove_world_element', {data: { elementId: elementId, bookId: book?.bookId, deletedAt, - }); + }}); } } if (!response) { @@ -123,7 +122,7 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World name: newElementName, }; if (isCurrentlyOffline() || localSeries) { - elementId = await window.electron.invoke('db:series:world:element:add', addData); + elementId = await tauri.addSeriesWorldElement(addData); } else { elementId = await System.authPostToServer( 'series/world/element/add', @@ -132,7 +131,7 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:world:element:add', addData); + addToQueue('add_series_world_element', {data: addData}); } } if (!elementId) { @@ -140,11 +139,7 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World return; } } else if (isCurrentlyOffline() || book?.localBook) { - elementId = await window.electron.invoke('db:book:world:element:add', { - elementType: section, - worldId: worlds[selectedWorldIndex].id, - elementName: newElementName, - }); + elementId = await tauri.addWorldElement(worlds[selectedWorldIndex].id, newElementName, section as string); } else { elementId = await System.authPostToServer('book/world/element/add', { elementType: section, @@ -153,12 +148,12 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World }, session.accessToken, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) { - addToQueue('db:book:world:element:add', { + addToQueue('add_world_element', {data: { elementType: section, worldId: worlds[selectedWorldIndex].id, elementId, elementName: newElementName, - }); + }}); } } if (!elementId) { diff --git a/components/book/settings/world/WorldSetting.tsx b/components/book/settings/world/WorldSetting.tsx index ae9cae9..cc0b456 100644 --- a/components/book/settings/world/WorldSetting.tsx +++ b/components/book/settings/world/WorldSetting.tsx @@ -24,6 +24,7 @@ import ToggleSwitch from "@/components/form/ToggleSwitch"; import {SeriesWorldProps, SeriesWorldListItem} from "@/lib/models/Series"; import SeriesImportSelector from "@/components/form/SeriesImportSelector"; import SyncFieldWrapper from "@/components/form/SyncFieldWrapper"; +import * as tauri from '@/lib/tauri'; export interface ElementSection { title: string; @@ -99,11 +100,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa try { let response: boolean; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:tool:update', { - bookId: currentEntityId, - toolName: 'worlds', - enabled: enabled - }); + response = await tauri.updateBookToolSetting(currentEntityId, 'worlds', enabled); } else { response = await System.authPatchToServer('book/tool-setting', { bookId: currentEntityId, @@ -111,11 +108,11 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa enabled: enabled }, session.accessToken, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:book:tool:update', { + addToQueue('update_book_tool_setting', {data: { bookId: currentEntityId, toolName: 'worlds', enabled: enabled - }); + }}); } } if (response && setBook && book) { @@ -176,7 +173,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa // Book mode: dual offline/online logic let response: WorldListResponse; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:worlds:get', {bookid: currentEntityId}); + response = await tauri.getWorlds(currentEntityId, true); } else { response = await System.authGetQueryToServer('book/worlds', session.accessToken, lang, { bookid: currentEntityId, @@ -237,10 +234,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa } } else if (isCurrentlyOffline() || book?.localBook) { // Book mode: offline/local - newWorldId = await window.electron.invoke('db:book:world:add', { - worldName: newWorldName, - bookId: currentEntityId, - }); + newWorldId = await tauri.addWorld(currentEntityId, newWorldName); if (!newWorldId) { errorMessage(t("worldSetting.addWorldError")); return; @@ -256,11 +250,11 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa return; } if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:book:world:add', { + addToQueue('add_world', {data: { worldName: newWorldName, worldId: newWorldId, bookId: currentEntityId, - }); + }}); } } const newWorld: WorldProps = { @@ -319,10 +313,7 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa }, session.accessToken, lang); } else if (isCurrentlyOffline() || book?.localBook) { // Book mode: offline/local - response = await window.electron.invoke('db:book:world:update', { - world: currentWorld, - bookId: currentEntityId, - }); + response = await tauri.updateWorld(currentWorld); } else { // Book mode: online response = await System.authPatchToServer('book/world/update', { @@ -330,10 +321,10 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa bookId: currentEntityId, }, session.accessToken, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:book:world:update', { + addToQueue('update_world', {data: { world: currentWorld, bookId: currentEntityId, - }); + }}); } } @@ -424,11 +415,11 @@ export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSa // Sync to local if book is synced if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { - addToQueue('db:book:world:add', { + addToQueue('add_world', {data: { worldName: seriesWorld.name, worldId: worldId, bookId: currentEntityId, - }); + }}); } const newWorld: WorldProps = { diff --git a/components/editor/DraftCompanion.tsx b/components/editor/DraftCompanion.tsx index 1ad9a14..f9f5549 100644 --- a/components/editor/DraftCompanion.tsx +++ b/components/editor/DraftCompanion.tsx @@ -34,6 +34,7 @@ import {AIUsageContext, AIUsageContextProps} from "@/context/AIUsageContext"; import {configs} from "@/lib/configs"; import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import AdvancedGenerationOptions from "@/components/form/AdvancedGenerationOptions"; +import * as tauri from '@/lib/tauri'; interface CompanionContent { version: number; @@ -113,26 +114,17 @@ export default function DraftCompanion() { async function getDraftContent(): Promise { try { let response: CompanionContent | null; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:chapter:content:companion', { + if (isCurrentlyOffline() || book?.localBook) { + response = await tauri.getCompanionContent( + chapter?.chapterId ?? '', + chapter?.chapterContent.version ?? 0, + ) as CompanionContent | null; + } else { + response = await System.authGetQueryToServer(`chapter/content/companion`, session.accessToken, lang, { bookid: book?.bookId, chapterid: chapter?.chapterId, version: chapter?.chapterContent.version, }); - } else { - if (book?.localBook) { - response = await window.electron.invoke('db:chapter:content:companion', { - bookid: book?.bookId, - chapterid: chapter?.chapterId, - version: chapter?.chapterContent.version, - }); - } else { - response = await System.authGetQueryToServer(`chapter/content/companion`, session.accessToken, lang, { - bookid: book?.bookId, - chapterid: chapter?.chapterId, - version: chapter?.chapterContent.version, - }); - } } if (response && mainEditor) { mainEditor.commands.setContent(JSON.parse(response.content)); @@ -169,16 +161,12 @@ export default function DraftCompanion() { async function fetchTags(): Promise { try { let responseTags: BookTags | null; - if (isCurrentlyOffline()) { - responseTags = await window.electron.invoke('db:book:tags', book?.bookId); + if (isCurrentlyOffline() || book?.localBook) { + responseTags = await tauri.getBookTags(book?.bookId ?? '') as BookTags | null; } else { - if (book?.localBook) { - responseTags = await window.electron.invoke('db:book:tags', book?.bookId); - } else { - responseTags = await System.authGetQueryToServer(`book/tags`, session.accessToken, lang, { - bookId: book?.bookId - }); - } + responseTags = await System.authGetQueryToServer(`book/tags`, session.accessToken, lang, { + bookId: book?.bookId + }); } if (responseTags) { setCharacters(responseTags.characters); diff --git a/components/editor/TextEditor.tsx b/components/editor/TextEditor.tsx index b5804c2..ec27839 100644 --- a/components/editor/TextEditor.tsx +++ b/components/editor/TextEditor.tsx @@ -36,6 +36,7 @@ import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; import {SyncedBook} from "@/lib/models/SyncedBook"; +import * as tauri from '@/lib/tauri'; interface ToolbarButton { action: () => void; @@ -302,12 +303,18 @@ export default function TextEditor() { currentTime: mainTimer }; if (isCurrentlyOffline() || book?.localBook){ - response = await window.electron.invoke('db:chapter:content:save', saveData); + response = await tauri.saveChapterContent({ + chapterId: saveData.chapterId, + version: saveData.version, + content: saveData.content, + totalWordCount: saveData.totalWordCount, + contentId: saveData.chapterId, + }); } else { response = await System.authPostToServer(`chapter/content`, saveData, session?.accessToken, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) { - addToQueue('db:chapter:content:save', saveData); + addToQueue('save_chapter_content', {data: saveData}); } } if (!response) { diff --git a/components/form/SyncFieldWrapper.tsx b/components/form/SyncFieldWrapper.tsx index 64b9ae1..e5859a7 100644 --- a/components/form/SyncFieldWrapper.tsx +++ b/components/form/SyncFieldWrapper.tsx @@ -12,6 +12,7 @@ import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContex import {BookContext} from '@/context/BookContext'; import {SyncedBook} from '@/lib/models/SyncedBook'; import System from '@/lib/models/System'; +import * as tauri from '@/lib/tauri'; export type SyncElementType = 'character' | 'world' | 'location' | 'spell'; @@ -72,10 +73,8 @@ export default function SyncFieldWrapper({ let response: SeriesSyncUploadResponse; if (isCurrentlyOffline() || book?.localBook) { - // Offline OU livre local → IPC - response = await window.electron.invoke('db:series:sync:upload', requestData); + response = await tauri.seriesSyncUpload(requestData) as SeriesSyncUploadResponse; } else { - // Online + livre serveur → Server response = await System.authPostToServer( 'series/propagate', requestData, @@ -83,9 +82,8 @@ export default function SyncFieldWrapper({ lang ); - // Si le livre a une copie locale → addToQueue pour sync if (book?.bookId && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book.bookId)) { - addToQueue('db:series:sync:upload', requestData); + addToQueue('series_sync_upload', {data: requestData}); } } diff --git a/components/leftbar/ScribeChapterComponent.tsx b/components/leftbar/ScribeChapterComponent.tsx index f2a10bf..e4965e4 100644 --- a/components/leftbar/ScribeChapterComponent.tsx +++ b/components/leftbar/ScribeChapterComponent.tsx @@ -15,6 +15,7 @@ import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; import {SyncedBook} from "@/lib/models/SyncedBook"; +import * as tauri from '@/lib/tauri'; export default function ScribeChapterComponent() { const t = useTranslations(); @@ -79,14 +80,10 @@ export default function ScribeChapterComponent() { async function getChapterList(): Promise { try { let response: ChapterListProps[]|null; - if (isCurrentlyOffline()){ - response = await window.electron.invoke('db:book:chapters', book?.bookId) + if (isCurrentlyOffline() || book?.localBook){ + response = await tauri.getChapters(book?.bookId ?? '') as ChapterListProps[]; } else { - if (book?.localBook){ - response = await window.electron.invoke('db:book:chapters', book?.bookId) - } else { - response = await System.authGetQueryToServer(`book/chapters?id=${book?.bookId}`, userToken, lang); - } + response = await System.authGetQueryToServer(`book/chapters?id=${book?.bookId}`, userToken, lang); } if (response) { setChapters(response); @@ -104,26 +101,14 @@ export default function ScribeChapterComponent() { const version: number = chapter?.chapterContent.version ? chapter?.chapterContent.version : 2; try { let response: ChapterProps | null - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:chapter:whole', { + if (isCurrentlyOffline() || book?.localBook) { + response = await tauri.getWholeChapter(chapterId, version, book?.bookId ?? ''); + } else { + response = await System.authGetQueryToServer(`chapter/whole`, userToken, lang, { bookid: book?.bookId, id: chapterId, version: version, - }) - } else { - if (book?.localBook){ - response = await window.electron.invoke('db:chapter:whole', { - bookid: book?.bookId, - id: chapterId, - version: version, - }) - } else { - response = await System.authGetQueryToServer(`chapter/whole`, userToken, lang, { - bookid: book?.bookId, - id: chapterId, - version: version, - }); - } + }); } if (!response) { errorMessage(t("scribeChapterComponent.errorFetchChapter")); @@ -148,12 +133,12 @@ export default function ScribeChapterComponent() { title: title, }; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:chapter:update', updateData); + response = await tauri.updateChapter(updateData.chapterId, updateData.title, updateData.chapterOrder); } else { response = await System.authPostToServer('chapter/update', updateData, userToken, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) { - addToQueue('db:chapter:update', updateData); + addToQueue('update_chapter', {data: updateData}); } } if (!response) { @@ -190,11 +175,7 @@ export default function ScribeChapterComponent() { let response:boolean = false; const deletedAt: number = System.timeStampInSeconds(); if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:chapter:remove', { - chapterId: removeChapterId, - bookId: book?.bookId, - deletedAt, - }); + response = await tauri.removeChapter(removeChapterId, book?.bookId ?? '', deletedAt); } else { response = await System.authDeleteToServer('chapter/remove', { chapterId: removeChapterId, @@ -203,11 +184,7 @@ export default function ScribeChapterComponent() { }, userToken, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) { - addToQueue('db:chapter:remove', { - chapterId: removeChapterId, - bookId: book?.bookId, - deletedAt, - }); + addToQueue('remove_chapter', {data: {chapterId: removeChapterId, bookId: book?.bookId, deletedAt}}); } } if (!response) { @@ -241,15 +218,16 @@ export default function ScribeChapterComponent() { title: chapterTitle }; if (isCurrentlyOffline() || book?.localBook){ - chapterId = await window.electron.invoke('db:chapter:add', addData); + chapterId = await tauri.addChapter({ + bookId: addData.bookId ?? '', + title: addData.title, + chapterOrder: addData.chapterOrder, + }); } else { chapterId = await System.authPostToServer('chapter/add', addData, userToken, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) { - addToQueue('db:chapter:add', { - ...addData, - chapterId, - }); + addToQueue('add_chapter', {data: {...addData, chapterId}}); } } if (!chapterId) { diff --git a/components/offline/OfflinePinSetup.tsx b/components/offline/OfflinePinSetup.tsx index 319e2c3..9adff30 100644 --- a/components/offline/OfflinePinSetup.tsx +++ b/components/offline/OfflinePinSetup.tsx @@ -5,6 +5,7 @@ import { SessionContext } from '@/context/SessionContext'; import { useTranslations } from 'next-intl'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faLock, faShieldAlt, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; +import * as tauri from '@/lib/tauri'; interface OfflinePinSetupProps { onClose?: () => void; @@ -53,15 +54,13 @@ export default function OfflinePinSetup({ onClose, onSuccess, showOnFirstLogin } setError(''); try { - if (window.electron) { - const result = await window.electron.offlinePinSet(pin); + const result = await tauri.offlinePinSet(pin); - if (result.success) { - await window.electron.offlineModeSet(true, 30); // 30 days sync interval - onSuccess?.(); - } else { - setError(result.error || t('offline.pin.errors.setupFailed')); - } + if (result.success) { + await tauri.offlineModeSet(true, 30); + onSuccess?.(); + } else { + setError(result.error || t('offline.pin.errors.setupFailed')); } } catch (error) { console.error('[OfflinePin] Error setting PIN:', error); diff --git a/components/offline/OfflinePinVerify.tsx b/components/offline/OfflinePinVerify.tsx index 069fbe2..6860cf7 100644 --- a/components/offline/OfflinePinVerify.tsx +++ b/components/offline/OfflinePinVerify.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faLock, faWifi, faEye, faEyeSlash, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'; import System from '@/lib/models/System'; +import * as tauri from '@/lib/tauri'; interface OfflinePinVerifyProps { onSuccess: (userId: string) => void; @@ -29,20 +30,18 @@ export default function OfflinePinVerify({ onSuccess, onCancel }: OfflinePinVeri setError(''); try { - if (window.electron) { - const result = await window.electron.offlinePinVerify(pin); + const result = await tauri.offlinePinVerify(pin); - if (result.success && result.userId) { - onSuccess(result.userId); + if (result.success && result.userId) { + onSuccess(result.userId); + } else { + setAttempts(prev => prev + 1); + setPin(''); + + if (attempts >= 2) { + setError(t('offline.pin.verify.tooManyAttempts')); } else { - setAttempts(prev => prev + 1); - setPin(''); - - if (attempts >= 2) { - setError(t('offline.pin.verify.tooManyAttempts')); - } else { - setError(result.error || t('offline.pin.verify.incorrect')); - } + setError(result.error || t('offline.pin.verify.incorrect')); } } } catch (error) { @@ -60,8 +59,8 @@ export default function OfflinePinVerify({ onSuccess, onCancel }: OfflinePinVeri const handleLogout = async () => { System.removeCookie("token"); - await window.electron.removeToken(); - window.electron.logout(); + await tauri.removeToken(); + tauri.logout(); }; return ( diff --git a/components/offline/OfflineToggle.tsx b/components/offline/OfflineToggle.tsx index a4d7fa3..4e57fd6 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 || !offlineMode.isDatabaseInitialized) { + if (!offlineMode.isDatabaseInitialized) { return null; } diff --git a/components/rightbar/ComposerRightBar.tsx b/components/rightbar/ComposerRightBar.tsx index 24875be..626048b 100644 --- a/components/rightbar/ComposerRightBar.tsx +++ b/components/rightbar/ComposerRightBar.tsx @@ -18,6 +18,7 @@ import QuillSense from "@/components/quillsense/QuillSenseComponent"; import {useTranslations} from "next-intl"; import {faSpinner} from "@fortawesome/free-solid-svg-icons"; import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; +import * as tauri from '@/lib/tauri'; // Lazy loaded Editor components const WorldEditor = lazy(function () { @@ -150,7 +151,7 @@ export default function ComposerRightBar(): React.JSX.Element { badge: t("composerRightBar.homeComponents.facebook.badge"), icon: faFacebook, action: function (): Promise { - return window.electron.openExternal('https://www.facebook.com/profile.php?id=61562628720878'); + return tauri.openExternal('https://www.facebook.com/profile.php?id=61562628720878'); } }, { @@ -160,7 +161,7 @@ export default function ComposerRightBar(): React.JSX.Element { badge: t("composerRightBar.homeComponents.discord.badge"), icon: faDiscord, action: function (): Promise { - return window.electron.openExternal('https://discord.gg/CHXRPvmaXm'); + return tauri.openExternal('https://discord.gg/CHXRPvmaXm'); } } ]; diff --git a/components/series/AddNewSeriesForm.tsx b/components/series/AddNewSeriesForm.tsx index 617d9a2..72ae0fd 100644 --- a/components/series/AddNewSeriesForm.tsx +++ b/components/series/AddNewSeriesForm.tsx @@ -17,6 +17,7 @@ import {SyncedBook} from "@/lib/models/SyncedBook"; import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; import {SyncedSeries, SyncedSeriesBook} from "@/lib/models/SyncedSeries"; +import * as tauri from '@/lib/tauri'; interface AddNewSeriesFormProps { setCloseForm: Dispatch>; @@ -88,7 +89,7 @@ export default function AddNewSeriesForm({setCloseForm, onSeriesCreated}: AddNew let response: string; if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:series:create', createData); + response = await tauri.createSeries(createData); } else { response = await System.authPostToServer( 'series/add', diff --git a/components/series/SeriesSettingSidebar.tsx b/components/series/SeriesSettingSidebar.tsx index 5afd65c..d60c991 100644 --- a/components/series/SeriesSettingSidebar.tsx +++ b/components/series/SeriesSettingSidebar.tsx @@ -23,6 +23,7 @@ import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {SyncedSeries} from "@/lib/models/SyncedSeries"; +import * as tauri from '@/lib/tauri'; interface SeriesSettingOption { id: string; @@ -62,7 +63,7 @@ export default function SeriesSettingSidebar( let success: boolean; if (isCurrentlyOffline() || localSeries) { - success = await window.electron.invoke('db:series:delete', deleteData); + success = await tauri.deleteSeries(deleteData.seriesId, deleteData.deletedAt); } else { success = await System.authDeleteToServer( 'series/delete', @@ -72,7 +73,7 @@ export default function SeriesSettingSidebar( ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:delete', deleteData); + addToQueue('delete_series', {data: deleteData}); } } diff --git a/components/series/settings/BasicSeriesInformation.tsx b/components/series/settings/BasicSeriesInformation.tsx index f0d66ff..eb6dc12 100644 --- a/components/series/settings/BasicSeriesInformation.tsx +++ b/components/series/settings/BasicSeriesInformation.tsx @@ -16,6 +16,7 @@ import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; import {SyncedSeries} from "@/lib/models/SyncedSeries"; +import * as tauri from '@/lib/tauri'; function BasicSeriesInformation(props: object, ref: React.Ref<{ handleSave: () => Promise }>) { const t = useTranslations(); @@ -45,7 +46,7 @@ function BasicSeriesInformation(props: object, ref: React.Ref<{ handleSave: () = let response: SeriesDetailResponse; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:detail', {seriesId}); + response = await tauri.getSeriesDetail(seriesId); } else { response = await System.authGetQueryToServer( 'series/detail', @@ -90,7 +91,7 @@ function BasicSeriesInformation(props: object, ref: React.Ref<{ handleSave: () = let success: boolean; if (isCurrentlyOffline() || localSeries) { - success = await window.electron.invoke('db:series:update', updateData); + success = await tauri.updateSeries(updateData); } else { const response: SeriesUpdateResponse = await System.authPutToServer( 'series/update', @@ -101,7 +102,7 @@ function BasicSeriesInformation(props: object, ref: React.Ref<{ handleSave: () = success = response.success; if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:update', updateData); + addToQueue('update_series', {data: updateData}); } } diff --git a/components/series/settings/SeriesBooksManager.tsx b/components/series/settings/SeriesBooksManager.tsx index 14537c9..39bb205 100644 --- a/components/series/settings/SeriesBooksManager.tsx +++ b/components/series/settings/SeriesBooksManager.tsx @@ -15,6 +15,7 @@ import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; import {SyncedSeries, SyncedSeriesBook} from "@/lib/models/SyncedSeries"; +import * as tauri from '@/lib/tauri'; function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Promise }>) { const t = useTranslations(); @@ -75,7 +76,7 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr let response: SeriesBookProps[]; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:books', {seriesId}); + response = await tauri.getSeriesBooks(seriesId); } else { response = await System.authGetQueryToServer( 'series/book/list', @@ -123,7 +124,7 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr let response: boolean; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:book:add', addData); + response = await tauri.addBookToSeries(addData.seriesId, addData.bookId); } else { response = await System.authPostToServer( 'series/book/add', @@ -133,7 +134,7 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:book:add', addData); + addToQueue('add_book_to_series', {data: addData}); } } @@ -180,7 +181,7 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr let response: boolean; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:book:remove', removeData); + response = await tauri.removeBookFromSeries(removeData.seriesId, removeData.bookId, removeData.deletedAt); } else { response = await System.authDeleteToServer( 'series/book/remove', @@ -190,7 +191,7 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:book:remove', removeData); + addToQueue('remove_book_from_series', {data: removeData}); } } @@ -247,7 +248,7 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr let response: boolean; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:book:reorder', reorderData); + response = await tauri.reorderSeriesBooks(reorderData.seriesId, reorderData.booksOrder); } else { response = await System.authPutToServer( 'series/book/reorder', @@ -257,7 +258,7 @@ function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Pr ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { - addToQueue('db:series:book:reorder', reorderData); + addToQueue('reorder_series_books', {data: reorderData}); } } diff --git a/context/OfflineProvider.tsx b/context/OfflineProvider.tsx index 6ed3cb6..e8f7832 100644 --- a/context/OfflineProvider.tsx +++ b/context/OfflineProvider.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, ReactNode } from 'react'; import OfflineContext, { OfflineMode, defaultOfflineMode } from './OfflineContext'; +import * as tauri from '@/lib/tauri'; interface OfflineProviderProps { children: ReactNode; @@ -12,40 +13,27 @@ export default function OfflineProvider({ children }: OfflineProviderProps) { const initializeDatabase = useCallback(async (userId: string, encryptionKey?: string): Promise => { try { - if (typeof window === 'undefined' || !(window as any).electron) { - console.warn('Not running in Electron, offline mode not available'); - return false; - } - let userKey = encryptionKey; if (!userKey) { - const storedKey = await (window as any).electron.getUserEncryptionKey(userId); + const storedKey = await tauri.getUserEncryptionKey(userId); if (storedKey) { userKey = storedKey; } else { - const keyResult = await (window as any).electron.generateEncryptionKey(userId); - if (!keyResult.success) { - throw new Error(keyResult.error || 'Failed to generate encryption key'); - } - userKey = keyResult.key; - await (window as any).electron.setUserEncryptionKey(userId, userKey); + throw new Error('No encryption key found for user'); } } - const result = await (window as any).electron.dbInitialize(userId, userKey); - if (!result.success) { - throw new Error(result.error || 'Failed to initialize database'); - } + await tauri.dbInitialize(userId, userKey); setOfflineMode(prev => ({ ...prev, isDatabaseInitialized: true, error: null })); - + return true; } catch (error) { - console.error('Failed to initialize database:', error); + console.error('Failed to initialize database:', error, 'userId:', userId, 'hasKey:', !!userKey); setOfflineMode(prev => ({ ...prev, isDatabaseInitialized: false, diff --git a/electron.d.ts b/electron.d.ts deleted file mode 100644 index 2bbbd1d..0000000 --- a/electron.d.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * TypeScript declarations for window.electron API - * Must match exactly with electron/preload.ts - * - * Usage: - * - Use invoke(channel, ...args) for all IPC calls - * - Shortcuts are provided for common operations (tokens, lang, encryption) - */ -export interface IElectronAPI { - // Platform info - platform: NodeJS.Platform; - - // Generic invoke method - use this for all IPC calls - invoke: (channel: string, ...args: any[]) => Promise; - - // Token management (shortcuts for convenience) - getToken: () => Promise; - setToken: (token: string) => Promise; - removeToken: () => Promise; - - // Language management (shortcuts for convenience) - getLang: () => Promise<'fr' | 'en'>; - setLang: (lang: 'fr' | 'en') => Promise; - - // Auth events (one-way communication) - loginSuccess: (token: string) => void; - logout: () => void; - - // User initialization (after getting user info from server) - initUser: (userId: string) => Promise<{ success: boolean; keyCreated?: boolean; error?: string }>; - - // Encryption key management (shortcuts for convenience) - generateEncryptionKey: (userId: string) => Promise; - getUserEncryptionKey: (userId: string) => Promise; - setUserEncryptionKey: (userId: string, encryptionKey: string) => Promise; - - // Database initialization (shortcut for convenience) - dbInitialize: (userId: string, encryptionKey: string) => Promise; - - // Open external links (browser/native app) - openExternal: (url: string) => Promise; - - // OAuth login via BrowserWindow - oauthLogin: (provider: 'google' | 'facebook' | 'apple', baseUrl: string) => Promise<{ - success: boolean; - code?: string; - state?: string; - error?: string; - }>; - - // Offline mode management - offlinePinSet: (pin: string) => Promise<{ success: boolean; error?: string }>; - offlinePinVerify: (pin: string) => Promise<{ success: boolean; userId?: string; error?: string }>; - offlineModeSet: (enabled: boolean, syncInterval?: number) => Promise<{ success: boolean }>; - offlineModeGet: () => Promise<{ enabled: boolean; syncInterval: number; hasPin: boolean; lastUserId?: string }>; - offlineSyncCheck: () => Promise<{ shouldSync: boolean; daysSinceSync?: number; syncInterval?: number }>; -} - -declare global { - interface Window { - electron: IElectronAPI; - } -} - -export {}; diff --git a/hooks/settings/useCharacters.ts b/hooks/settings/useCharacters.ts index d6d0605..1328716 100644 --- a/hooks/settings/useCharacters.ts +++ b/hooks/settings/useCharacters.ts @@ -2,6 +2,7 @@ import {useCallback, useContext, useEffect, useState} from 'react'; import {Attribute, CharacterListResponse, CharacterProps} from '@/lib/models/Character'; import {SeriesCharacterProps} from '@/lib/models/Series'; +import * as tauri from '@/lib/tauri'; import {SessionContext} from '@/context/SessionContext'; import {BookContext} from '@/context/BookContext'; import {AlertContext} from '@/context/AlertContext'; @@ -147,10 +148,7 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn let response: SeriesCharacterProps[]; // Dual logic: offline ou livre local → IPC, sinon serveur if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke( - 'db:series:character:list', - {seriesId: bookSeriesId} - ); + response = await tauri.getSeriesCharacterList(bookSeriesId); } else { response = await System.authGetQueryToServer( 'series/character/list', @@ -176,10 +174,7 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn // Series mode - dual logic let response: SeriesCharacterProps[]; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke( - 'db:series:character:list', - {seriesId: entityId} - ); + response = await tauri.getSeriesCharacterList(entityId); } else { response = await System.authGetQueryToServer( 'series/character/list', @@ -231,11 +226,11 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn // Pattern B: GET dans contexte livre let response: CharacterListResponse; if (isCurrentlyOffline()) { - // Offline → IPC - response = await window.electron.invoke('db:character:list', {bookid: entityId}); + // Offline → Tauri + response = await tauri.getCharacterList(entityId, true) as CharacterListResponse; } else if (book?.localBook) { - // Online mais livre local → IPC - response = await window.electron.invoke('db:character:list', {bookid: entityId}); + // Online mais livre local → Tauri + response = await tauri.getCharacterList(entityId, true) as CharacterListResponse; } else { // Online + livre serveur → Server response = await System.authGetQueryToServer( @@ -306,15 +301,15 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn let response: boolean; if (isCurrentlyOffline() || book?.localBook) { - // Offline OU livre local → IPC - response = await window.electron.invoke('db:book:tool:update', requestData); + // Offline OU livre local → Tauri + response = await tauri.updateBookToolSetting(requestData.bookId, requestData.toolName, requestData.enabled); } else { // Online + livre serveur → Server response = await System.authPatchToServer('book/tool-setting', requestData, userToken, lang); // Si le livre a une copie locale → addToQueue pour sync if (book?.bookId && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book.bookId)) { - addToQueue('db:book:tool:update', requestData); + addToQueue('update_book_tool_setting', {data: requestData}); } } @@ -385,7 +380,7 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn } }; if (isCurrentlyOffline() || localSeries) { - characterId = await window.electron.invoke('db:series:character:add', seriesCharacterData); + characterId = await tauri.addSeriesCharacter(seriesCharacterData); } else { characterId = await System.authPostToServer( 'series/character/add', @@ -394,7 +389,7 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:character:add', {...seriesCharacterData, id: characterId}); + addToQueue('add_series_character', {data: {...seriesCharacterData, id: characterId}}); } } } else { @@ -405,15 +400,15 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn }; if (isCurrentlyOffline() || book?.localBook) { - // Offline OU livre local → IPC - characterId = await window.electron.invoke('db:character:create', requestData); + // Offline OU livre local → Tauri + characterId = await tauri.createCharacter(requestData.character, requestData.bookId, requestData.id); } else { // Online + livre serveur → Server characterId = await System.authPostToServer('character/add', requestData, userToken, lang); // Si le livre a une copie locale → addToQueue pour sync if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:character:create', {...requestData, id: characterId}); + addToQueue('create_character', {data: {...requestData, id: characterId}}); } } } @@ -470,11 +465,11 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn } }; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:character:update', updateData); + response = await tauri.updateSeriesCharacter(updateData); } else { response = await System.authPatchToServer('series/character/update', updateData, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:character:update', updateData); + addToQueue('update_series_character', {data: updateData}); } } } else { @@ -484,15 +479,15 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn }; if (isCurrentlyOffline() || book?.localBook) { - // Offline OU livre local → IPC - response = await window.electron.invoke('db:character:update', requestData); + // Offline OU livre local → Tauri + response = await tauri.updateCharacter(requestData.character); } else { // Online + livre serveur → Server response = await System.authPostToServer('character/update', requestData, userToken, lang); // Si le livre a une copie locale → addToQueue pour sync if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:character:update', requestData); + addToQueue('update_character', {data: requestData}); } } } @@ -529,26 +524,26 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn // Series mode - dual logic const requestData = {characterId, deletedAt}; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:character:delete', requestData); + response = await tauri.deleteSeriesCharacter(requestData.characterId, requestData.deletedAt); } else { response = await System.authDeleteToServer('series/character/delete', requestData, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:character:delete', requestData); + addToQueue('delete_series_character', {data: requestData}); } } } else { // Pattern A: mutations const requestData = {characterId, bookId: entityId, deletedAt}; if (isCurrentlyOffline() || book?.localBook) { - // Offline OU livre local → IPC - response = await window.electron.invoke('db:character:delete', requestData); + // Offline OU livre local → Tauri + response = await tauri.deleteCharacter(requestData.characterId, requestData.bookId, requestData.deletedAt); } else { // Online + livre serveur → Server response = await System.authDeleteToServer('character/delete', requestData, userToken, lang); // Si le livre a une copie locale → addToQueue pour sync if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:character:delete', requestData); + addToQueue('delete_character', {data: requestData}); } } } @@ -595,25 +590,25 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn if (isSeriesMode) { // Series mode - dual logic if (isCurrentlyOffline() || localSeries) { - attributeId = await window.electron.invoke('db:series:character:attribute:add', requestData); + attributeId = await tauri.addSeriesCharacterAttribute(requestData); } else { attributeId = await System.authPostToServer('series/character/attribute/add', requestData, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:character:attribute:add', {...requestData, id: attributeId}); + addToQueue('add_series_character_attribute', {data: {...requestData, id: attributeId}}); } } } else { // Pattern A: mutations if (isCurrentlyOffline() || book?.localBook) { - // Offline OU livre local → IPC - attributeId = await window.electron.invoke('db:character:attribute:add', requestData); + // Offline OU livre local → Tauri + attributeId = await tauri.addCharacterAttribute(requestData.characterId, requestData.type, requestData.name, requestData.id); } else { // Online + livre serveur → Server attributeId = await System.authPostToServer('character/attribute/add', requestData, userToken, lang); // Si le livre a une copie locale → addToQueue pour sync if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:character:attribute:add', {...requestData, id: attributeId}); + addToQueue('add_character_attribute', {data: {...requestData, id: attributeId}}); } } } @@ -658,26 +653,26 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn // Series mode - dual logic const requestData = {attributeId: attrId, deletedAt}; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:character:attribute:delete', requestData); + response = await tauri.deleteSeriesCharacterAttribute(requestData.attributeId, requestData.deletedAt); } else { response = await System.authDeleteToServer('series/character/attribute/delete', requestData, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:character:attribute:delete', requestData); + addToQueue('delete_series_character_attribute', {data: requestData}); } } } else { // Pattern A: mutations const requestData = {attributeId: attrId, bookId: entityId, deletedAt}; if (isCurrentlyOffline() || book?.localBook) { - // Offline OU livre local → IPC - response = await window.electron.invoke('db:character:attribute:delete', requestData); + // Offline OU livre local → Tauri + response = await tauri.deleteCharacterAttribute(requestData.attributeId, requestData.bookId, requestData.deletedAt); } else { // Online + livre serveur → Server response = await System.authDeleteToServer('character/attribute/delete', requestData, userToken, lang); // Si le livre a une copie locale → addToQueue pour sync if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:character:attribute:delete', requestData); + addToQueue('delete_character_attribute', {data: requestData}); } } } @@ -734,8 +729,8 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn let seriesCharacterId: string; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - seriesCharacterId = await window.electron.invoke('db:series:character:add', seriesCharacterData); + // Mode offline ou livre local → Tauri + seriesCharacterId = await tauri.addSeriesCharacter(seriesCharacterData); } else { // Mode online → Serveur seriesCharacterId = await System.authPostToServer( @@ -746,7 +741,7 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn ); // Si la série a une copie locale → addToQueue if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) { - addToQueue('db:series:character:add', {...seriesCharacterData, id: seriesCharacterId}); + addToQueue('add_series_character', {data: {...seriesCharacterData, id: seriesCharacterId}}); } } @@ -760,14 +755,14 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn let updateResponse: boolean; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - updateResponse = await window.electron.invoke('db:character:update', updateData); + // Mode offline ou livre local → Tauri + updateResponse = await tauri.updateCharacter(updateData.character); } else { // Mode online → Serveur updateResponse = await System.authPostToServer('character/update', updateData, userToken, lang); // Si le livre a une copie locale → addToQueue if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:character:update', updateData); + addToQueue('update_character', {data: updateData}); } } @@ -869,14 +864,14 @@ export function useCharacters(config: UseCharactersConfig): UseCharactersReturn let characterId: string; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - characterId = await window.electron.invoke('db:character:create', requestData); + // Mode offline ou livre local → Tauri + characterId = await tauri.createCharacter(requestData.character, requestData.bookId, requestData.id); } else { // Mode online → Serveur characterId = await System.authPostToServer('character/add', requestData, userToken, lang); // Si le livre a une copie locale → addToQueue if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:character:create', {...requestData, id: characterId}); + addToQueue('create_character', {data: {...requestData, id: characterId}}); } } diff --git a/hooks/settings/useLocations.ts b/hooks/settings/useLocations.ts index 6e55710..7269018 100644 --- a/hooks/settings/useLocations.ts +++ b/hooks/settings/useLocations.ts @@ -15,6 +15,7 @@ import {SyncedBook} from '@/lib/models/SyncedBook'; import {SeriesContext, SeriesContextProps} from '@/context/SeriesContext'; import {SeriesSyncContext, SeriesSyncContextProps} from '@/context/SeriesSyncContext'; import {SyncedSeries} from '@/lib/models/SyncedSeries'; +import * as tauri from '@/lib/tauri'; export interface SubElement { id: string; @@ -142,10 +143,7 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { let response: SeriesLocationItem[]; // Dual logic: offline ou livre local → IPC, sinon serveur if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke( - 'db:series:location:list', - {seriesId: bookSeriesId} - ); + response = await tauri.getSeriesLocationList(bookSeriesId); } else { response = await System.authGetQueryToServer( 'series/location/list', @@ -171,10 +169,7 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { // Series mode - dual logic let response: SeriesLocationItem[]; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke( - 'db:series:location:list', - {seriesId: entityId} - ); + response = await tauri.getSeriesLocationList(entityId); } else { response = await System.authGetQueryToServer( 'series/location/list', @@ -209,9 +204,9 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { } else { let response: LocationListResponse; if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:location:all', {bookid: entityId}); + response = await tauri.getAllLocations(entityId, true) as unknown as LocationListResponse; } else if (book?.localBook) { - response = await window.electron.invoke('db:location:all', {bookid: entityId}); + response = await tauri.getAllLocations(entityId, true) as unknown as LocationListResponse; } else { response = await System.authGetQueryToServer( 'location/all', @@ -257,12 +252,12 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { }; let response: boolean; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:tool:update', requestData); + response = await tauri.updateBookToolSetting(requestData.bookId!, requestData.toolName, requestData.enabled); } else { response = await System.authPatchToServer('book/tool-setting', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) { - addToQueue('db:book:tool:update', requestData); + addToQueue('update_book_tool_setting', {data: requestData}); } } if (response && setBook && book) { @@ -300,7 +295,7 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { name: newSectionName, }; if (isCurrentlyOffline() || localSeries) { - sectionId = await window.electron.invoke('db:series:location:section:add', addData); + sectionId = await tauri.addSeriesLocationSection(addData); } else { sectionId = await System.authPostToServer( 'series/location/section/add', @@ -309,7 +304,7 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:location:section:add', {...addData, id: sectionId}); + addToQueue('add_series_location_section', {data: {...addData, id: sectionId}}); } } if (!sectionId) { @@ -322,12 +317,12 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { locationName: newSectionName, }; if (isCurrentlyOffline() || book?.localBook) { - sectionId = await window.electron.invoke('db:location:section:add', requestData); + sectionId = await tauri.addLocationSection(requestData.locationName, requestData.bookId, undefined, undefined); } else { sectionId = await System.authPostToServer('location/section/add', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:location:section:add', {...requestData, id: sectionId}); + addToQueue('add_location_section', {data: {...requestData, id: sectionId}}); } } if (!sectionId) { @@ -371,7 +366,7 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { name: newElementNames[sectionId], }; if (isCurrentlyOffline() || localSeries) { - elementId = await window.electron.invoke('db:series:location:element:add', addData); + elementId = await tauri.addSeriesLocationElement(addData); } else { elementId = await System.authPostToServer( 'series/location/element/add', @@ -380,7 +375,7 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:location:element:add', {...addData, id: elementId}); + addToQueue('add_series_location_element', {data: {...addData, id: elementId}}); } } if (!elementId) { @@ -394,12 +389,12 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { elementName: newElementNames[sectionId], }; if (isCurrentlyOffline() || book?.localBook) { - elementId = await window.electron.invoke('db:location:element:add', requestData); + elementId = await tauri.addLocationElement(requestData.locationId, requestData.elementName, undefined); } else { elementId = await System.authPostToServer('location/element/add', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:location:element:add', {...requestData, id: elementId}); + addToQueue('add_location_element', {data: {...requestData, id: elementId}}); } } if (!elementId) { @@ -453,7 +448,7 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { name: newSubElementNames[elementIndex], }; if (isCurrentlyOffline() || localSeries) { - subElementId = await window.electron.invoke('db:series:location:subelement:add', addData); + subElementId = await tauri.addSeriesLocationSubElement(addData); } else { subElementId = await System.authPostToServer( 'series/location/sub-element/add', @@ -462,7 +457,7 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:location:subelement:add', {...addData, id: subElementId}); + addToQueue('add_series_location_sub_element', {data: {...addData, id: subElementId}}); } } if (!subElementId) { @@ -475,12 +470,12 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { subElementName: newSubElementNames[elementIndex], }; if (isCurrentlyOffline() || book?.localBook) { - subElementId = await window.electron.invoke('db:location:subelement:add', requestData); + subElementId = await tauri.addLocationSubElement(requestData.elementId, requestData.subElementName, undefined); } else { subElementId = await System.authPostToServer('location/sub-element/add', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:location:subelement:add', {...requestData, id: subElementId}); + addToQueue('add_location_sub_element', {data: {...requestData, id: subElementId}}); } } if (!subElementId) { @@ -519,11 +514,11 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { // Series mode - dual logic const deleteData = {locationId: sectionId, deletedAt}; if (isCurrentlyOffline() || localSeries) { - success = await window.electron.invoke('db:series:location:delete', deleteData); + success = await tauri.deleteSeriesLocation(deleteData.locationId, deleteData.deletedAt); } else { success = await System.authDeleteToServer('series/location/delete', deleteData, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:location:delete', deleteData); + addToQueue('delete_series_location', {data: deleteData}); } } } else { @@ -531,12 +526,12 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { locationId: sectionId, bookId: entityId, deletedAt, }; if (isCurrentlyOffline() || book?.localBook) { - success = await window.electron.invoke('db:location:delete', requestData); + success = await tauri.deleteLocationSection(requestData.locationId, requestData.bookId, requestData.deletedAt); } else { success = await System.authDeleteToServer('location/delete', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:location:delete', requestData); + addToQueue('delete_location_section', {data: requestData}); } } } @@ -569,11 +564,11 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { // Series mode - dual logic const deleteData = {elementId, deletedAt}; if (isCurrentlyOffline() || localSeries) { - success = await window.electron.invoke('db:series:location:element:delete', deleteData); + success = await tauri.deleteSeriesLocationElement(deleteData.elementId!, deleteData.deletedAt); } else { success = await System.authDeleteToServer('series/location/element/delete', deleteData, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:location:element:delete', deleteData); + addToQueue('delete_series_location_element', {data: deleteData}); } } } else { @@ -581,12 +576,12 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { elementId, bookId: entityId, deletedAt, }; if (isCurrentlyOffline() || book?.localBook) { - success = await window.electron.invoke('db:location:element:delete', requestData); + success = await tauri.deleteLocationElement(requestData.elementId!, requestData.bookId, requestData.deletedAt); } else { success = await System.authDeleteToServer('location/element/delete', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:location:element:delete', requestData); + addToQueue('delete_location_element', {data: requestData}); } } } @@ -622,11 +617,11 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { // Series mode - dual logic const deleteData = {subElementId, deletedAt}; if (isCurrentlyOffline() || localSeries) { - success = await window.electron.invoke('db:series:location:subelement:delete', deleteData); + success = await tauri.deleteSeriesLocationSubElement(deleteData.subElementId!, deleteData.deletedAt); } else { success = await System.authDeleteToServer('series/location/sub-element/delete', deleteData, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:location:subelement:delete', deleteData); + addToQueue('delete_series_location_sub_element', {data: deleteData}); } } } else { @@ -634,12 +629,12 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { subElementId, bookId: entityId, deletedAt, }; if (isCurrentlyOffline() || book?.localBook) { - success = await window.electron.invoke('db:location:subelement:delete', requestData); + success = await tauri.deleteLocationSubElement(requestData.subElementId!, requestData.bookId, requestData.deletedAt); } else { success = await System.authDeleteToServer('location/sub-element/delete', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:location:subelement:delete', requestData); + addToQueue('delete_location_sub_element', {data: requestData}); } } } @@ -694,12 +689,12 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { }; let response: boolean; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:location:update', requestData); + response = await tauri.updateLocations(requestData.locations); } else { response = await System.authPostToServer('location/update', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:location:update', requestData); + addToQueue('update_locations', {data: requestData}); } } if (!response) { @@ -729,14 +724,14 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { let seriesLocationId: string; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - seriesLocationId = await window.electron.invoke('db:series:location:section:add', seriesLocationData); + // Mode offline ou livre local → Tauri + seriesLocationId = await tauri.addSeriesLocationSection(seriesLocationData); } else { // Mode online → Serveur seriesLocationId = await System.authPostToServer('series/location/section/add', seriesLocationData, userToken, lang); // Si la série a une copie locale → addToQueue avec l'ID du serveur if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) { - addToQueue('db:series:location:section:add', {...seriesLocationData, id: seriesLocationId}); + addToQueue('add_series_location_section', {data: {...seriesLocationData, id: seriesLocationId}}); } } @@ -749,14 +744,14 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { let updateResponse: boolean; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - updateResponse = await window.electron.invoke('db:location:section:update', updateData); + // Mode offline ou livre local → Tauri + updateResponse = await tauri.updateLocationSectionWithSeriesLink(updateData.sectionId, updateData.sectionName, updateData.seriesLocationId); } else { // Mode online → Serveur updateResponse = await System.authPostToServer('location/section/update', updateData, userToken, lang); // Si le livre a une copie locale → addToQueue if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:location:section:update', updateData); + addToQueue('update_location_section_with_series_link', {data: updateData}); } } @@ -799,14 +794,14 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { let sectionId: string; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - sectionId = await window.electron.invoke('db:location:section:add', sectionData); + // Mode offline ou livre local → Tauri + sectionId = await tauri.addLocationSection(sectionData.locationName, sectionData.bookId, undefined, sectionData.seriesLocationId); } else { // Mode online → Serveur sectionId = await System.authPostToServer('location/section/add', sectionData, userToken, lang); // Si le livre a une copie locale → addToQueue avec l'ID du serveur if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:location:section:add', {...sectionData, id: sectionId}); + addToQueue('add_location_section', {data: {...sectionData, id: sectionId}}); } } @@ -826,14 +821,14 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { let elementId: string; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - elementId = await window.electron.invoke('db:location:element:add', elementData); + // Mode offline ou livre local → Tauri + elementId = await tauri.addLocationElement(elementData.locationId, elementData.elementName, undefined); } else { // Mode online → Serveur elementId = await System.authPostToServer('location/element/add', elementData, userToken, lang); // Si le livre a une copie locale → addToQueue avec l'ID du serveur if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:location:element:add', {...elementData, id: elementId}); + addToQueue('add_location_element', {data: {...elementData, id: elementId}}); } } @@ -849,14 +844,14 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn { let subElementId: string; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - subElementId = await window.electron.invoke('db:location:subelement:add', subElementData); + // Mode offline ou livre local → Tauri + subElementId = await tauri.addLocationSubElement(subElementData.elementId, subElementData.subElementName, undefined); } else { // Mode online → Serveur subElementId = await System.authPostToServer('location/sub-element/add', subElementData, userToken, lang); // Si le livre a une copie locale → addToQueue avec l'ID du serveur if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:location:subelement:add', {...subElementData, id: subElementId}); + addToQueue('add_location_sub_element', {data: {...subElementData, id: subElementId}}); } } diff --git a/hooks/settings/useSpells.ts b/hooks/settings/useSpells.ts index 1cd4f68..aadc4aa 100644 --- a/hooks/settings/useSpells.ts +++ b/hooks/settings/useSpells.ts @@ -23,6 +23,7 @@ import {SyncedBook} from '@/lib/models/SyncedBook'; import {SeriesContext, SeriesContextProps} from '@/context/SeriesContext'; import {SeriesSyncContext, SeriesSyncContextProps} from '@/context/SeriesSyncContext'; import {SyncedSeries} from '@/lib/models/SyncedSeries'; +import * as tauri from '@/lib/tauri'; export interface UseSpellsConfig { entityType: 'book' | 'series'; @@ -113,10 +114,7 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { let response: SeriesSpellListResponse; // Dual logic: offline ou livre local → IPC, sinon serveur if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke( - 'db:series:spell:list', - {seriesId: bookSeriesId} - ); + response = await tauri.getSeriesSpellList(bookSeriesId) as SeriesSpellListResponse; } else { response = await System.authGetQueryToServer( 'series/spell/list', @@ -142,10 +140,7 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { // Series mode - dual logic let response: SeriesSpellListResponse; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke( - 'db:series:spell:list', - {seriesId: entityId} - ); + response = await tauri.getSeriesSpellList(entityId) as SeriesSpellListResponse; } else { response = await System.authGetQueryToServer( 'series/spell/list', @@ -174,9 +169,9 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { } else { let response: SpellListResponse; if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:spell:list', {bookid: entityId}); + response = await tauri.getSpellList(entityId, true) as SpellListResponse; } else if (book?.localBook) { - response = await window.electron.invoke('db:spell:list', {bookid: entityId}); + response = await tauri.getSpellList(entityId, true) as SpellListResponse; } else { response = await System.authGetQueryToServer( 'spell/list', @@ -242,10 +237,7 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { // Series mode - dual logic let response: SeriesSpellDetailResponse; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke( - 'db:series:spell:detail', - {spellId: spell.id} - ); + response = await tauri.getSeriesSpellDetail(spell.id) as SeriesSpellDetailResponse; } else { response = await System.authGetQueryToServer( 'series/spell/detail', @@ -270,9 +262,9 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { } else { let response: SpellProps; if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:spell:detail', {spellid: spell.id}); + response = await tauri.getSpellDetail(spell.id) as SpellProps; } else if (book?.localBook) { - response = await window.electron.invoke('db:spell:detail', {spellid: spell.id}); + response = await tauri.getSpellDetail(spell.id) as SpellProps; } else { response = await System.authGetQueryToServer( 'spell/detail', @@ -298,10 +290,7 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { if (response.seriesSpellId) { let seriesSpellResponse: SeriesSpellDetailResponse; if (isCurrentlyOffline() || book?.localBook) { - seriesSpellResponse = await window.electron.invoke( - 'db:series:spell:detail', - {spellId: response.seriesSpellId} - ); + seriesSpellResponse = await tauri.getSeriesSpellDetail(response.seriesSpellId) as SeriesSpellDetailResponse; } else { seriesSpellResponse = await System.authGetQueryToServer( 'series/spell/detail', @@ -353,11 +342,11 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { }; let response: boolean; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:tool:update', requestData); + response = await tauri.updateBookToolSetting(requestData.bookId, requestData.toolName, requestData.enabled); } else { response = await System.authPatchToServer('book/tool-setting', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) { - addToQueue('db:book:tool:update', requestData); + addToQueue('update_book_tool_setting', {data: requestData}); } } if (response && setBook && book) { @@ -410,11 +399,11 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { notes: spell.notes, }; if (isCurrentlyOffline() || localSeries) { - newSpellId = await window.electron.invoke('db:series:spell:add', data); + newSpellId = await tauri.addSeriesSpell(data); } else { newSpellId = await System.authPostToServer('series/spell/add', data, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:spell:add', {...data, id: newSpellId}); + addToQueue('add_series_spell', {data: {...data, id: newSpellId}}); } } } else { @@ -432,11 +421,11 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { } }; if (isCurrentlyOffline() || book?.localBook) { - newSpellId = await window.electron.invoke('db:spell:create', data); + newSpellId = await tauri.createSpell(data.bookId, data.spell); } else { newSpellId = await System.authPostToServer('spell/add', data, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:spell:create', {...data, id: newSpellId}); + addToQueue('create_spell', {data: {...data, id: newSpellId}}); } } } @@ -492,20 +481,20 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { if (isSeriesMode) { // Series mode - dual logic if (isCurrentlyOffline() || localSeries) { - success = await window.electron.invoke('db:series:spell:update', data); + success = await tauri.updateSeriesSpell(data); } else { success = await System.authPutToServer('series/spell/update', data, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:spell:update', data); + addToQueue('update_series_spell', {data}); } } } else { if (isCurrentlyOffline() || book?.localBook) { - success = await window.electron.invoke('db:spell:update', data); + success = await tauri.updateSpell(data.id, data); } else { success = await System.authPutToServer('spell/update', data, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:spell:update', data); + addToQueue('update_spell', {data}); } } } @@ -549,21 +538,21 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { // Series mode - dual logic const requestData = {spellId, deletedAt}; if (isCurrentlyOffline() || localSeries) { - success = await window.electron.invoke('db:series:spell:delete', requestData); + success = await tauri.deleteSeriesSpell(requestData.spellId, requestData.deletedAt); } else { success = await System.authDeleteToServer('series/spell/delete', requestData, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:spell:delete', requestData); + addToQueue('delete_series_spell', {data: requestData}); } } } else { const requestData = {spellId, bookId: entityId, deletedAt}; if (isCurrentlyOffline() || book?.localBook) { - success = await window.electron.invoke('db:spell:delete', requestData); + success = await tauri.deleteSpell(requestData.spellId, requestData.bookId, requestData.deletedAt); } else { success = await System.authDeleteToServer('spell/delete', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:spell:delete', requestData); + addToQueue('delete_spell', {data: requestData}); } } } @@ -605,7 +594,7 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { let seriesSpellId: string; if (isCurrentlyOffline() || book?.localBook) { // Mode offline ou livre local → IPC - seriesSpellId = await window.electron.invoke('db:series:spell:add', seriesSpellData); + seriesSpellId = await tauri.addSeriesSpell(seriesSpellData); } else { // Mode online → Serveur seriesSpellId = await System.authPostToServer( @@ -616,7 +605,7 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { ); // Si la série a une copie locale → addToQueue if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) { - addToQueue('db:series:spell:add', {...seriesSpellData, id: seriesSpellId}); + addToQueue('add_series_spell', {data: {...seriesSpellData, id: seriesSpellId}}); } } @@ -636,14 +625,14 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { let updateSuccess: boolean; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - updateSuccess = await window.electron.invoke('db:spell:update', updateData); + // Mode offline ou livre local → Tauri + updateSuccess = await tauri.updateSpell(updateData.id, updateData); } else { // Mode online → Serveur updateSuccess = await System.authPutToServer('spell/update', updateData, userToken, lang); // Si le livre a une copie locale → addToQueue if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:spell:update', updateData); + addToQueue('update_spell', {data: updateData}); } } @@ -680,11 +669,8 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { // 1. Récupérer les détails du sort de la série let seriesSpellDetail: SeriesSpellDetailResponse; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline → IPC pour récupérer les détails du sort de la série locale - seriesSpellDetail = await window.electron.invoke( - 'db:series:spell:detail', - {spellId: seriesSpellId} - ); + // Mode offline → Tauri pour récupérer les détails du sort de la série locale + seriesSpellDetail = await tauri.getSeriesSpellDetail(seriesSpellId) as SeriesSpellDetailResponse; } else { // Mode online → Serveur seriesSpellDetail = await System.authGetQueryToServer( @@ -715,14 +701,14 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { let createdSpellId: string; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - createdSpellId = await window.electron.invoke('db:spell:create', spellData); + // Mode offline ou livre local → Tauri + createdSpellId = await tauri.createSpell(spellData.bookId, spellData.spell); } else { // Mode online → Serveur createdSpellId = await System.authPostToServer('spell/add', spellData, userToken, lang); // Si le livre a une copie locale → addToQueue if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:spell:create', {...spellData, id: createdSpellId}); + addToQueue('create_spell', {data: {...spellData, id: createdSpellId}}); } } @@ -758,7 +744,7 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { }; let tagId: string; if (isCurrentlyOffline() || localSeries) { - tagId = await window.electron.invoke('db:series:spell:tag:add', addData); + tagId = await tauri.addSeriesSpellTag(addData); } else { tagId = await System.authPostToServer( 'series/spell/tag/add', @@ -767,7 +753,7 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:spell:tag:add', {...addData, id: tagId}); + addToQueue('add_series_spell_tag', {data: {...addData, id: tagId}}); } } if (tagId) { @@ -786,11 +772,11 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { }; let newTag: SpellTagProps; if (isCurrentlyOffline() || book?.localBook) { - newTag = await window.electron.invoke('db:spell:tag:create', requestData); + newTag = await tauri.createSpellTag(requestData.bookId, requestData.name, requestData.color) as SpellTagProps; } else { newTag = await System.authPostToServer('spell/tag/add', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:spell:tag:create', {...requestData, id: newTag?.id}); + addToQueue('create_spell_tag', {data: {...requestData, id: newTag?.id}}); } } if (newTag && newTag.id) { @@ -816,20 +802,20 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { if (isSeriesMode) { // Series mode - dual logic if (isCurrentlyOffline() || localSeries) { - success = await window.electron.invoke('db:series:spell:tag:update', requestData); + success = await tauri.updateSeriesSpellTag(requestData); } else { success = await System.authPutToServer('series/spell/tag/update', requestData, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:spell:tag:update', requestData); + addToQueue('update_series_spell_tag', {data: requestData}); } } } else { if (isCurrentlyOffline() || book?.localBook) { - success = await window.electron.invoke('db:spell:tag:update', requestData); + success = await tauri.updateSpellTag(requestData.tagId, requestData.name, requestData.color); } else { success = await System.authPutToServer('spell/tag/update', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:spell:tag:update', requestData); + addToQueue('update_spell_tag', {data: requestData}); } } } @@ -859,21 +845,21 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { // Series mode - dual logic const deleteData = {tagId, deletedAt}; if (isCurrentlyOffline() || localSeries) { - success = await window.electron.invoke('db:series:spell:tag:delete', deleteData); + success = await tauri.deleteSeriesSpellTag(deleteData.tagId, deleteData.deletedAt); } else { success = await System.authDeleteToServer('series/spell/tag/delete', deleteData, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:spell:tag:delete', deleteData); + addToQueue('delete_series_spell_tag', {data: deleteData}); } } } else { const requestData = {tagId, bookId: entityId, deletedAt}; if (isCurrentlyOffline() || book?.localBook) { - success = await window.electron.invoke('db:spell:tag:delete', requestData); + success = await tauri.deleteSpellTag(requestData.tagId, requestData.bookId, requestData.deletedAt); } else { success = await System.authDeleteToServer('spell/tag/delete', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:spell:tag:delete', requestData); + addToQueue('delete_spell_tag', {data: requestData}); } } } @@ -898,10 +884,7 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn { if (selectedSpell?.seriesSpellId) { let seriesSpellResponse: SeriesSpellDetailResponse; if (isCurrentlyOffline() || (isSeriesMode ? localSeries : book?.localBook)) { - seriesSpellResponse = await window.electron.invoke( - 'db:series:spell:detail', - {spellId: selectedSpell.seriesSpellId} - ); + seriesSpellResponse = await tauri.getSeriesSpellDetail(selectedSpell.seriesSpellId) as SeriesSpellDetailResponse; } else { seriesSpellResponse = await System.authGetQueryToServer( 'series/spell/detail', diff --git a/hooks/settings/useWorlds.ts b/hooks/settings/useWorlds.ts index 97a97b3..3ed5eef 100644 --- a/hooks/settings/useWorlds.ts +++ b/hooks/settings/useWorlds.ts @@ -17,6 +17,7 @@ import System from '@/lib/models/System'; import {useTranslations} from 'next-intl'; import {SelectBoxProps} from '@/shared/interface'; import {ViewMode} from '@/shared/interface'; +import * as tauri from '@/lib/tauri'; const initialWorldState: WorldProps = { id: '', @@ -135,10 +136,7 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { let response: SeriesWorldProps[]; // Dual logic: offline ou livre local → IPC, sinon serveur if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke( - 'db:series:world:list', - {seriesId: bookSeriesId} - ); + response = await tauri.getSeriesWorldList(bookSeriesId); } else { response = await System.authGetQueryToServer( 'series/world/list', @@ -164,10 +162,7 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { // Series mode - dual logic let response: SeriesWorldProps[]; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke( - 'db:series:world:list', - {seriesId: entityId} - ); + response = await tauri.getSeriesWorldList(entityId); } else { response = await System.authGetQueryToServer( 'series/world/list', @@ -212,9 +207,9 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { } else { let response: WorldListResponse; if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:book:worlds:get', {bookid: entityId}); + response = await tauri.getWorlds(entityId, true) as unknown as WorldListResponse; } else if (book?.localBook) { - response = await window.electron.invoke('db:book:worlds:get', {bookid: entityId}); + response = await tauri.getWorlds(entityId, true) as unknown as WorldListResponse; } else { response = await System.authGetQueryToServer( 'book/worlds', @@ -293,11 +288,11 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { }; let response: boolean; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:tool:update', requestData); + response = await tauri.updateBookToolSetting(requestData.bookId, requestData.toolName, requestData.enabled); } else { response = await System.authPatchToServer('book/tool-setting', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) { - addToQueue('db:book:tool:update', requestData); + addToQueue('update_book_tool_setting', {data: requestData}); } } if (response && setBook && book) { @@ -333,7 +328,7 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { name: newWorldName, }; if (isCurrentlyOffline() || localSeries) { - newWorldId = await window.electron.invoke('db:series:world:add', addData); + newWorldId = await tauri.addSeriesWorld(addData); } else { newWorldId = await System.authPostToServer( 'series/world/add', @@ -342,7 +337,7 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:world:add', {...addData, id: newWorldId}); + addToQueue('add_series_world', {data: {...addData, id: newWorldId}}); } } if (!newWorldId) { @@ -355,11 +350,11 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { bookId: entityId, }; if (isCurrentlyOffline() || book?.localBook) { - newWorldId = await window.electron.invoke('db:book:world:add', requestData); + newWorldId = await tauri.addWorld(requestData.bookId || entityId, requestData.worldName, requestData.id, requestData.seriesWorldId); } else { newWorldId = await System.authPostToServer('book/world/add', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:book:world:add', {...requestData, id: newWorldId}); + addToQueue('add_world', {data: {...requestData, id: newWorldId}}); } } if (!newWorldId) { @@ -410,11 +405,11 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { }; let response: boolean; if (isCurrentlyOffline() || localSeries) { - response = await window.electron.invoke('db:series:world:update', updateData); + response = await tauri.updateSeriesWorld(updateData); } else { response = await System.authPatchToServer('series/world/update', updateData, userToken, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { - addToQueue('db:series:world:update', updateData); + addToQueue('update_series_world', {data: updateData}); } } if (!response) { @@ -428,11 +423,11 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { }; let response: boolean; if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:world:update', requestData); + response = await tauri.updateWorld(requestData.world || requestData); } else { response = await System.authPatchToServer('book/world/update', requestData, userToken, lang); if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:book:world:update', requestData); + addToQueue('update_world', {data: requestData}); } } if (!response) { @@ -469,8 +464,8 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { let seriesWorldId: string; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - seriesWorldId = await window.electron.invoke('db:series:world:add', seriesWorldData); + // Mode offline ou livre local → Tauri + seriesWorldId = await tauri.addSeriesWorld(seriesWorldData); } else { // Mode online → Serveur seriesWorldId = await System.authPostToServer('series/world/add', { @@ -486,7 +481,7 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { }, userToken, lang); // Si la série a une copie locale → addToQueue if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) { - addToQueue('db:series:world:add', {...seriesWorldData, id: seriesWorldId}); + addToQueue('add_series_world', {data: {...seriesWorldData, id: seriesWorldId}}); } } @@ -501,14 +496,14 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { let updateResponse: boolean; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - updateResponse = await window.electron.invoke('db:book:world:update', updateData); + // Mode offline ou livre local → Tauri + updateResponse = await tauri.updateWorld(updateData.world || updateData); } else { // Mode online → Serveur updateResponse = await System.authPostToServer('book/world/update', updateData, userToken, lang); // Si le livre a une copie locale → addToQueue if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:book:world:update', updateData); + addToQueue('update_world', {data: updateData}); } } @@ -567,14 +562,14 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { let worldId: string; if (isCurrentlyOffline() || book?.localBook) { - // Mode offline ou livre local → IPC - worldId = await window.electron.invoke('db:book:world:add', requestData); + // Mode offline ou livre local → Tauri + worldId = await tauri.addWorld(requestData.bookId || entityId, requestData.worldName, requestData.id, requestData.seriesWorldId); } else { // Mode online → Serveur worldId = await System.authPostToServer('book/world/add', requestData, userToken, lang); // Si le livre a une copie locale → addToQueue if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { - addToQueue('db:book:world:add', {...requestData, id: worldId}); + addToQueue('add_world', {data: {...requestData, id: worldId}}); } } diff --git a/hooks/useSyncBooks.ts b/hooks/useSyncBooks.ts index 51fdae8..542ee1e 100644 --- a/hooks/useSyncBooks.ts +++ b/hooks/useSyncBooks.ts @@ -8,6 +8,7 @@ import {BooksSyncContext} from '@/context/BooksSyncContext'; import {CompleteBook} from '@/lib/models/Book'; import {BookSyncCompare, SyncedBook} from '@/lib/models/SyncedBook'; import {useTranslations} from 'next-intl'; +import * as tauri from '@/lib/tauri'; interface RemovedItemRecord { removal_id: string; @@ -46,7 +47,7 @@ export default function useSyncBooks() { if (isCurrentlyOffline()) return false; try { - const bookToSync: CompleteBook = await window.electron.invoke('db:book:uploadToServer', bookId); + const bookToSync: CompleteBook = await tauri.uploadBookToServer(bookId) as CompleteBook; if (!bookToSync) { errorMessage(t('bookCard.uploadError')); return false; @@ -86,7 +87,7 @@ export default function useSyncBooks() { errorMessage(t('bookCard.downloadError')); return false; } - const syncStatus: boolean = await window.electron.invoke('db:book:syncSave', response); + const syncStatus: boolean = await tauri.syncSaveBook(response); if (!syncStatus) { errorMessage(t('bookCard.downloadError')); return false; @@ -126,7 +127,7 @@ export default function useSyncBooks() { errorMessage(t('bookCard.syncFromServerError')); return false; } - const syncStatus: boolean = await window.electron.invoke('db:book:sync:toClient', response); + const syncStatus: boolean = await tauri.syncBookToClient(response); if (!syncStatus) { errorMessage(t('bookCard.syncFromServerError')); return false; @@ -154,7 +155,7 @@ export default function useSyncBooks() { errorMessage(t('bookCard.syncToServerError')); return false; } - const bookToSync: CompleteBook = await window.electron.invoke('db:book:sync:toServer', bookToFetch); + const bookToSync: CompleteBook = await tauri.syncBookToServer(bookToFetch) as CompleteBook; if (!bookToSync) { errorMessage(t('bookCard.syncToServerError')); return false; @@ -199,19 +200,13 @@ export default function useSyncBooks() { if (!isCurrentlyOffline()) { if (offlineMode.isDatabaseInitialized) { - localBooksResponse = await window.electron.invoke('db:books:synced'); + localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[]; - // Get lastOnlineTimestamp from localStorage (or 0 if not set) const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; - // Get local tombstones since lastOnlineTimestamp via IPC - const localTombstones: RemovedItemRecord[] = await window.electron.invoke( - 'db:tombstones:since', - lastOnlineTimestamp - ); + const localTombstones: RemovedItemRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as RemovedItemRecord[]; - // Call server with POST and tombstones const serverResponse: SyncedBooksResponse = await System.authPostToServer( 'books/synced', { lastOnlineTimestamp, tombstones: localTombstones }, @@ -221,8 +216,7 @@ export default function useSyncBooks() { serverBooksResponse = serverResponse.books; - // Apply server tombstones locally via IPC - await window.electron.invoke('db:tombstones:apply:books', serverResponse.tombstones); + await tauri.applyBookTombstones(serverResponse.tombstones as tauri.TombstoneRecord[]); } else { // No local DB but online - just get server books without tombstones const serverResponse: SyncedBooksResponse = await System.authPostToServer( @@ -235,7 +229,7 @@ export default function useSyncBooks() { } } else { if (offlineMode.isDatabaseInitialized) { - localBooksResponse = await window.electron.invoke('db:books:synced'); + localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[]; } } diff --git a/hooks/useSyncSeries.ts b/hooks/useSyncSeries.ts index d346c1e..a220bd6 100644 --- a/hooks/useSyncSeries.ts +++ b/hooks/useSyncSeries.ts @@ -7,6 +7,7 @@ import OfflineContext from '@/context/OfflineContext'; import { SeriesSyncContext } from '@/context/SeriesSyncContext'; import { SeriesSyncCompare, SyncedSeries } from '@/lib/models/SyncedSeries'; import { useTranslations } from 'next-intl'; +import * as tauri from '@/lib/tauri'; interface RemovedItemRecord { removal_id: string; @@ -72,7 +73,7 @@ export default function useSyncSeries() { if (isCurrentlyOffline()) return false; try { - const seriesToSync: CompleteSeries = await window.electron.invoke('db:series:uploadToServer', seriesId); + const seriesToSync: CompleteSeries = await tauri.uploadSeriesToServer(seriesId) as CompleteSeries; if (!seriesToSync) { errorMessage(t('seriesCard.uploadError')); return false; @@ -130,7 +131,7 @@ export default function useSyncSeries() { return false; } - const syncStatus: boolean = await window.electron.invoke('db:series:syncSave', response); + const syncStatus: boolean = await tauri.syncSaveSeries(response); if (!syncStatus) { errorMessage(t('seriesCard.downloadError')); return false; @@ -188,7 +189,7 @@ export default function useSyncSeries() { return false; } - const syncStatus: boolean = await window.electron.invoke('db:series:sync:toClient', response); + const syncStatus: boolean = await tauri.syncSeriesToClient(response); if (!syncStatus) { errorMessage(t('seriesCard.syncFromServerError')); return false; @@ -230,10 +231,7 @@ export default function useSyncSeries() { return true; } - const seriesToSync: CompleteSeries = await window.electron.invoke( - 'db:series:sync:toServer', - seriesToFetch - ); + const seriesToSync: CompleteSeries = await tauri.syncSeriesToServer(seriesToFetch) as CompleteSeries; if (!seriesToSync) { errorMessage(t('seriesCard.syncToServerError')); return false; @@ -288,19 +286,13 @@ export default function useSyncSeries() { if (!isCurrentlyOffline()) { if (offlineMode.isDatabaseInitialized) { - localSeriesResponse = await window.electron.invoke('db:series:synced'); + localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[]; - // Get lastOnlineTimestamp from localStorage (or 0 if not set) const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; - // Get local tombstones since lastOnlineTimestamp via IPC - const localTombstones: RemovedItemRecord[] = await window.electron.invoke( - 'db:tombstones:since', - lastOnlineTimestamp - ); + const localTombstones: RemovedItemRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as RemovedItemRecord[]; - // Call server with POST and tombstones const serverResponse: SyncedSeriesResponse = await System.authPostToServer( 'series/synced', { lastOnlineTimestamp, tombstones: localTombstones }, @@ -310,8 +302,7 @@ export default function useSyncSeries() { serverSeriesResponse = serverResponse.series; - // Apply server tombstones locally via IPC - await window.electron.invoke('db:tombstones:apply:series', serverResponse.tombstones); + await tauri.applySeriesTombstones(serverResponse.tombstones as tauri.TombstoneRecord[]); } else { // No local DB but online - just get server series without tombstones const serverResponse: SyncedSeriesResponse = await System.authPostToServer( @@ -324,7 +315,7 @@ export default function useSyncSeries() { } } else { if (offlineMode.isDatabaseInitialized) { - localSeriesResponse = await window.electron.invoke('db:series:synced'); + localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[]; } } diff --git a/lib/configs.ts b/lib/configs.ts index 8251ffc..467f669 100644 --- a/lib/configs.ts +++ b/lib/configs.ts @@ -8,7 +8,7 @@ export interface Configs { appVersion: string; } -const isProduction: boolean = true; +const isProduction: boolean = false; export const configs: Configs = { apiUrl: isProduction ? 'https://api.eritors.com/' : 'http://localhost:3001/', diff --git a/lib/models/System.ts b/lib/models/System.ts index 9fe08a8..4b397d0 100644 --- a/lib/models/System.ts +++ b/lib/models/System.ts @@ -39,7 +39,7 @@ export default class System{ }, params: { lang: lang, - plateforme: window.electron.platform, + plateforme: 'desktop', ...params }, url: configs.apiUrl + url, @@ -80,7 +80,7 @@ export default class System{ }, params: { lang: lang, - plateforme: window.electron.platform, + plateforme: 'desktop', }, url: configs.apiUrl + url, data: data @@ -108,7 +108,7 @@ export default class System{ }, params: { lang: lang, - plateforme: window.electron.platform, + plateforme: 'desktop', }, url: configs.apiUrl + url, data: data @@ -136,7 +136,7 @@ export default class System{ url: configs.apiUrl + url, params: { lang: lang, - plateforme: window.electron.platform, + plateforme: 'desktop', }, data: data }) @@ -164,7 +164,7 @@ export default class System{ url: configs.apiUrl + url, params: { lang: lang, - plateforme: window.electron.platform, + plateforme: 'desktop', }, data: data }) @@ -217,7 +217,7 @@ export default class System{ const formData: FormData = new FormData(); formData.append('file', file); formData.append('lang', lang); - formData.append('plateforme', window.electron.platform); + formData.append('plateforme', 'desktop'); const response: AxiosResponse = await axios({ method: 'POST', @@ -227,7 +227,7 @@ export default class System{ url: configs.apiUrl + url, params: { lang: lang, - plateforme: window.electron.platform, + plateforme: 'desktop', }, data: formData, }); @@ -255,7 +255,7 @@ export default class System{ url: configs.apiUrl + url, params: { lang: lang, - plateforme: window.electron.platform, + plateforme: 'desktop', }, data: data }) diff --git a/lib/utils/db-error-handler.ts b/lib/utils/db-error-handler.ts index 06ac00a..d344e32 100644 --- a/lib/utils/db-error-handler.ts +++ b/lib/utils/db-error-handler.ts @@ -1,6 +1,6 @@ /** * Database Error Handler for Frontend - * Handles errors from Electron IPC calls + * Handles errors from Tauri invoke calls */ export interface SerializedError { @@ -69,7 +69,7 @@ export async function handleDbOperation( * const { data, error, loading, execute } = useDbOperation(); * * const loadBooks = async () => { - * await execute(() => window.electron.invoke('db:book:getAll')); + * await execute(() => tauri.getBooks()); * }; */ export function useDbOperation() { diff --git a/package-lock.json b/package-lock.json index 099feed..78e9ed2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "eritorsscribe", - "version": "0.3.1", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eritorsscribe", - "version": "0.3.1", + "version": "0.4.1", "license": "ISC", "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", @@ -15,6 +15,8 @@ "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.0", "@tailwindcss/postcss": "^4.1.17", + "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-shell": "^2.3.5", "@tiptap/extension-color": "^3.10.7", "@tiptap/extension-gapcursor": "^3.10.7", "@tiptap/extension-highlight": "^3.10.7", @@ -40,6 +42,7 @@ }, "devDependencies": { "@electron/notarize": "^3.1.1", + "@tauri-apps/cli": "^2.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.10.1", "@types/pdfkit": "^0.17.5", @@ -2399,6 +2402,242 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tauri-apps/api": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", + "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", + "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.1", + "@tauri-apps/cli-darwin-x64": "2.10.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", + "@tauri-apps/cli-linux-arm64-musl": "2.10.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-musl": "2.10.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", + "@tauri-apps/cli-win32-x64-msvc": "2.10.1" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", + "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", + "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", + "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", + "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", + "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", + "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", + "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", + "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", + "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", + "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", + "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@tiptap/core": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz", diff --git a/package.json b/package.json index 87c7f1e..5560bba 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,11 @@ "build:mac": "next build && tsc --project tsconfig.electron.json && tsc --project tsconfig.preload.json && electron-builder build --mac", "build:win": "next build && tsc --project tsconfig.electron.json && tsc --project tsconfig.preload.json && electron-builder build --win", "build:linux": "next build && tsc --project tsconfig.electron.json && tsc --project tsconfig.preload.json && electron-builder build --linux", - "build:all": "next build && tsc --project tsconfig.electron.json && tsc --project tsconfig.preload.json && electron-builder build --mac --win --linux" + "build:all": "next build && tsc --project tsconfig.electron.json && tsc --project tsconfig.preload.json && electron-builder build --mac --win --linux", + "next:dev": "next dev -p 4000", + "next:build": "next build", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build" }, "keywords": [], "author": "", @@ -17,6 +21,7 @@ "description": "", "devDependencies": { "@electron/notarize": "^3.1.1", + "@tauri-apps/cli": "^2.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.10.1", "@types/pdfkit": "^0.17.5", @@ -36,6 +41,8 @@ "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.0", "@tailwindcss/postcss": "^4.1.17", + "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-shell": "^2.3.5", "@tiptap/extension-color": "^3.10.7", "@tiptap/extension-gapcursor": "^3.10.7", "@tiptap/extension-highlight": "^3.10.7", diff --git a/src-tauri/src/domains/act/mod.rs b/src-tauri/src/domains/act/mod.rs new file mode 100644 index 0000000..5608c55 --- /dev/null +++ b/src-tauri/src/domains/act/mod.rs @@ -0,0 +1,2 @@ +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/act/repo.rs b/src-tauri/src/domains/act/repo.rs new file mode 100644 index 0000000..e9437f9 --- /dev/null +++ b/src-tauri/src/domains/act/repo.rs @@ -0,0 +1,219 @@ +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct BookActSummariesTable { + pub act_sum_id: String, + pub book_id: String, + pub user_id: String, + pub act_index: i64, + pub last_update: i64, + pub summary: Option, +} + +pub struct SyncedActSummaryResult { + pub act_sum_id: String, + pub book_id: String, + pub last_update: i64, +} + +pub struct ActQuery { + pub act_index: i64, + pub summary: String, +} + +/// Fetches all acts for a specific book and user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of ActQuery objects containing act index and summary. +/// Errors if the database operation fails. +pub fn fetch_all_acts(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT act_index, summary FROM book_act_summaries WHERE book_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les actes.".to_string() } else { "Unable to retrieve acts.".to_string() }))?; + + let acts = statement + .query_map(params![book_id, user_id], |query_row| { + Ok(ActQuery { act_index: query_row.get(0)?, summary: query_row.get(1)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les actes.".to_string() } else { "Unable to retrieve acts.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les actes.".to_string() } else { "Unable to retrieve acts.".to_string() }))?; + + Ok(acts) +} + +/// Updates the summary of an existing act. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `act_id` - The unique identifier of the act summary +/// * `summary` - The new summary text +/// * `last_update` - The timestamp of the last update in seconds +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful, false otherwise. +/// Errors if the database operation fails. +pub fn update_act_summary( + conn: &Connection, user_id: &str, book_id: &str, act_id: i64, summary: &str, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE book_act_summaries SET summary=?1, last_update=?2 WHERE user_id=?3 AND book_id=?4 AND act_sum_id=?5", + params![summary, last_update, user_id, book_id, act_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le résumé de l'acte.".to_string() } else { "Unable to update act summary.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a new act summary into the database. +/// * `conn` - Database connection +/// * `act_summary_id` - The unique identifier for the new act summary +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `act_id` - The act index number +/// * `act_summary` - The summary text for the act +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the act summary ID if insertion was successful. +/// Errors if the database operation fails. +pub fn insert_act_summary( + conn: &Connection, act_summary_id: &str, user_id: &str, book_id: &str, act_id: i64, + act_summary: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_act_summaries (act_sum_id, book_id, user_id, act_index, summary, last_update) VALUES (?1,?2,?3,?4,?5,?6)", + params![act_summary_id, book_id, user_id, act_id, act_summary, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le résumé de l'acte.".to_string() } else { "Unable to add act summary.".to_string() }))?; + + if insert_result > 0 { + Ok(act_summary_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Erreur lors de l'ajout du résumé de l'acte.".to_string() } else { "Error adding act summary.".to_string() })) + } +} + +/// Fetches all act summaries for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of BookActSummariesTable objects. +/// Errors if the database operation fails. +pub fn fetch_book_act_summaries(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT act_sum_id, book_id, user_id, act_index, summary, last_update FROM book_act_summaries WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les résumés des actes.".to_string() } else { "Unable to retrieve act summaries.".to_string() }))?; + + let summaries = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookActSummariesTable { + act_sum_id: query_row.get(0)?, book_id: query_row.get(1)?, + user_id: query_row.get(2)?, act_index: query_row.get(3)?, + summary: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les résumés des actes.".to_string() } else { "Unable to retrieve act summaries.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les résumés des actes.".to_string() } else { "Unable to retrieve act summaries.".to_string() }))?; + + Ok(summaries) +} + +/// Fetches all synced act summaries for a user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of SyncedActSummaryResult objects containing sync metadata. +/// Errors if the database operation fails. +pub fn fetch_synced_act_summaries(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT act_sum_id, book_id, last_update FROM book_act_summaries WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les résumés d'actes synchronisés.".to_string() } else { "Unable to retrieve synced act summaries.".to_string() }))?; + + let synced_act_summaries = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedActSummaryResult { act_sum_id: query_row.get(0)?, book_id: query_row.get(1)?, last_update: query_row.get(2)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les résumés d'actes synchronisés.".to_string() } else { "Unable to retrieve synced act summaries.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les résumés d'actes synchronisés.".to_string() } else { "Unable to retrieve synced act summaries.".to_string() }))?; + + Ok(synced_act_summaries) +} + +/// Inserts a synced act summary from remote data. +/// * `conn` - Database connection +/// * `act_sum_id` - The unique identifier of the act summary +/// * `book_id` - The unique identifier of the book +/// * `user_id` - The unique identifier of the user +/// * `act_index` - The act index number +/// * `summary` - The summary text (can be null) +/// * `last_update` - The timestamp of the last update in seconds +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion was successful, false otherwise. +/// Errors if the database operation fails. +pub fn insert_sync_act_summary( + conn: &Connection, act_sum_id: &str, book_id: &str, user_id: &str, act_index: i64, + summary: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_act_summaries (act_sum_id, book_id, user_id, act_index, summary, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![act_sum_id, book_id, user_id, act_index, summary, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le résumé d'acte.".to_string() } else { "Unable to insert act summary.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Fetches a complete act summary by its unique identifier. +/// * `conn` - Database connection +/// * `id` - The unique identifier of the act summary +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of BookActSummariesTable objects. +/// Errors if the database operation fails. +pub fn fetch_complete_act_summary_by_id(conn: &Connection, id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT act_sum_id, book_id, user_id, act_index, summary, last_update FROM book_act_summaries WHERE act_sum_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le résumé d'acte complet.".to_string() } else { "Unable to retrieve complete act summary.".to_string() }))?; + + let act_summary = statement + .query_map(params![id], |query_row| { + Ok(BookActSummariesTable { + act_sum_id: query_row.get(0)?, book_id: query_row.get(1)?, + user_id: query_row.get(2)?, act_index: query_row.get(3)?, + summary: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le résumé d'acte complet.".to_string() } else { "Unable to retrieve complete act summary.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le résumé d'acte complet.".to_string() } else { "Unable to retrieve complete act summary.".to_string() }))?; + + Ok(act_summary) +} + +/// Checks if an act summary exists for a given user, book, and act index. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `act_index` - The act index number to check +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the act summary exists, false otherwise. +/// Errors if the database operation fails. +pub fn act_summarize_exist(conn: &Connection, user_id: &str, book_id: &str, act_index: i64, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_act_summaries WHERE user_id =?1 AND book_id =?2 AND act_index = ?3") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du résumé de l'acte.".to_string() } else { "Unable to check act summary existence.".to_string() }))?; + + let existence_check = statement + .query_row(params![user_id, book_id, act_index], |_query_row| Ok(true)) + .optional() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du résumé de l'acte.".to_string() } else { "Unable to check act summary existence.".to_string() }))?; + + Ok(existence_check.is_some()) +} diff --git a/src-tauri/src/domains/act/service.rs b/src-tauri/src/domains/act/service.rs new file mode 100644 index 0000000..30c3f54 --- /dev/null +++ b/src-tauri/src/domains/act/service.rs @@ -0,0 +1,439 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::act::repo; +use crate::domains::chapter::repo as chapter_repo; +use crate::domains::chapter::service as chapter_service; +use crate::domains::incident::repo as incident_repo; +use crate::domains::plotpoint::repo as plotpoint_repo; +use crate::error::AppResult; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActProps { + pub id: i64, + pub summary: Option, + pub incidents: Option>, + pub plot_points: Option>, + pub chapters: Option>, +} + +pub struct ActStory { + pub act_id: i64, + pub summary: String, + pub chapter_summary: String, + pub chapter_goal: String, + pub incidents: Vec, + pub plot_points: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ActChapter { + pub chapter_info_id: i64, + pub chapter_id: String, + pub title: String, + pub chapter_order: i64, + pub act_id: i64, + pub incident_id: Option, + pub plot_point_id: Option, + pub summary: String, + pub goal: String, +} + +pub struct SyncedActSummary { + pub id: String, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IncidentProps { + pub incident_id: String, + pub title: String, + pub summary: String, + pub chapters: Option>, +} + +pub struct IncidentStory { + pub incident_title: String, + pub incident_summary: String, + pub chapter_summary: String, + pub chapter_goal: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlotPointProps { + pub plot_point_id: String, + pub title: String, + pub summary: String, + pub linked_incident_id: Option, + pub chapters: Option>, +} + +pub struct PlotPointStory { + pub plot_title: String, + pub plot_summary: String, + pub chapter_summary: String, + pub chapter_goal: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChapterProps { + pub chapter_id: String, + pub title: String, + pub chapter_order: i64, +} + +/// Retrieves all chapters linked to acts for a specific book. +/// Decrypts titles, summaries, and goals using the user's encryption key. +/// Uses a cache to avoid decrypting the same chapter title multiple times. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns a list of decrypted act chapters. +fn get_all_chapter_from_acts(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let act_chapter_query_results: Vec = chapter_repo::fetch_all_chapter_for_acts(conn, user_id, book_id, lang)?; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + if act_chapter_query_results.is_empty() { + return Ok(vec![]); + } + + let mut act_chapters: Vec = Vec::new(); + let mut decrypted_title_cache: Vec<(String, String)> = Vec::new(); + + for chapter_query_result in &act_chapter_query_results { + let decrypted_title: String = if let Some(cached) = decrypted_title_cache.iter().find(|(id, _)| id == &chapter_query_result.chapter_id) { + cached.1.clone() + } else { + let title: String = decrypt_data_with_user_key(&chapter_query_result.title, &user_encryption_key)?; + decrypted_title_cache.push((chapter_query_result.chapter_id.clone(), title.clone())); + title + }; + + let decrypted_goal: String = if chapter_query_result.goal.is_empty() { String::new() } else { decrypt_data_with_user_key(&chapter_query_result.goal, &user_encryption_key)? }; + let decrypted_summary: String = if chapter_query_result.summary.is_empty() { String::new() } else { decrypt_data_with_user_key(&chapter_query_result.summary, &user_encryption_key)? }; + + act_chapters.push(ActChapter { + chapter_info_id: chapter_query_result.chapter_info_id, + chapter_id: chapter_query_result.chapter_id.clone(), + title: decrypted_title, + chapter_order: chapter_query_result.chapter_order, + act_id: chapter_query_result.act_id, + incident_id: chapter_query_result.incident_id.clone(), + plot_point_id: chapter_query_result.plot_point_id.clone(), + summary: decrypted_summary, + goal: decrypted_goal, + }); + } + + Ok(act_chapters) +} + +/// Retrieves all incidents for a specific book with their associated chapters. +/// Decrypts incident titles and summaries using the user's encryption key. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `act_chapters` - Array of chapters from acts to associate with incidents +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns a list of incident properties with decrypted data. +fn get_incidents(conn: &Connection, user_id: &str, book_id: &str, act_chapters: &[ActChapter], lang: Lang) -> AppResult> { + let incident_query_results: Vec = incident_repo::fetch_all_incitent_incidents(conn, user_id, book_id, lang)?; + let user_key: String = get_user_encryption_key(user_id)?; + let mut incidents: Vec = Vec::new(); + + if incident_query_results.is_empty() { + return Ok(incidents); + } + + for incident_record in &incident_query_results { + let mut associated_chapters: Vec = Vec::new(); + for chapter in act_chapters { + if chapter.incident_id.as_deref() == Some(&incident_record.incident_id) { + associated_chapters.push(ActChapter { + chapter_info_id: chapter.chapter_info_id, + chapter_id: chapter.chapter_id.clone(), + title: chapter.title.clone(), + chapter_order: chapter.chapter_order, + act_id: chapter.act_id, + incident_id: chapter.incident_id.clone(), + plot_point_id: chapter.plot_point_id.clone(), + summary: chapter.summary.clone(), + goal: chapter.goal.clone(), + }); + } + } + incidents.push(IncidentProps { + incident_id: incident_record.incident_id.clone(), + title: if incident_record.title.is_empty() { String::new() } else { decrypt_data_with_user_key(&incident_record.title, &user_key)? }, + summary: if incident_record.summary.is_empty() { String::new() } else { decrypt_data_with_user_key(&incident_record.summary, &user_key)? }, + chapters: Some(associated_chapters), + }); + } + + Ok(incidents) +} + +/// Retrieves all plot points for a specific book with their associated chapters. +/// Decrypts plot point titles and summaries using the user's encryption key. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `act_chapters` - Array of act chapters to associate with plot points +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns a list of plot point properties with their associated chapters. +fn get_plot_points(conn: &Connection, user_id: &str, book_id: &str, act_chapters: &[ActChapter], lang: Lang) -> AppResult> { + let plot_point_query_results: Vec = plotpoint_repo::fetch_all_plot_points(conn, user_id, book_id, lang)?; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let mut plot_points: Vec = Vec::new(); + + if plot_point_query_results.is_empty() { + return Ok(plot_points); + } + + for plot_point_row in &plot_point_query_results { + let mut associated_chapters: Vec = Vec::new(); + for chapter in act_chapters { + if chapter.plot_point_id.as_deref() == Some(&plot_point_row.plot_point_id) { + associated_chapters.push(ActChapter { + chapter_info_id: chapter.chapter_info_id, + chapter_id: chapter.chapter_id.clone(), + title: chapter.title.clone(), + chapter_order: chapter.chapter_order, + act_id: chapter.act_id, + incident_id: chapter.incident_id.clone(), + plot_point_id: chapter.plot_point_id.clone(), + summary: chapter.summary.clone(), + goal: chapter.goal.clone(), + }); + } + } + plot_points.push(PlotPointProps { + plot_point_id: plot_point_row.plot_point_id.clone(), + title: if plot_point_row.title.is_empty() { String::new() } else { decrypt_data_with_user_key(&plot_point_row.title, &user_encryption_key)? }, + summary: if plot_point_row.summary.is_empty() { String::new() } else { decrypt_data_with_user_key(&plot_point_row.summary, &user_encryption_key)? }, + linked_incident_id: plot_point_row.linked_incident_id.clone(), + chapters: Some(associated_chapters), + }); + } + + Ok(plot_points) +} + +/// Updates chapter information for multiple chapters including summary and goal. +/// Encrypts summaries and goals before storing in the database. +/// * `conn` - Database connection +/// * `chapters` - Array of ActChapter objects containing updated information + +/// Updates a chapter's title and order. +/// Encrypts the title and generates a hash before storing. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `chapter_id` - The unique identifier of the chapter +/// * `title` - The plain text title +/// * `chapter_order` - The chapter order position +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful. +fn update_chapter(conn: &Connection, user_id: &str, chapter_id: &str, title: &str, chapter_order: i64, lang: Lang) -> AppResult { + let hashed_title: String = hash_element(title); + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let encrypted_title: String = encrypt_data_with_user_key(title, &user_encryption_key)?; + chapter_repo::update_chapter(conn, user_id, chapter_id, &encrypted_title, &hashed_title, chapter_order, timestamp_in_seconds(), lang) +} + +/// Retrieves all acts data for a specific book, including chapters, incidents, and plot points. +/// Decrypts summaries using the user's encryption key. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns a list of Act objects with their associated data. +pub fn get_acts_data(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let act_chapters: Vec = get_all_chapter_from_acts(conn, user_id, book_id, lang)?; + let act_queries: Vec = repo::fetch_all_acts(conn, user_id, book_id, lang)?; + let book_incidents: Vec = get_incidents(conn, user_id, book_id, &act_chapters, lang)?; + let book_plot_points: Vec = get_plot_points(conn, user_id, book_id, &act_chapters, lang)?; + + let mut acts: Vec = Vec::new(); + + acts.push(ActProps { + id: 1, + summary: Some(String::new()), + incidents: None, + plot_points: None, + chapters: Some(act_chapters.iter().filter(|chapter| chapter.act_id == 1).map(|chapter| ActChapter { + chapter_info_id: chapter.chapter_info_id, + chapter_id: chapter.chapter_id.clone(), + title: chapter.title.clone(), + chapter_order: chapter.chapter_order, + act_id: chapter.act_id, + incident_id: chapter.incident_id.clone(), + plot_point_id: chapter.plot_point_id.clone(), + summary: chapter.summary.clone(), + goal: chapter.goal.clone(), + }).collect()), + }); + + acts.push(ActProps { + id: 2, + summary: Some(String::new()), + incidents: Some(book_incidents), + plot_points: None, + chapters: None, + }); + + acts.push(ActProps { + id: 3, + summary: Some(String::new()), + incidents: None, + plot_points: Some(book_plot_points), + chapters: None, + }); + + acts.push(ActProps { + id: 4, + summary: Some(String::new()), + incidents: None, + plot_points: None, + chapters: Some(act_chapters.iter().filter(|chapter| chapter.act_id == 4).map(|chapter| ActChapter { + chapter_info_id: chapter.chapter_info_id, + chapter_id: chapter.chapter_id.clone(), + title: chapter.title.clone(), + chapter_order: chapter.chapter_order, + act_id: chapter.act_id, + incident_id: chapter.incident_id.clone(), + plot_point_id: chapter.plot_point_id.clone(), + summary: chapter.summary.clone(), + goal: chapter.goal.clone(), + }).collect()), + }); + + acts.push(ActProps { + id: 5, + summary: Some(String::new()), + incidents: None, + plot_points: None, + chapters: Some(act_chapters.iter().filter(|chapter| chapter.act_id == 5).map(|chapter| ActChapter { + chapter_info_id: chapter.chapter_info_id, + chapter_id: chapter.chapter_id.clone(), + title: chapter.title.clone(), + chapter_order: chapter.chapter_order, + act_id: chapter.act_id, + incident_id: chapter.incident_id.clone(), + plot_point_id: chapter.plot_point_id.clone(), + summary: chapter.summary.clone(), + goal: chapter.goal.clone(), + }).collect()), + }); + + if !act_queries.is_empty() { + for act_query in &act_queries { + let act_index: usize = (act_query.act_index - 1) as usize; + if act_index < acts.len() { + acts[act_index].summary = if !act_query.summary.is_empty() { + Some(decrypt_data_with_user_key(&act_query.summary, &user_encryption_key)?) + } else { + Some(String::new()) + }; + } + } + } + + Ok(acts) +} + +/// Updates multiple acts including their summaries, incidents, plot points, and chapter information. +/// Encrypts all sensitive data before storing in the database. +/// * `conn` - Database connection +/// * `acts` - Array of act properties to update +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `user_key` - The user's encryption key for data encryption +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true when all updates are complete. +pub fn update_act( + conn: &Connection, acts: &[ActProps], user_id: &str, book_id: &str, user_key: &str, lang: Lang, +) -> AppResult { + for act in acts { + let act_incidents: &[IncidentProps] = act.incidents.as_deref().unwrap_or(&[]); + let act_id: i64 = act.id; + + if act_id == 1 || act_id == 4 || act_id == 5 { + let encrypted_act_summary: String = if let Some(ref summary) = act.summary { + if summary.is_empty() { String::new() } else { encrypt_data_with_user_key(summary, user_key)? } + } else { + String::new() + }; + let update_result = repo::update_act_summary(conn, user_id, book_id, act_id, &encrypted_act_summary, timestamp_in_seconds(), lang); + if update_result.is_err() { + let new_act_summary_id: String = create_unique_id(None); + repo::insert_act_summary(conn, &new_act_summary_id, user_id, book_id, act_id, &encrypted_act_summary, timestamp_in_seconds(), lang)?; + } + if let Some(ref chapters) = act.chapters { + chapter_service::update_chapter_infos(conn, chapters, user_id, act_id, book_id, None, None, lang)?; + } + } else if act_id == 2 { + for incident in act_incidents { + let encrypted_incident_summary: String = if incident.summary.is_empty() { String::new() } else { encrypt_data_with_user_key(&incident.summary, user_key)? }; + let incident_id: &str = &incident.incident_id; + let incident_title: &str = &incident.title; + let hashed_incident_title: String = hash_element(incident_title); + let encrypted_incident_title: String = encrypt_data_with_user_key(incident_title, user_key)?; + incident_repo::update_incident(conn, user_id, book_id, incident_id, &encrypted_incident_title, &hashed_incident_title, &encrypted_incident_summary, timestamp_in_seconds(), lang)?; + if let Some(ref chapters) = incident.chapters { + chapter_service::update_chapter_infos(conn, chapters, user_id, act_id, book_id, Some(incident_id), None, lang)?; + } + } + } else { + let act_plot_points: &[PlotPointProps] = act.plot_points.as_deref().unwrap_or(&[]); + for plot_point in act_plot_points { + let encrypted_plot_point_summary: String = if plot_point.summary.is_empty() { String::new() } else { encrypt_data_with_user_key(&plot_point.summary, user_key)? }; + let plot_point_id: &str = &plot_point.plot_point_id; + let plot_point_title: &str = &plot_point.title; + let hashed_plot_point_title: String = hash_element(plot_point_title); + let encrypted_plot_point_title: String = encrypt_data_with_user_key(plot_point_title, user_key)?; + plotpoint_repo::update_plot_point(conn, user_id, book_id, plot_point_id, &encrypted_plot_point_title, &hashed_plot_point_title, &encrypted_plot_point_summary, timestamp_in_seconds(), lang)?; + if let Some(ref chapters) = plot_point.chapters { + chapter_service::update_chapter_infos(conn, chapters, user_id, act_id, book_id, None, Some(plot_point_id), lang)?; + } + } + } + } + + Ok(true) +} + +/// Updates the story structure including acts and main chapters. +/// Encrypts chapter titles and updates their order in the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `acts` - Array of act properties to update +/// * `main_chapters` - Array of main chapter properties to update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true when all updates are complete. +pub fn update_story( + conn: &Connection, user_id: &str, book_id: &str, acts: &[ActProps], main_chapters: &[ChapterProps], lang: Lang, +) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + update_act(conn, acts, user_id, book_id, &user_encryption_key, lang)?; + + for chapter in main_chapters { + let chapter_id: &str = &chapter.chapter_id; + let chapter_title: &str = &chapter.title; + let chapter_order: i64 = chapter.chapter_order; + update_chapter(conn, user_id, chapter_id, chapter_title, chapter_order, lang)?; + } + + Ok(true) +} diff --git a/src-tauri/src/domains/book/commands.rs b/src-tauri/src/domains/book/commands.rs new file mode 100644 index 0000000..cea8052 --- /dev/null +++ b/src-tauri/src/domains/book/commands.rs @@ -0,0 +1,494 @@ +use serde::Deserialize; +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::act::service as act_service; +use crate::domains::book::service; +use crate::domains::chapter::service as chapter_service; +use crate::domains::download::service as download_service; +use crate::domains::export::service as export_service; +use crate::domains::guideline::service as guideline_service; +use crate::domains::incident::service as incident_service; +use crate::domains::issue::service as issue_service; +use crate::domains::plotpoint::service as plotpoint_service; +use crate::domains::sync::service as sync_service; +use crate::domains::upload::service as upload_service; +use crate::domains::world::service as world_service; +use crate::error::AppError; +use crate::shared::session::SessionState; + +// ─── Helpers ────────────────────────────────────────── + +fn get_session(session: &State) -> Result<(String, crate::shared::types::Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +fn get_conn<'a>(db: &'a State, _user_id: &str) -> Result, AppError> { + db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e))) +} + +// ─── Book CRUD ──────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateBookData { + pub title: String, + pub sub_title: Option, + pub summary: Option, + pub book_type: String, + pub serie_id: Option, + pub desired_release_date: Option, + pub desired_word_count: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateBookBasicInfoData { + pub book_id: String, + pub title: String, + pub sub_title: String, + pub summary: String, + pub publication_date: String, + pub word_count: i64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteBookData { + pub id: String, + pub deleted_at: i64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateBookToolData { + pub book_id: String, + pub tool_name: String, + pub enabled: bool, +} + +#[tauri::command] +pub fn get_books(db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + service::get_books(conn, &user_id, lang) +} + +#[tauri::command] +pub fn get_book(book_id: String, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + service::get_book(conn, &user_id, &book_id, lang) +} + +#[tauri::command] +pub fn get_book_basic_information(book_id: String, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + service::get_book(conn, &user_id, &book_id, lang) +} + +#[tauri::command] +pub fn create_book(data: CreateBookData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + service::add_book(conn, None, &user_id, &data.title, data.sub_title.as_deref().unwrap_or(""), data.summary.as_deref().unwrap_or(""), &data.book_type, data.serie_id.unwrap_or(0), data.desired_release_date.as_deref(), data.desired_word_count.unwrap_or(0), lang) +} + +#[tauri::command] +pub fn update_book_basic_info(data: UpdateBookBasicInfoData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + service::update_book_basic_information(conn, &user_id, &data.title, &data.sub_title, &data.summary, Some(&data.publication_date), data.word_count, &data.book_id, lang) +} + +#[tauri::command] +pub fn delete_book(data: DeleteBookData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + service::remove_book(conn, &user_id, &data.id, data.deleted_at, lang) +} + +#[tauri::command] +pub fn update_book_tool_setting(data: UpdateBookToolData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + service::update_book_tool_setting(conn, &user_id, &data.book_id, &data.tool_name, data.enabled, lang) +} + +// ─── Story ──────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetStoryData { + pub book_id: String, +} + +#[tauri::command] +pub fn get_book_story(data: GetStoryData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + let acts = act_service::get_acts_data(conn, &user_id, &data.book_id, lang)?; + let issues = issue_service::get_issues_from_book(conn, &user_id, &data.book_id, lang)?; + let main_chapters = chapter_service::get_all_chapters_from_a_book(conn, &user_id, &data.book_id, lang)?; + Ok(serde_json::json!({ "acts": acts, "issues": issues, "mainChapter": main_chapters })) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateStoryData { + pub book_id: String, + pub acts: Vec, + pub main_chapters: Vec, +} + +#[tauri::command] +pub fn update_book_story(data: UpdateStoryData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + act_service::update_story(conn, &user_id, &data.book_id, &data.acts, &data.main_chapters, lang) +} + +// ─── Incidents ──────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddIncidentData { + pub book_id: String, + pub name: String, + pub incident_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveIncidentData { + pub book_id: String, + pub incident_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn add_incident(data: AddIncidentData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + incident_service::add_new_incident(conn, &user_id, &data.book_id, &data.name, lang, data.incident_id.as_deref()) +} + +#[tauri::command] +pub fn remove_incident(data: RemoveIncidentData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + incident_service::remove_incident(conn, &user_id, &data.book_id, &data.incident_id, data.deleted_at, lang) +} + +// ─── Plot Points ────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddPlotPointData { + pub book_id: String, + pub name: String, + pub incident_id: String, + pub plot_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemovePlotPointData { + pub plot_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn add_plot_point(data: AddPlotPointData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + plotpoint_service::add_new_plot_point(conn, &user_id, &data.book_id, &data.incident_id, &data.name, lang, data.plot_id.as_deref()) +} + +#[tauri::command] +pub fn remove_plot_point(data: RemovePlotPointData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + plotpoint_service::remove_plot_point(conn, &user_id, &data.book_id, &data.plot_id, data.deleted_at, lang) +} + +// ─── Issues ─────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddIssueData { + pub book_id: String, + pub name: String, + pub issue_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveIssueData { + pub book_id: String, + pub issue_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn add_issue(data: AddIssueData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + issue_service::add_new_issue(conn, &user_id, &data.book_id, &data.name, lang, data.issue_id.as_deref()) +} + +#[tauri::command] +pub fn remove_issue(data: RemoveIssueData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + issue_service::remove_issue(conn, &user_id, &data.book_id, &data.issue_id, data.deleted_at, lang) +} + +// ─── Worlds ─────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetWorldsData { + pub book_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddWorldData { + pub book_id: String, + pub world_name: String, + pub id: Option, + pub series_world_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddWorldElementData { + pub world_id: String, + pub element_name: String, + pub element_type: String, + pub id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveWorldElementData { + pub element_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateWorldDataCmd { + pub world: world_service::WorldProps, +} + +#[tauri::command] +pub fn get_worlds(data: GetWorldsData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + world_service::get_worlds(conn, &user_id, &data.book_id, lang) +} + +#[tauri::command] +pub fn add_world(data: AddWorldData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + world_service::add_new_world(conn, &user_id, &data.book_id, &data.world_name, lang, data.id.as_deref(), data.series_world_id.as_deref()) +} + +#[tauri::command] +pub fn add_world_element(data: AddWorldElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + world_service::add_new_element_to_world(conn, &user_id, &data.world_id, &data.element_name, &data.element_type, lang, data.id.as_deref()) +} + +#[tauri::command] +pub fn remove_world_element(data: RemoveWorldElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + world_service::remove_element_from_world(conn, &user_id, &data.book_id, &data.element_id, data.deleted_at, lang) +} + +#[tauri::command] +pub fn update_world(data: UpdateWorldDataCmd, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + world_service::update_world(conn, &user_id, &data.world, lang) +} + +// ─── Guidelines ─────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetGuidelineData { + pub id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateGuidelineData { + pub book_id: String, + pub tone: Option, + pub atmosphere: Option, + pub writing_style: Option, + pub themes: Option, + pub symbolism: Option, + pub motifs: Option, + pub narrative_voice: Option, + pub pacing: Option, + pub intended_audience: Option, + pub key_messages: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAIGuidelineData { + pub book_id: String, + pub narrative_type: i64, + pub dialogue_type: i64, + pub plot_summary: String, + pub tone_atmosphere: String, + pub verb_tense: i64, + pub language: i64, + pub themes: String, +} + +#[tauri::command] +pub fn get_guideline(data: GetGuidelineData, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + guideline_service::get_guide_line(conn, &user_id, &data.id, lang) +} + +#[tauri::command] +pub fn update_guideline(data: UpdateGuidelineData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + guideline_service::update_guide_line(conn, &user_id, &data.book_id, data.tone.as_deref(), data.atmosphere.as_deref(), data.writing_style.as_deref(), data.themes.as_deref(), data.symbolism.as_deref(), data.motifs.as_deref(), data.narrative_voice.as_deref(), data.pacing.as_deref(), data.key_messages.as_deref(), data.intended_audience.as_deref(), lang) +} + +#[tauri::command] +pub fn get_ai_guideline(data: GetGuidelineData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + guideline_service::get_guide_line_ai(conn, &user_id, &data.id, lang) +} + +#[tauri::command] +pub fn update_ai_guideline(data: UpdateAIGuidelineData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + guideline_service::set_ai_guide_line(conn, &user_id, &data.book_id, data.narrative_type, data.dialogue_type, &data.plot_summary, &data.tone_atmosphere, data.verb_tense, data.language, &data.themes, lang) +} + +// ─── Export ─────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportInfoData { + pub book_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportBookData { + pub book_id: String, + pub format: String, + pub selections: Option>, +} + +#[tauri::command] +pub fn get_book_export_info(data: ExportInfoData, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + chapter_service::get_chapters_export_info(conn, &user_id, &data.book_id, lang) +} + +#[tauri::command] +pub fn export_book(data: ExportBookData, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + let book_data = chapter_service::get_complete_book_data_with_selections(conn, &user_id, &data.book_id, data.selections.as_deref(), lang)?; + match data.format.as_str() { + "epub" => { let result = export_service::transform_to_epub(&book_data)?; Ok(result.buffer) }, + "pdf" => { let result = export_service::transform_to_pdf(&book_data)?; Ok(result.buffer) }, + "docx" => { let result = export_service::transform_to_docx(&book_data)?; Ok(result.buffer) }, + _ => Err(AppError::Validation(if lang == crate::shared::types::Lang::Fr { "Format non supporté.".to_string() } else { "Unsupported format.".to_string() })), + } +} + +// ─── Sync ───────────────────────────────────────────── + +#[tauri::command] +pub fn get_synced_books(db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + sync_service::get_synced_books(conn, &user_id, lang) +} + +#[tauri::command] +pub fn upload_book_to_server(book_id: String, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + upload_service::upload_book_for_sync(conn, &user_id, &book_id, lang) +} + +#[tauri::command] +pub fn sync_save_book(data: service::CompleteBook, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + download_service::save_complete_book(conn, &user_id, &data, lang) +} + +#[tauri::command] +pub fn sync_book_to_client(data: service::CompleteBook, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + sync_service::sync_book_from_server_to_client(conn, &user_id, &data, lang) +} + +#[tauri::command] +pub fn sync_book_to_server(data: service::BookSyncCompare, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = get_conn(&db, &user_id)?; + let conn = db_manager.get_connection(&user_id)?; + sync_service::get_complete_sync_book(conn, &user_id, &data, lang) +} diff --git a/src-tauri/src/domains/book/mod.rs b/src-tauri/src/domains/book/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/book/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/book/repo.rs b/src-tauri/src/domains/book/repo.rs new file mode 100644 index 0000000..056dbcf --- /dev/null +++ b/src-tauri/src/domains/book/repo.rs @@ -0,0 +1,509 @@ +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct BookQuery { + pub book_id: String, + pub book_type: String, + pub author_id: String, + pub title: String, + pub hashed_title: String, + pub sub_title: Option, + pub hashed_sub_title: Option, + pub summary: Option, + pub serie_id: Option, + pub desired_release_date: Option, + pub desired_word_count: Option, + pub words_count: Option, + pub cover_image: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EritBooksTable { + pub book_id: String, + pub book_type: String, + pub author_id: String, + pub title: String, + pub hashed_title: String, + pub sub_title: Option, + pub hashed_sub_title: Option, + pub summary: Option, + pub serie_id: Option, + pub desired_release_date: Option, + pub desired_word_count: Option, + pub words_count: Option, + pub last_update: i64, + pub cover_image: Option, +} + +pub struct SyncedBookResult { + pub book_id: String, + pub book_type: String, + pub title: String, + pub sub_title: Option, + pub last_update: i64, +} + + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookToolsTable { + pub book_id: String, + pub user_id: String, + pub characters_enabled: i64, + pub worlds_enabled: i64, + pub locations_enabled: i64, + pub spells_enabled: i64, + pub last_update: i64, +} + +pub struct SyncedBookToolsResult { + pub last_update: i64, + pub characters_enabled: i64, + pub worlds_enabled: i64, + pub locations_enabled: i64, + pub spells_enabled: i64, +} + +/// Retrieves all books for a user. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `lang` - The language for error messages +/// Returns list of user's books. +pub fn fetch_books(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT book_id, type, author_id, title, sub_title, summary, serie_id, desired_release_date, desired_word_count, words_count, cover_image FROM erit_books WHERE author_id = ?1 ORDER BY book_id DESC") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la liste des livres.".to_string() } else { "Unable to retrieve book list.".to_string() }))?; + + let books = statement + .query_map(params![user_id], |query_row| { + Ok(BookQuery { + book_id: query_row.get(0)?, book_type: query_row.get(1)?, + author_id: query_row.get(2)?, title: query_row.get(3)?, + sub_title: query_row.get(4)?, summary: query_row.get(5)?, + serie_id: query_row.get(6)?, desired_release_date: query_row.get(7)?, + desired_word_count: query_row.get(8)?, words_count: query_row.get(9)?, + cover_image: query_row.get(10)?, hashed_title: String::new(), + hashed_sub_title: None, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la liste des livres.".to_string() } else { "Unable to retrieve book list.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la liste des livres.".to_string() } else { "Unable to retrieve book list.".to_string() }))?; + + Ok(books) +} + +/// Updates a book's cover image. +/// * `conn` - Database connection +/// * `book_id` - The book identifier +/// * `cover_image_name` - The cover image file name +/// * `user_id` - The user identifier +/// * `last_update` - The update timestamp +/// * `lang` - The language for error messages +/// Returns true if the update was successful. + +/// Retrieves a book by its identifier. +/// * `conn` - Database connection +/// * `book_id` - The book identifier +/// * `user_id` - The user identifier +/// * `lang` - The language for error messages +/// Returns the book information. +pub fn fetch_book(conn: &Connection, book_id: &str, user_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT book_id, author_id, title, summary, sub_title, cover_image, desired_release_date, desired_word_count, words_count, serie_id FROM erit_books WHERE book_id=?1 AND author_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations du livre.".to_string() } else { "Unable to retrieve book information.".to_string() }))?; + + let book = statement + .query_row(params![book_id, user_id], |query_row| { + Ok(BookQuery { + book_id: query_row.get(0)?, author_id: query_row.get(1)?, + title: query_row.get(2)?, summary: query_row.get(3)?, + sub_title: query_row.get(4)?, cover_image: query_row.get(5)?, + desired_release_date: query_row.get(6)?, desired_word_count: query_row.get(7)?, + words_count: query_row.get(8)?, serie_id: query_row.get(9)?, + book_type: String::new(), hashed_title: String::new(), + hashed_sub_title: None, + }) + }) + .map_err(|error| match error { + rusqlite::Error::QueryReturnedNoRows => AppError::NotFound(if lang == Lang::Fr { "Livre non trouvé.".to_string() } else { "Book not found.".to_string() }), + _ => AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations du livre.".to_string() } else { "Unable to retrieve book information.".to_string() }), + })?; + + Ok(book) +} + +/// Verifies if a book already exists for a user. +/// * `conn` - Database connection +/// * `hashed_title` - The hashed book title +/// * `hashed_sub_title` - The hashed book subtitle +/// * `user_id` - The user identifier +/// * `lang` - The language for error messages +/// Returns true if the book exists. +pub fn verify_book_exist(conn: &Connection, hashed_title: &str, hashed_sub_title: &str, user_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT book_id FROM erit_books WHERE hashed_title=?1 AND author_id=?2 AND hashed_sub_title=?3") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du livre.".to_string() } else { "Unable to verify book existence.".to_string() }))?; + + let exists = statement + .exists(params![hashed_title, user_id, hashed_sub_title]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du livre.".to_string() } else { "Unable to verify book existence.".to_string() }))?; + + Ok(exists) +} + +/// Inserts a new book into the database. +/// * `conn` - Database connection +/// * `book_id` - The book identifier +/// * `user_id` - The user identifier +/// * `encrypted_title` - The encrypted title +/// * `hashed_title` - The hashed title +/// * `encrypted_sub_title` - The encrypted subtitle +/// * `hashed_sub_title` - The hashed subtitle +/// * `encrypted_summary` - The encrypted summary +/// * `book_type` - The book type +/// * `serie` - The series identifier +/// * `publication_date` - The desired publication date +/// * `desired_word_count` - The desired word count +/// * `last_update` - The creation timestamp +/// * `lang` - The language for error messages +/// Returns the created book identifier. +pub fn insert_book( + conn: &Connection, book_id: &str, user_id: &str, encrypted_title: &str, hashed_title: &str, + encrypted_sub_title: &str, hashed_sub_title: &str, encrypted_summary: &str, book_type: &str, + serie: i64, publication_date: Option<&str>, desired_word_count: i64, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute("INSERT INTO erit_books (book_id, type, author_id, title, hashed_title, sub_title, hashed_sub_title, summary, serie_id, desired_release_date, desired_word_count, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", params![book_id, book_type, user_id, encrypted_title, hashed_title, encrypted_sub_title, hashed_sub_title, encrypted_summary, serie, publication_date, desired_word_count, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le livre.".to_string() } else { "Unable to add book.".to_string() }))?; + + if insert_result > 0 { + Ok(book_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Erreur lors de l'ajout du livre.".to_string() } else { "Error adding book.".to_string() })) + } +} + +/// Retrieves a book's cover image. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `lang` - The language for error messages +/// Returns the cover information. + +/// Updates a book's basic information. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `title` - The new title +/// * `hashed_title` - The hashed title +/// * `sub_title` - The new subtitle +/// * `hashed_sub_title` - The hashed subtitle +/// * `summary` - The new summary +/// * `publication_date` - The new publication date +/// * `word_count` - The new desired word count +/// * `book_id` - The book identifier +/// * `last_update` - The update timestamp +/// * `lang` - The language for error messages +/// Returns true if the update was successful. +pub fn update_book_basic_information( + conn: &Connection, user_id: &str, title: &str, hashed_title: &str, sub_title: &str, + hashed_sub_title: &str, summary: &str, publication_date: Option<&str>, word_count: i64, + book_id: &str, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute("UPDATE erit_books SET title=?1, hashed_title=?2, sub_title=?3, hashed_sub_title=?4, summary=?5, serie_id=?6, desired_release_date=?7, desired_word_count=?8, last_update=?9 WHERE author_id=?10 AND book_id=?11", params![title, hashed_title, sub_title, hashed_sub_title, summary, 0, publication_date, word_count, last_update, user_id, book_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour les informations du livre.".to_string() } else { "Unable to update book information.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Deletes a book from the database. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier to delete +/// * `lang` - The language for error messages +/// Returns true if the deletion was successful. +pub fn delete_book(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM erit_books WHERE author_id=?1 AND book_id=?2", params![user_id, book_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le livre.".to_string() } else { "Unable to delete book.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Retrieves all columns from erit_books table for a book. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `lang` - The language for error messages +/// Returns the complete book data. +pub fn fetch_erit_books_table(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT book_id, type, author_id, title, hashed_title, sub_title, hashed_sub_title, summary, serie_id, desired_release_date, desired_word_count, words_count, cover_image, last_update FROM erit_books WHERE book_id=?1 AND author_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations du livre.".to_string() } else { "Unable to retrieve book information.".to_string() }))?; + + let rows = statement + .query_map(params![book_id, user_id], |query_row| { + Ok(EritBooksTable { + book_id: query_row.get(0)?, book_type: query_row.get(1)?, + author_id: query_row.get(2)?, title: query_row.get(3)?, + hashed_title: query_row.get(4)?, sub_title: query_row.get(5)?, + hashed_sub_title: query_row.get(6)?, summary: query_row.get(7)?, + serie_id: query_row.get(8)?, desired_release_date: query_row.get(9)?, + desired_word_count: query_row.get(10)?, words_count: query_row.get(11)?, + cover_image: query_row.get(12)?, last_update: query_row.get(13)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations du livre.".to_string() } else { "Unable to retrieve book information.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations du livre.".to_string() } else { "Unable to retrieve book information.".to_string() }))?; + + Ok(rows) +} + +/// Retrieves synced books for a user. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `lang` - The language for error messages +/// Returns list of books with sync information. +pub fn fetch_synced_books(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT book_id, type, title, sub_title, last_update FROM erit_books WHERE author_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres synchronisés.".to_string() } else { "Unable to retrieve synced books.".to_string() }))?; + + let rows = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedBookResult { + book_id: query_row.get(0)?, book_type: query_row.get(1)?, + title: query_row.get(2)?, sub_title: query_row.get(3)?, + last_update: query_row.get(4)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres synchronisés.".to_string() } else { "Unable to retrieve synced books.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres synchronisés.".to_string() } else { "Unable to retrieve synced books.".to_string() }))?; + + Ok(rows) +} + +/// Inserts a synced book from the server. +/// * `conn` - Database connection +/// * `book_id` - The book identifier +/// * `user_id` - The user identifier +/// * `book_type` - The book type +/// * `title` - The encrypted title +/// * `hashed_title` - The hashed title +/// * `sub_title` - The encrypted subtitle +/// * `hashed_sub_title` - The hashed subtitle +/// * `summary` - The encrypted summary +/// * `serie_id` - The series identifier +/// * `desired_release_date` - The desired release date +/// * `desired_word_count` - The desired word count +/// * `words_count` - The current word count +/// * `cover_image` - The cover image file name +/// * `last_update` - The last update timestamp +/// * `lang` - The language for error messages +/// Returns true if the insertion was successful. +pub fn insert_sync_book( + conn: &Connection, book_id: &str, user_id: &str, book_type: &str, title: &str, + hashed_title: &str, sub_title: Option<&str>, hashed_sub_title: Option<&str>, + summary: Option<&str>, serie_id: Option, desired_release_date: Option<&str>, + desired_word_count: Option, words_count: Option, cover_image: Option<&str>, + last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute("INSERT INTO erit_books (book_id, author_id, type, title, hashed_title, sub_title, hashed_sub_title, summary, serie_id, desired_release_date, desired_word_count, words_count, cover_image, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", params![book_id, user_id, book_type, title, hashed_title, sub_title, hashed_sub_title, summary, serie_id, desired_release_date, desired_word_count, words_count, cover_image, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le livre synchronisé.".to_string() } else { "Unable to insert synced book.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Retrieves a complete book by its identifier (without author verification). +/// * `conn` - Database connection +/// * `book_id` - The book identifier +/// * `lang` - The language for error messages +/// Returns the complete book data. +pub fn fetch_complete_book_by_id(conn: &Connection, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT * FROM erit_books WHERE book_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le livre complet.".to_string() } else { "Unable to retrieve complete book.".to_string() }))?; + + let rows = statement + .query_map(params![book_id], |query_row| { + Ok(EritBooksTable { + book_id: query_row.get(0)?, book_type: query_row.get(1)?, + author_id: query_row.get(2)?, title: query_row.get(3)?, + hashed_title: query_row.get(4)?, sub_title: query_row.get(5)?, + hashed_sub_title: query_row.get(6)?, summary: query_row.get(7)?, + serie_id: query_row.get(8)?, desired_release_date: query_row.get(9)?, + desired_word_count: query_row.get(10)?, words_count: query_row.get(11)?, + cover_image: query_row.get(12)?, last_update: query_row.get(13)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le livre complet.".to_string() } else { "Unable to retrieve complete book.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le livre complet.".to_string() } else { "Unable to retrieve complete book.".to_string() }))?; + + Ok(rows) +} + +/// Retrieves the book tools settings for a user and book. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `lang` - The language for error messages +/// Returns the book tools settings or None if not found. +pub fn fetch_book_tools(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT book_id, user_id, characters_enabled, worlds_enabled, locations_enabled, spells_enabled, last_update FROM book_tools WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les paramètres des outils.".to_string() } else { "Unable to fetch tools settings.".to_string() }))?; + + let result = statement + .query_row(params![user_id, book_id], |query_row| { + Ok(BookToolsTable { + book_id: query_row.get(0)?, user_id: query_row.get(1)?, + characters_enabled: query_row.get(2)?, worlds_enabled: query_row.get(3)?, + locations_enabled: query_row.get(4)?, spells_enabled: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }); + + match result { + Ok(row) => Ok(Some(row)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les paramètres des outils.".to_string() } else { "Unable to fetch tools settings.".to_string() })), + } +} + +/// Updates a book tool setting. If no row exists, inserts a new one. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `tool_name` - The tool column name (characters_enabled, worlds_enabled, locations_enabled, spells_enabled) +/// * `enabled` - Whether the tool is enabled +/// * `last_update` - The update timestamp +/// * `lang` - The language for error messages +/// Returns true if the update or insert was successful. +pub fn update_book_tool_setting(conn: &Connection, user_id: &str, book_id: &str, tool_name: &str, enabled: bool, last_update: i64, lang: Lang) -> AppResult { + let enabled_value: i64 = if enabled { 1 } else { 0 }; + + let update_query = format!("UPDATE book_tools SET {}=?1, last_update=?2 WHERE user_id=?3 AND book_id=?4", tool_name); + let update_result = conn + .execute(&update_query, params![enabled_value, last_update, user_id, book_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour les paramètres des outils.".to_string() } else { "Unable to update tools settings.".to_string() }))?; + + if update_result > 0 { + return Ok(true); + } + + let characters_value: i64 = if tool_name == "characters_enabled" { enabled_value } else { 0 }; + let worlds_value: i64 = if tool_name == "worlds_enabled" { enabled_value } else { 0 }; + let locations_value: i64 = if tool_name == "locations_enabled" { enabled_value } else { 0 }; + let spells_value: i64 = if tool_name == "spells_enabled" { enabled_value } else { 0 }; + + let insert_result = conn + .execute("INSERT INTO book_tools (book_id, user_id, characters_enabled, worlds_enabled, locations_enabled, spells_enabled, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![book_id, user_id, characters_value, worlds_value, locations_value, spells_value, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour les paramètres des outils.".to_string() } else { "Unable to update tools settings.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Upserts book tools settings during sync. +/// Inserts if not exists, updates if exists. +/// * `conn` - Database connection +/// * `book_id` - The book identifier +/// * `user_id` - The user identifier +/// * `characters_enabled` - Whether characters tool is enabled +/// * `worlds_enabled` - Whether worlds tool is enabled +/// * `locations_enabled` - Whether locations tool is enabled +/// * `spells_enabled` - Whether spells tool is enabled +/// * `last_update` - The last update timestamp +/// * `lang` - The language for error messages +/// Returns true if the upsert was successful, false on error. +pub fn insert_sync_book_tools(conn: &Connection, book_id: &str, user_id: &str, characters_enabled: i64, worlds_enabled: i64, locations_enabled: i64, spells_enabled: i64, last_update: i64, _lang: Lang) -> bool { + let result = conn.execute("INSERT INTO book_tools (book_id, user_id, characters_enabled, worlds_enabled, locations_enabled, spells_enabled, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT (book_id, user_id) DO UPDATE SET characters_enabled = excluded.characters_enabled, worlds_enabled = excluded.worlds_enabled, locations_enabled = excluded.locations_enabled, spells_enabled = excluded.spells_enabled, last_update = excluded.last_update", params![book_id, user_id, characters_enabled, worlds_enabled, locations_enabled, spells_enabled, last_update]); + + match result { + Ok(_) => true, + Err(error) => { + eprintln!("[BookRepository] DB Error: {}", error); + false + } + } +} + +/// Retrieves synced book tools for a user and book. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `lang` - The language for error messages +/// Returns the synced book tools settings or None if not found. +pub fn fetch_synced_book_tools(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT last_update, characters_enabled, worlds_enabled, locations_enabled, spells_enabled FROM book_tools WHERE user_id = ?1 AND book_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les paramètres des outils.".to_string() } else { "Unable to fetch tools settings.".to_string() }))?; + + let result = statement + .query_row(params![user_id, book_id], |query_row| { + Ok(SyncedBookToolsResult { + last_update: query_row.get(0)?, characters_enabled: query_row.get(1)?, + worlds_enabled: query_row.get(2)?, locations_enabled: query_row.get(3)?, + spells_enabled: query_row.get(4)?, + }) + }); + + match result { + Ok(row) => Ok(Some(row)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les paramètres des outils.".to_string() } else { "Unable to fetch tools settings.".to_string() })), + } +} + +/// Checks if a book exists for a user. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `lang` - The language for error messages +/// Returns true if the book exists, false otherwise. +pub fn is_book_exist(conn: &Connection, user_id: &str, book_id: &str, _lang: Lang) -> bool { + let result = conn.prepare("SELECT 1 FROM erit_books WHERE author_id = ?1 AND book_id = ?2 LIMIT 1"); + + match result { + Ok(mut statement) => { + statement.exists(params![user_id, book_id]).unwrap_or(false) + } + Err(error) => { + eprintln!("DB Error: {}", error); + false + } + } +} + +/// Retrieves the series_id for a book from series_books table. +/// * `conn` - Database connection +/// * `book_id` - The book identifier +/// * `lang` - The language for error messages +/// Returns the series_id or None if book is not in a series. +pub fn fetch_book_series_id(conn: &Connection, book_id: &str, _lang: Lang) -> Option { + let result = conn.prepare("SELECT series_id FROM series_books WHERE book_id = ?1 LIMIT 1"); + + match result { + Ok(mut statement) => { + let query_result = statement.query_row(params![book_id], |query_row| { + query_row.get::<_, String>(0) + }); + match query_result { + Ok(series_id) => if series_id.is_empty() { None } else { Some(series_id) }, + Err(_) => None, + } + } + Err(error) => { + eprintln!("DB Error: {}", error); + None + } + } +} diff --git a/src-tauri/src/domains/book/service.rs b/src-tauri/src/domains/book/service.rs new file mode 100644 index 0000000..197a63e --- /dev/null +++ b/src-tauri/src/domains/book/service.rs @@ -0,0 +1,975 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::book::repo; +use crate::domains::chapter::repo as chapter_repo; +use crate::domains::chapter::service as chapter_service; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::domains::user::repo as user_repo; +use crate::error::{AppError, AppResult}; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedBookTools { + pub last_update: i64, + pub characters_enabled: bool, + pub worlds_enabled: bool, + pub locations_enabled: bool, + pub spells_enabled: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BookToolsSettings { + pub characters: bool, + pub worlds: bool, + pub locations: bool, + pub spells: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BookProps { + pub book_id: String, + pub book_type: String, + pub author_id: String, + pub title: String, + pub sub_title: String, + pub summary: String, + pub serie_id: i64, + pub series_id: Option, + pub desired_release_date: String, + pub desired_word_count: i64, + pub word_count: i64, + pub cover_image: String, + pub book_meta: Option, + pub tools: Option, +} + +pub use chapter_service::CompleteChapterContent; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompleteBookData { + pub book_id: String, + pub title: String, + pub sub_title: String, + pub summary: String, + pub cover_image: String, + pub user_infos: BookUserInfos, + pub chapters: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BookUserInfos { + pub first_name: String, + pub last_name: String, + pub author_name: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedBook { + pub id: String, + pub book_type: String, + pub title: String, + pub sub_title: Option, + pub last_update: i64, + pub chapters: Vec, + pub characters: Vec, + pub locations: Vec, + pub worlds: Vec, + pub incidents: Vec, + pub plot_points: Vec, + pub issues: Vec, + pub act_summaries: Vec, + pub guide_line: Option, + pub ai_guide_line: Option, + pub book_tools: Option, + pub spells: Vec, + pub spell_tags: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookSyncCompare { + pub id: String, + pub chapters: Vec, + pub chapter_contents: Vec, + pub chapter_infos: Vec, + pub characters: Vec, + pub character_attributes: Vec, + pub locations: Vec, + pub location_elements: Vec, + pub location_sub_elements: Vec, + pub worlds: Vec, + pub world_elements: Vec, + pub incidents: Vec, + pub plot_points: Vec, + pub issues: Vec, + pub act_summaries: Vec, + pub guide_line: bool, + pub ai_guide_line: bool, + pub book_tools: bool, + pub spells: Vec, + pub spell_tags: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedChapter { + pub id: String, + pub title: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedCharacter { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedLocation { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedWorld { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedIncident { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedPlotPoint { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedIssue { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedActSummary { + pub id: String, + pub last_update: i64, +} + +#[derive(Serialize)] +pub struct SyncedGuideLine { + pub last_update: i64, +} + +#[derive(Serialize)] +pub struct SyncedAIGuideLine { + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSpell { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSpellTag { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompleteBook { + pub erit_books: Vec, + pub act_summaries: Vec, + pub ai_guide_line: Vec, + pub chapters: Vec, + pub chapter_contents: Vec, + pub chapter_infos: Vec, + pub characters: Vec, + pub character_attributes: Vec, + pub guide_line: Vec, + pub incidents: Vec, + pub issues: Vec, + pub locations: Vec, + pub plot_points: Vec, + pub worlds: Vec, + pub world_elements: Vec, + pub location_elements: Vec, + pub location_sub_elements: Vec, + pub book_tools: Vec, + pub spells: Vec, + pub spell_tags: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookActSummariesTable { + pub summary_id: String, + pub book_id: String, + pub user_id: String, + pub act_number: i64, + pub summary: String, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookAIGuideLineTable { + pub user_id: String, + pub book_id: String, + pub global_resume: Option, + pub themes: Option, + pub verbe_tense: Option, + pub narrative_type: Option, + pub langue: Option, + pub dialogue_type: Option, + pub tone: Option, + pub atmosphere: Option, + pub current_resume: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookChaptersTable { + pub chapter_id: String, + pub book_id: String, + pub user_id: String, + pub title: String, + pub hashed_title: String, + pub chapter_order: i64, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookChapterContentTable { + pub content_id: String, + pub chapter_id: String, + pub user_id: String, + pub content: Option, + pub version: i64, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookChapterInfosTable { + pub chapter_id: String, + pub user_id: String, + pub summary: Option, + pub notes: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookCharactersTable { + pub character_id: String, + pub book_id: String, + pub user_id: String, + pub first_name: String, + pub last_name: Option, + pub nickname: Option, + pub age: Option, + pub gender: Option, + pub species: Option, + pub nationality: Option, + pub status: Option, + pub title: Option, + pub category: String, + pub image: Option, + pub role: Option, + pub biography: Option, + pub history: Option, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookCharactersAttributesTable { + pub attr_id: String, + pub character_id: String, + pub user_id: String, + pub attribute_name: String, + pub attribute_value: String, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookGuideLineTable { + pub user_id: String, + pub book_id: String, + pub tone: Option, + pub atmosphere: Option, + pub writing_style: Option, + pub themes: Option, + pub symbolism: Option, + pub motifs: Option, + pub narrative_voice: Option, + pub pacing: Option, + pub intended_audience: Option, + pub key_messages: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookIncidentsTable { + pub incident_id: String, + pub chapter_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub description: Option, + pub incident_order: i64, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookIssuesTable { + pub issue_id: String, + pub chapter_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub description: Option, + pub issue_order: i64, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookLocationTable { + pub loc_id: String, + pub book_id: String, + pub user_id: String, + pub loc_name: String, + pub loc_original_name: String, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookPlotPointsTable { + pub plot_point_id: String, + pub chapter_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub description: Option, + pub plot_point_order: i64, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookWorldTable { + pub world_id: String, + pub book_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub history: Option, + pub politics: Option, + pub economy: Option, + pub religion: Option, + pub languages: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookWorldElementsTable { + pub element_id: String, + pub world_id: String, + pub user_id: String, + pub element_type: i64, + pub name: String, + pub original_name: String, + pub description: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocationElementTable { + pub element_id: String, + pub location_id: String, + pub user_id: String, + pub element_name: String, + pub original_name: String, + pub element_description: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocationSubElementTable { + pub sub_element_id: String, + pub element_id: String, + pub user_id: String, + pub sub_elem_name: String, + pub original_name: String, + pub sub_elem_description: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookSpellsTable { + pub spell_id: String, + pub book_id: String, + pub user_id: String, + pub name: String, + pub name_hash: String, + pub description: String, + pub appearance: String, + pub tags: String, + pub power_level: Option, + pub components: Option, + pub limitations: Option, + pub notes: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BookSpellTagsTable { + pub tag_id: String, + pub book_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub color: Option, + pub last_update: i64, +} + +// ===== SERIES TABLE INTERFACES (for sync) ===== + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesTable { + pub series_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub description: Option, + pub cover_image: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesBooksTable { + pub series_id: String, + pub book_id: String, + pub book_order: i64, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesCharactersTable { + pub character_id: String, + pub series_id: String, + pub user_id: String, + pub first_name: String, + pub last_name: Option, + pub nickname: Option, + pub age: Option, + pub gender: Option, + pub species: Option, + pub nationality: Option, + pub status: Option, + pub title: Option, + pub category: String, + pub image: Option, + pub role: Option, + pub biography: Option, + pub history: Option, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesCharacterAttributesTable { + pub attr_id: String, + pub character_id: String, + pub user_id: String, + pub attribute_name: String, + pub attribute_value: String, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesWorldsTable { + pub world_id: String, + pub series_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub history: Option, + pub politics: Option, + pub economy: Option, + pub religion: Option, + pub languages: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesWorldElementsTable { + pub element_id: String, + pub world_id: String, + pub user_id: String, + pub element_type: i64, + pub name: String, + pub original_name: String, + pub description: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesLocationsTable { + pub loc_id: String, + pub series_id: String, + pub user_id: String, + pub loc_name: String, + pub loc_original_name: String, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesLocationElementsTable { + pub element_id: String, + pub location_id: String, + pub user_id: String, + pub element_name: String, + pub original_name: String, + pub element_description: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesLocationSubElementsTable { + pub sub_element_id: String, + pub element_id: String, + pub user_id: String, + pub sub_elem_name: String, + pub original_name: String, + pub sub_elem_description: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesSpellsTable { + pub spell_id: String, + pub series_id: String, + pub user_id: String, + pub name: String, + pub name_hash: String, + pub description: String, + pub appearance: String, + pub tags: String, + pub power_level: Option, + pub components: Option, + pub limitations: Option, + pub notes: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesSpellTagsTable { + pub tag_id: String, + pub series_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub color: Option, + pub last_update: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompleteSeries { + pub series: Vec, + pub series_books: Vec, + pub series_characters: Vec, + pub series_character_attributes: Vec, + pub series_worlds: Vec, + pub series_world_elements: Vec, + pub series_locations: Vec, + pub series_location_elements: Vec, + pub series_location_sub_elements: Vec, + pub series_spells: Vec, + pub series_spell_tags: Vec, +} + +// ===== SYNCED SERIES INTERFACES (lightweight, for comparison) ===== + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSeriesBook { + pub book_id: String, + pub order: i64, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSeriesCharacterAttribute { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSeriesCharacter { + pub id: String, + pub name: String, + pub last_update: i64, + pub attributes: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSeriesWorldElement { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSeriesWorld { + pub id: String, + pub name: String, + pub last_update: i64, + pub elements: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSeriesLocationSubElement { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSeriesLocationElement { + pub id: String, + pub name: String, + pub last_update: i64, + pub sub_elements: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSeriesLocation { + pub id: String, + pub name: String, + pub last_update: i64, + pub elements: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSeriesSpell { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSeriesSpellTag { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedSeries { + pub id: String, + pub name: String, + pub description: Option, + pub last_update: i64, + pub books: Vec, + pub characters: Vec, + pub worlds: Vec, + pub locations: Vec, + pub spells: Vec, + pub spell_tags: Vec, +} + +// ===== FUNCTIONS ===== + +/// Retrieves all books for a specific user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages +/// Returns a list of decrypted book properties. +/// Errors if the user encryption key is not found. +pub fn get_books(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let user_key: String = get_user_encryption_key(user_id)?; + let books: Vec = repo::fetch_books(conn, user_id, lang)?; + + if books.is_empty() { + return Ok(vec![]); + } + + let mut book_props_list: Vec = Vec::with_capacity(books.len()); + for book in books { + let decrypted_title: String = decrypt_data_with_user_key(&book.title, &user_key)?; + let decrypted_sub_title: String = if let Some(ref sub_title) = book.sub_title { decrypt_data_with_user_key(sub_title, &user_key)? } else { String::new() }; + let decrypted_summary: String = if let Some(ref summary) = book.summary { decrypt_data_with_user_key(summary, &user_key)? } else { String::new() }; + + book_props_list.push(BookProps { + book_id: book.book_id, + book_type: book.book_type, + author_id: book.author_id, + title: decrypted_title, + sub_title: decrypted_sub_title, + summary: decrypted_summary, + serie_id: book.serie_id.unwrap_or(0), + series_id: None, + desired_release_date: book.desired_release_date.unwrap_or_default(), + desired_word_count: book.desired_word_count.unwrap_or(0), + word_count: book.words_count.unwrap_or(0), + cover_image: book.cover_image.unwrap_or_default(), + book_meta: None, + tools: None, + }); + } + + Ok(book_props_list) +} + +/// Adds a new book to the database. +/// * `conn` - Database connection +/// * `book_id` - The unique identifier for the book (optional, will be generated if None) +/// * `user_id` - The unique identifier of the user +/// * `title` - The title of the book +/// * `sub_title` - The subtitle of the book +/// * `summary` - The summary of the book +/// * `book_type` - The type/genre of the book +/// * `serie` - The series identifier +/// * `publication_date` - The desired publication date +/// * `desired_word_count` - The target word count +/// * `lang` - The language for error messages +/// Returns the book ID. +/// Errors if a book with the same title already exists. +pub fn add_book( + conn: &Connection, book_id: Option<&str>, user_id: &str, title: &str, sub_title: &str, + summary: &str, book_type: &str, serie: i64, publication_date: Option<&str>, + desired_word_count: i64, lang: Lang, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let encrypted_title: String = encrypt_data_with_user_key(title, &user_key)?; + let encrypted_sub_title: String = if sub_title.is_empty() { String::new() } else { encrypt_data_with_user_key(sub_title, &user_key)? }; + let encrypted_summary: String = if summary.is_empty() { String::new() } else { encrypt_data_with_user_key(summary, &user_key)? }; + let hashed_title: String = hash_element(title); + let hashed_sub_title: String = if sub_title.is_empty() { String::new() } else { hash_element(sub_title) }; + + let book_already_exists: bool = repo::verify_book_exist(conn, &hashed_title, &hashed_sub_title, user_id, lang)?; + if book_already_exists { + return Err(AppError::Internal(if lang == Lang::Fr { format!("Tu as déjà un livre intitulé {} - {}.", title, sub_title) } else { format!("You already have a book named {} - {}.", title, sub_title) })); + } + + let final_book_id: String = create_unique_id(book_id); + let last_update: i64 = timestamp_in_seconds(); + repo::insert_book(conn, &final_book_id, user_id, &encrypted_title, &hashed_title, &encrypted_sub_title, &hashed_sub_title, &encrypted_summary, book_type, serie, publication_date, desired_word_count, last_update, lang) +} + +/// Retrieves a single book by its ID. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages +/// Returns the decrypted book properties with tools settings. +pub fn get_book(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let book_data: repo::BookQuery = repo::fetch_book(conn, book_id, user_id, lang)?; + let book_tools: Option = repo::fetch_book_tools(conn, user_id, book_id, lang)?; + let series_id: Option = repo::fetch_book_series_id(conn, book_id, lang); + + let decrypted_title: String = decrypt_data_with_user_key(&book_data.title, &user_key)?; + let decrypted_sub_title: String = if let Some(ref sub_title) = book_data.sub_title { decrypt_data_with_user_key(sub_title, &user_key)? } else { String::new() }; + let decrypted_summary: String = if let Some(ref summary) = book_data.summary { decrypt_data_with_user_key(summary, &user_key)? } else { String::new() }; + + Ok(BookProps { + book_id: book_data.book_id, + book_type: book_data.book_type, + author_id: book_data.author_id, + title: decrypted_title, + sub_title: decrypted_sub_title, + summary: decrypted_summary, + serie_id: book_data.serie_id.unwrap_or(0), + series_id, + desired_release_date: book_data.desired_release_date.unwrap_or_default(), + desired_word_count: book_data.desired_word_count.unwrap_or(0), + word_count: book_data.words_count.unwrap_or(0), + cover_image: book_data.cover_image.unwrap_or_default(), + book_meta: None, + tools: Some(BookToolsSettings { + characters: book_tools.as_ref().map_or(false, |book_tools_row| book_tools_row.characters_enabled == 1), + worlds: book_tools.as_ref().map_or(false, |book_tools_row| book_tools_row.worlds_enabled == 1), + locations: book_tools.as_ref().map_or(false, |book_tools_row| book_tools_row.locations_enabled == 1), + spells: book_tools.as_ref().map_or(false, |book_tools_row| book_tools_row.spells_enabled == 1), + }), + }) +} + +/// Updates basic information for a book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `title` - The new title +/// * `sub_title` - The new subtitle +/// * `summary` - The new summary +/// * `publication_date` - The new publication date +/// * `word_count` - The new word count +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages +/// Returns true if the update was successful. +pub fn update_book_basic_information( + conn: &Connection, user_id: &str, title: &str, sub_title: &str, summary: &str, + publication_date: Option<&str>, word_count: i64, book_id: &str, lang: Lang, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let encrypted_title: String = encrypt_data_with_user_key(title, &user_key)?; + let encrypted_sub_title: String = if sub_title.is_empty() { String::new() } else { encrypt_data_with_user_key(sub_title, &user_key)? }; + let encrypted_summary: String = if summary.is_empty() { String::new() } else { encrypt_data_with_user_key(summary, &user_key)? }; + let hashed_title: String = hash_element(title); + let hashed_sub_title: String = if sub_title.is_empty() { String::new() } else { hash_element(sub_title) }; + let last_update: i64 = timestamp_in_seconds(); + repo::update_book_basic_information(conn, user_id, &encrypted_title, &hashed_title, &encrypted_sub_title, &hashed_sub_title, &encrypted_summary, publication_date, word_count, book_id, last_update, lang) +} + +/// Removes a book from the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book to remove +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if the book was removed. +pub fn remove_book(conn: &Connection, user_id: &str, book_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_book(conn, user_id, book_id, lang)?; + if deleted { + tombstone_repo::insert(conn, book_id, "erit_books", book_id, None, user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Updates a book tool setting (characters, worlds, locations, or spells). +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `tool_name` - The tool name ("characters", "worlds", "locations", "spells") +/// * `enabled` - Whether the tool is enabled +/// * `lang` - The language for error messages +/// Returns true if the update was successful. +pub fn update_book_tool_setting(conn: &Connection, user_id: &str, book_id: &str, tool_name: &str, enabled: bool, lang: Lang) -> AppResult { + let column_name: String = format!("{}_enabled", tool_name); + let last_update: i64 = timestamp_in_seconds(); + repo::update_book_tool_setting(conn, user_id, book_id, &column_name, enabled, last_update, lang) +} + +/// Retrieves complete book data including chapters and user information. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages +/// Returns the complete book data with decrypted content. +pub fn complete_book_data(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let book_data: repo::BookQuery = repo::fetch_book(conn, book_id, user_id, lang)?; + let chapters: Vec = chapter_repo::fetch_complete_book_chapters(conn, book_id, lang)?; + let user_key: String = get_user_encryption_key(user_id)?; + let user_infos: user_repo::UserAccountQuery = user_repo::fetch_account_information(conn, user_id, lang)?; + + let book_title: String = decrypt_data_with_user_key(&book_data.title, &user_key)?; + let mut decrypted_chapters: Vec = Vec::with_capacity(chapters.len()); + + for chapter in chapters { + let decrypted_title: String = if chapter.title.is_empty() { String::new() } else { decrypt_data_with_user_key(&chapter.title, &user_key)? }; + let decrypted_content: String = if let Some(ref content) = chapter.content { decrypt_data_with_user_key(content, &user_key)? } else { String::new() }; + decrypted_chapters.push(CompleteChapterContent { + id: String::new(), + title: decrypted_title, + content: decrypted_content, + order: chapter.chapter_order, + version: None, + }); + } + + let cover_image: String = book_data.cover_image.unwrap_or_default(); + + Ok(CompleteBookData { + book_id: book_id.to_string(), + title: book_title, + sub_title: if let Some(ref sub_title) = book_data.sub_title { decrypt_data_with_user_key(sub_title, &user_key)? } else { String::new() }, + summary: if let Some(ref summary) = book_data.summary { decrypt_data_with_user_key(summary, &user_key)? } else { String::new() }, + cover_image, + user_infos: BookUserInfos { + first_name: if let Some(ref first_name) = user_infos.first_name { decrypt_data_with_user_key(first_name, &user_key)? } else { String::new() }, + last_name: if let Some(ref last_name) = user_infos.last_name { decrypt_data_with_user_key(last_name, &user_key)? } else { String::new() }, + author_name: if let Some(ref author_name) = user_infos.author_name { decrypt_data_with_user_key(author_name, &user_key)? } else { String::new() }, + }, + chapters: decrypted_chapters, + }) +} diff --git a/src-tauri/src/domains/chapter/commands.rs b/src-tauri/src/domains/chapter/commands.rs new file mode 100644 index 0000000..46ba438 --- /dev/null +++ b/src-tauri/src/domains/chapter/commands.rs @@ -0,0 +1,239 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::chapter::service; +use crate::error::AppError; +use crate::helpers::timestamp_in_seconds; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +// ─── Queries ────────────────────────────────────────── + +#[tauri::command] +pub fn get_chapters(book_id: String, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_all_chapters_from_a_book(conn, &user_id, &book_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetWholeChapterData { + pub id: String, + pub version: i64, + pub book_id: String, +} + +#[tauri::command] +pub fn get_whole_chapter(data: GetWholeChapterData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_whole_chapter(conn, &user_id, &data.id, data.version, Some(&data.book_id), lang) +} + +#[tauri::command] +pub fn get_chapter_story(chapter_id: String, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_chapter_story(conn, &user_id, &chapter_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetCompanionData { + pub chapter_id: String, + pub version: i64, +} + +#[tauri::command] +pub fn get_companion_content(data: GetCompanionData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_companion_content(conn, &user_id, &data.chapter_id, data.version, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetChapterContentData { + pub chapter_id: String, + pub version: i64, +} + +#[tauri::command] +pub fn get_chapter_content(data: GetChapterContentData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_chapter_content_by_version(conn, &user_id, &data.chapter_id, data.version, lang) +} + +// ─── Mutations ──────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveChapterContentData { + pub chapter_id: String, + pub version: i64, + pub content: Value, + pub total_word_count: i64, + pub content_id: String, +} + +#[tauri::command] +pub fn save_chapter_content(data: SaveChapterContentData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + let content_str = serde_json::to_string(&data.content).map_err(|e| AppError::Internal(format!("JSON serialize failed: {}", e)))?; + service::save_chapter_content(conn, &user_id, &data.chapter_id, data.version, &content_str, data.total_word_count, timestamp_in_seconds(), lang) +} + +#[tauri::command] +pub fn get_last_chapter(book_id: String, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_last_chapter(conn, &user_id, &book_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddChapterData { + pub book_id: String, + pub title: String, + pub chapter_order: i64, + pub chapter_id: Option, +} + +#[tauri::command] +pub fn add_chapter(data: AddChapterData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_chapter(conn, &user_id, &data.book_id, &data.title, 0, data.chapter_order, lang, data.chapter_id.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveChapterData { + pub chapter_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn remove_chapter(data: RemoveChapterData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::remove_chapter(conn, &user_id, &data.book_id, &data.chapter_id, data.deleted_at, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateChapterData { + pub chapter_id: String, + pub title: String, + pub chapter_order: i64, +} + +#[tauri::command] +pub fn update_chapter(data: UpdateChapterData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::update_chapter(conn, &user_id, &data.chapter_id, &data.title, data.chapter_order, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddChapterInfoData { + pub chapter_id: String, + pub act_id: i64, + pub book_id: String, + pub plot_id: Option, + pub incident_id: Option, + pub chapter_info_id: Option, +} + +#[tauri::command] +pub fn add_chapter_information(data: AddChapterInfoData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_chapter_information(conn, &user_id, &data.chapter_id, data.act_id, &data.book_id, data.plot_id.as_deref(), data.incident_id.as_deref(), lang, data.chapter_info_id.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveChapterInfoData { + pub chapter_info_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn remove_chapter_information(data: RemoveChapterInfoData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::remove_chapter_information(conn, &user_id, &data.book_id, &data.chapter_info_id, data.deleted_at, lang) +} + +// ─── Book Tags (aggregate) ─────────────────────────── + +#[derive(Serialize)] +pub struct Tag { + pub label: String, + pub value: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BookTags { + pub characters: Vec, + pub locations: Vec, + pub objects: Vec, + pub world_elements: Vec, +} + +#[tauri::command] +pub fn get_book_tags(book_id: String, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + + let character_response = crate::domains::character::service::get_character_list(conn, &user_id, &book_id, lang)?; + let character_tags: Vec = character_response.characters.into_iter().map(|character| Tag { label: character.name, value: character.id }).collect(); + + let location_elements = crate::domains::location::service::get_location_tags(conn, &user_id, &book_id, lang)?; + let location_tags: Vec = location_elements.into_iter().map(|element| Tag { label: element.name, value: element.id }).collect(); + + let spell_response = crate::domains::spell::service::get_spell_list(conn, &user_id, &book_id, lang)?; + let object_tags: Vec = spell_response.spells.into_iter().map(|spell| Tag { label: spell.name, value: spell.id }).collect(); + + let world_response = crate::domains::world::service::get_worlds(conn, &user_id, &book_id, lang)?; + let mut world_tags: Vec = Vec::new(); + for world in &world_response.worlds { + for element_list in [&world.laws, &world.biomes, &world.issues, &world.customs, &world.kingdoms, &world.climate, &world.resources, &world.wildlife, &world.arts, &world.ethnic_groups, &world.social_classes, &world.important_characters] { + for element in element_list { + world_tags.push(Tag { label: element.name.clone(), value: element.id.clone() }); + } + } + } + + Ok(BookTags { characters: character_tags, locations: location_tags, objects: object_tags, world_elements: world_tags }) +} diff --git a/src-tauri/src/domains/chapter/mod.rs b/src-tauri/src/domains/chapter/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/chapter/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/chapter/repo.rs b/src-tauri/src/domains/chapter/repo.rs new file mode 100644 index 0000000..6c1b1d8 --- /dev/null +++ b/src-tauri/src/domains/chapter/repo.rs @@ -0,0 +1,592 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::helpers::timestamp_in_seconds; +use crate::shared::types::Lang; + +pub struct ChapterQueryResult { + pub chapter_id: String, + pub title: String, + pub chapter_order: i64, +} + +pub struct ActChapterQuery { + pub chapter_info_id: i64, + pub chapter_id: String, + pub title: String, + pub chapter_order: i64, + pub act_id: i64, + pub incident_id: Option, + pub plot_point_id: Option, + pub summary: String, + pub goal: String, +} + +pub struct ChapterStoryQueryResult { + pub chapter_info_id: i64, + pub act_id: i64, + pub summary: String, + pub chapter_summary: String, + pub chapter_goal: String, + pub incident_id: Option, + pub incident_title: Option, + pub incident_summary: Option, + pub plot_point_id: Option, + pub plot_title: Option, + pub plot_summary: Option, +} + +pub struct LastChapterResult { + pub chapter_id: String, + pub version: i64, +} + +pub struct BookChaptersTable { + pub chapter_id: String, + pub book_id: String, + pub author_id: String, + pub title: String, + pub hashed_title: String, + pub words_count: Option, + pub chapter_order: i64, + pub last_update: i64, +} + +pub struct BookChapterInfosTable { + pub chapter_info_id: String, + pub chapter_id: String, + pub act_id: Option, + pub incident_id: Option, + pub plot_point_id: Option, + pub book_id: String, + pub author_id: String, + pub summary: Option, + pub goal: Option, + pub last_update: i64, +} + +pub struct SyncedChapterResult { + pub chapter_id: String, + pub book_id: String, + pub title: String, + pub last_update: i64, +} + +pub struct SyncedChapterInfoResult { + pub chapter_info_id: String, + pub chapter_id: Option, + pub book_id: String, + pub last_update: i64, +} + +pub struct ChapterBookResult { + pub title: String, + pub chapter_order: i64, + pub content: Option, +} + +pub struct ChapterExportInfoResult { + pub chapter_id: String, + pub title: String, + pub chapter_order: i64, + pub available_versions: String, +} + +pub struct SelectedChapterContentResult { + pub chapter_id: String, + pub title: String, + pub chapter_order: i64, + pub content: String, + pub version: i64, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChapterSelectionParam { + pub chapter_id: String, + pub version: i64, +} + +/// Checks if a chapter name already exists for a book. +pub fn check_name_duplication(conn: &Connection, user_id: &str, book_id: &str, hashed_title: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT chapter_id FROM book_chapters WHERE author_id=?1 AND book_id=?2 AND hashed_title=?3") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier la duplication du nom.".to_string() } else { "Unable to verify name duplication.".to_string() }))?; + + let exists = statement + .exists(params![user_id, book_id, hashed_title]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier la duplication du nom.".to_string() } else { "Unable to verify name duplication.".to_string() }))?; + + Ok(exists) +} + +/// Inserts a new chapter into the database. +pub fn insert_chapter(conn: &Connection, chapter_id: &str, user_id: &str, book_id: &str, title: &str, hashed_title: &str, words_count: i64, chapter_order: i64, lang: Lang) -> AppResult { + let insert_result = conn + .execute("INSERT INTO book_chapters (chapter_id, author_id, book_id, title, hashed_title, words_count, chapter_order, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", params![chapter_id, user_id, book_id, title, hashed_title, words_count, chapter_order, timestamp_in_seconds()]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le chapitre.".to_string() } else { "Unable to add chapter.".to_string() }))?; + + if insert_result > 0 { + Ok(chapter_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est passé lors de l'ajout du chapitre.".to_string() } else { "Error adding chapter.".to_string() })) + } +} + +/// Retrieves all chapters with their act information for a book. +pub fn fetch_all_chapter_for_acts(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT ci.chapter_info_id AS chapter_info_id, ci.chapter_id AS chapter_id, chapter.title, chapter.chapter_order, ci.act_id, ci.incident_id AS incident_id, ci.plot_point_id AS plot_point_id, ci.summary, ci.goal FROM book_chapter_infos AS ci INNER JOIN book_chapters AS chapter ON chapter.chapter_id = ci.chapter_id WHERE ci.book_id = ?1 AND ci.author_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres pour les actes.".to_string() } else { "Unable to retrieve chapters for acts.".to_string() }))?; + + let rows = statement + .query_map(params![book_id, user_id], |query_row| { + Ok(ActChapterQuery { + chapter_info_id: query_row.get(0)?, chapter_id: query_row.get(1)?, + title: query_row.get(2)?, chapter_order: query_row.get(3)?, + act_id: query_row.get(4)?, incident_id: query_row.get(5)?, + plot_point_id: query_row.get(6)?, summary: query_row.get(7)?, + goal: query_row.get(8)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres pour les actes.".to_string() } else { "Unable to retrieve chapters for acts.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres pour les actes.".to_string() } else { "Unable to retrieve chapters for acts.".to_string() }))?; + + Ok(rows) +} + +/// Retrieves all chapters from a book ordered by chapter order. +pub fn fetch_all_chapter_from_a_book(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT chapter_id, title, chapter_order FROM book_chapters WHERE book_id=?1 AND author_id=?2 ORDER BY chapter_order") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres.".to_string() } else { "Unable to retrieve chapters.".to_string() }))?; + + let rows = statement + .query_map(params![book_id, user_id], |query_row| { + Ok(ChapterQueryResult { + chapter_id: query_row.get(0)?, title: query_row.get(1)?, + chapter_order: query_row.get(2)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres.".to_string() } else { "Unable to retrieve chapters.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres.".to_string() } else { "Unable to retrieve chapters.".to_string() }))?; + + Ok(rows) +} + +/// Deletes a chapter from the database. +pub fn delete_chapter(conn: &Connection, user_id: &str, chapter_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM book_chapters WHERE author_id=?1 AND chapter_id=?2", params![user_id, chapter_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le chapitre.".to_string() } else { "Unable to delete chapter.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Inserts chapter information linking a chapter to an act. +pub fn insert_chapter_information( + conn: &Connection, chapter_info_id: &str, user_id: &str, chapter_id: &str, + act_id: i64, book_id: &str, plot_id: Option<&str>, incident_id: Option<&str>, lang: Lang, +) -> AppResult { + let mut statement = conn + .prepare("SELECT chapter_info_id FROM book_chapter_infos WHERE chapter_id=?1 AND act_id=?2 AND book_id=?3 AND plot_point_id=?4 AND incident_id=?5 AND author_id=?6") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'information du chapitre.".to_string() } else { "Unable to verify chapter information existence.".to_string() }))?; + + let existing = statement + .exists(params![chapter_id, act_id, book_id, plot_id, incident_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'information du chapitre.".to_string() } else { "Unable to verify chapter information existence.".to_string() }))?; + + if existing { + return Err(AppError::Internal(if lang == Lang::Fr { "Le chapitre est déjà lié.".to_string() } else { "Chapter is already linked.".to_string() })); + } + + let insert_result = conn + .execute("INSERT INTO book_chapter_infos (chapter_info_id, chapter_id, act_id, book_id, author_id, incident_id, plot_point_id, summary, goal, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", params![chapter_info_id, chapter_id, act_id, book_id, user_id, incident_id, plot_id, "", "", timestamp_in_seconds()]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter l'information du chapitre.".to_string() } else { "Unable to add chapter information.".to_string() }))?; + + if insert_result > 0 { + Ok(chapter_info_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite pendant la liaison du chapitre.".to_string() } else { "Error linking chapter.".to_string() })) + } +} + +/// Updates a chapter's basic information. +pub fn update_chapter(conn: &Connection, user_id: &str, chapter_id: &str, encrypted_title: &str, hash_title: &str, chapter_order: i64, last_update: i64, lang: Lang) -> AppResult { + let update_result = conn + .execute("UPDATE book_chapters SET title=?1, hashed_title=?2, chapter_order=?3, last_update=?4 WHERE author_id=?5 AND chapter_id=?6", params![encrypted_title, hash_title, chapter_order, last_update, user_id, chapter_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le chapitre.".to_string() } else { "Unable to update chapter.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Updates chapter information (summary and goal). +pub fn update_chapter_infos( + conn: &Connection, user_id: &str, chapter_id: &str, act_id: i64, book_id: &str, + incident_id: Option<&str>, plot_id: Option<&str>, summary: &str, goal: Option<&str>, + last_update: i64, lang: Lang, +) -> AppResult { + let mut query = "UPDATE book_chapter_infos SET summary=?1,goal=?2,last_update=?3 WHERE chapter_id = ?4 AND act_id = ?5 AND book_id = ?6".to_string(); + let mut param_values: Vec> = vec![ + Box::new(summary.to_string()), Box::new(goal.map(|g| g.to_string())), + Box::new(last_update), Box::new(chapter_id.to_string()), + Box::new(act_id), Box::new(book_id.to_string()), + ]; + + if let Some(incident) = incident_id { + let idx = param_values.len() + 1; + query.push_str(&format!(" AND incident_id=?{}", idx)); + param_values.push(Box::new(incident.to_string())); + } else { + query.push_str(" AND incident_id IS NULL"); + } + + if let Some(plot) = plot_id { + let idx = param_values.len() + 1; + query.push_str(&format!(" AND plot_point_id=?{}", idx)); + param_values.push(Box::new(plot.to_string())); + } else { + query.push_str(" AND plot_point_id IS NULL"); + } + + let idx = param_values.len() + 1; + query.push_str(&format!(" AND author_id=?{}", idx)); + param_values.push(Box::new(user_id.to_string())); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + let update_result = conn + .execute(&query, params_ref.as_slice()) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour les informations du chapitre.".to_string() } else { "Unable to update chapter information.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Retrieves the last opened chapter for a book. +pub fn fetch_last_chapter(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT chapter_id as chapter_id,version FROM user_last_chapter WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le dernier chapitre ouvert.".to_string() } else { "Unable to retrieve last opened chapter.".to_string() }))?; + + let result = statement + .query_row(params![user_id, book_id], |query_row| { + Ok(LastChapterResult { chapter_id: query_row.get(0)?, version: query_row.get(1)? }) + }); + + match result { + Ok(row) => Ok(Some(row)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le dernier chapitre ouvert.".to_string() } else { "Unable to retrieve last opened chapter.".to_string() })), + } +} + +/// Updates or inserts the last chapter record for a user. +pub fn update_last_chapter_record(conn: &Connection, user_id: &str, book_id: &str, chapter_id: &str, version: i64, lang: Lang) -> AppResult { + let update_result = conn + .execute("UPDATE user_last_chapter SET chapter_id=?1, version=?2 WHERE user_id=?3 AND book_id=?4", params![chapter_id, version, user_id, book_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'enregistrer le dernier chapitre.".to_string() } else { "Unable to save last chapter.".to_string() }))?; + + if update_result > 0 { + return Ok(true); + } + + let insert_result = conn + .execute("INSERT INTO user_last_chapter (user_id, book_id, chapter_id, version) VALUES (?1,?2,?3,?4)", params![user_id, book_id, chapter_id, version]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'enregistrer le dernier chapitre.".to_string() } else { "Unable to save last chapter.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Retrieves chapter story information including act, incident, and plot point data. +pub fn fetch_chapter_story(conn: &Connection, user_id: &str, chapter_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT chapter_info_id, chapter.act_id, act_sum.summary, chapter.summary AS chapter_summary, chapter.goal AS chapter_goal, chapter.incident_id, incident.title AS incident_title, incident.summary AS incident_summary, chapter.plot_point_id, plot.title AS plot_title, plot.summary AS plot_summary FROM book_chapter_infos AS chapter LEFT JOIN book_incidents AS incident ON chapter.incident_id=incident.incident_id LEFT JOIN book_plot_points AS plot ON chapter.plot_point_id=plot.plot_point_id LEFT JOIN book_act_summaries AS act_sum ON chapter.act_id=act_sum.act_sum_id AND chapter.book_id=act_sum.book_id WHERE chapter.chapter_id=?1 AND chapter.author_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'histoire du chapitre.".to_string() } else { "Unable to retrieve chapter story.".to_string() }))?; + + let rows = statement + .query_map(params![chapter_id, user_id], |query_row| { + Ok(ChapterStoryQueryResult { + chapter_info_id: query_row.get(0)?, act_id: query_row.get(1)?, + summary: query_row.get::<_, Option>(2)?.unwrap_or_default(), + chapter_summary: query_row.get::<_, Option>(3)?.unwrap_or_default(), + chapter_goal: query_row.get::<_, Option>(4)?.unwrap_or_default(), + incident_id: query_row.get(5)?, incident_title: query_row.get(6)?, + incident_summary: query_row.get(7)?, plot_point_id: query_row.get(8)?, + plot_title: query_row.get(9)?, plot_summary: query_row.get(10)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'histoire du chapitre.".to_string() } else { "Unable to retrieve chapter story.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'histoire du chapitre.".to_string() } else { "Unable to retrieve chapter story.".to_string() }))?; + + Ok(rows) +} + +/// Deletes chapter information by its identifier. +pub fn delete_chapter_information(conn: &Connection, user_id: &str, chapter_info_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM book_chapter_infos WHERE chapter_info_id=?1 AND author_id=?2", params![chapter_info_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer les informations du chapitre.".to_string() } else { "Unable to delete chapter information.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Checks if a chapter exists. +pub fn is_chapter_exist(conn: &Connection, user_id: &str, chapter_id: &str, _lang: Lang) -> bool { + let result = conn.prepare("SELECT 1 FROM book_chapters WHERE chapter_id=?1 AND author_id=?2"); + + match result { + Ok(mut statement) => statement.exists(params![chapter_id, user_id]).unwrap_or(false), + Err(error) => { + eprintln!("DB Error: {}", error); + false + } + } +} + +/// Checks if chapter info exists. +pub fn is_chapter_info_exist(conn: &Connection, user_id: &str, chapter_id: &str, _lang: Lang) -> bool { + let result = conn.prepare("SELECT 1 FROM book_chapter_infos WHERE chapter_id=?1 AND author_id=?2"); + + match result { + Ok(mut statement) => statement.exists(params![chapter_id, user_id]).unwrap_or(false), + Err(error) => { + eprintln!("DB Error: {}", error); + false + } + } +} + +/// Retrieves complete book chapters with their content. +pub fn fetch_complete_book_chapters(conn: &Connection, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT title, chapter_order, content.content FROM book_chapters AS chapter LEFT JOIN book_chapter_content AS content ON chapter.chapter_id = content.chapter_id AND content.version = (SELECT MAX(version) FROM book_chapter_content WHERE chapter_id = chapter.chapter_id AND version > 1) WHERE chapter.book_id = ?1 ORDER BY chapter.chapter_order") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres.".to_string() } else { "Unable to retrieve chapters.".to_string() }))?; + + let chapters = statement + .query_map(params![book_id], |query_row| { + Ok(ChapterBookResult { + title: query_row.get(0)?, chapter_order: query_row.get(1)?, + content: query_row.get(2)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres.".to_string() } else { "Unable to retrieve chapters.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres.".to_string() } else { "Unable to retrieve chapters.".to_string() }))?; + + if chapters.is_empty() { + return Err(AppError::NotFound(if lang == Lang::Fr { "Aucun chapitre trouvé.".to_string() } else { "No chapters found.".to_string() })); + } + + Ok(chapters) +} + +/// Retrieves all chapters for a book. +pub fn fetch_book_chapters(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT chapter_id, book_id, author_id, title, hashed_title, words_count, chapter_order, last_update FROM book_chapters WHERE author_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres.".to_string() } else { "Unable to retrieve chapters.".to_string() }))?; + + let rows = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookChaptersTable { + chapter_id: query_row.get(0)?, book_id: query_row.get(1)?, + author_id: query_row.get(2)?, title: query_row.get(3)?, + hashed_title: query_row.get(4)?, words_count: query_row.get(5)?, + chapter_order: query_row.get(6)?, last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres.".to_string() } else { "Unable to retrieve chapters.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres.".to_string() } else { "Unable to retrieve chapters.".to_string() }))?; + + Ok(rows) +} + +/// Retrieves chapter information for a specific chapter. +pub fn fetch_book_chapter_infos(conn: &Connection, user_id: &str, chapter_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT chapter_info_id, chapter_id, act_id, incident_id, plot_point_id, book_id, author_id, summary, goal, last_update FROM book_chapter_infos WHERE author_id=?1 AND chapter_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les infos des chapitres.".to_string() } else { "Unable to retrieve chapter infos.".to_string() }))?; + + let rows = statement + .query_map(params![user_id, chapter_id], |query_row| { + Ok(BookChapterInfosTable { + chapter_info_id: query_row.get(0)?, chapter_id: query_row.get(1)?, + act_id: query_row.get(2)?, incident_id: query_row.get(3)?, + plot_point_id: query_row.get(4)?, book_id: query_row.get(5)?, + author_id: query_row.get(6)?, summary: query_row.get(7)?, + goal: query_row.get(8)?, last_update: query_row.get(9)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les infos des chapitres.".to_string() } else { "Unable to retrieve chapter infos.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les infos des chapitres.".to_string() } else { "Unable to retrieve chapter infos.".to_string() }))?; + + Ok(rows) +} + +/// Retrieves synced chapters for a user. +pub fn fetch_synced_chapters(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT chapter_id, book_id, title, last_update FROM book_chapters WHERE author_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres synchronisés.".to_string() } else { "Unable to retrieve synced chapters.".to_string() }))?; + + let rows = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedChapterResult { + chapter_id: query_row.get(0)?, book_id: query_row.get(1)?, + title: query_row.get(2)?, last_update: query_row.get(3)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres synchronisés.".to_string() } else { "Unable to retrieve synced chapters.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les chapitres synchronisés.".to_string() } else { "Unable to retrieve synced chapters.".to_string() }))?; + + Ok(rows) +} + +/// Retrieves synced chapter infos for a user. +pub fn fetch_synced_chapter_infos(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT chapter_info_id, chapter_id, book_id, last_update FROM book_chapter_infos WHERE author_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les infos des chapitres synchronisés.".to_string() } else { "Unable to retrieve synced chapter infos.".to_string() }))?; + + let rows = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedChapterInfoResult { + chapter_info_id: query_row.get(0)?, chapter_id: query_row.get(1)?, + book_id: query_row.get(2)?, last_update: query_row.get(3)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les infos des chapitres synchronisés.".to_string() } else { "Unable to retrieve synced chapter infos.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les infos des chapitres synchronisés.".to_string() } else { "Unable to retrieve synced chapter infos.".to_string() }))?; + + Ok(rows) +} + +/// Inserts a synced chapter from the server. +pub fn insert_sync_chapter(conn: &Connection, chapter_id: &str, book_id: &str, author_id: &str, title: &str, hashed_title: Option<&str>, words_count: Option, chapter_order: Option, last_update: i64, lang: Lang) -> AppResult { + let insert_result = conn + .execute("INSERT INTO book_chapters (chapter_id, book_id, author_id, title, hashed_title, words_count, chapter_order, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![chapter_id, book_id, author_id, title, hashed_title, words_count, chapter_order, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le chapitre.".to_string() } else { "Unable to insert chapter.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Inserts synced chapter info from the server. +pub fn insert_sync_chapter_info( + conn: &Connection, chapter_info_id: &str, chapter_id: &str, act_id: Option, + incident_id: Option<&str>, plot_point_id: Option<&str>, book_id: &str, author_id: &str, + summary: Option<&str>, goal: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute("INSERT INTO book_chapter_infos (chapter_info_id, chapter_id, act_id, incident_id, plot_point_id, book_id, author_id, summary, goal, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![chapter_info_id, chapter_id, act_id, incident_id, plot_point_id, book_id, author_id, summary, goal, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer les infos du chapitre.".to_string() } else { "Unable to insert chapter info.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Retrieves a complete chapter by its identifier. +pub fn fetch_complete_chapter_by_id(conn: &Connection, chapter_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT chapter_id, book_id, author_id, title, hashed_title, words_count, chapter_order, last_update FROM book_chapters WHERE chapter_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le chapitre complet.".to_string() } else { "Unable to retrieve complete chapter.".to_string() }))?; + + let rows = statement + .query_map(params![chapter_id], |query_row| { + Ok(BookChaptersTable { + chapter_id: query_row.get(0)?, book_id: query_row.get(1)?, + author_id: query_row.get(2)?, title: query_row.get(3)?, + hashed_title: query_row.get(4)?, words_count: query_row.get(5)?, + chapter_order: query_row.get(6)?, last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le chapitre complet.".to_string() } else { "Unable to retrieve complete chapter.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le chapitre complet.".to_string() } else { "Unable to retrieve complete chapter.".to_string() }))?; + + Ok(rows) +} + +/// Retrieves complete chapter info by its identifier. +pub fn fetch_complete_chapter_info_by_id(conn: &Connection, chapter_info_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT chapter_info_id, chapter_id, act_id, incident_id, plot_point_id, book_id, author_id, summary, goal, last_update FROM book_chapter_infos WHERE chapter_info_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations de chapitre complètes.".to_string() } else { "Unable to retrieve complete chapter info.".to_string() }))?; + + let rows = statement + .query_map(params![chapter_info_id], |query_row| { + Ok(BookChapterInfosTable { + chapter_info_id: query_row.get(0)?, chapter_id: query_row.get(1)?, + act_id: query_row.get(2)?, incident_id: query_row.get(3)?, + plot_point_id: query_row.get(4)?, book_id: query_row.get(5)?, + author_id: query_row.get(6)?, summary: query_row.get(7)?, + goal: query_row.get(8)?, last_update: query_row.get(9)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations de chapitre complètes.".to_string() } else { "Unable to retrieve complete chapter info.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations de chapitre complètes.".to_string() } else { "Unable to retrieve complete chapter info.".to_string() }))?; + + Ok(rows) +} + +/// Retrieves chapter export information for a book. +pub fn fetch_chapters_export_info(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT bc.chapter_id, bc.title, bc.chapter_order, GROUP_CONCAT(DISTINCT bcc.version) AS available_versions FROM book_chapters bc LEFT JOIN book_chapter_content bcc ON bc.chapter_id = bcc.chapter_id WHERE bc.author_id = ?1 AND bc.book_id = ?2 GROUP BY bc.chapter_id, bc.title, bc.chapter_order ORDER BY bc.chapter_order") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations d'export des chapitres.".to_string() } else { "Unable to retrieve chapters export info.".to_string() }))?; + + let rows = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(ChapterExportInfoResult { + chapter_id: query_row.get(0)?, title: query_row.get(1)?, + chapter_order: query_row.get(2)?, + available_versions: query_row.get::<_, Option>(3)?.unwrap_or_default(), + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations d'export des chapitres.".to_string() } else { "Unable to retrieve chapters export info.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations d'export des chapitres.".to_string() } else { "Unable to retrieve chapters export info.".to_string() }))?; + + Ok(rows) +} + +/// Retrieves content for selected chapters with specific versions. +pub fn fetch_selected_chapters_content(conn: &Connection, book_id: &str, selections: &[ChapterSelectionParam], lang: Lang) -> AppResult> { + let conditions: Vec = selections.iter().enumerate().map(|(index, _)| { + let base = 2 + index * 2; + format!("(chapter.chapter_id = ?{} AND content.version = ?{})", base, base + 1) + }).collect(); + + let query = format!("SELECT chapter.chapter_id, chapter.title, chapter.chapter_order, content.content, content.version FROM book_chapters AS chapter INNER JOIN book_chapter_content AS content ON chapter.chapter_id = content.chapter_id WHERE chapter.book_id = ?1 AND ({}) ORDER BY chapter.chapter_order", conditions.join(" OR ")); + + let mut param_values: Vec> = vec![Box::new(book_id.to_string())]; + for selection in selections { + param_values.push(Box::new(selection.chapter_id.clone())); + param_values.push(Box::new(selection.version)); + } + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + + let mut statement = conn + .prepare(&query) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu des chapitres sélectionnés.".to_string() } else { "Unable to retrieve selected chapters content.".to_string() }))?; + + let rows = statement + .query_map(params_ref.as_slice(), |query_row| { + Ok(SelectedChapterContentResult { + chapter_id: query_row.get(0)?, title: query_row.get(1)?, + chapter_order: query_row.get(2)?, content: query_row.get(3)?, + version: query_row.get(4)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu des chapitres sélectionnés.".to_string() } else { "Unable to retrieve selected chapters content.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu des chapitres sélectionnés.".to_string() } else { "Unable to retrieve selected chapters content.".to_string() }))?; + + Ok(rows) +} diff --git a/src-tauri/src/domains/chapter/service.rs b/src-tauri/src/domains/chapter/service.rs new file mode 100644 index 0000000..095178a --- /dev/null +++ b/src-tauri/src/domains/chapter/service.rs @@ -0,0 +1,692 @@ +use std::collections::HashMap; + +use rusqlite::Connection; +use serde::Serialize; +use serde_json::Value; + +use crate::crypto::encryption::{decrypt_data_with_user_key, encrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::book::service as book_service; +use crate::domains::chapter::repo; +use crate::domains::chapter_content::repo as chapter_content_repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::{AppError, AppResult}; +use crate::helpers::{create_unique_id, html_to_text, timestamp_in_seconds}; +use crate::shared::types::Lang; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChapterContent { + pub version: i64, + pub content: String, + pub words_count: i64, +} + +pub struct ChapterContentData { + pub title: String, + pub chapter_order: i64, + pub content: String, + pub words_count: i64, + pub version: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChapterProps { + pub chapter_id: String, + pub title: String, + pub chapter_order: i64, + pub chapter_content: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompanionContent { + pub version: i64, + pub content: String, + pub words_count: i64, +} + +pub struct SyncedChapter { + pub id: String, + pub name: String, + pub last_update: i64, + pub contents: Vec, + pub info: Option, +} + +pub struct SyncedChapterContent { + pub id: String, + pub last_update: i64, +} + +pub struct SyncedChapterInfo { + pub id: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompleteChapterContent { + pub id: String, + pub title: String, + pub content: String, + pub order: i64, + pub version: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChapterExportInfo { + pub chapter_id: String, + pub title: String, + pub chapter_order: i64, + pub available_versions: Vec, +} + +pub use crate::domains::act::service::ActChapter; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IncidentStory { + pub incident_title: String, + pub incident_summary: String, + pub chapter_summary: String, + pub chapter_goal: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlotPointStory { + pub plot_title: String, + pub plot_summary: String, + pub chapter_summary: String, + pub chapter_goal: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActStory { + pub act_id: i64, + pub summary: String, + pub chapter_summary: String, + pub chapter_goal: String, + pub incidents: Vec, + pub plot_points: Vec, +} + +struct TipTapMark { + mark_type: String, + attrs: Option>, +} + +struct TipTapNode { + node_type: Option, + text: Option, + content: Option>, + attrs: Option>, + marks: Option>, +} + +/// Retrieves all chapters from a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages +/// Returns an array of ChapterProps containing chapter details. +pub fn get_all_chapters_from_a_book(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let chapter_query_results: Vec = repo::fetch_all_chapter_from_a_book(conn, user_id, book_id, lang)?; + let mut decrypted_chapters: Vec = Vec::new(); + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + for chapter_result in chapter_query_results { + let decrypted_title: String = decrypt_data_with_user_key(&chapter_result.title, &user_encryption_key)?; + decrypted_chapters.push(ChapterProps { + chapter_id: chapter_result.chapter_id, + title: decrypted_title, + chapter_order: chapter_result.chapter_order, + chapter_content: None, + }); + } + + Ok(decrypted_chapters) +} + +/// Retrieves all chapters organized by acts for a specific book. +/// Caches decrypted titles to avoid redundant decryption operations. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book + +/// Retrieves a complete chapter with its content for a specific version. +/// Optionally updates the last chapter record for the book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `chapter_id` - The unique identifier of the chapter +/// * `version` - The version number of the chapter content +/// * `book_id` - Optional book identifier to update last chapter record +/// * `lang` - The language for error messages +/// Returns ChapterProps containing chapter details and content. +pub fn get_whole_chapter(conn: &Connection, user_id: &str, chapter_id: &str, version: i64, book_id: Option<&str>, lang: Lang) -> AppResult { + let chapter_content_result: chapter_content_repo::ChapterContentQueryResult = chapter_content_repo::fetch_whole_chapter(conn, user_id, chapter_id, version, lang)?; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + if let Some(book_id_value) = book_id { + repo::update_last_chapter_record(conn, user_id, book_id_value, chapter_id, version, lang)?; + } + + Ok(ChapterProps { + chapter_id: chapter_content_result.chapter_id, + title: decrypt_data_with_user_key(&chapter_content_result.title, &user_encryption_key)?, + chapter_order: chapter_content_result.chapter_order, + chapter_content: Some(ChapterContent { + content: if chapter_content_result.content.is_empty() { String::new() } else { decrypt_data_with_user_key(&chapter_content_result.content, &user_encryption_key)? }, + version, + words_count: chapter_content_result.words_count, + }), + }) +} + +/// Saves the content of a chapter for a specific version. +/// Encrypts the content before storing it in the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `chapter_id` - The unique identifier of the chapter +/// * `version` - The version number of the chapter content +/// * `content` - The JSON content to save +/// * `words_count` - The word count of the content +/// * `current_time` - The current timestamp (unused, actual timestamp is generated) +/// * `lang` - The language for error messages +/// Returns true if the content was saved successfully, false otherwise. +pub fn save_chapter_content(conn: &Connection, user_id: &str, chapter_id: &str, version: i64, content: &str, words_count: i64, _current_time: i64, lang: Lang) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let encrypted_content: String = encrypt_data_with_user_key(content, &user_encryption_key)?; + chapter_content_repo::update_chapter_content(conn, user_id, chapter_id, version, &encrypted_content, words_count, timestamp_in_seconds(), lang) +} + +/// Retrieves the last accessed chapter for a specific book. +/// Falls back to the first chapter content if no last chapter record exists. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages +/// Returns ChapterProps containing chapter details and content, or None if no chapters exist. +pub fn get_last_chapter(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let last_chapter_record: Option = repo::fetch_last_chapter(conn, user_id, book_id, lang)?; + + if let Some(last_chapter) = last_chapter_record { + let chapter_props: ChapterProps = get_whole_chapter(conn, user_id, &last_chapter.chapter_id, last_chapter.version, Some(book_id), lang)?; + return Ok(Some(chapter_props)); + } + + let chapter_content_results: Vec = chapter_content_repo::fetch_last_chapter_content(conn, user_id, book_id, lang)?; + + if chapter_content_results.is_empty() { + return Ok(None); + } + + let first_chapter_content: &chapter_content_repo::ChapterContentQueryResult = &chapter_content_results[0]; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + Ok(Some(ChapterProps { + chapter_id: first_chapter_content.chapter_id.clone(), + title: if first_chapter_content.title.is_empty() { String::new() } else { decrypt_data_with_user_key(&first_chapter_content.title, &user_encryption_key)? }, + chapter_order: first_chapter_content.chapter_order, + chapter_content: Some(ChapterContent { + content: if first_chapter_content.content.is_empty() { String::new() } else { decrypt_data_with_user_key(&first_chapter_content.content, &user_encryption_key)? }, + version: first_chapter_content.version, + words_count: first_chapter_content.words_count, + }), + })) +} + +/// Adds a new chapter to a book. +/// Validates that the chapter name is unique within the book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `title` - The title of the new chapter +/// * `words_count` - The initial word count of the chapter +/// * `chapter_order` - The order position of the chapter +/// * `lang` - The language for error messages +/// * `existing_chapter_id` - Optional existing chapter ID for updates +/// Returns the unique identifier of the created chapter. +/// Errors if a chapter with the same name already exists. +pub fn add_chapter(conn: &Connection, user_id: &str, book_id: &str, title: &str, words_count: i64, chapter_order: i64, lang: Lang, existing_chapter_id: Option<&str>) -> AppResult { + let hashed_title: String = hash_element(title); + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let encrypted_title: String = encrypt_data_with_user_key(title, &user_encryption_key)?; + + if existing_chapter_id.is_none() && repo::check_name_duplication(conn, user_id, book_id, &hashed_title, lang)? { + return Err(AppError::Internal(if lang == Lang::Fr { "Ce nom de chapitre existe déjà.".to_string() } else { "This chapter name already exists.".to_string() })); + } + + let chapter_id: String = create_unique_id(existing_chapter_id); + repo::insert_chapter(conn, &chapter_id, user_id, book_id, &encrypted_title, &hashed_title, words_count, chapter_order, lang) +} + +/// Removes a chapter from the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `chapter_id` - The unique identifier of the chapter to remove +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if the chapter was removed successfully, false otherwise. +pub fn remove_chapter(conn: &Connection, user_id: &str, book_id: &str, chapter_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_chapter(conn, user_id, chapter_id, lang)?; + if deleted { + tombstone_repo::insert(conn, book_id, "book_chapters", chapter_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Adds chapter information linking a chapter to an act, plot point, and/or incident. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `chapter_id` - The unique identifier of the chapter +/// * `act_id` - The act number the chapter belongs to +/// * `book_id` - The unique identifier of the book +/// * `plot_id` - Optional plot point identifier +/// * `incident_id` - Optional incident identifier +/// * `lang` - The language for error messages +/// * `existing_chapter_info_id` - Optional existing chapter info ID for updates +/// Returns the unique identifier of the created chapter information. +pub fn add_chapter_information(conn: &Connection, user_id: &str, chapter_id: &str, act_id: i64, book_id: &str, plot_id: Option<&str>, incident_id: Option<&str>, lang: Lang, existing_chapter_info_id: Option<&str>) -> AppResult { + let chapter_info_id: String = create_unique_id(existing_chapter_info_id); + repo::insert_chapter_information(conn, &chapter_info_id, user_id, chapter_id, act_id, book_id, plot_id, incident_id, lang) +} + +/// Updates a chapter's title and order position. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `chapter_id` - The unique identifier of the chapter +/// * `title` - The new title for the chapter +/// * `chapter_order` - The new order position for the chapter +/// * `lang` - The language for error messages +/// Returns true if the chapter was updated successfully, false otherwise. +pub fn update_chapter(conn: &Connection, user_id: &str, chapter_id: &str, title: &str, chapter_order: i64, lang: Lang) -> AppResult { + let hashed_title: String = hash_element(title); + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let encrypted_title: String = encrypt_data_with_user_key(title, &user_encryption_key)?; + repo::update_chapter(conn, user_id, chapter_id, &encrypted_title, &hashed_title, chapter_order, timestamp_in_seconds(), lang) +} + +/// Updates chapter information for multiple chapters including summary and goal. +/// * `conn` - Database connection +/// * `chapters` - Array of ActChapter objects containing updated information +/// * `user_id` - The unique identifier of the user +/// * `act_id` - The act number the chapters belong to +/// * `book_id` - The unique identifier of the book +/// * `incident_id` - Optional incident identifier +/// * `plot_id` - Optional plot point identifier +/// * `lang` - The language for error messages +pub fn update_chapter_infos(conn: &Connection, chapters: &[ActChapter], user_id: &str, act_id: i64, book_id: &str, incident_id: Option<&str>, plot_id: Option<&str>, lang: Lang) -> AppResult<()> { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + for chapter_data in chapters { + let encrypted_summary: String = if chapter_data.summary.is_empty() { String::new() } else { encrypt_data_with_user_key(&chapter_data.summary, &user_encryption_key)? }; + let encrypted_goal: String = if chapter_data.goal.is_empty() { String::new() } else { encrypt_data_with_user_key(&chapter_data.goal, &user_encryption_key)? }; + let chapter_id: &str = &chapter_data.chapter_id; + repo::update_chapter_infos(conn, user_id, chapter_id, act_id, book_id, incident_id, plot_id, &encrypted_summary, Some(&encrypted_goal), timestamp_in_seconds(), lang)?; + } + + Ok(()) +} + +/// Retrieves the companion content for a chapter (previous version content). +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `chapter_id` - The unique identifier of the chapter +/// * `version` - The current version number (companion is version - 1) +/// * `lang` - The language for error messages +/// Returns CompanionContent containing the previous version's content. +pub fn get_companion_content(conn: &Connection, user_id: &str, chapter_id: &str, version: i64, lang: Lang) -> AppResult { + let companion_version: i64 = version - 1; + let companion_content_results: Vec = chapter_content_repo::fetch_companion_content(conn, user_id, chapter_id, companion_version, lang)?; + + if companion_content_results.is_empty() { + return Ok(CompanionContent { + version, + content: String::new(), + words_count: 0, + }); + } + + let companion_content_data: &chapter_content_repo::CompanionContentQueryResult = &companion_content_results[0]; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + Ok(CompanionContent { + version: companion_content_data.version, + content: if companion_content_data.content.is_empty() { String::new() } else { decrypt_data_with_user_key(&companion_content_data.content, &user_encryption_key)? }, + words_count: companion_content_data.words_count, + }) +} + +/// Retrieves the story context for a chapter including act summaries, incidents, and plot points. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `chapter_id` - The unique identifier of the chapter +/// * `lang` - The language for error messages +/// Returns an array of ActStory containing story context organized by act. +pub fn get_chapter_story(conn: &Connection, user_id: &str, chapter_id: &str, lang: Lang) -> AppResult> { + let chapter_story_results: Vec = repo::fetch_chapter_story(conn, user_id, chapter_id, lang)?; + let mut act_stories_map: HashMap = HashMap::new(); + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + for story_result in &chapter_story_results { + let act_id: i64 = story_result.act_id; + + if !act_stories_map.contains_key(&act_id) { + act_stories_map.insert(act_id, ActStory { + act_id, + summary: if story_result.summary.is_empty() { String::new() } else { decrypt_data_with_user_key(&story_result.summary, &user_encryption_key)? }, + chapter_summary: if story_result.chapter_summary.is_empty() { String::new() } else { decrypt_data_with_user_key(&story_result.chapter_summary, &user_encryption_key)? }, + chapter_goal: if story_result.chapter_goal.is_empty() { String::new() } else { decrypt_data_with_user_key(&story_result.chapter_goal, &user_encryption_key)? }, + incidents: Vec::new(), + plot_points: Vec::new(), + }); + } + + if let Some(ref _incident_id) = story_result.incident_id { + let decrypted_incident_title: String = if let Some(ref incident_title) = story_result.incident_title { decrypt_data_with_user_key(incident_title, &user_encryption_key)? } else { String::new() }; + let decrypted_incident_summary: String = if let Some(ref incident_summary) = story_result.incident_summary { decrypt_data_with_user_key(incident_summary, &user_encryption_key)? } else { String::new() }; + + let act_story = act_stories_map.get(&act_id).unwrap(); + let incident_already_exists: bool = act_story.incidents.iter().any( + |existing_incident| existing_incident.incident_title == decrypted_incident_title && existing_incident.incident_summary == decrypted_incident_summary + ); + + if !incident_already_exists { + let chapter_summary: String = if story_result.chapter_summary.is_empty() { String::new() } else { decrypt_data_with_user_key(&story_result.chapter_summary, &user_encryption_key)? }; + let chapter_goal: String = if story_result.chapter_goal.is_empty() { String::new() } else { decrypt_data_with_user_key(&story_result.chapter_goal, &user_encryption_key)? }; + let act_story_mut = act_stories_map.get_mut(&act_id).unwrap(); + act_story_mut.incidents.push(IncidentStory { + incident_title: decrypted_incident_title, + incident_summary: decrypted_incident_summary, + chapter_summary, + chapter_goal, + }); + } + } + + if let Some(ref _plot_point_id) = story_result.plot_point_id { + let decrypted_plot_title: String = if let Some(ref plot_title) = story_result.plot_title { decrypt_data_with_user_key(plot_title, &user_encryption_key)? } else { String::new() }; + let decrypted_plot_summary: String = if let Some(ref plot_summary) = story_result.plot_summary { decrypt_data_with_user_key(plot_summary, &user_encryption_key)? } else { String::new() }; + + let act_story = act_stories_map.get(&act_id).unwrap(); + let plot_point_already_exists: bool = act_story.plot_points.iter().any( + |existing_plot_point| existing_plot_point.plot_title == decrypted_plot_title && existing_plot_point.plot_summary == decrypted_plot_summary + ); + + if !plot_point_already_exists { + let chapter_summary: String = if story_result.chapter_summary.is_empty() { String::new() } else { decrypt_data_with_user_key(&story_result.chapter_summary, &user_encryption_key)? }; + let chapter_goal: String = if story_result.chapter_goal.is_empty() { String::new() } else { decrypt_data_with_user_key(&story_result.chapter_goal, &user_encryption_key)? }; + let act_story_mut = act_stories_map.get_mut(&act_id).unwrap(); + act_story_mut.plot_points.push(PlotPointStory { + plot_title: decrypted_plot_title, + plot_summary: decrypted_plot_summary, + chapter_summary, + chapter_goal, + }); + } + } + } + + Ok(act_stories_map.into_values().collect()) +} + +/// Retrieves the content of a specific chapter version. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `chapter_id` - The unique identifier of the chapter +/// * `version` - The version number of the content to retrieve +/// * `lang` - The language for error messages +/// Returns the decrypted content string, or empty string if not found. +pub fn get_chapter_content_by_version(conn: &Connection, user_id: &str, chapter_id: &str, version: i64, lang: Lang) -> AppResult { + let content_result: chapter_content_repo::ContentQueryResult = chapter_content_repo::fetch_chapter_content_by_version(conn, user_id, chapter_id, version, lang)?; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + if content_result.content.is_empty() { Ok(String::new()) } else { Ok(decrypt_data_with_user_key(&content_result.content, &user_encryption_key)?) } +} + +/// Removes chapter information by its identifier. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `chapter_info_id` - The unique identifier of the chapter information to remove +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if the chapter information was removed successfully, false otherwise. +pub fn remove_chapter_information(conn: &Connection, user_id: &str, book_id: &str, chapter_info_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_chapter_information(conn, user_id, chapter_info_id, lang)?; + if deleted { + tombstone_repo::insert(conn, book_id, "book_chapter_infos", chapter_info_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Converts TipTap JSON content to HTML string. +/// Handles various node types including paragraphs, headings, lists, and text marks. +/// * `tip_tap_content` - The TipTap JSON content to convert +/// Returns the converted HTML string. +pub fn tip_tap_to_html(tip_tap_content: &Value) -> String { + fn escape_html_characters(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") + } + + fn parse_marks(marks_value: &Value) -> Option> { + marks_value.as_array().map(|marks_array| { + marks_array.iter().map(|mark_value| { + TipTapMark { + mark_type: mark_value.get("type").and_then(|t| t.as_str()).unwrap_or("").to_string(), + attrs: mark_value.get("attrs").and_then(|a| a.as_object()).cloned(), + } + }).collect() + }) + } + + fn parse_node(node_value: &Value) -> TipTapNode { + let content: Option> = node_value.get("content").and_then(|c| c.as_array()).map(|children| { + children.iter().map(|child| parse_node(child)).collect() + }); + TipTapNode { + node_type: node_value.get("type").and_then(|t| t.as_str()).map(|s| s.to_string()), + text: node_value.get("text").and_then(|t| t.as_str()).map(|s| s.to_string()), + content, + attrs: node_value.get("attrs").and_then(|a| a.as_object()).cloned(), + marks: node_value.get("marks").map(|m| parse_marks(m)).flatten(), + } + } + + fn render_text_with_marks(text: &str, marks: &Option>) -> String { + let escaped_text: String = escape_html_characters(text); + match marks { + None => escaped_text, + Some(marks_list) if marks_list.is_empty() => escaped_text, + Some(marks_list) => { + let mut rendered_text: String = escaped_text; + for mark in marks_list { + match mark.mark_type.as_str() { + "bold" => rendered_text = format!("{}", rendered_text), + "italic" => rendered_text = format!("{}", rendered_text), + "underline" => rendered_text = format!("{}", rendered_text), + "strike" => rendered_text = format!("{}", rendered_text), + "code" => rendered_text = format!("{}", rendered_text), + "link" => { + let link_href: String = mark.attrs.as_ref() + .and_then(|attrs| attrs.get("href")) + .and_then(|href| href.as_str()) + .map(|href| escape_html_characters(href)) + .unwrap_or_else(|| "#".to_string()); + rendered_text = format!("{}", link_href, rendered_text); + } + _ => {} + } + } + rendered_text + } + } + } + + fn render_tip_tap_node(node: &TipTapNode) -> String { + if let Some(ref node_type) = node.node_type { + if node_type == "text" { + let text_content: &str = node.text.as_deref().unwrap_or("\u{00A0}"); + return render_text_with_marks(text_content, &node.marks); + } + } else { + return String::new(); + } + + let children_html: String = node.content.as_ref() + .map(|children| children.iter().map(|child| render_tip_tap_node(child)).collect::>().join("")) + .unwrap_or_default(); + + let text_align_style: String = node.attrs.as_ref() + .and_then(|attrs| attrs.get("textAlign")) + .and_then(|align| align.as_str()) + .map(|align| format!(" style=\"text-align: {}\"", align)) + .unwrap_or_default(); + + match node.node_type.as_deref().unwrap_or("") { + "doc" => children_html, + "paragraph" => { + let paragraph_content: &str = if children_html.is_empty() { "\u{00A0}" } else { &children_html }; + format!("{}

", text_align_style, paragraph_content) + } + "heading" => { + let heading_level: i64 = node.attrs.as_ref() + .and_then(|attrs| attrs.get("level")) + .and_then(|level| level.as_i64()) + .unwrap_or(1); + format!("{}", heading_level, text_align_style, children_html, heading_level) + } + "bulletList" => format!("
    {}
", children_html), + "orderedList" => format!("
    {}
", children_html), + "listItem" => format!("
  • {}
  • ", children_html), + "blockquote" => format!("
    {}
    ", children_html), + "codeBlock" => format!("
    {}
    ", children_html), + "hardBreak" => "
    ".to_string(), + "horizontalRule" => "
    ".to_string(), + _ => children_html, + } + } + + let content_node: TipTapNode = parse_node(tip_tap_content); + render_tip_tap_node(&content_node) +} + +/// Retrieves all chapters with their content data for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages +/// Returns an array of ChapterContentData containing chapter details with content. + +/// 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. +/// * `book_chapters` - Array of CompleteChapterContent from the book +/// Returns an array of ChapterContentData with processed content. +pub fn get_chapters_or_sheet(book_chapters: &[CompleteChapterContent]) -> Vec { + let mut processed_chapters: Vec = Vec::new(); + let sheet_content: Option<&CompleteChapterContent> = book_chapters.iter().find(|chapter| chapter.order == -1); + let regular_chapter: Option<&CompleteChapterContent> = book_chapters.iter().find(|chapter| chapter.order > 0); + + if sheet_content.is_some() && regular_chapter.is_none() { + let sheet: &CompleteChapterContent = sheet_content.unwrap(); + let parsed_content: Value = serde_json::from_str(&sheet.content).unwrap_or(Value::Null); + processed_chapters.push(ChapterContentData { + title: sheet.title.clone(), + chapter_order: sheet.order, + content: html_to_text(&tip_tap_to_html(&parsed_content)), + words_count: 0, + version: sheet.version.unwrap_or(0), + }); + } else if regular_chapter.is_some() { + for chapter_data in book_chapters { + if chapter_data.order < 0 { continue; } + let parsed_content: Value = serde_json::from_str(&chapter_data.content).unwrap_or(Value::Null); + processed_chapters.push(ChapterContentData { + title: chapter_data.title.clone(), + chapter_order: chapter_data.order, + content: html_to_text(&tip_tap_to_html(&parsed_content)), + words_count: 0, + version: chapter_data.version.unwrap_or(0), + }); + } + } + + processed_chapters +} + +/// Retrieves export information for all chapters of a book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages +/// Returns an array of ChapterExportInfo with available versions. +pub fn get_chapters_export_info(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let results: Vec = repo::fetch_chapters_export_info(conn, user_id, book_id, lang)?; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let mut export_infos: Vec = Vec::new(); + + for result in results { + if result.available_versions.is_empty() { continue; } + let mut versions: Vec = result.available_versions + .split(',') + .filter_map(|version_string| version_string.trim().parse::().ok()) + .collect(); + if versions.is_empty() { continue; } + versions.sort(); + export_infos.push(ChapterExportInfo { + chapter_id: result.chapter_id, + title: if result.title.is_empty() { String::new() } else { decrypt_data_with_user_key(&result.title, &user_encryption_key)? }, + chapter_order: result.chapter_order, + available_versions: versions, + }); + } + + Ok(export_infos) +} + +/// Retrieves complete book data with selected chapter versions. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `selections` - Optional array of chapter selections with specific versions +/// * `lang` - The language for error messages +/// Returns complete book data with the selected chapter contents. +pub fn get_complete_book_data_with_selections(conn: &Connection, user_id: &str, book_id: &str, selections: Option<&[repo::ChapterSelectionParam]>, lang: Lang) -> AppResult { + if selections.is_none() || selections.map_or(true, |s| s.is_empty()) { + return book_service::complete_book_data(conn, user_id, book_id, lang); + } + + let book_data: book_service::CompleteBookData = book_service::complete_book_data(conn, user_id, book_id, lang)?; + let selected_results: Vec = repo::fetch_selected_chapters_content(conn, book_id, selections.unwrap(), lang)?; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let mut selected_chapters: Vec = Vec::new(); + + for result in selected_results { + selected_chapters.push(book_service::CompleteChapterContent { + id: result.chapter_id, + title: if result.title.is_empty() { String::new() } else { decrypt_data_with_user_key(&result.title, &user_encryption_key)? }, + content: if result.content.is_empty() { String::new() } else { decrypt_data_with_user_key(&result.content, &user_encryption_key)? }, + order: result.chapter_order, + version: None, + }); + } + + Ok(book_service::CompleteBookData { + book_id: book_data.book_id, + title: book_data.title, + sub_title: book_data.sub_title, + summary: book_data.summary, + cover_image: book_data.cover_image, + user_infos: book_data.user_infos, + chapters: selected_chapters, + }) +} diff --git a/src-tauri/src/domains/chapter_content/mod.rs b/src-tauri/src/domains/chapter_content/mod.rs new file mode 100644 index 0000000..5608c55 --- /dev/null +++ b/src-tauri/src/domains/chapter_content/mod.rs @@ -0,0 +1,2 @@ +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/chapter_content/repo.rs b/src-tauri/src/domains/chapter_content/repo.rs new file mode 100644 index 0000000..14395f9 --- /dev/null +++ b/src-tauri/src/domains/chapter_content/repo.rs @@ -0,0 +1,302 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::helpers::create_unique_id; +use crate::shared::types::Lang; + +pub struct ChapterContentQueryResult { + pub chapter_id: String, + pub version: i64, + pub content: String, + pub words_count: i64, + pub title: String, + pub chapter_order: i64, +} + +pub struct ContentQueryResult { + pub content: String, +} + +pub struct CompanionContentQueryResult { + pub version: i64, + pub content: String, + pub words_count: i64, +} + +pub struct BookChapterContentTable { + pub content_id: String, + pub chapter_id: String, + pub author_id: String, + pub version: i64, + pub content: Option, + pub words_count: i64, + pub time_on_it: i64, + pub last_update: i64, +} + +pub struct SyncedChapterContentResult { + pub content_id: String, + pub chapter_id: String, + pub last_update: i64, +} + +/// Fetches the last chapter content for a given book. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `book_id` - The ID of the book +/// * `lang` - The language for error messages +/// Returns an array of chapter content results ordered by chapter order and version descending. +pub fn fetch_last_chapter_content(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT book_chapters.chapter_id as chapter_id, COALESCE(book_chapter_content.version, 2) AS version, COALESCE(book_chapter_content.content, '') AS content, COALESCE(book_chapter_content.words_count, 0) AS words_count, book_chapters.title, book_chapters.chapter_order FROM book_chapters LEFT JOIN book_chapter_content ON book_chapters.chapter_id = book_chapter_content.chapter_id WHERE book_chapters.author_id = ?1 AND book_chapters.book_id = ?2 ORDER BY book_chapters.chapter_order DESC, book_chapter_content.version DESC LIMIT 1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le dernier chapitre.".to_string() } else { "Unable to retrieve last chapter.".to_string() }))?; + + let rows = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(ChapterContentQueryResult { + chapter_id: query_row.get(0)?, version: query_row.get(1)?, + content: query_row.get(2)?, words_count: query_row.get(3)?, + title: query_row.get(4)?, chapter_order: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le dernier chapitre.".to_string() } else { "Unable to retrieve last chapter.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le dernier chapitre.".to_string() } else { "Unable to retrieve last chapter.".to_string() }))?; + + Ok(rows) +} + +/// Updates the content of a chapter. If no existing content is found, inserts a new record. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `chapter_id` - The ID of the chapter +/// * `version` - The version number of the content +/// * `encrypt_content` - The encrypted content string +/// * `words_count` - The word count of the content +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages +/// Returns true if the update or insert was successful. +pub fn update_chapter_content( + conn: &Connection, user_id: &str, chapter_id: &str, version: i64, + encrypt_content: &str, words_count: i64, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute("UPDATE book_chapter_content SET content=?1, words_count=?2, last_update=?3 WHERE chapter_id=?4 AND author_id=?5 AND version=?6", params![encrypt_content, words_count, last_update, chapter_id, user_id, version]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le contenu du chapitre.".to_string() } else { "Unable to update chapter content.".to_string() }))?; + + if update_result > 0 { + return Ok(true); + } + + let content_id = create_unique_id(None); + let insert_result = conn + .execute("INSERT INTO book_chapter_content (content_id, chapter_id, author_id, version, content, words_count, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7)", params![content_id, chapter_id, user_id, version, encrypt_content, words_count, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le contenu du chapitre.".to_string() } else { "Unable to update chapter content.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Fetches companion content for a specific chapter and version. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `chapter_id` - The ID of the chapter +/// * `version` - The version number to fetch +/// * `lang` - The language for error messages +/// Returns an array of companion content results. +pub fn fetch_companion_content(conn: &Connection, user_id: &str, chapter_id: &str, version: i64, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT version, content, words_count FROM book_chapter_content WHERE author_id=?1 AND chapter_id=?2 AND version=?3") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu compagnon.".to_string() } else { "Unable to retrieve companion content.".to_string() }))?; + + let rows = statement + .query_map(params![user_id, chapter_id, version], |query_row| { + Ok(CompanionContentQueryResult { + version: query_row.get(0)?, content: query_row.get(1)?, + words_count: query_row.get(2)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu compagnon.".to_string() } else { "Unable to retrieve companion content.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu compagnon.".to_string() } else { "Unable to retrieve companion content.".to_string() }))?; + + Ok(rows) +} + +/// Fetches chapter content by its order position within a book. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `chapter_order` - The order position of the chapter +/// * `book_id` - The ID of the book +/// * `lang` - The language for error messages +/// Returns the content query result for the specified chapter. + +/// Fetches chapter content by chapter ID and version number. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `chapter_id` - The ID of the chapter +/// * `version` - The version number to fetch +/// * `lang` - The language for error messages +/// Returns the content query result for the specified version. +pub fn fetch_chapter_content_by_version(conn: &Connection, user_id: &str, chapter_id: &str, version: i64, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT content FROM book_chapter_content WHERE author_id=?1 AND chapter_id=?2 AND version=?3") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu du chapitre.".to_string() } else { "Unable to retrieve chapter content.".to_string() }))?; + + let chapter_content = statement + .query_row(params![user_id, chapter_id, version], |query_row| { + Ok(ContentQueryResult { content: query_row.get(0)? }) + }) + .map_err(|error| match error { + rusqlite::Error::QueryReturnedNoRows => AppError::NotFound(if lang == Lang::Fr { "Aucun chapitre trouvé avec cette version.".to_string() } else { "No chapter found with this version.".to_string() }), + _ => AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu du chapitre.".to_string() } else { "Unable to retrieve chapter content.".to_string() }), + })?; + + Ok(chapter_content) +} + +/// Checks whether chapter content exists for a given content ID and user. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `content_id` - The ID of the content to check +/// * `lang` - The language for error messages +/// Returns true if the chapter content exists, false otherwise. +pub fn is_chapter_content_exist(conn: &Connection, user_id: &str, content_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_chapter_content WHERE content_id=?1 AND author_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du contenu du chapitre.".to_string() } else { "Unable to check chapter content existence.".to_string() }))?; + + let exists = statement + .exists(params![content_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du contenu du chapitre.".to_string() } else { "Unable to check chapter content existence.".to_string() }))?; + + Ok(exists) +} + +/// Fetches all chapter contents for a specific chapter belonging to a user. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `chapter_id` - The ID of the chapter +/// * `lang` - The language for error messages +/// Returns an array of book chapter content records. +pub fn fetch_book_chapter_contents(conn: &Connection, user_id: &str, chapter_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT content_id, chapter_id, author_id, version, content, words_count, time_on_it, last_update FROM book_chapter_content WHERE author_id=?1 AND chapter_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu des chapitres.".to_string() } else { "Unable to retrieve chapter contents.".to_string() }))?; + + let rows = statement + .query_map(params![user_id, chapter_id], |query_row| { + Ok(BookChapterContentTable { + content_id: query_row.get(0)?, chapter_id: query_row.get(1)?, + author_id: query_row.get(2)?, version: query_row.get(3)?, + content: query_row.get(4)?, words_count: query_row.get(5)?, + time_on_it: query_row.get(6)?, last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu des chapitres.".to_string() } else { "Unable to retrieve chapter contents.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu des chapitres.".to_string() } else { "Unable to retrieve chapter contents.".to_string() }))?; + + Ok(rows) +} + +/// Fetches all synced chapter contents for a user (content ID, chapter ID, and last update timestamp). +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `lang` - The language for error messages +/// Returns an array of synced chapter content results. +pub fn fetch_synced_chapter_contents(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT content_id, chapter_id, last_update FROM book_chapter_content WHERE author_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu des chapitres synchronisés.".to_string() } else { "Unable to retrieve synced chapter contents.".to_string() }))?; + + let rows = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedChapterContentResult { + content_id: query_row.get(0)?, chapter_id: query_row.get(1)?, + last_update: query_row.get(2)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu des chapitres synchronisés.".to_string() } else { "Unable to retrieve synced chapter contents.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu des chapitres synchronisés.".to_string() } else { "Unable to retrieve synced chapter contents.".to_string() }))?; + + Ok(rows) +} + +/// Inserts a new chapter content record during synchronization. +/// * `conn` - Database connection +/// * `content_id` - The unique ID for the content +/// * `chapter_id` - The ID of the chapter +/// * `author_id` - The ID of the author +/// * `version` - The version number of the content +/// * `content` - The content string (can be null) +/// * `words_count` - The word count of the content +/// * `time_on_it` - The time spent on this content +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages +/// Returns true if the insert was successful. +pub fn insert_sync_chapter_content( + conn: &Connection, content_id: &str, chapter_id: &str, author_id: &str, version: i64, + content: Option<&str>, words_count: i64, time_on_it: i64, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute("INSERT INTO book_chapter_content (content_id, chapter_id, author_id, version, content, words_count, time_on_it, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![content_id, chapter_id, author_id, version, content, words_count, time_on_it, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le contenu du chapitre.".to_string() } else { "Unable to insert chapter content.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Fetches the complete chapter content record by its content ID. +/// * `conn` - Database connection +/// * `content_id` - The ID of the content to fetch +/// * `lang` - The language for error messages +/// Returns an array of book chapter content records. +pub fn fetch_complete_chapter_content_by_id(conn: &Connection, content_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT content_id, chapter_id, author_id, version, content, words_count, time_on_it, last_update FROM book_chapter_content WHERE content_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu de chapitre complet.".to_string() } else { "Unable to retrieve complete chapter content.".to_string() }))?; + + let rows = statement + .query_map(params![content_id], |query_row| { + Ok(BookChapterContentTable { + content_id: query_row.get(0)?, chapter_id: query_row.get(1)?, + author_id: query_row.get(2)?, version: query_row.get(3)?, + content: query_row.get(4)?, words_count: query_row.get(5)?, + time_on_it: query_row.get(6)?, last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu de chapitre complet.".to_string() } else { "Unable to retrieve complete chapter content.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le contenu de chapitre complet.".to_string() } else { "Unable to retrieve complete chapter content.".to_string() }))?; + + Ok(rows) +} + +/// Fetches a complete chapter with its content by joining chapters and chapter content tables. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `chapter_id` - The ID of the chapter +/// * `version` - The version number of the content to fetch +/// * `lang` - The language for error messages +/// Returns the chapter content query result with chapter metadata. +pub fn fetch_whole_chapter(conn: &Connection, user_id: &str, chapter_id: &str, version: i64, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT chapter.chapter_id as chapter_id, chapter.title as title, chapter.chapter_order, chapter.words_count, content.content AS content, content.version as version FROM book_chapters AS chapter LEFT JOIN book_chapter_content AS content ON content.chapter_id = chapter.chapter_id AND content.version = ?1 WHERE chapter.chapter_id = ?2 AND chapter.author_id = ?3") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le chapitre.".to_string() } else { "Unable to retrieve chapter.".to_string() }))?; + + let whole_chapter = statement + .query_row(params![version, chapter_id, user_id], |query_row| { + Ok(ChapterContentQueryResult { + chapter_id: query_row.get(0)?, title: query_row.get(1)?, + chapter_order: query_row.get(2)?, words_count: query_row.get(3)?, + content: query_row.get::<_, Option>(4)?.unwrap_or_default(), + version: query_row.get::<_, Option>(5)?.unwrap_or(2), + }) + }) + .map_err(|error| match error { + rusqlite::Error::QueryReturnedNoRows => AppError::NotFound(if lang == Lang::Fr { "Aucun chapitre trouvé avec cet ID.".to_string() } else { "No chapter found with this ID.".to_string() }), + _ => AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le chapitre.".to_string() } else { "Unable to retrieve chapter.".to_string() }), + })?; + + Ok(whole_chapter) +} diff --git a/src-tauri/src/domains/chapter_content/service.rs b/src-tauri/src/domains/chapter_content/service.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src-tauri/src/domains/chapter_content/service.rs @@ -0,0 +1 @@ + diff --git a/src-tauri/src/domains/character/commands.rs b/src-tauri/src/domains/character/commands.rs new file mode 100644 index 0000000..903689a --- /dev/null +++ b/src-tauri/src/domains/character/commands.rs @@ -0,0 +1,123 @@ +use serde::Deserialize; +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::character::service; +use crate::error::AppError; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetCharacterListData { + pub book_id: String, + pub enabled: bool, +} + +#[tauri::command] +pub fn get_character_list(data: GetCharacterListData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_character_list(conn, &user_id, &data.book_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetCharacterAttributesData { + pub character_id: String, +} + +#[tauri::command] +pub fn get_character_attributes(data: GetCharacterAttributesData, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_attributes(conn, &data.character_id, &user_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateCharacterData { + pub character: service::CharacterPropsPost, + pub book_id: String, + pub id: Option, +} + +#[tauri::command] +pub fn create_character(data: CreateCharacterData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_new_character(conn, &user_id, &data.character, &data.book_id, lang, data.id.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddCharacterAttributeData { + pub character_id: String, + pub r#type: String, + pub name: String, + pub id: Option, +} + +#[tauri::command] +pub fn add_character_attribute(data: AddCharacterAttributeData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_new_attribute(conn, &data.character_id, &user_id, &data.r#type, &data.name, lang, data.id.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteCharacterAttributeData { + pub attribute_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn delete_character_attribute(data: DeleteCharacterAttributeData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_attribute(conn, &user_id, &data.book_id, &data.attribute_id, data.deleted_at, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateCharacterData { + pub character: service::CharacterPropsPost, +} + +#[tauri::command] +pub fn update_character(data: UpdateCharacterData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::update_character(conn, &user_id, &data.character, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteCharacterData { + pub character_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn delete_character(data: DeleteCharacterData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_character(conn, &user_id, &data.book_id, &data.character_id, data.deleted_at, lang) +} diff --git a/src-tauri/src/domains/character/mod.rs b/src-tauri/src/domains/character/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/character/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/character/repo.rs b/src-tauri/src/domains/character/repo.rs new file mode 100644 index 0000000..decc153 --- /dev/null +++ b/src-tauri/src/domains/character/repo.rs @@ -0,0 +1,663 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct BookCharactersTable { + pub character_id: String, + pub book_id: String, + pub user_id: String, + pub first_name: String, + pub last_name: Option, + pub nickname: Option, + pub age: Option, + pub gender: Option, + pub species: Option, + pub nationality: Option, + pub status: Option, + pub category: String, + pub title: Option, + pub image: Option, + pub role: Option, + pub biography: Option, + pub history: Option, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, + pub last_update: i64, +} + +pub struct SyncedCharacterResult { + pub character_id: String, + pub book_id: String, + pub first_name: String, + pub last_update: i64, +} + +pub struct SyncedCharacterAttributeResult { + pub attr_id: String, + pub character_id: String, + pub attribute_name: String, + pub last_update: i64, +} + +pub struct BookCharactersAttributesTable { + pub attr_id: String, + pub character_id: String, + pub user_id: String, + pub attribute_name: String, + pub attribute_value: String, + pub last_update: i64, +} + +pub struct CharacterResult { + pub character_id: String, + pub first_name: String, + pub last_name: String, + pub nickname: Option, + pub age: Option, + pub gender: Option, + pub species: Option, + pub nationality: Option, + pub status: Option, + pub title: String, + pub category: String, + pub image: String, + pub role: String, + pub biography: String, + pub history: String, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, + pub series_character_id: Option, +} + +pub struct AttributeResult { + pub attr_id: String, + pub attribute_name: String, + pub attribute_value: String, +} + +pub struct CompleteCharacterResult { + pub character_id: String, + pub first_name: String, + pub last_name: String, + pub nickname: Option, + pub age: Option, + pub gender: Option, + pub species: Option, + pub nationality: Option, + pub status: Option, + pub category: String, + pub title: String, + pub role: String, + pub biography: String, + pub history: String, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, + pub attribute_name: String, + pub attribute_value: String, +} + +pub struct CharacterData { + pub first_name: String, + pub last_name: Option, + pub nickname: Option, + pub age: Option, + pub gender: Option, + pub species: Option, + pub nationality: Option, + pub status: Option, + pub title: Option, + pub category: Option, + pub image: Option, + pub role: Option, + pub biography: Option, + pub history: Option, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, +} + +pub struct SyncCharacterData { + pub first_name: String, + pub last_name: Option, + pub nickname: Option, + pub age: Option, + pub gender: Option, + pub species: Option, + pub nationality: Option, + pub status: Option, + pub category: String, + pub title: Option, + pub image: Option, + pub role: Option, + pub biography: Option, + pub history: Option, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, +} + +/// Fetches all characters for a specific book and user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of character results. +pub fn fetch_characters(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT character_id, first_name, last_name, nickname, age, gender, species, nationality, status, title, category, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, series_character_id FROM book_characters WHERE book_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages.".to_string() } else { "Unable to retrieve characters.".to_string() }))?; + + let characters = statement + .query_map(params![book_id, user_id], |query_row| { + Ok(CharacterResult { + character_id: query_row.get(0)?, first_name: query_row.get(1)?, + last_name: query_row.get(2)?, nickname: query_row.get(3)?, + age: query_row.get(4)?, gender: query_row.get(5)?, + species: query_row.get(6)?, nationality: query_row.get(7)?, + status: query_row.get(8)?, title: query_row.get(9)?, + category: query_row.get(10)?, image: query_row.get(11)?, + role: query_row.get(12)?, biography: query_row.get(13)?, + history: query_row.get(14)?, speech_pattern: query_row.get(15)?, + catchphrase: query_row.get(16)?, residence: query_row.get(17)?, + notes: query_row.get(18)?, color: query_row.get(19)?, + series_character_id: query_row.get(20)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages.".to_string() } else { "Unable to retrieve characters.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages.".to_string() } else { "Unable to retrieve characters.".to_string() }))?; + + Ok(characters) +} + +/// Adds a new character to the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier for the new character +/// * `character_data` - Object containing all encrypted character fields +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// * `series_character_id` - Optional series character identifier +/// Returns the character ID if successful. +pub fn add_new_character( + conn: &Connection, user_id: &str, character_id: &str, character_data: &CharacterData, + book_id: &str, lang: Lang, series_character_id: Option<&str>, last_update: i64, +) -> AppResult { + let insert_result = if let Some(series_id) = series_character_id { + conn.execute( + "INSERT INTO book_characters (character_id, book_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, category, title, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, series_character_id, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24)", + params![character_id, book_id, user_id, character_data.first_name, character_data.last_name, character_data.nickname, character_data.age, character_data.gender, character_data.species, character_data.nationality, character_data.status, character_data.category, character_data.title, character_data.image, character_data.role, character_data.biography, character_data.history, character_data.speech_pattern, character_data.catchphrase, character_data.residence, character_data.notes, character_data.color, series_id, last_update], + ) + } else { + conn.execute( + "INSERT INTO book_characters (character_id, book_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, category, title, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23)", + params![character_id, book_id, user_id, character_data.first_name, character_data.last_name, character_data.nickname, character_data.age, character_data.gender, character_data.species, character_data.nationality, character_data.status, character_data.category, character_data.title, character_data.image, character_data.role, character_data.biography, character_data.history, character_data.speech_pattern, character_data.catchphrase, character_data.residence, character_data.notes, character_data.color, last_update], + ) + }; + + let insert_result = insert_result + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le personnage.".to_string() } else { "Unable to add character.".to_string() }))?; + + if insert_result > 0 { + Ok(character_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de l'ajout du personnage.".to_string() } else { "Error adding character.".to_string() })) + } +} + +/// Inserts a new attribute for a character. +/// * `conn` - Database connection +/// * `attribute_id` - The unique identifier for the new attribute +/// * `character_id` - The unique identifier of the character +/// * `user_id` - The unique identifier of the user +/// * `attribute_type` - The attribute name/type +/// * `name` - The attribute value +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the attribute ID if successful. +pub fn insert_attribute( + conn: &Connection, attribute_id: &str, character_id: &str, user_id: &str, + attribute_type: &str, name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_characters_attributes (attr_id, character_id, user_id, attribute_name, attribute_value, last_update) VALUES (?1,?2,?3,?4,?5,?6)", + params![attribute_id, character_id, user_id, attribute_type, name, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter l'attribut.".to_string() } else { "Unable to add attribute.".to_string() }))?; + + if insert_result > 0 { + Ok(attribute_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de l'ajout de l'attribut.".to_string() } else { "Error adding attribute.".to_string() })) + } +} + +/// Updates an existing character's information. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `id` - The unique identifier of the character to update +/// * `character_data` - Object containing all encrypted character fields +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// * `series_character_id` - Optional series character identifier +/// Returns true if the update was successful, false otherwise. +pub fn update_character( + conn: &Connection, user_id: &str, id: &str, character_data: &CharacterData, + last_update: i64, lang: Lang, series_character_id: Option<&str>, +) -> AppResult { + let update_result = if let Some(series_id) = series_character_id { + conn.execute( + "UPDATE book_characters SET first_name=?1, last_name=?2, nickname=?3, age=?4, gender=?5, species=?6, nationality=?7, status=?8, title=?9, category=?10, image=?11, role=?12, biography=?13, history=?14, speech_pattern=?15, catchphrase=?16, residence=?17, notes=?18, color=?19, series_character_id=?20, last_update=?21 WHERE character_id=?22 AND user_id=?23", + params![character_data.first_name, character_data.last_name, character_data.nickname, character_data.age, character_data.gender, character_data.species, character_data.nationality, character_data.status, character_data.title, character_data.category, character_data.image, character_data.role, character_data.biography, character_data.history, character_data.speech_pattern, character_data.catchphrase, character_data.residence, character_data.notes, character_data.color, series_id, last_update, id, user_id], + ) + } else { + conn.execute( + "UPDATE book_characters SET first_name=?1, last_name=?2, nickname=?3, age=?4, gender=?5, species=?6, nationality=?7, status=?8, title=?9, category=?10, image=?11, role=?12, biography=?13, history=?14, speech_pattern=?15, catchphrase=?16, residence=?17, notes=?18, color=?19, last_update=?20 WHERE character_id=?21 AND user_id=?22", + params![character_data.first_name, character_data.last_name, character_data.nickname, character_data.age, character_data.gender, character_data.species, character_data.nationality, character_data.status, character_data.title, character_data.category, character_data.image, character_data.role, character_data.biography, character_data.history, character_data.speech_pattern, character_data.catchphrase, character_data.residence, character_data.notes, character_data.color, last_update, id, user_id], + ) + }; + + let update_result = update_result + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le personnage.".to_string() } else { "Unable to update character.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Deletes a character and all its related data (attributes) from the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character to delete +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion was successful, false otherwise. +pub fn delete_character(conn: &Connection, user_id: &str, character_id: &str, lang: Lang) -> AppResult { + conn.execute( + "DELETE FROM book_characters_attributes WHERE character_id=?1 AND user_id=?2", + params![character_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le personnage.".to_string() } else { "Unable to delete character.".to_string() }))?; + + let delete_result = conn + .execute( + "DELETE FROM book_characters WHERE character_id=?1 AND user_id=?2", + params![character_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le personnage.".to_string() } else { "Unable to delete character.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Deletes a character attribute from the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `attribute_id` - The unique identifier of the attribute to delete +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion was successful, false otherwise. +pub fn delete_attribute(conn: &Connection, user_id: &str, attribute_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute( + "DELETE FROM book_characters_attributes WHERE attr_id=?1 AND user_id=?2", + params![attribute_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer l'attribut.".to_string() } else { "Unable to delete attribute.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Fetches all attributes for a specific character. +/// * `conn` - Database connection +/// * `character_id` - The unique identifier of the character +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of attribute results. +pub fn fetch_attributes(conn: &Connection, character_id: &str, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT attr_id, attribute_name, attribute_value FROM book_characters_attributes WHERE character_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les attributs.".to_string() } else { "Unable to retrieve attributes.".to_string() }))?; + + let attributes = statement + .query_map(params![character_id, user_id], |query_row| { + Ok(AttributeResult { attr_id: query_row.get(0)?, attribute_name: query_row.get(1)?, attribute_value: query_row.get(2)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les attributs.".to_string() } else { "Unable to retrieve attributes.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les attributs.".to_string() } else { "Unable to retrieve attributes.".to_string() }))?; + + Ok(attributes) +} + +/// Fetches complete character information including attributes, optionally filtered by character IDs. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `tags` - An optional array of character IDs to filter by +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of complete character results with attributes. +pub fn fetch_complete_characters(conn: &Connection, user_id: &str, book_id: &str, tags: &[String], lang: Lang) -> AppResult> { + let mut query = "SELECT charac.character_id, first_name, last_name, nickname, age, gender, species, nationality, status, category, title, role, biography, history, speech_pattern, catchphrase, residence, notes, color, attribute_name, attribute_value FROM book_characters AS charac LEFT JOIN book_characters_attributes AS attr ON charac.character_id=attr.character_id WHERE charac.user_id=?1 AND charac.book_id=?2".to_string(); + let mut param_values: Vec> = Vec::new(); + param_values.push(Box::new(user_id.to_string())); + param_values.push(Box::new(book_id.to_string())); + + if !tags.is_empty() { + let placeholders: String = tags.iter().enumerate().map(|(index, _)| format!("?{}", index + 3)).collect::>().join(","); + query += &format!(" AND charac.character_id IN ({})", placeholders); + for tag in tags { + param_values.push(Box::new(tag.clone())); + } + } + + let param_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|param| param.as_ref()).collect(); + + let mut statement = conn + .prepare(&query) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages complets.".to_string() } else { "Unable to retrieve complete characters.".to_string() }))?; + + let characters = statement + .query_map(param_refs.as_slice(), |query_row| { + Ok(CompleteCharacterResult { + character_id: query_row.get(0)?, first_name: query_row.get(1)?, + last_name: query_row.get(2)?, nickname: query_row.get(3)?, + age: query_row.get(4)?, gender: query_row.get(5)?, + species: query_row.get(6)?, nationality: query_row.get(7)?, + status: query_row.get(8)?, category: query_row.get(9)?, + title: query_row.get(10)?, role: query_row.get(11)?, + biography: query_row.get(12)?, history: query_row.get(13)?, + speech_pattern: query_row.get(14)?, catchphrase: query_row.get(15)?, + residence: query_row.get(16)?, notes: query_row.get(17)?, + color: query_row.get(18)?, attribute_name: query_row.get(19)?, + attribute_value: query_row.get(20)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages complets.".to_string() } else { "Unable to retrieve complete characters.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages complets.".to_string() } else { "Unable to retrieve complete characters.".to_string() }))?; + + if characters.is_empty() { + return Err(AppError::NotFound(if lang == Lang::Fr { "Aucun personnage complet trouvé.".to_string() } else { "No complete characters found.".to_string() })); + } + + Ok(characters) +} + +/// Updates an existing character attribute. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_attribute_id` - The unique identifier of the attribute to update +/// * `attribute_name` - The new attribute name +/// * `attribute_value` - The new attribute value +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful, false otherwise. +pub fn update_character_attribute( + conn: &Connection, user_id: &str, character_attribute_id: &str, + attribute_name: &str, attribute_value: &str, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE book_characters_attributes SET attribute_name=?1, attribute_value=?2, last_update=?3 WHERE attr_id=?4 AND user_id=?5", + params![attribute_name, attribute_value, last_update, character_attribute_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour l'attribut du personnage.".to_string() } else { "Unable to update character attribute.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Checks if a character exists in the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character to check +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the character exists, false otherwise. +pub fn is_character_exist(conn: &Connection, user_id: &str, character_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_characters WHERE character_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du personnage.".to_string() } else { "Unable to check character existence.".to_string() }))?; + + let exists = statement + .query_row(params![character_id, user_id], |_query_row| Ok(true)) + .unwrap_or(false); + + Ok(exists) +} + +/// Checks if a character attribute exists in the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_attribute_id` - The unique identifier of the attribute to check +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the attribute exists, false otherwise. +pub fn is_character_attribute_exist(conn: &Connection, user_id: &str, character_attribute_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_characters_attributes WHERE attr_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'attribut du personnage.".to_string() } else { "Unable to check character attribute existence.".to_string() }))?; + + let exists = statement + .query_row(params![character_attribute_id, user_id], |_query_row| Ok(true)) + .unwrap_or(false); + + Ok(exists) +} + +/// Fetches all characters for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of book characters. +pub fn fetch_book_characters(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT character_id, book_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, category, title, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update FROM book_characters WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages.".to_string() } else { "Unable to retrieve characters.".to_string() }))?; + + let characters = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookCharactersTable { + character_id: query_row.get(0)?, book_id: query_row.get(1)?, + user_id: query_row.get(2)?, first_name: query_row.get(3)?, + last_name: query_row.get(4)?, nickname: query_row.get(5)?, + age: query_row.get(6)?, gender: query_row.get(7)?, + species: query_row.get(8)?, nationality: query_row.get(9)?, + status: query_row.get(10)?, category: query_row.get(11)?, + title: query_row.get(12)?, image: query_row.get(13)?, + role: query_row.get(14)?, biography: query_row.get(15)?, + history: query_row.get(16)?, speech_pattern: query_row.get(17)?, + catchphrase: query_row.get(18)?, residence: query_row.get(19)?, + notes: query_row.get(20)?, color: query_row.get(21)?, + last_update: query_row.get(22)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages.".to_string() } else { "Unable to retrieve characters.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages.".to_string() } else { "Unable to retrieve characters.".to_string() }))?; + + Ok(characters) +} + +/// Fetches all attributes for a specific character. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of character attributes. +pub fn fetch_book_characters_attributes(conn: &Connection, user_id: &str, character_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT attr_id, character_id, user_id, attribute_name, attribute_value, last_update FROM book_characters_attributes WHERE user_id=?1 AND character_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les attributs des personnages.".to_string() } else { "Unable to retrieve character attributes.".to_string() }))?; + + let attributes = statement + .query_map(params![user_id, character_id], |query_row| { + Ok(BookCharactersAttributesTable { + attr_id: query_row.get(0)?, character_id: query_row.get(1)?, + user_id: query_row.get(2)?, attribute_name: query_row.get(3)?, + attribute_value: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les attributs des personnages.".to_string() } else { "Unable to retrieve character attributes.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les attributs des personnages.".to_string() } else { "Unable to retrieve character attributes.".to_string() }))?; + + Ok(attributes) +} + +/// Fetches all synced characters for a user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced character results. +pub fn fetch_synced_characters(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT character_id, book_id, first_name, last_update FROM book_characters WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages synchronisés.".to_string() } else { "Unable to retrieve synced characters.".to_string() }))?; + + let synced_characters = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedCharacterResult { character_id: query_row.get(0)?, book_id: query_row.get(1)?, first_name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages synchronisés.".to_string() } else { "Unable to retrieve synced characters.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les personnages synchronisés.".to_string() } else { "Unable to retrieve synced characters.".to_string() }))?; + + Ok(synced_characters) +} + +/// Fetches all synced character attributes for a user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced character attribute results. +pub fn fetch_synced_character_attributes(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT attr_id, character_id, attribute_name, last_update FROM book_characters_attributes WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les attributs des personnages synchronisés.".to_string() } else { "Unable to retrieve synced character attributes.".to_string() }))?; + + let synced_attributes = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedCharacterAttributeResult { attr_id: query_row.get(0)?, character_id: query_row.get(1)?, attribute_name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les attributs des personnages synchronisés.".to_string() } else { "Unable to retrieve synced character attributes.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les attributs des personnages synchronisés.".to_string() } else { "Unable to retrieve synced character attributes.".to_string() }))?; + + Ok(synced_attributes) +} + +/// Inserts a synced character into the database. +/// * `conn` - Database connection +/// * `character_id` - The unique identifier of the character +/// * `book_id` - The unique identifier of the book +/// * `user_id` - The unique identifier of the user +/// * `character_data` - Object containing all character fields +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion was successful, false otherwise. +pub fn insert_sync_character( + conn: &Connection, character_id: &str, book_id: &str, user_id: &str, + character_data: &SyncCharacterData, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_characters (character_id, book_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, category, title, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23)", + params![character_id, book_id, user_id, character_data.first_name, character_data.last_name, character_data.nickname, character_data.age, character_data.gender, character_data.species, character_data.nationality, character_data.status, character_data.category, character_data.title, character_data.image, character_data.role, character_data.biography, character_data.history, character_data.speech_pattern, character_data.catchphrase, character_data.residence, character_data.notes, character_data.color, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le personnage.".to_string() } else { "Unable to insert character.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Inserts a synced character attribute into the database. +/// * `conn` - Database connection +/// * `attr_id` - The unique identifier of the attribute +/// * `character_id` - The unique identifier of the character +/// * `user_id` - The unique identifier of the user +/// * `attribute_name` - The name of the attribute +/// * `attribute_value` - The value of the attribute +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion was successful, false otherwise. +pub fn insert_sync_character_attribute( + conn: &Connection, attr_id: &str, character_id: &str, user_id: &str, + attribute_name: &str, attribute_value: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_characters_attributes (attr_id, character_id, user_id, attribute_name, attribute_value, last_update) VALUES (?1,?2,?3,?4,?5,?6)", + params![attr_id, character_id, user_id, attribute_name, attribute_value, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer l'attribut du personnage.".to_string() } else { "Unable to insert character attribute.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Fetches a complete character by its ID. +/// * `conn` - Database connection +/// * `id` - The unique identifier of the character +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of book characters (typically one). +pub fn fetch_complete_character_by_id(conn: &Connection, id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT character_id, book_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, category, title, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update FROM book_characters WHERE character_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le personnage complet.".to_string() } else { "Unable to retrieve complete character.".to_string() }))?; + + let character = statement + .query_map(params![id], |query_row| { + Ok(BookCharactersTable { + character_id: query_row.get(0)?, book_id: query_row.get(1)?, + user_id: query_row.get(2)?, first_name: query_row.get(3)?, + last_name: query_row.get(4)?, nickname: query_row.get(5)?, + age: query_row.get(6)?, gender: query_row.get(7)?, + species: query_row.get(8)?, nationality: query_row.get(9)?, + status: query_row.get(10)?, category: query_row.get(11)?, + title: query_row.get(12)?, image: query_row.get(13)?, + role: query_row.get(14)?, biography: query_row.get(15)?, + history: query_row.get(16)?, speech_pattern: query_row.get(17)?, + catchphrase: query_row.get(18)?, residence: query_row.get(19)?, + notes: query_row.get(20)?, color: query_row.get(21)?, + last_update: query_row.get(22)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le personnage complet.".to_string() } else { "Unable to retrieve complete character.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le personnage complet.".to_string() } else { "Unable to retrieve complete character.".to_string() }))?; + + Ok(character) +} + +/// Fetches a complete character attribute by its ID. +/// * `conn` - Database connection +/// * `id` - The unique identifier of the attribute +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of character attributes (typically one). +pub fn fetch_complete_character_attribute_by_id(conn: &Connection, id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT attr_id, character_id, user_id, attribute_name, attribute_value, last_update FROM book_characters_attributes WHERE attr_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'attribut de personnage complet.".to_string() } else { "Unable to retrieve complete character attribute.".to_string() }))?; + + let attribute = statement + .query_map(params![id], |query_row| { + Ok(BookCharactersAttributesTable { + attr_id: query_row.get(0)?, character_id: query_row.get(1)?, + user_id: query_row.get(2)?, attribute_name: query_row.get(3)?, + attribute_value: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'attribut de personnage complet.".to_string() } else { "Unable to retrieve complete character attribute.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'attribut de personnage complet.".to_string() } else { "Unable to retrieve complete character attribute.".to_string() }))?; + + Ok(attribute) +} diff --git a/src-tauri/src/domains/character/service.rs b/src-tauri/src/domains/character/service.rs new file mode 100644 index 0000000..7ee2ccd --- /dev/null +++ b/src-tauri/src/domains/character/service.rs @@ -0,0 +1,656 @@ +use std::collections::HashMap; + +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::book::repo as book_repo; +use crate::domains::character::repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::AppResult; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CharacterPropsPost { + pub id: Option, + pub name: String, + pub last_name: String, + pub nickname: String, + pub age: Option, + pub gender: String, + pub species: String, + pub nationality: String, + pub status: String, + pub category: String, + pub title: String, + pub image: String, + pub physical: Vec, + pub psychological: Vec, + pub relations: Vec, + pub skills: Vec, + pub weaknesses: Vec, + pub strengths: Vec, + pub goals: Vec, + pub motivations: Vec, + pub arc: Vec, + pub secrets: Vec, + pub fears: Vec, + pub flaws: Vec, + pub beliefs: Vec, + pub conflicts: Vec, + pub quotes: Vec, + pub distinguishing_marks: Vec, + pub items: Vec, + pub affiliations: Vec, + pub role: String, + pub biography: Option, + pub history: Option, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, + pub series_character_id: Option, +} + +#[derive(Deserialize)] +pub struct AttributeName { + pub name: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CharacterProps { + pub id: String, + pub name: String, + pub last_name: String, + pub nickname: String, + pub age: Option, + pub gender: String, + pub species: String, + pub nationality: String, + pub status: String, + pub title: String, + pub category: String, + pub image: String, + pub role: String, + pub biography: String, + pub history: String, + pub speech_pattern: String, + pub catchphrase: String, + pub residence: String, + pub notes: String, + pub color: String, + pub series_character_id: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CharacterListResponse { + pub characters: Vec, + pub enabled: bool, +} + +pub struct CompleteCharacterProps { + pub id: Option, + pub name: String, + pub last_name: String, + pub nickname: String, + pub age: Option, + pub gender: String, + pub species: String, + pub nationality: String, + pub status: String, + pub title: String, + pub category: String, + pub image: Option, + pub role: String, + pub biography: String, + pub history: String, + pub speech_pattern: String, + pub catchphrase: String, + pub residence: String, + pub notes: String, + pub color: String, + pub physical: Vec, + pub psychological: Vec, + pub relations: Vec, + pub skills: Vec, + pub weaknesses: Vec, + pub strengths: Vec, + pub goals: Vec, + pub motivations: Vec, + pub arc: Vec, + pub secrets: Vec, + pub fears: Vec, + pub flaws: Vec, + pub beliefs: Vec, + pub conflicts: Vec, + pub quotes: Vec, + pub distinguishing_marks: Vec, + pub items: Vec, + pub affiliations: Vec, +} + +#[derive(Serialize)] +pub struct Attribute { + pub id: String, + pub name: String, +} + +#[derive(Serialize)] +pub struct CharacterAttribute { + pub r#type: String, + pub values: Vec, +} + +pub struct SyncedCharacter { + pub id: String, + pub name: String, + pub last_update: i64, + pub attributes: Vec, +} + +pub struct SyncedCharacterAttribute { + pub id: String, + pub name: String, + pub last_update: i64, +} + +/// Retrieves a list of all characters for a specific book. +/// Decrypts character data using the user's encryption key. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language code for localization +/// Returns a CharacterListResponse containing decrypted characters and enabled status. +pub fn get_character_list(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let book_tools: Option = book_repo::fetch_book_tools(conn, user_id, book_id, lang)?; + let enabled: bool = book_tools.map_or(false, |book_tools_row| book_tools_row.characters_enabled == 1); + + let user_key: String = get_user_encryption_key(user_id)?; + let encrypted_characters: Vec = repo::fetch_characters(conn, user_id, book_id, lang)?; + + if encrypted_characters.is_empty() { + return Ok(CharacterListResponse { characters: vec![], enabled }); + } + + let mut decrypted_character_list: Vec = Vec::with_capacity(encrypted_characters.len()); + for encrypted_character in encrypted_characters { + decrypted_character_list.push(CharacterProps { + id: encrypted_character.character_id, + name: if encrypted_character.first_name.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.first_name, &user_key)? }, + last_name: if encrypted_character.last_name.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.last_name, &user_key)? }, + nickname: if let Some(ref value) = encrypted_character.nickname { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + age: if let Some(ref value) = encrypted_character.age { Some(decrypt_data_with_user_key(value, &user_key)?.parse::().unwrap_or(0)) } else { None }, + gender: if let Some(ref value) = encrypted_character.gender { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + species: if let Some(ref value) = encrypted_character.species { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + nationality: if let Some(ref value) = encrypted_character.nationality { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + status: if let Some(ref value) = encrypted_character.status { decrypt_data_with_user_key(value, &user_key)? } else { "alive".to_string() }, + title: if encrypted_character.title.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.title, &user_key)? }, + category: if encrypted_character.category.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.category, &user_key)? }, + image: if encrypted_character.image.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.image, &user_key)? }, + role: if encrypted_character.role.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.role, &user_key)? }, + biography: if encrypted_character.biography.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.biography, &user_key)? }, + history: if encrypted_character.history.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.history, &user_key)? }, + speech_pattern: if let Some(ref value) = encrypted_character.speech_pattern { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + catchphrase: if let Some(ref value) = encrypted_character.catchphrase { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + residence: if let Some(ref value) = encrypted_character.residence { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + notes: if let Some(ref value) = encrypted_character.notes { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + color: if let Some(ref value) = encrypted_character.color { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + series_character_id: encrypted_character.series_character_id, + }); + } + + Ok(CharacterListResponse { characters: decrypted_character_list, enabled }) +} + +/// Creates a new character with all its attributes for a specific book. +/// Encrypts all character data before storing in the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character` - The character data to be created +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language code for localization +/// * `existing_character_id` - Optional existing character ID for updates or imports +/// Returns the unique identifier of the newly created character. +pub fn add_new_character(conn: &Connection, user_id: &str, character: &CharacterPropsPost, book_id: &str, lang: Lang, existing_character_id: Option<&str>) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let character_id: String = create_unique_id(existing_character_id); + let last_update: i64 = timestamp_in_seconds(); + + let character_data = repo::CharacterData { + first_name: encrypt_data_with_user_key(&character.name, &user_key)?, + last_name: Some(encrypt_data_with_user_key(&character.last_name, &user_key)?), + nickname: Some(encrypt_data_with_user_key(if character.nickname.is_empty() { "" } else { &character.nickname }, &user_key)?), + age: if let Some(age_value) = character.age { Some(encrypt_data_with_user_key(&age_value.to_string(), &user_key)?) } else { None }, + gender: Some(encrypt_data_with_user_key(if character.gender.is_empty() { "" } else { &character.gender }, &user_key)?), + species: Some(encrypt_data_with_user_key(if character.species.is_empty() { "" } else { &character.species }, &user_key)?), + nationality: Some(encrypt_data_with_user_key(if character.nationality.is_empty() { "" } else { &character.nationality }, &user_key)?), + status: Some(encrypt_data_with_user_key(if character.status.is_empty() { "alive" } else { &character.status }, &user_key)?), + title: Some(encrypt_data_with_user_key(&character.title, &user_key)?), + category: Some(encrypt_data_with_user_key(&character.category, &user_key)?), + image: Some(encrypt_data_with_user_key(&character.image, &user_key)?), + role: Some(encrypt_data_with_user_key(&character.role, &user_key)?), + biography: Some(encrypt_data_with_user_key(character.biography.as_deref().unwrap_or(""), &user_key)?), + history: Some(encrypt_data_with_user_key(character.history.as_deref().unwrap_or(""), &user_key)?), + speech_pattern: Some(encrypt_data_with_user_key(character.speech_pattern.as_deref().unwrap_or(""), &user_key)?), + catchphrase: Some(encrypt_data_with_user_key(character.catchphrase.as_deref().unwrap_or(""), &user_key)?), + residence: Some(encrypt_data_with_user_key(character.residence.as_deref().unwrap_or(""), &user_key)?), + notes: Some(encrypt_data_with_user_key(character.notes.as_deref().unwrap_or(""), &user_key)?), + color: Some(encrypt_data_with_user_key(character.color.as_deref().unwrap_or(""), &user_key)?), + }; + + let series_character_id: Option<&str> = character.series_character_id.as_deref(); + repo::add_new_character(conn, user_id, &character_id, &character_data, book_id, lang, series_character_id, last_update)?; + + let attribute_arrays: Vec<(&str, &Vec)> = vec![ + ("physical", &character.physical), + ("psychological", &character.psychological), + ("relations", &character.relations), + ("skills", &character.skills), + ("weaknesses", &character.weaknesses), + ("strengths", &character.strengths), + ("goals", &character.goals), + ("motivations", &character.motivations), + ("arc", &character.arc), + ("secrets", &character.secrets), + ("fears", &character.fears), + ("flaws", &character.flaws), + ("beliefs", &character.beliefs), + ("conflicts", &character.conflicts), + ("quotes", &character.quotes), + ("distinguishingMarks", &character.distinguishing_marks), + ("items", &character.items), + ("affiliations", &character.affiliations), + ]; + + for (attribute_type, attribute_items) in attribute_arrays { + if !attribute_items.is_empty() { + for attribute_item in attribute_items { + add_new_attribute(conn, &character_id, user_id, attribute_type, &attribute_item.name, lang, None)?; + } + } + } + + Ok(character_id) +} + +/// Updates an existing character's core properties. +/// Encrypts all updated data before storing in the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character` - The character data with updated values +/// * `lang` - The language code for localization +/// Returns true if the update was successful, false otherwise. +pub fn update_character(conn: &Connection, user_id: &str, character: &CharacterPropsPost, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + + let character_id: &str = match character.id.as_deref() { + Some(id) => id, + None => return Ok(false), + }; + + let last_update: i64 = timestamp_in_seconds(); + + let character_data = repo::CharacterData { + first_name: encrypt_data_with_user_key(&character.name, &user_key)?, + last_name: Some(encrypt_data_with_user_key(&character.last_name, &user_key)?), + nickname: Some(encrypt_data_with_user_key(if character.nickname.is_empty() { "" } else { &character.nickname }, &user_key)?), + age: if let Some(age_value) = character.age { Some(encrypt_data_with_user_key(&age_value.to_string(), &user_key)?) } else { None }, + gender: Some(encrypt_data_with_user_key(if character.gender.is_empty() { "" } else { &character.gender }, &user_key)?), + species: Some(encrypt_data_with_user_key(if character.species.is_empty() { "" } else { &character.species }, &user_key)?), + nationality: Some(encrypt_data_with_user_key(if character.nationality.is_empty() { "" } else { &character.nationality }, &user_key)?), + status: Some(encrypt_data_with_user_key(if character.status.is_empty() { "alive" } else { &character.status }, &user_key)?), + title: Some(encrypt_data_with_user_key(&character.title, &user_key)?), + category: Some(encrypt_data_with_user_key(&character.category, &user_key)?), + image: Some(encrypt_data_with_user_key(&character.image, &user_key)?), + role: Some(encrypt_data_with_user_key(&character.role, &user_key)?), + biography: Some(encrypt_data_with_user_key(character.biography.as_deref().unwrap_or(""), &user_key)?), + history: Some(encrypt_data_with_user_key(character.history.as_deref().unwrap_or(""), &user_key)?), + speech_pattern: Some(encrypt_data_with_user_key(character.speech_pattern.as_deref().unwrap_or(""), &user_key)?), + catchphrase: Some(encrypt_data_with_user_key(character.catchphrase.as_deref().unwrap_or(""), &user_key)?), + residence: Some(encrypt_data_with_user_key(character.residence.as_deref().unwrap_or(""), &user_key)?), + notes: Some(encrypt_data_with_user_key(character.notes.as_deref().unwrap_or(""), &user_key)?), + color: Some(encrypt_data_with_user_key(character.color.as_deref().unwrap_or(""), &user_key)?), + }; + + let series_character_id: Option<&str> = character.series_character_id.as_deref(); + repo::update_character(conn, user_id, character_id, &character_data, last_update, lang, series_character_id) +} + +/// Adds a new attribute to a character. +/// Attributes are categorized properties like physical traits, skills, or goals. +/// * `conn` - Database connection +/// * `character_id` - The unique identifier of the character +/// * `user_id` - The unique identifier of the user +/// * `r#type` - The type/category of the attribute (e.g., 'physical', 'skills') +/// * `name` - The value/name of the attribute +/// * `lang` - The language code for localization +/// * `existing_attribute_id` - Optional existing attribute ID for updates or imports +/// Returns the unique identifier of the newly created attribute. +pub fn add_new_attribute(conn: &Connection, character_id: &str, user_id: &str, r#type: &str, name: &str, lang: Lang, existing_attribute_id: Option<&str>) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let attribute_id: String = create_unique_id(existing_attribute_id); + let encrypted_type: String = encrypt_data_with_user_key(r#type, &user_key)?; + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let last_update: i64 = timestamp_in_seconds(); + repo::insert_attribute(conn, &attribute_id, character_id, user_id, &encrypted_type, &encrypted_name, last_update, lang) +} + +/// Deletes an attribute from a character. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `attribute_id` - The unique identifier of the attribute to delete +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language code for localization +/// Returns true if the deletion was successful, false otherwise. +pub fn delete_attribute(conn: &Connection, user_id: &str, book_id: &str, attribute_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_attribute(conn, user_id, attribute_id, lang)?; + if deleted { + let removal_id: String = create_unique_id(None); + tombstone_repo::insert(conn, &removal_id, "book_characters_attributes", attribute_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Deletes a character and all its related data. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `character_id` - The unique identifier of the character to delete +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language code for localization +/// Returns true if the deletion was successful. +pub fn delete_character(conn: &Connection, user_id: &str, book_id: &str, character_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_character(conn, user_id, character_id, lang)?; + if deleted { + let removal_id: String = create_unique_id(None); + tombstone_repo::insert(conn, &removal_id, "book_characters", character_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Retrieves all attributes for a specific character, grouped by type. +/// Decrypts attribute data using the user's encryption key. +/// * `conn` - Database connection +/// * `character_id` - The unique identifier of the character +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language code for localization +/// Returns an array of character attributes grouped by type. +pub fn get_attributes(conn: &Connection, character_id: &str, user_id: &str, lang: Lang) -> AppResult> { + let user_key: String = get_user_encryption_key(user_id)?; + let encrypted_attributes: Vec = repo::fetch_attributes(conn, character_id, user_id, lang)?; + + if encrypted_attributes.is_empty() { + return Ok(vec![]); + } + + let mut attributes_by_type: HashMap> = HashMap::new(); + + for encrypted_attribute in encrypted_attributes { + let decrypted_type: String = decrypt_data_with_user_key(&encrypted_attribute.attribute_name, &user_key)?; + let decrypted_value: String = if encrypted_attribute.attribute_value.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_attribute.attribute_value, &user_key)? }; + + attributes_by_type.entry(decrypted_type).or_insert_with(Vec::new).push(Attribute { + id: encrypted_attribute.attr_id, + name: decrypted_value, + }); + } + + let character_attributes: Vec = attributes_by_type.into_iter().map(|(attribute_type, values)| CharacterAttribute { + r#type: attribute_type, + values, + }).collect(); + + Ok(character_attributes) +} + +/// Retrieves complete character data including all attributes for multiple characters. +/// Used for exporting or displaying full character profiles. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `characters` - An array of character IDs to retrieve +/// * `lang` - The language code for localization +/// Returns an array of complete character objects with all their attributes. +pub fn get_complete_character_list(conn: &Connection, user_id: &str, book_id: &str, characters: &[String], lang: Lang) -> AppResult> { + let encrypted_character_list: Vec = match repo::fetch_complete_characters(conn, user_id, book_id, characters, lang) { + Ok(result) => result, + Err(_) => return Ok(vec![]), + }; + + if encrypted_character_list.is_empty() { + return Ok(vec![]); + } + + let user_key: String = get_user_encryption_key(user_id)?; + let mut complete_characters_map: HashMap = HashMap::new(); + + for encrypted_character in &encrypted_character_list { + if encrypted_character.character_id.is_empty() { + continue; + } + + if !complete_characters_map.contains_key(&encrypted_character.character_id) { + let decrypted_character = CompleteCharacterProps { + id: None, + name: if encrypted_character.first_name.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.first_name, &user_key)? }, + last_name: if encrypted_character.last_name.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.last_name, &user_key)? }, + nickname: if let Some(ref value) = encrypted_character.nickname { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + age: if let Some(ref value) = encrypted_character.age { Some(decrypt_data_with_user_key(value, &user_key)?.parse::().unwrap_or(0)) } else { None }, + gender: if let Some(ref value) = encrypted_character.gender { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + species: if let Some(ref value) = encrypted_character.species { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + nationality: if let Some(ref value) = encrypted_character.nationality { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + status: if let Some(ref value) = encrypted_character.status { decrypt_data_with_user_key(value, &user_key)? } else { "alive".to_string() }, + title: if encrypted_character.title.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.title, &user_key)? }, + category: if encrypted_character.category.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.category, &user_key)? }, + image: None, + role: if encrypted_character.role.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.role, &user_key)? }, + biography: if encrypted_character.biography.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.biography, &user_key)? }, + history: if encrypted_character.history.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.history, &user_key)? }, + speech_pattern: if let Some(ref value) = encrypted_character.speech_pattern { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + catchphrase: if let Some(ref value) = encrypted_character.catchphrase { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + residence: if let Some(ref value) = encrypted_character.residence { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + notes: if let Some(ref value) = encrypted_character.notes { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + color: if let Some(ref value) = encrypted_character.color { decrypt_data_with_user_key(value, &user_key)? } else { String::new() }, + physical: vec![], + psychological: vec![], + relations: vec![], + skills: vec![], + weaknesses: vec![], + strengths: vec![], + goals: vec![], + motivations: vec![], + arc: vec![], + secrets: vec![], + fears: vec![], + flaws: vec![], + beliefs: vec![], + conflicts: vec![], + quotes: vec![], + distinguishing_marks: vec![], + items: vec![], + affiliations: vec![], + }; + complete_characters_map.insert(encrypted_character.character_id.clone(), decrypted_character); + } + + let character_entry: &mut CompleteCharacterProps = match complete_characters_map.get_mut(&encrypted_character.character_id) { + Some(entry) => entry, + None => continue, + }; + + if encrypted_character.attribute_name.is_empty() { + continue; + } + + let decrypted_attribute_name: String = decrypt_data_with_user_key(&encrypted_character.attribute_name, &user_key)?; + let decrypted_attribute_value: String = if encrypted_character.attribute_value.is_empty() { String::new() } else { decrypt_data_with_user_key(&encrypted_character.attribute_value, &user_key)? }; + + let attribute = Attribute { id: String::new(), name: decrypted_attribute_value }; + + match decrypted_attribute_name.as_str() { + "physical" => character_entry.physical.push(attribute), + "psychological" => character_entry.psychological.push(attribute), + "relations" => character_entry.relations.push(attribute), + "skills" => character_entry.skills.push(attribute), + "weaknesses" => character_entry.weaknesses.push(attribute), + "strengths" => character_entry.strengths.push(attribute), + "goals" => character_entry.goals.push(attribute), + "motivations" => character_entry.motivations.push(attribute), + "arc" => character_entry.arc.push(attribute), + "secrets" => character_entry.secrets.push(attribute), + "fears" => character_entry.fears.push(attribute), + "flaws" => character_entry.flaws.push(attribute), + "beliefs" => character_entry.beliefs.push(attribute), + "conflicts" => character_entry.conflicts.push(attribute), + "quotes" => character_entry.quotes.push(attribute), + "distinguishingMarks" => character_entry.distinguishing_marks.push(attribute), + "items" => character_entry.items.push(attribute), + "affiliations" => character_entry.affiliations.push(attribute), + _ => {} + } + } + + Ok(complete_characters_map.into_values().collect()) +} + +/// Generates a formatted vCard-style string representation of characters. +/// Useful for AI context or text-based exports. +/// * `characters` - An array of complete character objects to format +/// Returns a formatted string containing all character information. +pub fn character_v_card(characters: &[CompleteCharacterProps]) -> String { + let mut unique_characters_map: HashMap = HashMap::new(); + + for character in characters { + let character_identifier: String = if !character.name.is_empty() { + character.name.clone() + } else if let Some(ref id) = character.id { + id.clone() + } else { + "unknown".to_string() + }; + + if !unique_characters_map.contains_key(&character_identifier) { + unique_characters_map.insert(character_identifier.clone(), CompleteCharacterProps { + id: None, + name: character.name.clone(), + last_name: character.last_name.clone(), + nickname: character.nickname.clone(), + age: character.age, + gender: character.gender.clone(), + species: character.species.clone(), + nationality: character.nationality.clone(), + status: character.status.clone(), + title: character.title.clone(), + category: character.category.clone(), + image: None, + role: character.role.clone(), + biography: character.biography.clone(), + history: character.history.clone(), + speech_pattern: character.speech_pattern.clone(), + catchphrase: character.catchphrase.clone(), + residence: character.residence.clone(), + notes: character.notes.clone(), + color: character.color.clone(), + physical: vec![], + psychological: vec![], + relations: vec![], + skills: vec![], + weaknesses: vec![], + strengths: vec![], + goals: vec![], + motivations: vec![], + arc: vec![], + secrets: vec![], + fears: vec![], + flaws: vec![], + beliefs: vec![], + conflicts: vec![], + quotes: vec![], + distinguishing_marks: vec![], + items: vec![], + affiliations: vec![], + }); + } + + let aggregated_character_data: &mut CompleteCharacterProps = unique_characters_map.get_mut(&character_identifier).unwrap(); + + for attribute in &character.physical { aggregated_character_data.physical.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.psychological { aggregated_character_data.psychological.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.relations { aggregated_character_data.relations.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.skills { aggregated_character_data.skills.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.weaknesses { aggregated_character_data.weaknesses.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.strengths { aggregated_character_data.strengths.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.goals { aggregated_character_data.goals.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.motivations { aggregated_character_data.motivations.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.arc { aggregated_character_data.arc.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.secrets { aggregated_character_data.secrets.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.fears { aggregated_character_data.fears.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.flaws { aggregated_character_data.flaws.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.beliefs { aggregated_character_data.beliefs.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.conflicts { aggregated_character_data.conflicts.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.quotes { aggregated_character_data.quotes.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.distinguishing_marks { aggregated_character_data.distinguishing_marks.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.items { aggregated_character_data.items.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + for attribute in &character.affiliations { aggregated_character_data.affiliations.push(Attribute { id: attribute.id.clone(), name: attribute.name.clone() }); } + } + + let formatted_characters_description: String = unique_characters_map.values().map(|character| { + let mut character_description_lines: Vec = Vec::new(); + + let full_name: String = [&character.name, &character.last_name].iter().filter(|name| !name.is_empty()).map(|name| name.as_str()).collect::>().join(" "); + if !full_name.is_empty() { + character_description_lines.push(format!("Nom : {}", full_name)); + } + + let simple_properties: Vec<(&str, &str)> = vec![ + ("Category", &character.category), + ("Title", &character.title), + ("Role", &character.role), + ("Biography", &character.biography), + ("History", &character.history), + ]; + for (property_label, property_value) in simple_properties { + if !property_value.is_empty() { + character_description_lines.push(format!("{} : {}", property_label, property_value)); + } + } + + let array_properties: Vec<(&str, &Vec)> = vec![ + ("Physical", &character.physical), + ("Psychological", &character.psychological), + ("Relations", &character.relations), + ("Skills", &character.skills), + ("Weaknesses", &character.weaknesses), + ("Strengths", &character.strengths), + ("Goals", &character.goals), + ("Motivations", &character.motivations), + ("Arc", &character.arc), + ("Secrets", &character.secrets), + ("Fears", &character.fears), + ("Flaws", &character.flaws), + ("Beliefs", &character.beliefs), + ("Conflicts", &character.conflicts), + ("Quotes", &character.quotes), + ("DistinguishingMarks", &character.distinguishing_marks), + ("Items", &character.items), + ("Affiliations", &character.affiliations), + ]; + for (capitalized_property_key, attribute_values) in array_properties { + if !attribute_values.is_empty() { + let formatted_attribute_values: String = attribute_values.iter().map(|attribute_item| attribute_item.name.as_str()).collect::>().join(", "); + character_description_lines.push(format!("{} : {}", capitalized_property_key, formatted_attribute_values)); + } + } + + character_description_lines.join("\n") + }).collect::>().join("\n\n"); + + formatted_characters_description +} diff --git a/src-tauri/src/domains/cover/mod.rs b/src-tauri/src/domains/cover/mod.rs new file mode 100644 index 0000000..50aab12 --- /dev/null +++ b/src-tauri/src/domains/cover/mod.rs @@ -0,0 +1 @@ +pub mod service; diff --git a/src-tauri/src/domains/cover/service.rs b/src-tauri/src/domains/cover/service.rs new file mode 100644 index 0000000..36db9ea --- /dev/null +++ b/src-tauri/src/domains/cover/service.rs @@ -0,0 +1,53 @@ +use std::fs; +use std::path::Path; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use rusqlite::Connection; + +use crate::crypto::encryption::decrypt_data_with_user_key; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::book::repo; +use crate::error::AppResult; +use crate::helpers::timestamp_in_seconds; +use crate::shared::types::Lang; + +/// Retrieves and decrypts the cover picture for a specific book. +/// Returns the decrypted cover image data, or an empty string if not found. +pub fn get_cover_picture(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let cover_query: repo::BookCoverQuery = repo::fetch_book_cover(conn, user_id, book_id, lang)?; + if !cover_query.cover_image.is_empty() { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + decrypt_data_with_user_key(&cover_query.cover_image, &user_encryption_key) + } else { + Ok(String::new()) + } +} + +/// Deletes the cover picture association for a specific book. +/// Clears the cover image reference in the database. +/// Returns true if the cover was successfully deleted, false otherwise. +pub fn delete_cover_picture(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let _existing_cover_name: String = get_cover_picture(conn, user_id, book_id, lang)?; + let last_update: i64 = timestamp_in_seconds(); + repo::update_book_cover(conn, book_id, "", user_id, last_update, lang) +} + +/// Retrieves and decrypts a picture file, returning it as a base64-encoded string. +/// Returns the base64-encoded image data, or an empty string if the image cannot be read. +pub fn get_picture(_user_id: &str, user_key: &str, image: &str, _lang: Lang) -> String { + if image.is_empty() { + return String::new(); + } + match try_get_picture(user_key, image) { + Ok(base64_data) => base64_data, + Err(_) => String::new(), + } +} + +fn try_get_picture(user_key: &str, image: &str) -> AppResult { + let decrypted_file_name: String = decrypt_data_with_user_key(image, user_key)?; + let user_directory: &Path = Path::new(&decrypted_file_name); + let file_data: Vec = fs::read(user_directory) + .map_err(|error| crate::error::AppError::Internal(error.to_string()))?; + Ok(BASE64.encode(&file_data)) +} diff --git a/src-tauri/src/domains/download/mod.rs b/src-tauri/src/domains/download/mod.rs new file mode 100644 index 0000000..50aab12 --- /dev/null +++ b/src-tauri/src/domains/download/mod.rs @@ -0,0 +1 @@ +pub mod service; diff --git a/src-tauri/src/domains/download/service.rs b/src-tauri/src/domains/download/service.rs new file mode 100644 index 0000000..438b466 --- /dev/null +++ b/src-tauri/src/domains/download/service.rs @@ -0,0 +1,211 @@ +use rusqlite::Connection; + +use crate::crypto::encryption::encrypt_data_with_user_key; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::act::repo as act_repo; +use crate::domains::book::repo as book_repo; +use crate::domains::book::service::CompleteBook; +use crate::domains::chapter::repo as chapter_repo; +use crate::domains::character::repo as character_repo; +use crate::domains::character::repo::SyncCharacterData; +use crate::domains::guideline::repo as guideline_repo; +use crate::domains::incident::repo as incident_repo; +use crate::domains::issue::repo as issue_repo; +use crate::domains::location::repo as location_repo; +use crate::domains::plotpoint::repo as plotpoint_repo; +use crate::domains::spell::repo as spell_repo; +use crate::domains::spell_tag::repo as spell_tag_repo; +use crate::domains::chapter_content::repo as chapter_content_repo; +use crate::domains::world::repo as world_repo; +use crate::error::AppResult; +use crate::shared::types::Lang; + +/// Saves a complete book with all its associated data to the local database. +/// This method encrypts all sensitive data using the user's encryption key before storing. +/// It processes and inserts all book components including chapters, incidents, plot points, +/// chapter contents, chapter infos, characters, character attributes, locations, location elements, +/// location sub-elements, worlds, world elements, act summaries, AI guidelines, guidelines, and issues. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user who owns the book +/// * `data` - The complete book data structure containing all book components to save +/// * `lang` - The language code for localization +/// Returns true if all data was saved successfully, false otherwise. +pub fn save_complete_book(conn: &Connection, user_id: &str, data: &CompleteBook, lang: Lang) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + let book_data = &data.erit_books[0]; + let encrypted_book_title: String = encrypt_data_with_user_key(&book_data.title, &user_encryption_key)?; + let encrypted_book_sub_title: Option = if let Some(ref sub_title) = book_data.sub_title { Some(encrypt_data_with_user_key(sub_title, &user_encryption_key)?) } else { None }; + let encrypted_book_summary: Option = if let Some(ref summary) = book_data.summary { Some(encrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None }; + let encrypted_book_cover_image: Option = if let Some(ref cover_image) = book_data.cover_image { Some(encrypt_data_with_user_key(cover_image, &user_encryption_key)?) } else { None }; + + let book_inserted: bool = book_repo::insert_sync_book(conn, &book_data.book_id, user_id, &book_data.book_type, &encrypted_book_title, &book_data.hashed_title, encrypted_book_sub_title.as_deref(), book_data.hashed_sub_title.as_deref(), encrypted_book_summary.as_deref(), book_data.serie_id, book_data.desired_release_date.as_deref(), book_data.desired_word_count, book_data.words_count, encrypted_book_cover_image.as_deref(), book_data.last_update, lang)?; + if !book_inserted { return Ok(false); } + + for chapter in &data.chapters { + let encrypted_chapter_title: String = encrypt_data_with_user_key(&chapter.title, &user_encryption_key)?; + let chapter_inserted: bool = chapter_repo::insert_sync_chapter(conn, &chapter.chapter_id, &chapter.book_id, user_id, &encrypted_chapter_title, Some(&chapter.hashed_title), None, Some(chapter.chapter_order), chapter.last_update, lang)?; + if !chapter_inserted { return Ok(false); } + } + + for incident in &data.incidents { + let encrypted_incident_title: String = encrypt_data_with_user_key(&incident.name, &user_encryption_key)?; + let encrypted_incident_summary: Option = if let Some(ref description) = incident.description { Some(encrypt_data_with_user_key(description, &user_encryption_key)?) } else { None }; + let incident_inserted: bool = incident_repo::insert_sync_incident(conn, &incident.incident_id, user_id, &incident.chapter_id, &encrypted_incident_title, &incident.hashed_name, encrypted_incident_summary.as_deref(), incident.last_update, lang)?; + if !incident_inserted { return Ok(false); } + } + + for plot_point in &data.plot_points { + let encrypted_plot_point_title: String = encrypt_data_with_user_key(&plot_point.name, &user_encryption_key)?; + let encrypted_plot_point_summary: Option = if let Some(ref description) = plot_point.description { Some(encrypt_data_with_user_key(description, &user_encryption_key)?) } else { None }; + let plot_point_inserted: bool = plotpoint_repo::insert_sync_plot_point(conn, &plot_point.plot_point_id, &encrypted_plot_point_title, &plot_point.hashed_name, encrypted_plot_point_summary.as_deref(), None, user_id, &plot_point.chapter_id, plot_point.last_update, lang)?; + if !plot_point_inserted { return Ok(false); } + } + + for chapter_content in &data.chapter_contents { + let encrypted_chapter_content: Option = if let Some(ref content) = chapter_content.content { Some(encrypt_data_with_user_key(content, &user_encryption_key)?) } else { None }; + let chapter_content_inserted: bool = chapter_content_repo::insert_sync_chapter_content(conn, &chapter_content.content_id, &chapter_content.chapter_id, user_id, chapter_content.version, encrypted_chapter_content.as_deref(), 0, 0, chapter_content.last_update, lang)?; + if !chapter_content_inserted { return Ok(false); } + } + + for chapter_info in &data.chapter_infos { + let encrypted_chapter_summary: Option = if let Some(ref summary) = chapter_info.summary { Some(encrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None }; + let encrypted_chapter_goal: Option = if let Some(ref notes) = chapter_info.notes { Some(encrypt_data_with_user_key(notes, &user_encryption_key)?) } else { None }; + let chapter_info_inserted: bool = chapter_repo::insert_sync_chapter_info(conn, &chapter_info.chapter_id, &chapter_info.chapter_id, None, None, None, &chapter_info.chapter_id, user_id, encrypted_chapter_summary.as_deref(), encrypted_chapter_goal.as_deref(), chapter_info.last_update, lang)?; + if !chapter_info_inserted { return Ok(false); } + } + + for character in &data.characters { + let character_data = SyncCharacterData { + first_name: encrypt_data_with_user_key(&character.first_name, &user_encryption_key)?, + last_name: if let Some(ref last_name) = character.last_name { Some(encrypt_data_with_user_key(last_name, &user_encryption_key)?) } else { None }, + nickname: if let Some(ref nickname) = character.nickname { Some(encrypt_data_with_user_key(nickname, &user_encryption_key)?) } else { None }, + age: if let Some(ref age) = character.age { Some(encrypt_data_with_user_key(&age.to_string(), &user_encryption_key)?) } else { None }, + gender: if let Some(ref gender) = character.gender { Some(encrypt_data_with_user_key(gender, &user_encryption_key)?) } else { None }, + species: if let Some(ref species) = character.species { Some(encrypt_data_with_user_key(species, &user_encryption_key)?) } else { None }, + nationality: if let Some(ref nationality) = character.nationality { Some(encrypt_data_with_user_key(nationality, &user_encryption_key)?) } else { None }, + status: if let Some(ref status) = character.status { Some(encrypt_data_with_user_key(status, &user_encryption_key)?) } else { None }, + category: encrypt_data_with_user_key(&character.category, &user_encryption_key)?, + title: if let Some(ref title) = character.title { Some(encrypt_data_with_user_key(title, &user_encryption_key)?) } else { None }, + image: if let Some(ref image) = character.image { Some(encrypt_data_with_user_key(image, &user_encryption_key)?) } else { None }, + role: if let Some(ref role) = character.role { Some(encrypt_data_with_user_key(role, &user_encryption_key)?) } else { None }, + biography: if let Some(ref biography) = character.biography { Some(encrypt_data_with_user_key(biography, &user_encryption_key)?) } else { None }, + history: if let Some(ref history) = character.history { Some(encrypt_data_with_user_key(history, &user_encryption_key)?) } else { None }, + speech_pattern: if let Some(ref speech_pattern) = character.speech_pattern { Some(encrypt_data_with_user_key(speech_pattern, &user_encryption_key)?) } else { None }, + catchphrase: if let Some(ref catchphrase) = character.catchphrase { Some(encrypt_data_with_user_key(catchphrase, &user_encryption_key)?) } else { None }, + residence: if let Some(ref residence) = character.residence { Some(encrypt_data_with_user_key(residence, &user_encryption_key)?) } else { None }, + notes: if let Some(ref notes) = character.notes { Some(encrypt_data_with_user_key(notes, &user_encryption_key)?) } else { None }, + color: if let Some(ref color) = character.color { Some(encrypt_data_with_user_key(color, &user_encryption_key)?) } else { None }, + }; + let character_inserted: bool = character_repo::insert_sync_character(conn, &character.character_id, &character.book_id, user_id, &character_data, character.last_update, lang)?; + if !character_inserted { return Ok(false); } + } + + for character_attribute in &data.character_attributes { + let encrypted_attribute_name: String = encrypt_data_with_user_key(&character_attribute.attribute_name, &user_encryption_key)?; + let encrypted_attribute_value: String = encrypt_data_with_user_key(&character_attribute.attribute_value, &user_encryption_key)?; + let character_attribute_inserted: bool = character_repo::insert_sync_character_attribute(conn, &character_attribute.attr_id, &character_attribute.character_id, user_id, &encrypted_attribute_name, &encrypted_attribute_value, character_attribute.last_update, lang)?; + if !character_attribute_inserted { return Ok(false); } + } + + for location in &data.locations { + let encrypted_location_name: String = encrypt_data_with_user_key(&location.loc_name, &user_encryption_key)?; + let location_inserted: bool = location_repo::insert_sync_location(conn, &location.loc_id, &location.book_id, user_id, &encrypted_location_name, &location.loc_original_name, location.last_update, lang)?; + if !location_inserted { return Ok(false); } + } + + for location_element in &data.location_elements { + let encrypted_location_element_name: String = encrypt_data_with_user_key(&location_element.element_name, &user_encryption_key)?; + let encrypted_location_element_description: Option = if let Some(ref element_description) = location_element.element_description { Some(encrypt_data_with_user_key(element_description, &user_encryption_key)?) } else { None }; + let location_element_inserted: bool = location_repo::insert_sync_location_element(conn, &location_element.element_id, &location_element.location_id, user_id, &encrypted_location_element_name, &location_element.original_name, encrypted_location_element_description.as_deref(), location_element.last_update, lang)?; + if !location_element_inserted { return Ok(false); } + } + + for location_sub_element in &data.location_sub_elements { + let encrypted_sub_element_name: String = encrypt_data_with_user_key(&location_sub_element.sub_elem_name, &user_encryption_key)?; + let encrypted_sub_element_description: Option = if let Some(ref sub_elem_description) = location_sub_element.sub_elem_description { Some(encrypt_data_with_user_key(sub_elem_description, &user_encryption_key)?) } else { None }; + let location_sub_element_inserted: bool = location_repo::insert_sync_location_sub_element(conn, &location_sub_element.sub_element_id, &location_sub_element.element_id, user_id, &encrypted_sub_element_name, &location_sub_element.original_name, encrypted_sub_element_description.as_deref(), location_sub_element.last_update, lang)?; + if !location_sub_element_inserted { return Ok(false); } + } + + for world in &data.worlds { + let encrypted_world_name: String = encrypt_data_with_user_key(&world.name, &user_encryption_key)?; + let encrypted_world_history: Option = if let Some(ref history) = world.history { Some(encrypt_data_with_user_key(history, &user_encryption_key)?) } else { None }; + let encrypted_world_politics: Option = if let Some(ref politics) = world.politics { Some(encrypt_data_with_user_key(politics, &user_encryption_key)?) } else { None }; + let encrypted_world_economy: Option = if let Some(ref economy) = world.economy { Some(encrypt_data_with_user_key(economy, &user_encryption_key)?) } else { None }; + let encrypted_world_religion: Option = if let Some(ref religion) = world.religion { Some(encrypt_data_with_user_key(religion, &user_encryption_key)?) } else { None }; + let encrypted_world_languages: Option = if let Some(ref languages) = world.languages { Some(encrypt_data_with_user_key(languages, &user_encryption_key)?) } else { None }; + let world_inserted: bool = world_repo::insert_sync_world(conn, &world.world_id, &encrypted_world_name, &world.hashed_name, user_id, &world.book_id, encrypted_world_history.as_deref(), encrypted_world_politics.as_deref(), encrypted_world_economy.as_deref(), encrypted_world_religion.as_deref(), encrypted_world_languages.as_deref(), world.last_update, lang)?; + if !world_inserted { return Ok(false); } + } + + for world_element in &data.world_elements { + let encrypted_world_element_name: String = encrypt_data_with_user_key(&world_element.name, &user_encryption_key)?; + let encrypted_world_element_description: Option = if let Some(ref description) = world_element.description { Some(encrypt_data_with_user_key(description, &user_encryption_key)?) } else { None }; + let world_element_inserted: bool = world_repo::insert_sync_world_element(conn, &world_element.element_id, &world_element.world_id, user_id, world_element.element_type, &encrypted_world_element_name, &world_element.original_name, encrypted_world_element_description.as_deref(), world_element.last_update, lang)?; + if !world_element_inserted { return Ok(false); } + } + + for act_summary in &data.act_summaries { + let encrypted_act_summary: String = encrypt_data_with_user_key(&act_summary.summary, &user_encryption_key)?; + let act_summary_inserted: bool = act_repo::insert_sync_act_summary(conn, &act_summary.summary_id, &act_summary.book_id, user_id, act_summary.act_number, Some(&encrypted_act_summary), act_summary.last_update, lang)?; + if !act_summary_inserted { return Ok(false); } + } + + for ai_guideline in &data.ai_guide_line { + let encrypted_global_resume: Option = if let Some(ref global_resume) = ai_guideline.global_resume { Some(encrypt_data_with_user_key(global_resume, &user_encryption_key)?) } else { None }; + let encrypted_themes: Option = if let Some(ref themes) = ai_guideline.themes { Some(encrypt_data_with_user_key(themes, &user_encryption_key)?) } else { None }; + let encrypted_tone: Option = if let Some(ref tone) = ai_guideline.tone { Some(encrypt_data_with_user_key(tone, &user_encryption_key)?) } else { None }; + let encrypted_atmosphere: Option = if let Some(ref atmosphere) = ai_guideline.atmosphere { Some(encrypt_data_with_user_key(atmosphere, &user_encryption_key)?) } else { None }; + let encrypted_current_resume: Option = if let Some(ref current_resume) = ai_guideline.current_resume { Some(encrypt_data_with_user_key(current_resume, &user_encryption_key)?) } else { None }; + let ai_guideline_inserted: bool = guideline_repo::insert_sync_ai_guide_line(conn, user_id, &ai_guideline.book_id, encrypted_global_resume.as_deref(), encrypted_themes.as_deref(), ai_guideline.verbe_tense, ai_guideline.narrative_type, ai_guideline.langue, ai_guideline.dialogue_type, encrypted_tone.as_deref(), encrypted_atmosphere.as_deref(), encrypted_current_resume.as_deref(), ai_guideline.last_update, lang)?; + if !ai_guideline_inserted { return Ok(false); } + } + + for guideline in &data.guide_line { + let encrypted_tone: Option = if let Some(ref tone) = guideline.tone { Some(encrypt_data_with_user_key(tone, &user_encryption_key)?) } else { None }; + let encrypted_atmosphere: Option = if let Some(ref atmosphere) = guideline.atmosphere { Some(encrypt_data_with_user_key(atmosphere, &user_encryption_key)?) } else { None }; + let encrypted_writing_style: Option = if let Some(ref writing_style) = guideline.writing_style { Some(encrypt_data_with_user_key(writing_style, &user_encryption_key)?) } else { None }; + let encrypted_themes: Option = if let Some(ref themes) = guideline.themes { Some(encrypt_data_with_user_key(themes, &user_encryption_key)?) } else { None }; + let encrypted_symbolism: Option = if let Some(ref symbolism) = guideline.symbolism { Some(encrypt_data_with_user_key(symbolism, &user_encryption_key)?) } else { None }; + let encrypted_motifs: Option = if let Some(ref motifs) = guideline.motifs { Some(encrypt_data_with_user_key(motifs, &user_encryption_key)?) } else { None }; + let encrypted_narrative_voice: Option = if let Some(ref narrative_voice) = guideline.narrative_voice { Some(encrypt_data_with_user_key(narrative_voice, &user_encryption_key)?) } else { None }; + let encrypted_pacing: Option = if let Some(ref pacing) = guideline.pacing { Some(encrypt_data_with_user_key(pacing, &user_encryption_key)?) } else { None }; + let encrypted_intended_audience: Option = if let Some(ref intended_audience) = guideline.intended_audience { Some(encrypt_data_with_user_key(intended_audience, &user_encryption_key)?) } else { None }; + let encrypted_key_messages: Option = if let Some(ref key_messages) = guideline.key_messages { Some(encrypt_data_with_user_key(key_messages, &user_encryption_key)?) } else { None }; + let guideline_inserted: bool = guideline_repo::insert_sync_guide_line(conn, user_id, &guideline.book_id, encrypted_tone.as_deref(), encrypted_atmosphere.as_deref(), encrypted_writing_style.as_deref(), encrypted_themes.as_deref(), encrypted_symbolism.as_deref(), encrypted_motifs.as_deref(), encrypted_narrative_voice.as_deref(), encrypted_pacing.as_deref(), encrypted_intended_audience.as_deref(), encrypted_key_messages.as_deref(), guideline.last_update, lang)?; + if !guideline_inserted { return Ok(false); } + } + + for issue in &data.issues { + let encrypted_issue_name: String = encrypt_data_with_user_key(&issue.name, &user_encryption_key)?; + let issue_inserted: bool = issue_repo::insert_sync_issue(conn, &issue.issue_id, user_id, &issue.chapter_id, &encrypted_issue_name, &issue.hashed_name, issue.last_update, lang)?; + if !issue_inserted { return Ok(false); } + } + + for book_tool in &data.book_tools { + let book_tool_inserted: bool = book_repo::insert_sync_book_tools(conn, &book_tool.book_id, user_id, book_tool.characters_enabled, book_tool.worlds_enabled, book_tool.locations_enabled, book_tool.spells_enabled, book_tool.last_update, lang); + if !book_tool_inserted { return Ok(false); } + } + + for spell_tag in &data.spell_tags { + let encrypted_tag_name: String = encrypt_data_with_user_key(&spell_tag.name, &user_encryption_key)?; + let spell_tag_inserted: bool = spell_tag_repo::insert_sync_spell_tag(conn, &spell_tag.tag_id, &spell_tag.book_id, user_id, &encrypted_tag_name, &spell_tag.hashed_name, spell_tag.color.as_deref(), spell_tag.last_update, lang)?; + if !spell_tag_inserted { return Ok(false); } + } + + for spell in &data.spells { + let encrypted_name: String = encrypt_data_with_user_key(&spell.name, &user_encryption_key)?; + let encrypted_description: String = encrypt_data_with_user_key(&spell.description, &user_encryption_key)?; + let encrypted_appearance: String = encrypt_data_with_user_key(&spell.appearance, &user_encryption_key)?; + let encrypted_tags: String = encrypt_data_with_user_key(&spell.tags, &user_encryption_key)?; + let encrypted_power_level: Option = if let Some(ref power_level) = spell.power_level { Some(encrypt_data_with_user_key(power_level, &user_encryption_key)?) } else { None }; + let encrypted_components: Option = if let Some(ref components) = spell.components { Some(encrypt_data_with_user_key(components, &user_encryption_key)?) } else { None }; + let encrypted_limitations: Option = if let Some(ref limitations) = spell.limitations { Some(encrypt_data_with_user_key(limitations, &user_encryption_key)?) } else { None }; + let encrypted_notes: Option = if let Some(ref notes) = spell.notes { Some(encrypt_data_with_user_key(notes, &user_encryption_key)?) } else { None }; + let spell_inserted: bool = spell_repo::insert_sync_spell(conn, &spell.spell_id, &spell.book_id, user_id, &encrypted_name, &spell.name_hash, Some(&encrypted_description), Some(&encrypted_appearance), Some(&encrypted_tags), encrypted_power_level.as_deref(), encrypted_components.as_deref(), encrypted_limitations.as_deref(), encrypted_notes.as_deref(), spell.last_update, lang)?; + if !spell_inserted { return Ok(false); } + } + + Ok(true) +} diff --git a/src-tauri/src/domains/export/mod.rs b/src-tauri/src/domains/export/mod.rs new file mode 100644 index 0000000..50aab12 --- /dev/null +++ b/src-tauri/src/domains/export/mod.rs @@ -0,0 +1 @@ +pub mod service; diff --git a/src-tauri/src/domains/export/service.rs b/src-tauri/src/domains/export/service.rs new file mode 100644 index 0000000..fa39e65 --- /dev/null +++ b/src-tauri/src/domains/export/service.rs @@ -0,0 +1,251 @@ +use std::io::Cursor; + +use docx_rs::{ + AlignmentType as DocxAlignmentType, Docx, Paragraph as DocxParagraph, Run, +}; +use epub_builder::{EpubBuilder, EpubContent, ReferenceType, ZipLibrary}; +use printpdf::{Mm, PdfDocument}; +use serde_json::Value; + +use crate::domains::book::service::{CompleteBookData, CompleteChapterContent}; +use crate::domains::chapter::service::{get_chapters_or_sheet, tip_tap_to_html, ChapterContentData}; +use crate::error::{AppError, AppResult}; + +pub const MAIN_STYLE: &str = r#"h1 { + font-size: 24px !important; + font-weight: bold !important; + text-indent: 24px !important; +} +p { + text-indent: 30px !important; + margin-top: 0.7em !important; + margin-bottom: 0.7em !important; + text-align: justify !important; +}"#; + +pub struct ExportResult { + pub buffer: Vec, + pub file_name: String, +} + +/// Transforms book data into a DOCX document. +/// * `book_data` - The complete book data to export +/// Returns the DOCX buffer and filename. +pub fn transform_to_docx(book_data: &CompleteBookData) -> AppResult { + let book_title: &str = &book_data.title; + let filename: String = format!("{}.docx", book_title); + + let mut docx: Docx = Docx::new(); + + docx = docx.add_paragraph( + DocxParagraph::new() + .add_run(Run::new().add_text(book_title).bold().size(48)) + .align(DocxAlignmentType::Center) + ); + + if !book_data.sub_title.is_empty() { + docx = docx.add_paragraph( + DocxParagraph::new() + .add_run(Run::new().add_text(&book_data.sub_title).italic().size(32)) + .align(DocxAlignmentType::Center) + ); + } + + if !book_data.summary.is_empty() { + docx = docx.add_paragraph( + DocxParagraph::new() + .add_run(Run::new().add_text(&book_data.summary).italic().size(24)) + .align(DocxAlignmentType::Both) + ); + } + + let chapters: Vec = get_chapters_or_sheet(&book_data.chapters); + + for chapter in &chapters { + if chapter.content.is_empty() { + continue; + } + + docx = docx.add_paragraph( + DocxParagraph::new() + .add_run(Run::new().add_text(&chapter.title).bold().size(32)) + .align(DocxAlignmentType::Center) + .page_break_before(true) + ); + + let paragraphs: Vec<&str> = chapter.content.split('\n').collect(); + + for paragraph in paragraphs { + if paragraph.trim().is_empty() { + continue; + } + docx = docx.add_paragraph( + DocxParagraph::new() + .add_run(Run::new().add_text(paragraph).size(24)) + .align(DocxAlignmentType::Both) + ); + } + } + + let mut buffer: Vec = Vec::new(); + docx.build() + .pack(&mut Cursor::new(&mut buffer)) + .map_err(|error| AppError::Internal(format!("DOCX generation failed: {}", error)))?; + + Ok(ExportResult { buffer, file_name: filename }) +} + +/// Transforms book data into a PDF document. +/// * `book_data` - The complete book data to export +/// Returns the PDF buffer and filename. +pub fn transform_to_pdf(book_data: &CompleteBookData) -> AppResult { + let book_title: &str = &book_data.title; + let filename: String = format!("{}.pdf", book_title); + + let (pdf_document, page_index, layer_index) = PdfDocument::new(book_title, Mm(210.0), Mm(297.0), "Title Page"); + let font = pdf_document + .add_builtin_font(printpdf::BuiltinFont::Helvetica) + .map_err(|error| AppError::Internal(format!("PDF font error: {}", error)))?; + + let current_layer = pdf_document.get_page(page_index).get_layer(layer_index); + let mut current_y: f32 = 270.0; + + current_layer.use_text(book_title, 20.0, Mm(20.0), Mm(current_y), &font); + current_y -= 10.0; + + if !book_data.sub_title.is_empty() && book_data.sub_title.trim() != "" { + current_layer.use_text(&book_data.sub_title, 16.0, Mm(20.0), Mm(current_y), &font); + current_y -= 10.0; + } + + if !book_data.summary.is_empty() && book_data.summary.trim() != "" { + current_layer.use_text(&book_data.summary, 12.0, Mm(20.0), Mm(current_y), &font); + } + + let chapters: Vec = get_chapters_or_sheet(&book_data.chapters); + + for chapter in &chapters { + if chapter.content.is_empty() { + continue; + } + + let (new_page_index, new_layer_index) = pdf_document.add_page(Mm(210.0), Mm(297.0), &chapter.title); + let chapter_layer = pdf_document.get_page(new_page_index).get_layer(new_layer_index); + let mut chapter_y: f32 = 270.0; + + chapter_layer.use_text(&chapter.title, 16.0, Mm(20.0), Mm(chapter_y), &font); + chapter_y -= 10.0; + + let lines: Vec<&str> = chapter.content.split('\n').collect(); + for line in lines { + if chapter_y < 20.0 { + break; + } + chapter_layer.use_text(line, 12.0, Mm(20.0), Mm(chapter_y), &font); + chapter_y -= 6.0; + } + } + + let buffer: Vec = pdf_document + .save_to_bytes() + .map_err(|error| AppError::Internal(format!("PDF generation failed: {}", error)))?; + + Ok(ExportResult { buffer, file_name: filename }) +} + +/// Transforms book data into an EPUB document. +/// * `book_data` - The complete book data to export +/// Returns the EPUB buffer and filename. +pub fn transform_to_epub(book_data: &CompleteBookData) -> AppResult { + let book_title: &str = &book_data.title; + let book_id: &str = &book_data.book_id; + + let zip_library = ZipLibrary::new().map_err(|error| AppError::Internal(format!("EPUB zip error: {}", error)))?; + let mut epub_builder: EpubBuilder = EpubBuilder::new(zip_library) + .map_err(|error| AppError::Internal(format!("EPUB builder error: {}", error)))?; + + let full_title: String = if book_data.sub_title.is_empty() { + book_title.to_string() + } else { + format!("{} - {}", book_title, &book_data.sub_title) + }; + + epub_builder + .metadata("title", &full_title) + .map_err(|error| AppError::Internal(format!("EPUB metadata error: {}", error)))?; + epub_builder + .metadata("language", "fr") + .map_err(|error| AppError::Internal(format!("EPUB metadata error: {}", error)))?; + epub_builder + .metadata("identifier", &format!("urn:uuid:{}", book_id)) + .map_err(|error| AppError::Internal(format!("EPUB metadata error: {}", error)))?; + epub_builder + .metadata("author", &format!("{} {}", &book_data.user_infos.first_name, &book_data.user_infos.last_name)) + .map_err(|error| AppError::Internal(format!("EPUB metadata error: {}", error)))?; + epub_builder + .metadata("publisher", "ERitors Scribe") + .map_err(|error| AppError::Internal(format!("EPUB metadata error: {}", error)))?; + + epub_builder + .stylesheet(MAIN_STYLE.as_bytes()) + .map_err(|error| AppError::Internal(format!("EPUB stylesheet error: {}", error)))?; + + let has_regular_chapters: bool = book_data.chapters.iter().any(|chapter| chapter.order > 0); + let chapters_to_export: Vec<&CompleteChapterContent> = if has_regular_chapters { + book_data.chapters.iter().filter(|chapter| chapter.order > 0).collect() + } else { + book_data.chapters.iter().filter(|chapter| chapter.order == -1).collect() + }; + + for chapter in &chapters_to_export { + if chapter.content.is_empty() { + continue; + } + + let chapter_index: String = format!("chapter{}", chapter.order); + let parsed_content: Value = serde_json::from_str(&chapter.content) + .map_err(|error| AppError::Internal(format!("JSON parse error: {}", error)))?; + let html_content: String = tip_tap_to_html(&parsed_content); + + let xhtml_page: String = format!( + r#" + + + {} + + + + {} + + "#, + &chapter.title, html_content + ); + + epub_builder + .add_content( + EpubContent::new(format!("{}.xhtml", chapter_index), xhtml_page.as_bytes()) + .title(&chapter.title) + .reftype(ReferenceType::Text), + ) + .map_err(|error| AppError::Internal(format!("EPUB content error: {}", error)))?; + } + + if !book_data.cover_image.is_empty() { + let image_buffer: Vec = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &book_data.cover_image, + ) + .map_err(|error| AppError::Internal(format!("Base64 decode error: {}", error)))?; + + epub_builder + .add_cover_image("cover.jpg", image_buffer.as_slice(), "image/jpeg") + .map_err(|error| AppError::Internal(format!("EPUB cover error: {}", error)))?; + } + + let mut epub_buffer: Vec = Vec::new(); + epub_builder + .generate(&mut epub_buffer) + .map_err(|error| AppError::Internal(format!("EPUB generation failed: {}", error)))?; + + Ok(ExportResult { buffer: epub_buffer, file_name: format!("{}.epub", book_title) }) +} diff --git a/src-tauri/src/domains/guideline/mod.rs b/src-tauri/src/domains/guideline/mod.rs new file mode 100644 index 0000000..5608c55 --- /dev/null +++ b/src-tauri/src/domains/guideline/mod.rs @@ -0,0 +1,2 @@ +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/guideline/repo.rs b/src-tauri/src/domains/guideline/repo.rs new file mode 100644 index 0000000..1884ae6 --- /dev/null +++ b/src-tauri/src/domains/guideline/repo.rs @@ -0,0 +1,435 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct BookAIGuideLineTable { + pub user_id: String, + pub book_id: String, + pub global_resume: Option, + pub themes: Option, + pub verbe_tense: Option, + pub narrative_type: Option, + pub langue: Option, + pub dialogue_type: Option, + pub tone: Option, + pub atmosphere: Option, + pub current_resume: Option, + pub last_update: i64, +} + +pub struct BookGuideLineTable { + pub user_id: String, + pub book_id: String, + pub tone: Option, + pub atmosphere: Option, + pub writing_style: Option, + pub themes: Option, + pub symbolism: Option, + pub motifs: Option, + pub narrative_voice: Option, + pub pacing: Option, + pub intended_audience: Option, + pub key_messages: Option, + pub last_update: i64, +} + +pub struct SyncedGuideLineResult { + pub book_id: String, + pub last_update: i64, +} + +pub struct SyncedAIGuideLineResult { + pub book_id: String, + pub last_update: i64, +} + +pub struct GuideLineQuery { + pub tone: String, + pub atmosphere: String, + pub writing_style: String, + pub themes: String, + pub symbolism: String, + pub motifs: String, + pub narrative_voice: String, + pub pacing: String, + pub intended_audience: String, + pub key_messages: String, +} + +pub struct GuideLineAIQuery { + pub user_id: String, + pub book_id: String, + pub global_resume: Option, + pub themes: Option, + pub verbe_tense: Option, + pub narrative_type: Option, + pub langue: Option, + pub dialogue_type: Option, + pub tone: Option, + pub atmosphere: Option, + pub current_resume: Option, + pub meta: String, +} + +/// Fetches the guideline for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of guideline query results. +/// Errors if the guideline cannot be retrieved. +pub fn fetch_guide_line(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT tone, atmosphere, writing_style, themes, symbolism, motifs, narrative_voice, pacing, intended_audience, key_messages FROM book_guide_line WHERE book_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la ligne directrice.".to_string() } else { "Unable to retrieve guideline.".to_string() }))?; + + let guidelines = statement + .query_map(params![book_id, user_id], |query_row| { + Ok(GuideLineQuery { + tone: query_row.get(0)?, atmosphere: query_row.get(1)?, + writing_style: query_row.get(2)?, themes: query_row.get(3)?, + symbolism: query_row.get(4)?, motifs: query_row.get(5)?, + narrative_voice: query_row.get(6)?, pacing: query_row.get(7)?, + intended_audience: query_row.get(8)?, key_messages: query_row.get(9)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la ligne directrice.".to_string() } else { "Unable to retrieve guideline.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la ligne directrice.".to_string() } else { "Unable to retrieve guideline.".to_string() }))?; + + Ok(guidelines) +} + +/// Updates or inserts a guideline for a specific book. +/// If the guideline exists, it updates it; otherwise, it inserts a new one. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `encrypted_tone` - The encrypted tone value +/// * `encrypted_atmosphere` - The encrypted atmosphere value +/// * `encrypted_writing_style` - The encrypted writing style value +/// * `encrypted_themes` - The encrypted themes value +/// * `encrypted_symbolism` - The encrypted symbolism value +/// * `encrypted_motifs` - The encrypted motifs value +/// * `encrypted_narrative_voice` - The encrypted narrative voice value +/// * `encrypted_pacing` - The encrypted pacing value +/// * `encrypted_key_messages` - The encrypted key messages value +/// * `encrypted_intended_audience` - The encrypted intended audience value +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the operation was successful. +/// Errors if the guideline cannot be updated or inserted. +pub fn update_guide_line( + conn: &Connection, user_id: &str, book_id: &str, encrypted_tone: Option<&str>, + encrypted_atmosphere: Option<&str>, encrypted_writing_style: Option<&str>, + encrypted_themes: Option<&str>, encrypted_symbolism: Option<&str>, + encrypted_motifs: Option<&str>, encrypted_narrative_voice: Option<&str>, + encrypted_pacing: Option<&str>, encrypted_key_messages: Option<&str>, + encrypted_intended_audience: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE book_guide_line SET tone=?1, atmosphere=?2, writing_style=?3, themes=?4, symbolism=?5, motifs=?6, narrative_voice=?7, pacing=?8, intended_audience=?9, key_messages=?10, last_update=?11 WHERE user_id=?12 AND book_id=?13", + params![encrypted_tone, encrypted_atmosphere, encrypted_writing_style, encrypted_themes, encrypted_symbolism, encrypted_motifs, encrypted_narrative_voice, encrypted_pacing, encrypted_intended_audience, encrypted_key_messages, last_update, user_id, book_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour la ligne directrice.".to_string() } else { "Unable to update guideline.".to_string() }))?; + + if update_result > 0 { + Ok(true) + } else { + let insert_result = conn + .execute( + "INSERT INTO book_guide_line (user_id, book_id, tone, atmosphere, writing_style, themes, symbolism, motifs, narrative_voice, pacing, intended_audience, key_messages, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)", + params![user_id, book_id, encrypted_tone, encrypted_atmosphere, encrypted_writing_style, encrypted_themes, encrypted_symbolism, encrypted_motifs, encrypted_narrative_voice, encrypted_pacing, encrypted_intended_audience, encrypted_key_messages, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour la ligne directrice.".to_string() } else { "Unable to update guideline.".to_string() }))?; + + Ok(insert_result > 0) + } +} + +/// Inserts or updates an AI guideline for a specific book. +/// If the AI guideline exists, it updates it; otherwise, it inserts a new one. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `narrative_type` - The narrative type identifier +/// * `dialogue_type` - The dialogue type identifier +/// * `encrypted_plot_summary` - The encrypted plot summary +/// * `encrypted_tone_atmosphere` - The encrypted tone and atmosphere value +/// * `verb_tense` - The verb tense identifier +/// * `language` - The language identifier +/// * `encrypted_themes` - The encrypted themes value +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the operation was successful. +/// Errors if the AI guideline cannot be inserted or updated. +pub fn insert_ai_guide_line( + conn: &Connection, user_id: &str, book_id: &str, narrative_type: Option, + dialogue_type: Option, encrypted_plot_summary: Option<&str>, + encrypted_tone_atmosphere: Option<&str>, verb_tense: Option, + language: Option, encrypted_themes: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE book_ai_guide_line SET narrative_type=?1, dialogue_type=?2, global_resume=?3, atmosphere=?4, verbe_tense=?5, langue=?6, themes=?7, last_update=?8 WHERE user_id=?9 AND book_id=?10", + params![narrative_type, dialogue_type, encrypted_plot_summary, encrypted_tone_atmosphere, verb_tense, language, encrypted_themes, last_update, user_id, book_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer la ligne directrice IA.".to_string() } else { "Unable to insert AI guideline.".to_string() }))?; + + if update_result > 0 { + Ok(true) + } else { + let insert_result = conn + .execute( + "INSERT INTO book_ai_guide_line (user_id, book_id, global_resume, themes, verbe_tense, narrative_type, langue, dialogue_type, tone, atmosphere, current_resume, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", + params![user_id, book_id, encrypted_plot_summary, encrypted_themes, verb_tense, narrative_type, language, dialogue_type, encrypted_tone_atmosphere, encrypted_tone_atmosphere, encrypted_plot_summary, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer la ligne directrice IA.".to_string() } else { "Unable to insert AI guideline.".to_string() }))?; + + Ok(insert_result > 0) + } +} + +/// Fetches the AI guideline for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the AI guideline query result. +/// Errors if the AI guideline cannot be retrieved or is not found. +pub fn fetch_guide_line_ai(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT narrative_type, dialogue_type, global_resume, atmosphere, verbe_tense, langue, themes, current_resume FROM book_ai_guide_line WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la ligne directrice IA.".to_string() } else { "Unable to retrieve AI guideline.".to_string() }))?; + + let ai_guideline = statement + .query_row(params![user_id, book_id], |query_row| { + Ok(GuideLineAIQuery { + narrative_type: query_row.get(0)?, dialogue_type: query_row.get(1)?, + global_resume: query_row.get(2)?, atmosphere: query_row.get(3)?, + verbe_tense: query_row.get(4)?, langue: query_row.get(5)?, + themes: query_row.get(6)?, current_resume: query_row.get(7)?, + user_id: user_id.to_string(), book_id: book_id.to_string(), + tone: None, meta: String::new(), + }) + }) + .map_err(|error| match error { + rusqlite::Error::QueryReturnedNoRows => AppError::NotFound(if lang == Lang::Fr { "Ligne directrice IA non trouvée.".to_string() } else { "AI guideline not found.".to_string() }), + _ => AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la ligne directrice IA.".to_string() } else { "Unable to retrieve AI guideline.".to_string() }), + })?; + + Ok(ai_guideline) +} + +/// Fetches the book AI guideline table data for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of book AI guideline table entries. +/// Errors if the AI guideline cannot be retrieved. +pub fn fetch_book_ai_guide_line(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT user_id, book_id, global_resume, themes, verbe_tense, narrative_type, langue, dialogue_type, tone, atmosphere, current_resume, last_update FROM book_ai_guide_line WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la ligne directrice IA.".to_string() } else { "Unable to retrieve AI guideline.".to_string() }))?; + + let ai_guidelines = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookAIGuideLineTable { + user_id: query_row.get(0)?, book_id: query_row.get(1)?, + global_resume: query_row.get(2)?, themes: query_row.get(3)?, + verbe_tense: query_row.get(4)?, narrative_type: query_row.get(5)?, + langue: query_row.get(6)?, dialogue_type: query_row.get(7)?, + tone: query_row.get(8)?, atmosphere: query_row.get(9)?, + current_resume: query_row.get(10)?, last_update: query_row.get(11)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la ligne directrice IA.".to_string() } else { "Unable to retrieve AI guideline.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la ligne directrice IA.".to_string() } else { "Unable to retrieve AI guideline.".to_string() }))?; + + Ok(ai_guidelines) +} + +/// Fetches the book guideline table data for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of book guideline table entries. +/// Errors if the guideline cannot be retrieved. +pub fn fetch_book_guide_line_table(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT user_id, book_id, tone, atmosphere, writing_style, themes, symbolism, motifs, narrative_voice, pacing, intended_audience, key_messages, last_update FROM book_guide_line WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la ligne directrice.".to_string() } else { "Unable to retrieve guideline.".to_string() }))?; + + let guidelines = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookGuideLineTable { + user_id: query_row.get(0)?, book_id: query_row.get(1)?, + tone: query_row.get(2)?, atmosphere: query_row.get(3)?, + writing_style: query_row.get(4)?, themes: query_row.get(5)?, + symbolism: query_row.get(6)?, motifs: query_row.get(7)?, + narrative_voice: query_row.get(8)?, pacing: query_row.get(9)?, + intended_audience: query_row.get(10)?, key_messages: query_row.get(11)?, + last_update: query_row.get(12)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la ligne directrice.".to_string() } else { "Unable to retrieve guideline.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la ligne directrice.".to_string() } else { "Unable to retrieve guideline.".to_string() }))?; + + Ok(guidelines) +} + +/// Fetches all synced guidelines for a specific user. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced guideline results containing book_id and last_update. +/// Errors if the synced guidelines cannot be retrieved. +pub fn fetch_synced_guide_line(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT book_id, last_update FROM book_guide_line WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lignes directrices synchronisées.".to_string() } else { "Unable to retrieve synced guidelines.".to_string() }))?; + + let synced_guidelines = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedGuideLineResult { book_id: query_row.get(0)?, last_update: query_row.get(1)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lignes directrices synchronisées.".to_string() } else { "Unable to retrieve synced guidelines.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lignes directrices synchronisées.".to_string() } else { "Unable to retrieve synced guidelines.".to_string() }))?; + + Ok(synced_guidelines) +} + +/// Fetches all synced AI guidelines for a specific user. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced AI guideline results containing book_id and last_update. +/// Errors if the synced AI guidelines cannot be retrieved. +pub fn fetch_synced_ai_guide_line(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT book_id, last_update FROM book_ai_guide_line WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lignes directrices IA synchronisées.".to_string() } else { "Unable to retrieve synced AI guidelines.".to_string() }))?; + + let synced_ai_guidelines = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedAIGuideLineResult { book_id: query_row.get(0)?, last_update: query_row.get(1)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lignes directrices IA synchronisées.".to_string() } else { "Unable to retrieve synced AI guidelines.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lignes directrices IA synchronisées.".to_string() } else { "Unable to retrieve synced AI guidelines.".to_string() }))?; + + Ok(synced_ai_guidelines) +} + +/// Checks if a guideline exists for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the guideline exists, false otherwise. +/// Errors if the existence check fails. +pub fn guide_line_exist(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_guide_line WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de la ligne directrice.".to_string() } else { "Unable to check guideline existence.".to_string() }))?; + + let exists = statement + .query_row(params![user_id, book_id], |_query_row| Ok(true)) + .unwrap_or(false); + + Ok(exists) +} + +/// Checks if an AI guideline exists for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the AI guideline exists, false otherwise. +/// Errors if the existence check fails. +pub fn ai_guide_line_exist(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_ai_guide_line WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de la ligne directrice IA.".to_string() } else { "Unable to check AI guideline existence.".to_string() }))?; + + let exists = statement + .query_row(params![user_id, book_id], |_query_row| Ok(true)) + .unwrap_or(false); + + Ok(exists) +} + +/// Inserts a synced AI guideline for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `global_resume` - The global resume value (nullable) +/// * `themes` - The themes value (nullable) +/// * `verbe_tense` - The verb tense identifier (nullable) +/// * `narrative_type` - The narrative type identifier (nullable) +/// * `langue` - The language identifier (nullable) +/// * `dialogue_type` - The dialogue type identifier (nullable) +/// * `tone` - The tone value (nullable) +/// * `atmosphere` - The atmosphere value (nullable) +/// * `current_resume` - The current resume value (nullable) +/// * `last_update` - The last update timestamp +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion was successful. +/// Errors if the AI guideline cannot be inserted. +pub fn insert_sync_ai_guide_line( + conn: &Connection, user_id: &str, book_id: &str, global_resume: Option<&str>, + themes: Option<&str>, verbe_tense: Option, narrative_type: Option, + langue: Option, dialogue_type: Option, tone: Option<&str>, + atmosphere: Option<&str>, current_resume: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_ai_guide_line (user_id, book_id, global_resume, themes, verbe_tense, narrative_type, langue, dialogue_type, tone, atmosphere, current_resume, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![user_id, book_id, global_resume, themes, verbe_tense, narrative_type, langue, dialogue_type, tone, atmosphere, current_resume, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer la ligne directrice IA.".to_string() } else { "Unable to insert AI guideline.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Inserts a synced guideline for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The user identifier +/// * `book_id` - The book identifier +/// * `tone` - The tone value (nullable) +/// * `atmosphere` - The atmosphere value (nullable) +/// * `writing_style` - The writing style value (nullable) +/// * `themes` - The themes value (nullable) +/// * `symbolism` - The symbolism value (nullable) +/// * `motifs` - The motifs value (nullable) +/// * `narrative_voice` - The narrative voice value (nullable) +/// * `pacing` - The pacing value (nullable) +/// * `intended_audience` - The intended audience value (nullable) +/// * `key_messages` - The key messages value (nullable) +/// * `last_update` - The last update timestamp +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion was successful. +/// Errors if the guideline cannot be inserted. +pub fn insert_sync_guide_line( + conn: &Connection, user_id: &str, book_id: &str, tone: Option<&str>, + atmosphere: Option<&str>, writing_style: Option<&str>, themes: Option<&str>, + symbolism: Option<&str>, motifs: Option<&str>, narrative_voice: Option<&str>, + pacing: Option<&str>, intended_audience: Option<&str>, key_messages: Option<&str>, + last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_guide_line (user_id, book_id, tone, atmosphere, writing_style, themes, symbolism, motifs, narrative_voice, pacing, intended_audience, key_messages, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![user_id, book_id, tone, atmosphere, writing_style, themes, symbolism, motifs, narrative_voice, pacing, intended_audience, key_messages, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer la ligne directrice.".to_string() } else { "Unable to insert guideline.".to_string() }))?; + + Ok(insert_result > 0) +} diff --git a/src-tauri/src/domains/guideline/service.rs b/src-tauri/src/domains/guideline/service.rs new file mode 100644 index 0000000..9fc46ff --- /dev/null +++ b/src-tauri/src/domains/guideline/service.rs @@ -0,0 +1,208 @@ +use rusqlite::Connection; +use serde::Serialize; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::guideline::repo; +use crate::error::{AppError, AppResult}; +use crate::helpers::timestamp_in_seconds; +use crate::shared::types::Lang; + +/// Represents the synced guideline data for a book. +pub struct SyncedGuideLine { + pub last_update: i64, +} + +/// Represents the synced AI guideline data for a book. +pub struct SyncedAIGuideLine { + pub last_update: i64, +} + +/// Represents the decrypted guideline properties for a book. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GuideLineProps { + pub tone: String, + pub atmosphere: String, + pub writing_style: String, + pub themes: String, + pub symbolism: String, + pub motifs: String, + pub narrative_voice: String, + pub pacing: String, + pub intended_audience: String, + pub key_messages: String, +} + +/// Represents the decrypted AI guideline data for a book. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GuideLineAI { + pub narrative_type: Option, + pub dialogue_type: Option, + pub global_resume: Option, + pub atmosphere: Option, + pub verbe_tense: Option, + pub langue: Option, + pub current_resume: Option, + pub themes: Option, +} + +/// Retrieves and decrypts the guideline for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the decrypted guideline properties or None if not found. +pub fn get_guide_line(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let guide_line_results: Vec = repo::fetch_guide_line(conn, user_id, book_id, lang)?; + + if guide_line_results.is_empty() { + return Ok(None); + } + + let guide_line_data: &repo::GuideLineQuery = &guide_line_results[0]; + let encryption_key: String = get_user_encryption_key(user_id)?; + + Ok(Some(GuideLineProps { + tone: if guide_line_data.tone.is_empty() { String::new() } else { decrypt_data_with_user_key(&guide_line_data.tone, &encryption_key)? }, + atmosphere: if guide_line_data.atmosphere.is_empty() { String::new() } else { decrypt_data_with_user_key(&guide_line_data.atmosphere, &encryption_key)? }, + writing_style: if guide_line_data.writing_style.is_empty() { String::new() } else { decrypt_data_with_user_key(&guide_line_data.writing_style, &encryption_key)? }, + themes: if guide_line_data.themes.is_empty() { String::new() } else { decrypt_data_with_user_key(&guide_line_data.themes, &encryption_key)? }, + symbolism: if guide_line_data.symbolism.is_empty() { String::new() } else { decrypt_data_with_user_key(&guide_line_data.symbolism, &encryption_key)? }, + motifs: if guide_line_data.motifs.is_empty() { String::new() } else { decrypt_data_with_user_key(&guide_line_data.motifs, &encryption_key)? }, + narrative_voice: if guide_line_data.narrative_voice.is_empty() { String::new() } else { decrypt_data_with_user_key(&guide_line_data.narrative_voice, &encryption_key)? }, + pacing: if guide_line_data.pacing.is_empty() { String::new() } else { decrypt_data_with_user_key(&guide_line_data.pacing, &encryption_key)? }, + intended_audience: if guide_line_data.intended_audience.is_empty() { String::new() } else { decrypt_data_with_user_key(&guide_line_data.intended_audience, &encryption_key)? }, + key_messages: if guide_line_data.key_messages.is_empty() { String::new() } else { decrypt_data_with_user_key(&guide_line_data.key_messages, &encryption_key)? }, + })) +} + +/// Updates or creates a guideline for a specific book with encrypted data. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `tone` - The tone setting for the book (nullable) +/// * `atmosphere` - The atmosphere setting for the book (nullable) +/// * `writing_style` - The writing style for the book (nullable) +/// * `themes` - The themes for the book (nullable) +/// * `symbolism` - The symbolism elements for the book (nullable) +/// * `motifs` - The motifs for the book (nullable) +/// * `narrative_voice` - The narrative voice for the book (nullable) +/// * `pacing` - The pacing setting for the book (nullable) +/// * `key_messages` - The key messages for the book (nullable) +/// * `intended_audience` - The intended audience for the book (nullable) +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful, false otherwise. +pub fn update_guide_line( + conn: &Connection, user_id: &str, book_id: &str, tone: Option<&str>, + atmosphere: Option<&str>, writing_style: Option<&str>, themes: Option<&str>, + symbolism: Option<&str>, motifs: Option<&str>, narrative_voice: Option<&str>, + pacing: Option<&str>, key_messages: Option<&str>, intended_audience: Option<&str>, + lang: Lang, +) -> AppResult { + let encryption_key: String = get_user_encryption_key(user_id)?; + + let encrypted_tone: String = if let Some(tone_value) = tone { encrypt_data_with_user_key(tone_value, &encryption_key)? } else { String::new() }; + let encrypted_atmosphere: String = if let Some(atmosphere_value) = atmosphere { encrypt_data_with_user_key(atmosphere_value, &encryption_key)? } else { String::new() }; + let encrypted_writing_style: String = if let Some(writing_style_value) = writing_style { encrypt_data_with_user_key(writing_style_value, &encryption_key)? } else { String::new() }; + let encrypted_themes: String = if let Some(themes_value) = themes { encrypt_data_with_user_key(themes_value, &encryption_key)? } else { String::new() }; + let encrypted_symbolism: String = if let Some(symbolism_value) = symbolism { encrypt_data_with_user_key(symbolism_value, &encryption_key)? } else { String::new() }; + let encrypted_motifs: String = if let Some(motifs_value) = motifs { encrypt_data_with_user_key(motifs_value, &encryption_key)? } else { String::new() }; + let encrypted_narrative_voice: String = if let Some(narrative_voice_value) = narrative_voice { encrypt_data_with_user_key(narrative_voice_value, &encryption_key)? } else { String::new() }; + let encrypted_pacing: String = if let Some(pacing_value) = pacing { encrypt_data_with_user_key(pacing_value, &encryption_key)? } else { String::new() }; + let encrypted_key_messages: String = if let Some(key_messages_value) = key_messages { encrypt_data_with_user_key(key_messages_value, &encryption_key)? } else { String::new() }; + let encrypted_intended_audience: String = if let Some(intended_audience_value) = intended_audience { encrypt_data_with_user_key(intended_audience_value, &encryption_key)? } else { String::new() }; + + let last_update: i64 = timestamp_in_seconds(); + + repo::update_guide_line( + conn, user_id, book_id, + Some(&encrypted_tone), Some(&encrypted_atmosphere), Some(&encrypted_writing_style), + Some(&encrypted_themes), Some(&encrypted_symbolism), Some(&encrypted_motifs), + Some(&encrypted_narrative_voice), Some(&encrypted_pacing), + Some(&encrypted_key_messages), Some(&encrypted_intended_audience), + last_update, lang, + ) +} + +/// Retrieves and decrypts the AI guideline for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the decrypted AI guideline data with default values if not found. +/// Errors if an unexpected error occurs during retrieval. +pub fn get_guide_line_ai(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let encryption_key: String = get_user_encryption_key(user_id)?; + + match repo::fetch_guide_line_ai(conn, user_id, book_id, lang) { + Ok(ai_guide_line_data) => { + Ok(GuideLineAI { + narrative_type: ai_guide_line_data.narrative_type, + dialogue_type: ai_guide_line_data.dialogue_type, + global_resume: Some(if let Some(ref global_resume) = ai_guide_line_data.global_resume { decrypt_data_with_user_key(global_resume, &encryption_key)? } else { String::new() }), + atmosphere: Some(if let Some(ref atmosphere) = ai_guide_line_data.atmosphere { decrypt_data_with_user_key(atmosphere, &encryption_key)? } else { String::new() }), + verbe_tense: ai_guide_line_data.verbe_tense, + themes: Some(if let Some(ref themes) = ai_guide_line_data.themes { decrypt_data_with_user_key(themes, &encryption_key)? } else { String::new() }), + current_resume: Some(if let Some(ref current_resume) = ai_guide_line_data.current_resume { decrypt_data_with_user_key(current_resume, &encryption_key)? } else { String::new() }), + langue: ai_guide_line_data.langue, + }) + } + Err(error) => { + if error.to_string().contains("not found") || error.to_string().contains("non trouvée") { + Ok(GuideLineAI { + narrative_type: Some(0), + dialogue_type: Some(0), + global_resume: Some(String::new()), + atmosphere: Some(String::new()), + verbe_tense: Some(0), + themes: Some(String::new()), + current_resume: Some(String::new()), + langue: Some(0), + }) + } else { + Err(AppError::Internal(if lang == Lang::Fr { + "Erreur inconnue lors de la recuperation de la ligne directrice de l'IA.".to_string() + } else { + "Unknown error while fetching AI guideline.".to_string() + })) + } + } + } +} + +/// Creates or updates an AI guideline for a specific book with encrypted data. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `narrative_type` - The narrative type identifier +/// * `dialogue_type` - The dialogue type identifier +/// * `plot_summary` - The plot summary text to be encrypted +/// * `tone_atmosphere` - The tone and atmosphere description to be encrypted +/// * `verb_tense` - The verb tense identifier +/// * `language` - The language identifier +/// * `themes` - The themes description to be encrypted +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the operation was successful, false otherwise. +pub fn set_ai_guide_line( + conn: &Connection, user_id: &str, book_id: &str, narrative_type: i64, + dialogue_type: i64, plot_summary: &str, tone_atmosphere: &str, verb_tense: i64, + language: i64, themes: &str, lang: Lang, +) -> AppResult { + let encryption_key: String = get_user_encryption_key(user_id)?; + + let encrypted_plot_summary: String = if plot_summary.is_empty() { String::new() } else { encrypt_data_with_user_key(plot_summary, &encryption_key)? }; + let encrypted_tone_atmosphere: String = if tone_atmosphere.is_empty() { String::new() } else { encrypt_data_with_user_key(tone_atmosphere, &encryption_key)? }; + let encrypted_themes: String = if themes.is_empty() { String::new() } else { encrypt_data_with_user_key(themes, &encryption_key)? }; + + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_ai_guide_line( + conn, user_id, book_id, + Some(narrative_type), Some(dialogue_type), + Some(&encrypted_plot_summary), Some(&encrypted_tone_atmosphere), + Some(verb_tense), Some(language), Some(&encrypted_themes), + last_update, lang, + ) +} diff --git a/src-tauri/src/domains/incident/mod.rs b/src-tauri/src/domains/incident/mod.rs new file mode 100644 index 0000000..5608c55 --- /dev/null +++ b/src-tauri/src/domains/incident/mod.rs @@ -0,0 +1,2 @@ +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/incident/repo.rs b/src-tauri/src/domains/incident/repo.rs new file mode 100644 index 0000000..cde0e57 --- /dev/null +++ b/src-tauri/src/domains/incident/repo.rs @@ -0,0 +1,248 @@ +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct BookIncidentsTable { + pub incident_id: String, + pub author_id: String, + pub book_id: String, + pub title: String, + pub hashed_title: String, + pub summary: Option, + pub last_update: i64, +} + +pub struct SyncedIncidentResult { + pub incident_id: String, + pub book_id: String, + pub title: String, + pub last_update: i64, +} + +pub struct IncidentQuery { + pub incident_id: String, + pub title: String, + pub summary: String, +} + +/// Fetches all incidents for a specific book belonging to a user. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user (author) +/// * `book_id` - The ID of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of incidents with their ID, title, and summary. +/// Errors if the database query fails. +pub fn fetch_all_incitent_incidents(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT incident_id, title, summary FROM book_incidents WHERE author_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les incidents.".to_string() } else { "Unable to retrieve incidents.".to_string() }))?; + + let incidents = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(IncidentQuery { incident_id: query_row.get(0)?, title: query_row.get(1)?, summary: query_row.get(2)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les incidents.".to_string() } else { "Unable to retrieve incidents.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les incidents.".to_string() } else { "Unable to retrieve incidents.".to_string() }))?; + + Ok(incidents) +} + +/// Inserts a new incident into the database. +/// * `conn` - Database connection +/// * `incident_id` - The unique ID for the new incident +/// * `user_id` - The ID of the user (author) +/// * `book_id` - The ID of the book +/// * `encrypted_name` - The encrypted title of the incident +/// * `hashed_name` - The hashed title of the incident +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the incident ID if insertion was successful. +/// Errors if the database insertion fails. +pub fn insert_new_incident( + conn: &Connection, incident_id: &str, user_id: &str, book_id: &str, + encrypted_name: &str, hashed_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_incidents (incident_id, author_id, book_id, title, hashed_title, last_update) VALUES (?1,?2,?3,?4,?5,?6)", + params![incident_id, user_id, book_id, encrypted_name, hashed_name, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter l'élément déclencheur.".to_string() } else { "Unable to add incident.".to_string() }))?; + + if insert_result > 0 { + Ok(incident_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de l'ajout de l'élément déclencheur.".to_string() } else { "Error adding incident.".to_string() })) + } +} + +/// Deletes an incident from the database. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user (author) +/// * `book_id` - The ID of the book +/// * `incident_id` - The ID of the incident to delete +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the incident was deleted, false otherwise. +/// Errors if the database deletion fails. +pub fn delete_incident(conn: &Connection, user_id: &str, book_id: &str, incident_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute( + "DELETE FROM book_incidents WHERE author_id=?1 AND book_id=?2 AND incident_id=?3", + params![user_id, book_id, incident_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer l'élément déclencheur.".to_string() } else { "Unable to delete incident.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Updates an existing incident in the database. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user (author) +/// * `book_id` - The ID of the book +/// * `incident_id` - The ID of the incident to update +/// * `encrypted_incident_name` - The new encrypted title +/// * `incident_hashed_name` - The new hashed title +/// * `incident_summary` - The new summary +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the incident was updated, false otherwise. +/// Errors if the database update fails. +pub fn update_incident( + conn: &Connection, user_id: &str, book_id: &str, incident_id: &str, + encrypted_incident_name: &str, incident_hashed_name: &str, incident_summary: &str, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE book_incidents SET title=?1, hashed_title=?2, summary=?3, last_update=?4 WHERE author_id=?5 AND book_id=?6 AND incident_id=?7", + params![encrypted_incident_name, incident_hashed_name, incident_summary, last_update, user_id, book_id, incident_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour l'incident.".to_string() } else { "Unable to update incident.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Fetches all incidents for a book with complete information. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user (author) +/// * `book_id` - The ID of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of complete incident records. +/// Errors if the database query fails. +pub fn fetch_book_incidents(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT incident_id, author_id, book_id, title, hashed_title, summary, last_update FROM book_incidents WHERE author_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les incidents.".to_string() } else { "Unable to retrieve incidents.".to_string() }))?; + + let incidents = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookIncidentsTable { + incident_id: query_row.get(0)?, author_id: query_row.get(1)?, + book_id: query_row.get(2)?, title: query_row.get(3)?, + hashed_title: query_row.get(4)?, summary: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les incidents.".to_string() } else { "Unable to retrieve incidents.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les incidents.".to_string() } else { "Unable to retrieve incidents.".to_string() }))?; + + Ok(incidents) +} + +/// Fetches all synced incidents for a user across all books. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user (author) +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced incident records with minimal information. +/// Errors if the database query fails. +pub fn fetch_synced_incidents(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT incident_id, book_id, title, last_update FROM book_incidents WHERE author_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les incidents synchronisés.".to_string() } else { "Unable to retrieve synced incidents.".to_string() }))?; + + let synced_incidents = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedIncidentResult { incident_id: query_row.get(0)?, book_id: query_row.get(1)?, title: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les incidents synchronisés.".to_string() } else { "Unable to retrieve synced incidents.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les incidents synchronisés.".to_string() } else { "Unable to retrieve synced incidents.".to_string() }))?; + + Ok(synced_incidents) +} + +/// Inserts a synced incident into the database. +/// * `conn` - Database connection +/// * `incident_id` - The unique ID for the incident +/// * `author_id` - The ID of the author +/// * `book_id` - The ID of the book +/// * `title` - The encrypted title +/// * `hashed_title` - The hashed title +/// * `summary` - The encrypted summary (can be null) +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the incident was inserted, false otherwise. +/// Errors if the database insertion fails. +pub fn insert_sync_incident( + conn: &Connection, incident_id: &str, author_id: &str, book_id: &str, + title: &str, hashed_title: &str, summary: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_incidents (incident_id, author_id, book_id, title, hashed_title, summary, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![incident_id, author_id, book_id, title, hashed_title, summary, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer l'incident.".to_string() } else { "Unable to insert incident.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Fetches complete incident information by its ID. +/// * `conn` - Database connection +/// * `incident_id` - The ID of the incident to fetch +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array containing the incident record (empty if not found). +/// Errors if the database query fails. +pub fn fetch_complete_incident_by_id(conn: &Connection, incident_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT incident_id, author_id, book_id, title, hashed_title, summary, last_update FROM book_incidents WHERE incident_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'incident complet.".to_string() } else { "Unable to retrieve complete incident.".to_string() }))?; + + let incident = statement + .query_map(params![incident_id], |query_row| { + Ok(BookIncidentsTable { + incident_id: query_row.get(0)?, author_id: query_row.get(1)?, + book_id: query_row.get(2)?, title: query_row.get(3)?, + hashed_title: query_row.get(4)?, summary: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'incident complet.".to_string() } else { "Unable to retrieve complete incident.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'incident complet.".to_string() } else { "Unable to retrieve complete incident.".to_string() }))?; + + Ok(incident) +} + +/// Checks if an incident exists in the database. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user (author) +/// * `book_id` - The ID of the book +/// * `incident_id` - The ID of the incident to check +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the incident exists, false otherwise. +/// Errors if the database query fails. +pub fn incident_exist(conn: &Connection, user_id: &str, book_id: &str, incident_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_incidents WHERE book_id=?1 AND incident_id=?2 AND author_id=?3") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'incident.".to_string() } else { "Unable to check incident existence.".to_string() }))?; + + let existing_incident = statement + .query_row(params![book_id, incident_id, user_id], |query_row| query_row.get::<_, i64>(0)) + .optional() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'incident.".to_string() } else { "Unable to check incident existence.".to_string() }))?; + + Ok(existing_incident.is_some()) +} diff --git a/src-tauri/src/domains/incident/service.rs b/src-tauri/src/domains/incident/service.rs new file mode 100644 index 0000000..a97a1ad --- /dev/null +++ b/src-tauri/src/domains/incident/service.rs @@ -0,0 +1,123 @@ +use rusqlite::Connection; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::chapter::repo::ActChapterQuery; +use crate::domains::incident::repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::AppResult; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +/// Represents the story details of an incident within a chapter. +pub struct IncidentStory { + pub incident_title: String, + pub incident_summary: String, + pub chapter_summary: String, + pub chapter_goal: String, +} + +/// Represents a synced incident with minimal information for comparison. +pub struct SyncedIncident { + pub id: String, + pub name: String, + pub last_update: i64, +} + +/// Represents the properties of an incident with its associated chapters. +pub struct IncidentProps { + pub incident_id: String, + pub title: String, + pub summary: String, + pub chapters: Vec, +} + +/// Creates a new incident for a book. +/// Encrypts the incident name and generates a hashed version for indexing. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user creating the incident +/// * `book_id` - The unique identifier of the book to add the incident to +/// * `name` - The plain text name of the incident +/// * `lang` - The language for error messages +/// * `existing_incident_id` - Optional existing incident ID to use instead of generating a new one +/// Returns the unique identifier of the created incident. +pub fn add_new_incident( + conn: &Connection, user_id: &str, book_id: &str, name: &str, + lang: Lang, existing_incident_id: Option<&str>, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let hashed_name: String = hash_element(name); + let incident_id: String = create_unique_id(existing_incident_id); + let last_update: i64 = timestamp_in_seconds(); + repo::insert_new_incident(conn, &incident_id, user_id, book_id, &encrypted_name, &hashed_name, last_update, lang) +} + +/// Retrieves all incidents for a specific book with their associated chapters. +/// Decrypts incident titles and summaries using the user's encryption key. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `act_chapters` - Array of chapters from acts to associate with incidents +/// * `lang` - The language for error messages +/// Returns an array of incident properties with decrypted data. +pub fn get_incitents_incidents( + conn: &Connection, user_id: &str, book_id: &str, + act_chapters: &[ActChapterQuery], lang: Lang, +) -> AppResult> { + let incident_query_results: Vec = repo::fetch_all_incitent_incidents(conn, user_id, book_id, lang)?; + let user_key: String = get_user_encryption_key(user_id)?; + let mut incidents: Vec = Vec::new(); + + if !incident_query_results.is_empty() { + for incident_record in &incident_query_results { + let mut associated_chapters: Vec = Vec::new(); + for chapter in act_chapters { + if let Some(ref incident_id) = chapter.incident_id { + if incident_id == &incident_record.incident_id { + associated_chapters.push(ActChapterQuery { + chapter_info_id: chapter.chapter_info_id, + chapter_id: chapter.chapter_id.clone(), + title: chapter.title.clone(), + chapter_order: chapter.chapter_order, + act_id: chapter.act_id, + incident_id: chapter.incident_id.clone(), + plot_point_id: chapter.plot_point_id.clone(), + summary: chapter.summary.clone(), + goal: chapter.goal.clone(), + }); + } + } + } + let decrypted_title: String = if incident_record.title.is_empty() { String::new() } else { decrypt_data_with_user_key(&incident_record.title, &user_key)? }; + let decrypted_summary: String = if incident_record.summary.is_empty() { String::new() } else { decrypt_data_with_user_key(&incident_record.summary, &user_key)? }; + incidents.push(IncidentProps { + incident_id: incident_record.incident_id.clone(), + title: decrypted_title, + summary: decrypted_summary, + chapters: associated_chapters, + }); + } + } + + Ok(incidents) +} + +/// Removes an incident from a book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `incident_id` - The unique identifier of the incident to remove +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if the incident was successfully deleted, false otherwise. +pub fn remove_incident( + conn: &Connection, user_id: &str, book_id: &str, incident_id: &str, + deleted_at: i64, lang: Lang, +) -> AppResult { + let deleted: bool = repo::delete_incident(conn, user_id, book_id, incident_id, lang)?; + if deleted { + tombstone_repo::insert(conn, incident_id, "book_incidents", incident_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} diff --git a/src-tauri/src/domains/issue/mod.rs b/src-tauri/src/domains/issue/mod.rs new file mode 100644 index 0000000..5608c55 --- /dev/null +++ b/src-tauri/src/domains/issue/mod.rs @@ -0,0 +1,2 @@ +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/issue/repo.rs b/src-tauri/src/domains/issue/repo.rs new file mode 100644 index 0000000..5a981c8 --- /dev/null +++ b/src-tauri/src/domains/issue/repo.rs @@ -0,0 +1,225 @@ +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct BookIssuesTable { + pub issue_id: String, + pub author_id: String, + pub book_id: String, + pub name: String, + pub hashed_issue_name: String, + pub last_update: i64, +} + +pub struct SyncedIssueResult { + pub issue_id: String, + pub book_id: String, + pub name: String, + pub last_update: i64, +} + +pub struct IssueQuery { + pub issue_id: String, + pub name: String, +} + +/// Fetches all issues associated with a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user/author +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of issues with their IDs and names. +pub fn fetch_issues_from_book(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT issue_id, name FROM book_issues WHERE author_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les problématiques.".to_string() } else { "Unable to retrieve issues.".to_string() }))?; + + let issues = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(IssueQuery { issue_id: query_row.get(0)?, name: query_row.get(1)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les problématiques.".to_string() } else { "Unable to retrieve issues.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les problématiques.".to_string() } else { "Unable to retrieve issues.".to_string() }))?; + + Ok(issues) +} + +/// Inserts a new issue into the database after verifying it doesn't already exist. +/// * `conn` - Database connection +/// * `issue_id` - The unique identifier for the new issue +/// * `user_id` - The unique identifier of the user/author +/// * `book_id` - The unique identifier of the book +/// * `encrypted_name` - The encrypted name of the issue +/// * `hashed_name` - The hashed name of the issue for duplicate checking +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the issue ID if successfully inserted. +pub fn insert_new_issue( + conn: &Connection, issue_id: &str, user_id: &str, book_id: &str, + encrypted_name: &str, hashed_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let existing_issue: Option = conn + .query_row("SELECT issue_id FROM book_issues WHERE hashed_issue_name=?1 AND book_id=?2 AND author_id=?3", params![hashed_name, book_id, user_id], |query_row| query_row.get(0)) + .optional() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de la problématique.".to_string() } else { "Unable to verify issue existence.".to_string() }))?; + + if existing_issue.is_some() { + return Err(AppError::Validation(if lang == Lang::Fr { "La problématique existe déjà.".to_string() } else { "This issue already exists.".to_string() })); + } + + let insert_result = conn + .execute("INSERT INTO book_issues (issue_id, author_id, book_id, name, hashed_issue_name, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![issue_id, user_id, book_id, encrypted_name, hashed_name, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter la problématique.".to_string() } else { "Unable to add issue.".to_string() }))?; + + if insert_result == 0 { + return Err(AppError::Internal(if lang == Lang::Fr { "Erreur pendant l'ajout de la problématique.".to_string() } else { "Error adding issue.".to_string() })); + } + + Ok(issue_id.to_string()) +} + +/// Deletes an issue from the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user/author +/// * `issue_id` - The unique identifier of the issue to delete +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the issue was successfully deleted, false otherwise. +pub fn delete_issue(conn: &Connection, user_id: &str, issue_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM book_issues WHERE author_id=?1 AND issue_id=?2", params![user_id, issue_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer la problématique.".to_string() } else { "Unable to delete issue.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Fetches all complete issue records for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user/author +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of complete issue records. +pub fn fetch_book_issues(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT issue_id, author_id, book_id, name, hashed_issue_name, last_update FROM book_issues WHERE author_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les problématiques.".to_string() } else { "Unable to retrieve issues.".to_string() }))?; + + let issues = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookIssuesTable { + issue_id: query_row.get(0)?, author_id: query_row.get(1)?, + book_id: query_row.get(2)?, name: query_row.get(3)?, + hashed_issue_name: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les problématiques.".to_string() } else { "Unable to retrieve issues.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les problématiques.".to_string() } else { "Unable to retrieve issues.".to_string() }))?; + + Ok(issues) +} + +/// Fetches all synced issues for a specific user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user/author +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced issue records. +pub fn fetch_synced_issues(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT issue_id, book_id, name, last_update FROM book_issues WHERE author_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les problématiques synchronisées.".to_string() } else { "Unable to retrieve synced issues.".to_string() }))?; + + let synced_issues = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedIssueResult { issue_id: query_row.get(0)?, book_id: query_row.get(1)?, name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les problématiques synchronisées.".to_string() } else { "Unable to retrieve synced issues.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les problématiques synchronisées.".to_string() } else { "Unable to retrieve synced issues.".to_string() }))?; + + Ok(synced_issues) +} + +/// Inserts a synced issue from remote into the local database. +/// * `conn` - Database connection +/// * `issue_id` - The unique identifier of the issue +/// * `author_id` - The unique identifier of the author +/// * `book_id` - The unique identifier of the book +/// * `name` - The encrypted name of the issue +/// * `hashed_issue_name` - The hashed name of the issue +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the issue was successfully inserted, false otherwise. +pub fn insert_sync_issue( + conn: &Connection, issue_id: &str, author_id: &str, book_id: &str, + name: &str, hashed_issue_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute("INSERT INTO book_issues (issue_id, author_id, book_id, name, hashed_issue_name, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![issue_id, author_id, book_id, name, hashed_issue_name, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer la problématique.".to_string() } else { "Unable to insert issue.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Fetches a complete issue record by its ID. +/// * `conn` - Database connection +/// * `issue_id` - The unique identifier of the issue +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of complete issue records. +pub fn fetch_complete_issue_by_id(conn: &Connection, issue_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT issue_id, author_id, book_id, name, hashed_issue_name, last_update FROM book_issues WHERE issue_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le problème complet.".to_string() } else { "Unable to retrieve complete issue.".to_string() }))?; + + let issues = statement + .query_map(params![issue_id], |query_row| { + Ok(BookIssuesTable { + issue_id: query_row.get(0)?, author_id: query_row.get(1)?, + book_id: query_row.get(2)?, name: query_row.get(3)?, + hashed_issue_name: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le problème complet.".to_string() } else { "Unable to retrieve complete issue.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le problème complet.".to_string() } else { "Unable to retrieve complete issue.".to_string() }))?; + + Ok(issues) +} + +/// Updates an existing issue in the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user/author +/// * `book_id` - The unique identifier of the book +/// * `issue_id` - The unique identifier of the issue to update +/// * `name` - The new encrypted name of the issue +/// * `hashed_name` - The new hashed name of the issue +/// * `last_update` - The timestamp of the update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the issue was successfully updated, false otherwise. +pub fn update_issue( + conn: &Connection, user_id: &str, book_id: &str, issue_id: &str, + name: &str, hashed_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute("UPDATE book_issues SET name = ?1, hashed_issue_name = ?2, last_update = ?3 WHERE issue_id = ?4 AND author_id = ?5 AND book_id = ?6", params![name, hashed_name, last_update, issue_id, user_id, book_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour la problématique.".to_string() } else { "Unable to update issue.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Checks if an issue exists in the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user/author +/// * `book_id` - The unique identifier of the book +/// * `issue_id` - The unique identifier of the issue to check +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the issue exists, false otherwise. +pub fn issue_exist(conn: &Connection, user_id: &str, book_id: &str, issue_id: &str, lang: Lang) -> AppResult { + let existing_issue: Option = conn + .query_row("SELECT 1 FROM book_issues WHERE issue_id=?1 AND author_id=?2 AND book_id=?3", params![issue_id, user_id, book_id], |query_row| query_row.get(0)) + .optional() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du problème.".to_string() } else { "Unable to check issue existence.".to_string() }))?; + + Ok(existing_issue.is_some()) +} diff --git a/src-tauri/src/domains/issue/service.rs b/src-tauri/src/domains/issue/service.rs new file mode 100644 index 0000000..78110b5 --- /dev/null +++ b/src-tauri/src/domains/issue/service.rs @@ -0,0 +1,82 @@ +use rusqlite::Connection; +use serde::Serialize; + +use crate::crypto::encryption::{decrypt_data_with_user_key, encrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::issue::repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::AppResult; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +/// Represents a synced issue with its metadata. +pub struct SyncedIssue { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +pub struct IssueProps { + pub id: String, + pub name: String, +} + +/// Retrieves all issues associated with a specific book. +/// Decrypts issue names using the user's encryption key. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns a list of decrypted issues. +pub fn get_issues_from_book(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let issue_query_results: Vec = repo::fetch_issues_from_book(conn, user_id, book_id, lang)?; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let mut decrypted_issues: Vec = Vec::new(); + + if !issue_query_results.is_empty() { + for issue_record in issue_query_results { + decrypted_issues.push(IssueProps { + id: issue_record.issue_id, + name: decrypt_data_with_user_key(&issue_record.name, &user_encryption_key)?, + }); + } + } + + Ok(decrypted_issues) +} + +/// Creates a new issue for a book. +/// Encrypts and hashes the issue name before storage. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `name` - The plain text name of the issue +/// * `lang` - The language for error messages ("fr" or "en") +/// * `existing_issue_id` - Optional existing issue ID for syncing purposes +/// Returns the unique identifier of the created issue. +pub fn add_new_issue(conn: &Connection, user_id: &str, book_id: &str, name: &str, lang: Lang, existing_issue_id: Option<&str>) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let encrypted_name: String = encrypt_data_with_user_key(name, &user_encryption_key)?; + let hashed_name: String = hash_element(name); + let issue_id: String = create_unique_id(existing_issue_id); + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_new_issue(conn, &issue_id, user_id, book_id, &encrypted_name, &hashed_name, last_update, lang) +} + +/// Removes an issue from the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `issue_id` - The unique identifier of the issue to remove +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the issue was successfully removed, false otherwise. +pub fn remove_issue(conn: &Connection, user_id: &str, book_id: &str, issue_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_issue(conn, user_id, issue_id, lang)?; + if deleted { + tombstone_repo::insert(conn, book_id, "book_issues", issue_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} diff --git a/src-tauri/src/domains/location/commands.rs b/src-tauri/src/domains/location/commands.rs new file mode 100644 index 0000000..4c037ef --- /dev/null +++ b/src-tauri/src/domains/location/commands.rs @@ -0,0 +1,158 @@ +use serde::Deserialize; +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::location::service; +use crate::error::AppError; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAllLocationsData { + pub book_id: String, + pub enabled: bool, +} + +#[tauri::command] +pub fn get_all_locations(data: GetAllLocationsData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_all_locations(conn, &user_id, &data.book_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddLocationSectionData { + pub location_name: String, + pub book_id: String, + pub id: Option, + pub series_location_id: Option, +} + +#[tauri::command] +pub fn add_location_section(data: AddLocationSectionData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_location_section(conn, &user_id, &data.location_name, &data.book_id, lang, data.id.as_deref(), data.series_location_id.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddLocationElementData { + pub location_id: String, + pub element_name: String, + pub id: Option, +} + +#[tauri::command] +pub fn add_location_element(data: AddLocationElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_location_element(conn, &user_id, &data.location_id, &data.element_name, lang, data.id.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddLocationSubElementData { + pub element_id: String, + pub sub_element_name: String, + pub id: Option, +} + +#[tauri::command] +pub fn add_location_sub_element(data: AddLocationSubElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_location_sub_element(conn, &user_id, &data.element_id, &data.sub_element_name, lang, data.id.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateLocationsData { + pub locations: Vec, +} + +#[tauri::command] +pub fn update_locations(data: UpdateLocationsData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + let (success, message) = service::update_location_section(conn, &user_id, &data.locations, lang)?; + Ok(serde_json::json!({"success": success, "message": message})) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateLocationSectionWithSeriesLinkData { + pub section_id: String, + pub section_name: Option, + pub series_location_id: Option, +} + +#[tauri::command] +pub fn update_location_section_with_series_link(data: UpdateLocationSectionWithSeriesLinkData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::update_section_with_series_link(conn, &user_id, &data.section_id, data.section_name.as_deref(), data.series_location_id.as_deref(), lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteLocationSectionData { + pub location_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn delete_location_section(data: DeleteLocationSectionData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_location_section(conn, &user_id, &data.book_id, &data.location_id, data.deleted_at, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteLocationElementData { + pub element_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn delete_location_element(data: DeleteLocationElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_location_element(conn, &user_id, &data.book_id, &data.element_id, data.deleted_at, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteLocationSubElementData { + pub sub_element_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn delete_location_sub_element(data: DeleteLocationSubElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_location_sub_element(conn, &user_id, &data.book_id, &data.sub_element_id, data.deleted_at, lang) +} diff --git a/src-tauri/src/domains/location/mod.rs b/src-tauri/src/domains/location/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/location/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/location/repo.rs b/src-tauri/src/domains/location/repo.rs new file mode 100644 index 0000000..6b76733 --- /dev/null +++ b/src-tauri/src/domains/location/repo.rs @@ -0,0 +1,788 @@ +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct LocationQueryResult { + pub loc_id: String, + pub loc_name: String, + pub element_id: Option, + pub element_name: Option, + pub element_description: Option, + pub sub_element_id: Option, + pub sub_elem_name: Option, + pub sub_elem_description: Option, + pub series_location_id: Option, +} + +pub struct LocationElementQueryResult { + pub sub_element_id: Option, + pub sub_elem_name: Option, + pub sub_elem_description: Option, + pub element_id: String, + pub element_name: String, + pub element_description: Option, +} + +pub struct LocationByTagResult { + pub element_name: String, + pub element_description: Option, + pub sub_elem_name: Option, + pub sub_elem_description: Option, +} + +pub struct BookLocationTable { + pub loc_id: String, + pub book_id: String, + pub user_id: String, + pub loc_name: String, + pub loc_original_name: String, + pub last_update: i64, +} + +pub struct LocationElementTable { + pub element_id: String, + pub location: String, + pub user_id: String, + pub element_name: String, + pub original_name: String, + pub element_description: Option, + pub last_update: i64, +} + +pub struct LocationSubElementTable { + pub sub_element_id: String, + pub element_id: String, + pub user_id: String, + pub sub_elem_name: String, + pub original_name: String, + pub sub_elem_description: Option, + pub last_update: i64, +} + +pub struct SyncedLocationResult { + pub loc_id: String, + pub book_id: String, + pub loc_name: String, + pub last_update: i64, +} + +pub struct SyncedLocationElementResult { + pub element_id: String, + pub location: String, + pub element_name: String, + pub last_update: i64, +} + +pub struct SyncedLocationSubElementResult { + pub sub_element_id: String, + pub element_id: String, + pub sub_elem_name: String, + pub last_update: i64, +} + +/// Retrieves all locations with their elements and sub-elements for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `book_id` - The book's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of location query results with nested elements. +pub fn get_location(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT loc_id, loc_name, element.element_id AS element_id, element.element_name, element.element_description, sub_elem.sub_element_id AS sub_element_id, sub_elem.sub_elem_name, sub_elem.sub_elem_description, location.series_location_id FROM book_location AS location LEFT JOIN location_element AS element ON location.loc_id = element.location LEFT JOIN location_sub_element AS sub_elem ON element.element_id = sub_elem.element_id WHERE location.user_id = ?1 AND location.book_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les emplacements.".to_string() } else { "Unable to retrieve locations.".to_string() }))?; + + let locations = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(LocationQueryResult { + loc_id: query_row.get(0)?, + loc_name: query_row.get(1)?, + element_id: query_row.get(2)?, + element_name: query_row.get(3)?, + element_description: query_row.get(4)?, + sub_element_id: query_row.get(5)?, + sub_elem_name: query_row.get(6)?, + sub_elem_description: query_row.get(7)?, + series_location_id: query_row.get(8)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les emplacements.".to_string() } else { "Unable to retrieve locations.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les emplacements.".to_string() } else { "Unable to retrieve locations.".to_string() }))?; + + Ok(locations) +} + +/// Inserts a new location section for a book. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `location_id` - The new location's unique identifier +/// * `book_id` - The book's unique identifier +/// * `encrypted_name` - The encrypted location name +/// * `original_name` - The original (unencrypted) location name +/// * `last_update` - The timestamp of the last update +/// * `series_location_id` - The series location ID (optional) +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the location ID if insertion was successful. +pub fn insert_location( + conn: &Connection, user_id: &str, location_id: &str, book_id: &str, encrypted_name: &str, + original_name: &str, last_update: i64, series_location_id: Option<&str>, lang: Lang, +) -> AppResult { + let insert_result = match series_location_id { + Some(series_id) => conn.execute( + "INSERT INTO book_location (loc_id, book_id, user_id, loc_name, loc_original_name, series_location_id, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![location_id, book_id, user_id, encrypted_name, original_name, series_id, last_update], + ), + None => conn.execute( + "INSERT INTO book_location (loc_id, book_id, user_id, loc_name, loc_original_name, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![location_id, book_id, user_id, encrypted_name, original_name, last_update], + ), + } + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter la section d'emplacement.".to_string() } else { "Unable to add location section.".to_string() }))?; + + if insert_result > 0 { + Ok(location_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de l'ajout de la section d'emplacement.".to_string() } else { "Error adding location section.".to_string() })) + } +} + +/// Inserts a new location element within a location section. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `element_id` - The new element's unique identifier +/// * `location_id` - The parent location's unique identifier +/// * `encrypted_name` - The encrypted element name +/// * `original_name` - The original (unencrypted) element name +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the element ID if insertion was successful. +pub fn insert_location_element( + conn: &Connection, user_id: &str, element_id: &str, location_id: &str, + encrypted_name: &str, original_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO location_element (element_id, location, user_id, element_name, original_name, element_description, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![element_id, location_id, user_id, encrypted_name, original_name, "", last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter l'élément d'emplacement.".to_string() } else { "Unable to add location element.".to_string() }))?; + + if insert_result > 0 { + Ok(element_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de l'ajout de l'élément d'emplacement.".to_string() } else { "Error adding location element.".to_string() })) + } +} + +/// Inserts a new sub-element within a location element. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `sub_element_id` - The new sub-element's unique identifier +/// * `element_id` - The parent element's unique identifier +/// * `encrypted_name` - The encrypted sub-element name +/// * `original_name` - The original (unencrypted) sub-element name +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the sub-element ID if insertion was successful. +pub fn insert_location_sub_element( + conn: &Connection, user_id: &str, sub_element_id: &str, element_id: &str, + encrypted_name: &str, original_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO location_sub_element (sub_element_id, element_id, user_id, sub_elem_name, original_name, sub_elem_description, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![sub_element_id, element_id, user_id, encrypted_name, original_name, "", last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le sous-élément d'emplacement.".to_string() } else { "Unable to add location sub-element.".to_string() }))?; + + if insert_result > 0 { + Ok(sub_element_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de l'ajout du sous-élément d'emplacement.".to_string() } else { "Error adding location sub-element.".to_string() })) + } +} + +/// Updates an existing location sub-element's name and description. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `id` - The sub-element's unique identifier +/// * `encrypted_name` - The new encrypted sub-element name +/// * `original_name` - The new original (unencrypted) sub-element name +/// * `encrypt_description` - The new encrypted description +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update affected at least one row. +pub fn update_location_sub_element( + conn: &Connection, user_id: &str, id: &str, encrypted_name: &str, + original_name: &str, encrypt_description: &str, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE location_sub_element SET sub_elem_name = ?1, original_name = ?2, sub_elem_description = ?3, last_update = ?4 WHERE sub_element_id = ?5 AND user_id = ?6", + params![encrypted_name, original_name, encrypt_description, last_update, id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le sous-élément d'emplacement.".to_string() } else { "Unable to update location sub-element.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Updates an existing location element's name and description. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `id` - The element's unique identifier +/// * `encrypted_name` - The new encrypted element name +/// * `original_name` - The new original (unencrypted) element name +/// * `encrypted_description` - The new encrypted description +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update affected at least one row. +pub fn update_location_element( + conn: &Connection, user_id: &str, id: &str, encrypted_name: &str, + original_name: &str, encrypted_description: &str, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE location_element SET element_name = ?1, original_name = ?2, element_description = ?3, last_update = ?4 WHERE element_id = ?5 AND user_id = ?6", + params![encrypted_name, original_name, encrypted_description, last_update, id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour l'élément d'emplacement.".to_string() } else { "Unable to update location element.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Updates an existing location section's name. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `id` - The location section's unique identifier +/// * `encrypted_name` - The new encrypted location name +/// * `original_name` - The new original (unencrypted) location name +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update affected at least one row. +pub fn update_location_section( + conn: &Connection, user_id: &str, id: &str, encrypted_name: &str, + original_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE book_location SET loc_name = ?1, loc_original_name = ?2, last_update = ?3 WHERE loc_id = ?4 AND user_id = ?5", + params![encrypted_name, original_name, last_update, id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour la section d'emplacement.".to_string() } else { "Unable to update location section.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Deletes a location section by its ID. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `location_id` - The location section's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion affected at least one row. +pub fn delete_location_section(conn: &Connection, user_id: &str, location_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM book_location WHERE loc_id = ?1 AND user_id = ?2", params![location_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer la section d'emplacement.".to_string() } else { "Unable to delete location section.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Deletes a location element by its ID. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `element_id` - The element's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion affected at least one row. +pub fn delete_location_element(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM location_element WHERE element_id = ?1 AND user_id = ?2", params![element_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer l'élément d'emplacement.".to_string() } else { "Unable to delete location element.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Deletes a location sub-element by its ID. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `sub_element_id` - The sub-element's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion affected at least one row. +pub fn delete_location_sub_element(conn: &Connection, user_id: &str, sub_element_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM location_sub_element WHERE sub_element_id = ?1 AND user_id = ?2", params![sub_element_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le sous-élément d'emplacement.".to_string() } else { "Unable to delete location sub-element.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Fetches all location elements and sub-elements for tagging purposes. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `book_id` - The book's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of location elements with their sub-elements. +pub fn fetch_location_tags(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT se.sub_element_id AS sub_element_id, se.sub_elem_name, se.sub_elem_description, el.element_id AS element_id, el.element_name, el.element_description FROM location_sub_element AS se RIGHT JOIN location_element AS el ON se.element_id = el.element_id LEFT JOIN book_location AS lo ON el.location = lo.loc_id WHERE lo.book_id = ?1 AND lo.user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags d'emplacement.".to_string() } else { "Unable to retrieve location tags.".to_string() }))?; + + let location_tags = statement + .query_map(params![book_id, user_id], |query_row| { + Ok(LocationElementQueryResult { + sub_element_id: query_row.get(0)?, + sub_elem_name: query_row.get(1)?, + sub_elem_description: query_row.get(2)?, + element_id: query_row.get(3)?, + element_name: query_row.get(4)?, + element_description: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags d'emplacement.".to_string() } else { "Unable to retrieve location tags.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags d'emplacement.".to_string() } else { "Unable to retrieve location tags.".to_string() }))?; + + Ok(location_tags) +} + +/// Fetches locations by their tag IDs (element or sub-element IDs). +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `locations` - An array of location tag IDs to search for +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of locations matching the provided tags. +/// Errors if no tags are provided or no locations are found. +pub fn fetch_locations_by_tags(conn: &Connection, user_id: &str, locations: &[String], lang: Lang) -> AppResult> { + if locations.is_empty() { + return Err(AppError::Validation(if lang == Lang::Fr { "Aucun tag fourni.".to_string() } else { "No tags provided.".to_string() })); + } + + let location_placeholders: String = locations.iter().map(|_| "?").collect::>().join(","); + let query = format!("SELECT el.element_name, el.element_description, se.sub_elem_name, se.sub_elem_description FROM location_element AS el LEFT JOIN location_sub_element AS se ON el.element_id = se.element_id WHERE el.user_id = ?1 AND (el.element_id IN ({}) OR se.sub_element_id IN ({}))", location_placeholders, location_placeholders); + + let mut statement = conn + .prepare(&query) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les emplacements par tags.".to_string() } else { "Unable to retrieve locations by tags.".to_string() }))?; + + let mut param_values: Vec> = Vec::new(); + param_values.push(Box::new(user_id.to_string())); + for location in locations { + param_values.push(Box::new(location.clone())); + } + for location in locations { + param_values.push(Box::new(location.clone())); + } + let param_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|parameter| parameter.as_ref() as &dyn rusqlite::types::ToSql).collect(); + + let locations_by_tags = statement + .query_map(param_refs.as_slice(), |query_row| { + Ok(LocationByTagResult { + element_name: query_row.get(0)?, + element_description: query_row.get(1)?, + sub_elem_name: query_row.get(2)?, + sub_elem_description: query_row.get(3)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les emplacements par tags.".to_string() } else { "Unable to retrieve locations by tags.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les emplacements par tags.".to_string() } else { "Unable to retrieve locations by tags.".to_string() }))?; + + if locations_by_tags.is_empty() { + return Err(AppError::NotFound(if lang == Lang::Fr { "Aucun emplacement trouvé avec ces tags.".to_string() } else { "No locations found with these tags.".to_string() })); + } + + Ok(locations_by_tags) +} + +/// Checks if a location exists in the database. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `loc_id` - The location's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the location exists, false otherwise. +pub fn is_location_exist(conn: &Connection, user_id: &str, loc_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_location WHERE loc_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'emplacement.".to_string() } else { "Unable to check location existence.".to_string() }))?; + + let existing_location = statement + .query_row(params![loc_id, user_id], |query_row| query_row.get::<_, i32>(0)) + .optional() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'emplacement.".to_string() } else { "Unable to check location existence.".to_string() }))?; + + Ok(existing_location.is_some()) +} + +/// Checks if a location element exists in the database. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `element_id` - The element's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the location element exists, false otherwise. +pub fn is_location_element_exist(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM location_element WHERE element_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'élément d'emplacement.".to_string() } else { "Unable to check location element existence.".to_string() }))?; + + let existing_element = statement + .query_row(params![element_id, user_id], |query_row| query_row.get::<_, i32>(0)) + .optional() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'élément d'emplacement.".to_string() } else { "Unable to check location element existence.".to_string() }))?; + + Ok(existing_element.is_some()) +} + +/// Checks if a location sub-element exists in the database. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `sub_element_id` - The sub-element's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the location sub-element exists, false otherwise. +pub fn is_location_sub_element_exist(conn: &Connection, user_id: &str, sub_element_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM location_sub_element WHERE sub_element_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du sous-élément d'emplacement.".to_string() } else { "Unable to check location sub-element existence.".to_string() }))?; + + let existing_sub_element = statement + .query_row(params![sub_element_id, user_id], |query_row| query_row.get::<_, i32>(0)) + .optional() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du sous-élément d'emplacement.".to_string() } else { "Unable to check location sub-element existence.".to_string() }))?; + + Ok(existing_sub_element.is_some()) +} + +/// Fetches all locations for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `book_id` - The book's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of book location records. +pub fn fetch_book_locations(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT loc_id, book_id, user_id, loc_name, loc_original_name, last_update FROM book_location WHERE user_id = ?1 AND book_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux.".to_string() } else { "Unable to retrieve locations.".to_string() }))?; + + let book_locations = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookLocationTable { + loc_id: query_row.get(0)?, book_id: query_row.get(1)?, + user_id: query_row.get(2)?, loc_name: query_row.get(3)?, + loc_original_name: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux.".to_string() } else { "Unable to retrieve locations.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux.".to_string() } else { "Unable to retrieve locations.".to_string() }))?; + + Ok(book_locations) +} + +/// Fetches all elements for a specific location. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `location_id` - The location's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of location element records. +pub fn fetch_location_elements(conn: &Connection, user_id: &str, location_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, location, user_id, element_name, original_name, element_description, last_update FROM location_element WHERE user_id = ?1 AND location = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu.".to_string() } else { "Unable to retrieve location elements.".to_string() }))?; + + let location_elements = statement + .query_map(params![user_id, location_id], |query_row| { + Ok(LocationElementTable { + element_id: query_row.get(0)?, location: query_row.get(1)?, + user_id: query_row.get(2)?, element_name: query_row.get(3)?, + original_name: query_row.get(4)?, element_description: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu.".to_string() } else { "Unable to retrieve location elements.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu.".to_string() } else { "Unable to retrieve location elements.".to_string() }))?; + + Ok(location_elements) +} + +/// Fetches all sub-elements for a specific location element. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `element_id` - The element's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of location sub-element records. +pub fn fetch_location_sub_elements(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sub_element_id, element_id, user_id, sub_elem_name, original_name, sub_elem_description, last_update FROM location_sub_element WHERE user_id = ?1 AND element_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu.".to_string() } else { "Unable to retrieve location sub-elements.".to_string() }))?; + + let location_sub_elements = statement + .query_map(params![user_id, element_id], |query_row| { + Ok(LocationSubElementTable { + sub_element_id: query_row.get(0)?, element_id: query_row.get(1)?, + user_id: query_row.get(2)?, sub_elem_name: query_row.get(3)?, + original_name: query_row.get(4)?, sub_elem_description: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu.".to_string() } else { "Unable to retrieve location sub-elements.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu.".to_string() } else { "Unable to retrieve location sub-elements.".to_string() }))?; + + Ok(location_sub_elements) +} + +/// Fetches all synced locations for a user (used for synchronization). +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced location records. +pub fn fetch_synced_locations(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT loc_id, book_id, loc_name, last_update FROM book_location WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux synchronisés.".to_string() } else { "Unable to retrieve synced locations.".to_string() }))?; + + let synced_locations = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedLocationResult { loc_id: query_row.get(0)?, book_id: query_row.get(1)?, loc_name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux synchronisés.".to_string() } else { "Unable to retrieve synced locations.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux synchronisés.".to_string() } else { "Unable to retrieve synced locations.".to_string() }))?; + + Ok(synced_locations) +} + +/// Fetches all synced location elements for a user (used for synchronization). +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced location element records. +pub fn fetch_synced_location_elements(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, location, element_name, last_update FROM location_element WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu synchronisés.".to_string() } else { "Unable to retrieve synced location elements.".to_string() }))?; + + let synced_location_elements = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedLocationElementResult { element_id: query_row.get(0)?, location: query_row.get(1)?, element_name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu synchronisés.".to_string() } else { "Unable to retrieve synced location elements.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu synchronisés.".to_string() } else { "Unable to retrieve synced location elements.".to_string() }))?; + + Ok(synced_location_elements) +} + +/// Fetches all synced location sub-elements for a user (used for synchronization). +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced location sub-element records. +pub fn fetch_synced_location_sub_elements(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sub_element_id, element_id, sub_elem_name, last_update FROM location_sub_element WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu synchronisés.".to_string() } else { "Unable to retrieve synced location sub-elements.".to_string() }))?; + + let synced_location_sub_elements = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedLocationSubElementResult { sub_element_id: query_row.get(0)?, element_id: query_row.get(1)?, sub_elem_name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu synchronisés.".to_string() } else { "Unable to retrieve synced location sub-elements.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu synchronisés.".to_string() } else { "Unable to retrieve synced location sub-elements.".to_string() }))?; + + Ok(synced_location_sub_elements) +} + +/// Inserts a synced location from the remote server. +/// * `conn` - Database connection +/// * `loc_id` - The location's unique identifier +/// * `book_id` - The book's unique identifier +/// * `user_id` - The user's unique identifier +/// * `loc_name` - The encrypted location name +/// * `loc_original_name` - The original (unencrypted) location name +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion affected at least one row. +pub fn insert_sync_location( + conn: &Connection, loc_id: &str, book_id: &str, user_id: &str, + loc_name: &str, loc_original_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_location (loc_id, book_id, user_id, loc_name, loc_original_name, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![loc_id, book_id, user_id, loc_name, loc_original_name, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le lieu.".to_string() } else { "Unable to insert location.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Inserts a synced location element from the remote server. +/// * `conn` - Database connection +/// * `element_id` - The element's unique identifier +/// * `location` - The parent location's unique identifier +/// * `user_id` - The user's unique identifier +/// * `element_name` - The encrypted element name +/// * `original_name` - The original (unencrypted) element name +/// * `element_description` - The encrypted element description (can be null) +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion affected at least one row. +pub fn insert_sync_location_element( + conn: &Connection, element_id: &str, location: &str, user_id: &str, element_name: &str, + original_name: &str, element_description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO location_element (element_id, location, user_id, element_name, original_name, element_description, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![element_id, location, user_id, element_name, original_name, element_description, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer l'élément du lieu.".to_string() } else { "Unable to insert location element.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Inserts a synced location sub-element from the remote server. +/// * `conn` - Database connection +/// * `sub_element_id` - The sub-element's unique identifier +/// * `element_id` - The parent element's unique identifier +/// * `user_id` - The user's unique identifier +/// * `sub_elem_name` - The encrypted sub-element name +/// * `original_name` - The original (unencrypted) sub-element name +/// * `sub_elem_description` - The encrypted sub-element description (can be null) +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion affected at least one row. +pub fn insert_sync_location_sub_element( + conn: &Connection, sub_element_id: &str, element_id: &str, user_id: &str, sub_elem_name: &str, + original_name: &str, sub_elem_description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO location_sub_element (sub_element_id, element_id, user_id, sub_elem_name, original_name, sub_elem_description, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![sub_element_id, element_id, user_id, sub_elem_name, original_name, sub_elem_description, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le sous-élément du lieu.".to_string() } else { "Unable to insert location sub-element.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Fetches complete location data by its ID (without user filtering). +/// * `conn` - Database connection +/// * `id` - The location's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of book location records. +pub fn fetch_complete_location_by_id(conn: &Connection, id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT loc_id, book_id, user_id, loc_name, loc_original_name, last_update FROM book_location WHERE loc_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lieu complet.".to_string() } else { "Unable to retrieve complete location.".to_string() }))?; + + let complete_location = statement + .query_map(params![id], |query_row| { + Ok(BookLocationTable { + loc_id: query_row.get(0)?, book_id: query_row.get(1)?, + user_id: query_row.get(2)?, loc_name: query_row.get(3)?, + loc_original_name: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lieu complet.".to_string() } else { "Unable to retrieve complete location.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lieu complet.".to_string() } else { "Unable to retrieve complete location.".to_string() }))?; + + Ok(complete_location) +} + +/// Fetches complete location element data by its ID (without user filtering). +/// * `conn` - Database connection +/// * `id` - The element's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of location element records. +pub fn fetch_complete_location_element_by_id(conn: &Connection, id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, location, user_id, element_name, original_name, element_description, last_update FROM location_element WHERE element_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'élément de lieu complet.".to_string() } else { "Unable to retrieve complete location element.".to_string() }))?; + + let complete_location_element = statement + .query_map(params![id], |query_row| { + Ok(LocationElementTable { + element_id: query_row.get(0)?, location: query_row.get(1)?, + user_id: query_row.get(2)?, element_name: query_row.get(3)?, + original_name: query_row.get(4)?, element_description: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'élément de lieu complet.".to_string() } else { "Unable to retrieve complete location element.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'élément de lieu complet.".to_string() } else { "Unable to retrieve complete location element.".to_string() }))?; + + Ok(complete_location_element) +} + +/// Fetches complete location sub-element data by its ID (without user filtering). +/// * `conn` - Database connection +/// * `id` - The sub-element's unique identifier +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of location sub-element records. +pub fn fetch_complete_location_sub_element_by_id(conn: &Connection, id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sub_element_id, element_id, user_id, sub_elem_name, original_name, sub_elem_description, last_update FROM location_sub_element WHERE sub_element_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sous-élément de lieu complet.".to_string() } else { "Unable to retrieve complete location sub-element.".to_string() }))?; + + let complete_location_sub_element = statement + .query_map(params![id], |query_row| { + Ok(LocationSubElementTable { + sub_element_id: query_row.get(0)?, element_id: query_row.get(1)?, + user_id: query_row.get(2)?, sub_elem_name: query_row.get(3)?, + original_name: query_row.get(4)?, sub_elem_description: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sous-élément de lieu complet.".to_string() } else { "Unable to retrieve complete location sub-element.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sous-élément de lieu complet.".to_string() } else { "Unable to retrieve complete location sub-element.".to_string() }))?; + + Ok(complete_location_sub_element) +} + +/// Updates a location section with optional name change and series link. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `section_id` - The section's unique identifier +/// * `encrypted_name` - The new encrypted name (optional) +/// * `original_name` - The new original name (optional) +/// * `series_location_id` - The series location ID to link (optional, None to unlink) +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful. +pub fn update_section_with_series_link( + conn: &Connection, user_id: &str, section_id: &str, encrypted_name: Option<&str>, + original_name: Option<&str>, series_location_id: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let mut set_clauses: Vec = vec![format!("last_update={}", last_update)]; + let mut param_values: Vec> = Vec::new(); + + if let (Some(enc_name), Some(orig_name)) = (encrypted_name, original_name) { + set_clauses.push("loc_name=?".to_string()); + set_clauses.push("loc_original_name=?".to_string()); + param_values.push(Box::new(enc_name.to_string())); + param_values.push(Box::new(orig_name.to_string())); + } + + if let Some(series_id) = series_location_id { + set_clauses.push("series_location_id=?".to_string()); + param_values.push(Box::new(series_id.to_string())); + } + + param_values.push(Box::new(section_id.to_string())); + param_values.push(Box::new(user_id.to_string())); + + let query = format!("UPDATE book_location SET {} WHERE loc_id=? AND user_id=?", set_clauses.join(", ")); + let param_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|parameter| parameter.as_ref() as &dyn rusqlite::types::ToSql).collect(); + + let update_result = conn + .execute(&query, param_refs.as_slice()) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour la section d'emplacement.".to_string() } else { "Unable to update location section.".to_string() }))?; + + Ok(update_result > 0) +} diff --git a/src-tauri/src/domains/location/service.rs b/src-tauri/src/domains/location/service.rs new file mode 100644 index 0000000..86ab42d --- /dev/null +++ b/src-tauri/src/domains/location/service.rs @@ -0,0 +1,462 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::book::repo as book_repo; +use crate::domains::location::repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::AppResult; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +#[derive(Serialize, Deserialize)] +pub struct SubElement { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Element { + pub id: String, + pub name: String, + pub description: String, + pub sub_elements: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocationProps { + pub id: String, + pub name: String, + pub elements: Vec, + pub series_location_id: Option, +} + +#[derive(Serialize)] +pub struct LocationListResponse { + pub locations: Vec, + pub enabled: bool, +} + +/// Synced location sub-element (lightweight, for comparison). +pub struct SyncedLocationSubElement { + pub id: String, + pub name: String, + pub last_update: i64, +} + +/// Synced location element (lightweight, for comparison). +pub struct SyncedLocationElement { + pub id: String, + pub name: String, + pub last_update: i64, + pub sub_elements: Vec, +} + +/// Synced location (lightweight, for comparison). +pub struct SyncedLocation { + pub id: String, + pub name: String, + pub last_update: i64, + pub elements: Vec, +} + +/// Retrieves all locations for a given user and book. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `book_id` - The book's unique identifier +/// * `lang` - The language for error messages +/// Returns LocationListResponse containing an array of locations and enabled flag. +pub fn get_all_locations(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let book_tools: Option = book_repo::fetch_book_tools(conn, user_id, book_id, lang)?; + let enabled: bool = book_tools.map_or(false, |book_tools_row| book_tools_row.locations_enabled == 1); + + let location_records: Vec = repo::get_location(conn, user_id, book_id, lang)?; + if location_records.is_empty() { + return Ok(LocationListResponse { locations: vec![], enabled }); + } + + let user_key: String = get_user_encryption_key(user_id)?; + let mut location_array: Vec = Vec::new(); + + for record in &location_records { + let location_index: Option = location_array.iter().position(|loc| loc.id == record.loc_id); + + let location_idx: usize = match location_index { + Some(idx) => idx, + None => { + let decrypted_name: String = decrypt_data_with_user_key(&record.loc_name, &user_key)?; + location_array.push(LocationProps { + id: record.loc_id.clone(), + name: decrypted_name, + elements: vec![], + series_location_id: record.series_location_id.clone(), + }); + location_array.len() - 1 + } + }; + + if let Some(ref element_id) = record.element_id { + let element_index: Option = location_array[location_idx].elements.iter().position(|elem| elem.id == *element_id); + + let element_idx: usize = match element_index { + Some(idx) => idx, + None => { + let decrypted_name: String = decrypt_data_with_user_key(record.element_name.as_deref().unwrap_or(""), &user_key)?; + let decrypted_description: String = if let Some(ref element_description) = record.element_description { + decrypt_data_with_user_key(element_description, &user_key)? + } else { + String::new() + }; + location_array[location_idx].elements.push(Element { + id: element_id.clone(), + name: decrypted_name, + description: decrypted_description, + sub_elements: vec![], + }); + location_array[location_idx].elements.len() - 1 + } + }; + + if let Some(ref sub_element_id) = record.sub_element_id { + let sub_element_exists: bool = location_array[location_idx].elements[element_idx] + .sub_elements + .iter() + .any(|sub| sub.id == *sub_element_id); + + if !sub_element_exists { + let decrypted_name: String = decrypt_data_with_user_key(record.sub_elem_name.as_deref().unwrap_or(""), &user_key)?; + let decrypted_description: String = if let Some(ref sub_elem_description) = record.sub_elem_description { + decrypt_data_with_user_key(sub_elem_description, &user_key)? + } else { + String::new() + }; + location_array[location_idx].elements[element_idx].sub_elements.push(SubElement { + id: sub_element_id.clone(), + name: decrypted_name, + description: decrypted_description, + }); + } + } + } + } + + Ok(LocationListResponse { locations: location_array, enabled }) +} + +/// Adds a new location section for a book. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `location_name` - The name of the location to create +/// * `book_id` - The book's unique identifier +/// * `lang` - The language for error messages +/// * `existing_location_id` - Optional existing location ID to use instead of generating a new one +/// * `series_location_id` - The series location ID to link (optional) +/// Returns the ID of the created location. +pub fn add_location_section( + conn: &Connection, user_id: &str, location_name: &str, book_id: &str, lang: Lang, + existing_location_id: Option<&str>, series_location_id: Option<&str>, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let hashed_name: String = hash_element(location_name); + let encrypted_name: String = encrypt_data_with_user_key(location_name, &user_key)?; + let location_id: String = create_unique_id(existing_location_id); + let last_update: i64 = timestamp_in_seconds(); + repo::insert_location(conn, user_id, &location_id, book_id, &encrypted_name, &hashed_name, last_update, series_location_id, lang) +} + +/// Adds a new element to a location. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `location_id` - The parent location's unique identifier +/// * `element_name` - The name of the element to create +/// * `lang` - The language for error messages +/// * `existing_element_id` - Optional existing element ID to use instead of generating a new one +/// Returns the ID of the created element. +pub fn add_location_element( + conn: &Connection, user_id: &str, location_id: &str, element_name: &str, lang: Lang, + existing_element_id: Option<&str>, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let hashed_name: String = hash_element(element_name); + let encrypted_name: String = encrypt_data_with_user_key(element_name, &user_key)?; + let element_id: String = create_unique_id(existing_element_id); + let last_update: i64 = timestamp_in_seconds(); + repo::insert_location_element(conn, user_id, &element_id, location_id, &encrypted_name, &hashed_name, last_update, lang) +} + +/// Adds a new sub-element to a location element. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `element_id` - The parent element's unique identifier +/// * `sub_element_name` - The name of the sub-element to create +/// * `lang` - The language for error messages +/// * `existing_sub_element_id` - Optional existing sub-element ID to use instead of generating a new one +/// Returns the ID of the created sub-element. +pub fn add_location_sub_element( + conn: &Connection, user_id: &str, element_id: &str, sub_element_name: &str, lang: Lang, + existing_sub_element_id: Option<&str>, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let hashed_name: String = hash_element(sub_element_name); + let encrypted_name: String = encrypt_data_with_user_key(sub_element_name, &user_key)?; + let sub_element_id: String = create_unique_id(existing_sub_element_id); + let last_update: i64 = timestamp_in_seconds(); + repo::insert_location_sub_element(conn, user_id, &sub_element_id, element_id, &encrypted_name, &hashed_name, last_update, lang) +} + +/// Updates multiple location sections along with their elements and sub-elements. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `locations` - Array of location properties to update +/// * `lang` - The language for response messages +/// Returns an object indicating success and a localized message. +pub fn update_location_section( + conn: &Connection, user_id: &str, locations: &[LocationProps], lang: Lang, +) -> AppResult<(bool, String)> { + let user_key: String = get_user_encryption_key(user_id)?; + + for location in locations { + let hashed_location_name: String = hash_element(&location.name); + let encrypted_location_name: String = encrypt_data_with_user_key(&location.name, &user_key)?; + let last_update: i64 = timestamp_in_seconds(); + repo::update_location_section(conn, user_id, &location.id, &encrypted_location_name, &hashed_location_name, last_update, lang)?; + + for element in &location.elements { + let hashed_element_name: String = hash_element(&element.name); + let encrypted_element_name: String = encrypt_data_with_user_key(&element.name, &user_key)?; + let encrypted_element_description: String = if element.description.is_empty() { String::new() } else { encrypt_data_with_user_key(&element.description, &user_key)? }; + let last_update: i64 = timestamp_in_seconds(); + repo::update_location_element(conn, user_id, &element.id, &encrypted_element_name, &hashed_element_name, &encrypted_element_description, last_update, lang)?; + + for sub_element in &element.sub_elements { + let hashed_sub_element_name: String = hash_element(&sub_element.name); + let encrypted_sub_element_name: String = encrypt_data_with_user_key(&sub_element.name, &user_key)?; + let encrypted_sub_element_description: String = if sub_element.description.is_empty() { String::new() } else { encrypt_data_with_user_key(&sub_element.description, &user_key)? }; + let last_update: i64 = timestamp_in_seconds(); + repo::update_location_sub_element(conn, user_id, &sub_element.id, &encrypted_sub_element_name, &hashed_sub_element_name, &encrypted_sub_element_description, last_update, lang)?; + } + } + } + + let message: String = if lang == Lang::Fr { "Les sections ont été mis à jour.".to_string() } else { "Sections have been updated.".to_string() }; + Ok((true, message)) +} + +/// Updates a location section with optional name change and series link. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `section_id` - The unique identifier of the section +/// * `section_name` - The new name (optional) +/// * `series_location_id` - The series location ID to link (optional, None to unlink) +/// * `lang` - The language for error messages +/// Returns true if the update was successful. +pub fn update_section_with_series_link( + conn: &Connection, user_id: &str, section_id: &str, section_name: Option<&str>, + series_location_id: Option<&str>, lang: Lang, +) -> AppResult { + let encrypted_name: Option = if let Some(name) = section_name { + let user_key: String = get_user_encryption_key(user_id)?; + Some(encrypt_data_with_user_key(name, &user_key)?) + } else { + None + }; + let original_name_hash: Option = section_name.map(hash_element); + let last_update: i64 = timestamp_in_seconds(); + repo::update_section_with_series_link(conn, user_id, section_id, encrypted_name.as_deref(), original_name_hash.as_deref(), series_location_id, last_update, lang) +} + +/// Deletes a location section and all its associated elements and sub-elements. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `book_id` - The book's unique identifier +/// * `location_id` - The location's unique identifier to delete +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if the deletion was successful. +pub fn delete_location_section(conn: &Connection, user_id: &str, book_id: &str, location_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_location_section(conn, user_id, location_id, lang)?; + if deleted { + tombstone_repo::insert(conn, location_id, "book_location", location_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Deletes a location element and all its associated sub-elements. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `book_id` - The book's unique identifier +/// * `element_id` - The element's unique identifier to delete +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if the deletion was successful. +pub fn delete_location_element(conn: &Connection, user_id: &str, book_id: &str, element_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_location_element(conn, user_id, element_id, lang)?; + if deleted { + tombstone_repo::insert(conn, element_id, "location_element", element_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Deletes a location sub-element. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `book_id` - The book's unique identifier +/// * `sub_element_id` - The sub-element's unique identifier to delete +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if the deletion was successful. +pub fn delete_location_sub_element(conn: &Connection, user_id: &str, book_id: &str, sub_element_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_location_sub_element(conn, user_id, sub_element_id, lang)?; + if deleted { + tombstone_repo::insert(conn, sub_element_id, "location_sub_element", sub_element_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Retrieves location tags (elements or sub-elements) for tagging purposes. +/// Returns sub-elements when an element has multiple sub-elements, otherwise returns the element itself. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `book_id` - The book's unique identifier +/// * `lang` - The language for error messages +/// Returns an array of sub-elements suitable for tagging. +pub fn get_location_tags(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let tag_records: Vec = repo::fetch_location_tags(conn, user_id, book_id, lang)?; + if tag_records.is_empty() { + return Ok(vec![]); + } + + let user_key: String = get_user_encryption_key(user_id)?; + + let mut element_counts: std::collections::HashMap = std::collections::HashMap::new(); + for record in &tag_records { + *element_counts.entry(record.element_id.clone()).or_insert(0) += 1; + } + + let mut sub_elements: Vec = Vec::new(); + let mut processed_ids: std::collections::HashSet = std::collections::HashSet::new(); + + for record in &tag_records { + let element_count: usize = *element_counts.get(&record.element_id).unwrap_or(&0); + + if element_count > 1 { + if let Some(ref sub_element_id) = record.sub_element_id { + if processed_ids.contains(sub_element_id) { + continue; + } + let decrypted_name: String = decrypt_data_with_user_key(record.sub_elem_name.as_deref().unwrap_or(""), &user_key)?; + let decrypted_description: String = if let Some(ref sub_elem_description) = record.sub_elem_description { + decrypt_data_with_user_key(sub_elem_description, &user_key)? + } else { + String::new() + }; + sub_elements.push(SubElement { + id: sub_element_id.clone(), + name: decrypted_name, + description: decrypted_description, + }); + processed_ids.insert(sub_element_id.clone()); + } + } else if element_count == 1 && record.sub_element_id.is_none() { + if processed_ids.contains(&record.element_id) { + continue; + } + let decrypted_name: String = decrypt_data_with_user_key(&record.element_name, &user_key)?; + let decrypted_description: String = if let Some(ref element_description) = record.element_description { + decrypt_data_with_user_key(element_description, &user_key)? + } else { + String::new() + }; + sub_elements.push(SubElement { + id: record.element_id.clone(), + name: decrypted_name, + description: decrypted_description, + }); + processed_ids.insert(record.element_id.clone()); + } + } + + Ok(sub_elements) +} + +/// Retrieves location elements filtered by specific tag IDs. +/// * `conn` - Database connection +/// * `user_id` - The user's unique identifier +/// * `locations` - Array of location tag IDs to filter by +/// * `lang` - The language for error messages +/// Returns an array of elements with their associated sub-elements. +pub fn get_locations_by_tags(conn: &Connection, user_id: &str, locations: &[String], lang: Lang) -> AppResult> { + let location_tag_records: Vec = repo::fetch_locations_by_tags(conn, user_id, locations, lang)?; + if location_tag_records.is_empty() { + return Ok(vec![]); + } + + let user_key: String = get_user_encryption_key(user_id)?; + let mut location_elements: Vec = Vec::new(); + + for record in &location_tag_records { + let element_index: Option = location_elements.iter().position(|elem| elem.name == record.element_name); + + let element_idx: usize = match element_index { + Some(idx) => idx, + None => { + let decrypted_name: String = decrypt_data_with_user_key(&record.element_name, &user_key)?; + let decrypted_description: String = if let Some(ref element_description) = record.element_description { + decrypt_data_with_user_key(element_description, &user_key)? + } else { + String::new() + }; + location_elements.push(Element { + id: String::new(), + name: decrypted_name, + description: decrypted_description, + sub_elements: vec![], + }); + location_elements.len() - 1 + } + }; + + if let Some(ref sub_elem_name) = record.sub_elem_name { + let sub_element_exists: bool = location_elements[element_idx].sub_elements.iter().any(|sub| sub.name == *sub_elem_name); + if !sub_element_exists { + let decrypted_name: String = decrypt_data_with_user_key(sub_elem_name, &user_key)?; + let decrypted_description: String = if let Some(ref sub_elem_description) = record.sub_elem_description { + decrypt_data_with_user_key(sub_elem_description, &user_key)? + } else { + String::new() + }; + location_elements[element_idx].sub_elements.push(SubElement { + id: String::new(), + name: decrypted_name, + description: decrypted_description, + }); + } + } + } + + Ok(location_elements) +} + +/// Generates a formatted description string from an array of location elements. +/// * `locations` - Array of location elements to describe +/// Returns a formatted string with location names and descriptions. +pub fn locations_description(locations: &[Element]) -> String { + locations + .iter() + .map(|location| { + let mut description_fields: Vec = Vec::new(); + if !location.name.is_empty() { + description_fields.push(format!("Nom : {}", location.name)); + } + if !location.description.is_empty() { + description_fields.push(format!("Description : {}", location.description)); + } + description_fields.join("\n") + }) + .collect::>() + .join("\n\n") +} diff --git a/src-tauri/src/domains/mod.rs b/src-tauri/src/domains/mod.rs new file mode 100644 index 0000000..e62629e --- /dev/null +++ b/src-tauri/src/domains/mod.rs @@ -0,0 +1,27 @@ +pub mod act; +pub mod book; +pub mod chapter; +pub mod chapter_content; +pub mod character; +pub mod cover; +pub mod download; +pub mod export; +pub mod guideline; +pub mod incident; +pub mod offline; +pub mod issue; +pub mod location; +pub mod plotpoint; +pub mod series; +pub mod series_character; +pub mod series_location; +pub mod series_spell; +pub mod series_sync; +pub mod series_world; +pub mod spell; +pub mod spell_tag; +pub mod sync; +pub mod tombstone; +pub mod upload; +pub mod user; +pub mod world; diff --git a/src-tauri/src/domains/offline/commands.rs b/src-tauri/src/domains/offline/commands.rs new file mode 100644 index 0000000..0242d5f --- /dev/null +++ b/src-tauri/src/domains/offline/commands.rs @@ -0,0 +1,124 @@ +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::crypto::key_manager; +use crate::db::connection::DbManager; +use crate::db::schema; +use crate::error::AppError; +use crate::shared::session::SessionState; + +#[derive(Deserialize)] +pub struct PinData { + pub pin: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OfflineResult { + pub success: bool, + pub error: Option, + pub user_id: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OfflineModeStatus { + pub enabled: bool, + pub sync_interval: i64, + pub has_pin: bool, + pub last_user_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OfflineModeData { + pub enabled: bool, + pub sync_interval_days: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncCheckResult { + pub should_sync: bool, + pub days_since_sync: Option, + pub sync_interval: Option, +} + +#[tauri::command] +pub fn offline_pin_set(data: PinData, session: State) -> Result { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + drop(session_guard); + + let hashed_pin: String = bcrypt::hash(&data.pin, 10) + .map_err(|e| AppError::Encryption(format!("Failed to hash PIN: {}", e)))?; + + key_manager::set_pin_hash(&user_id, &hashed_pin)?; + + Ok(OfflineResult { success: true, error: None, user_id: None }) +} + +#[tauri::command] +pub fn offline_pin_verify(data: PinData, db: State, session: State) -> Result { + let last_user_id: Option = key_manager::get_last_user_id()?; + let last_user_id: String = match last_user_id { + Some(id) => id, + None => return Ok(OfflineResult { success: false, error: Some("No offline account found".to_string()), user_id: None }), + }; + + let hashed_pin: Option = key_manager::get_pin_hash(&last_user_id)?; + let hashed_pin: String = match hashed_pin { + Some(hash) => hash, + None => return Ok(OfflineResult { success: false, error: Some("No PIN configured".to_string()), user_id: None }), + }; + + let is_valid: bool = bcrypt::verify(&data.pin, &hashed_pin) + .map_err(|e| AppError::Encryption(format!("Failed to verify PIN: {}", e)))?; + + if !is_valid { + return Ok(OfflineResult { success: false, error: Some("Invalid PIN".to_string()), user_id: None }); + } + + let has_key: bool = key_manager::has_user_encryption_key(&last_user_id); + if !has_key { + return Ok(OfflineResult { success: false, error: Some("No encryption key found".to_string()), user_id: None }); + } + + let mut db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + db_manager.initialize(&last_user_id)?; + let conn = db_manager.get_connection(&last_user_id)?; + schema::run_migrations(conn)?; + + let mut session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + session_guard.user_id = Some(last_user_id.clone()); + + Ok(OfflineResult { success: true, error: None, user_id: Some(last_user_id) }) +} + +#[tauri::command] +pub fn offline_mode_get() -> Result { + let last_user_id: Option = key_manager::get_last_user_id()?; + let has_pin: bool = match &last_user_id { + Some(id) => key_manager::get_pin_hash(id)?.is_some(), + None => false, + }; + + Ok(OfflineModeStatus { + enabled: false, + sync_interval: 30, + has_pin, + last_user_id, + }) +} + +#[tauri::command] +pub fn offline_mode_set(_data: OfflineModeData) -> Result { + // Offline mode preferences are handled by the frontend/keyring + // This is a no-op placeholder that the frontend can call + Ok(true) +} + +#[tauri::command] +pub fn offline_sync_check() -> Result { + Ok(SyncCheckResult { should_sync: false, days_since_sync: None, sync_interval: None }) +} diff --git a/src-tauri/src/domains/offline/mod.rs b/src-tauri/src/domains/offline/mod.rs new file mode 100644 index 0000000..a4b9af6 --- /dev/null +++ b/src-tauri/src/domains/offline/mod.rs @@ -0,0 +1 @@ +pub mod commands; diff --git a/src-tauri/src/domains/plotpoint/mod.rs b/src-tauri/src/domains/plotpoint/mod.rs new file mode 100644 index 0000000..5608c55 --- /dev/null +++ b/src-tauri/src/domains/plotpoint/mod.rs @@ -0,0 +1,2 @@ +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/plotpoint/repo.rs b/src-tauri/src/domains/plotpoint/repo.rs new file mode 100644 index 0000000..1387398 --- /dev/null +++ b/src-tauri/src/domains/plotpoint/repo.rs @@ -0,0 +1,264 @@ +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct BookPlotPointsTable { + pub plot_point_id: String, + pub title: String, + pub hashed_title: String, + pub summary: Option, + pub linked_incident_id: Option, + pub author_id: String, + pub book_id: String, + pub last_update: i64, +} + +pub struct SyncedPlotPointResult { + pub plot_point_id: String, + pub book_id: String, + pub title: String, + pub last_update: i64, +} + +pub struct PlotPointQuery { + pub plot_point_id: String, + pub title: String, + pub summary: String, + pub linked_incident_id: Option, +} + +/// Fetches all plot points for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `book_id` - The ID of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of plot points with their basic information. +/// Errors if the plot points cannot be retrieved. +pub fn fetch_all_plot_points(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT plot_point_id, title, summary, linked_incident_id FROM book_plot_points WHERE author_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les points d'intrigue.".to_string() } else { "Unable to retrieve plot points.".to_string() }))?; + + let plot_points = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(PlotPointQuery { + plot_point_id: query_row.get(0)?, + title: query_row.get(1)?, + summary: query_row.get::<_, Option>(2)?.unwrap_or_default(), + linked_incident_id: query_row.get(3)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les points d'intrigue.".to_string() } else { "Unable to retrieve plot points.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les points d'intrigue.".to_string() } else { "Unable to retrieve plot points.".to_string() }))?; + + Ok(plot_points) +} + +/// Inserts a new plot point into the database. +/// * `conn` - Database connection +/// * `plot_point_id` - The unique ID for the new plot point +/// * `user_id` - The ID of the user/author +/// * `book_id` - The ID of the book +/// * `encrypted_name` - The encrypted title of the plot point +/// * `hashed_name` - The hashed title for duplicate checking +/// * `incident_id` - The ID of the linked incident (can be empty string) +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the ID of the newly created plot point. +/// Errors if the plot point already exists or cannot be added. +pub fn insert_new_plot_point( + conn: &Connection, plot_point_id: &str, user_id: &str, book_id: &str, + encrypted_name: &str, hashed_name: &str, incident_id: &str, last_update: i64, lang: Lang, +) -> AppResult { + let existing_plot_point: Option = conn + .query_row("SELECT plot_point_id FROM book_plot_points WHERE author_id=?1 AND book_id=?2 AND hashed_title=?3", params![user_id, book_id, hashed_name], |query_row| query_row.get(0)) + .optional() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du point d'intrigue.".to_string() } else { "Unable to verify plot point existence.".to_string() }))?; + + if existing_plot_point.is_some() { + return Err(AppError::Validation(if lang == Lang::Fr { "Ce point de l'intrigue existe déjà.".to_string() } else { "This plot point already exists.".to_string() })); + } + + let insert_result = conn + .execute("INSERT INTO book_plot_points (plot_point_id,title,hashed_title,author_id,book_id,linked_incident_id,last_update) VALUES (?1,?2,?3,?4,?5,?6,?7)", params![plot_point_id, encrypted_name, hashed_name, user_id, book_id, incident_id, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le point d'intrigue.".to_string() } else { "Unable to add plot point.".to_string() }))?; + + if insert_result == 0 { + return Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de l'ajout du point d'intrigue.".to_string() } else { "Error adding plot point.".to_string() })); + } + + Ok(plot_point_id.to_string()) +} + +/// Deletes a plot point from the database. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `plot_point_id` - The ID of the plot point to delete +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the plot point was deleted, false otherwise. +/// Errors if the plot point cannot be deleted. +pub fn delete_plot_point(conn: &Connection, user_id: &str, plot_point_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM book_plot_points WHERE author_id=?1 AND plot_point_id=?2", params![user_id, plot_point_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le point d'intrigue.".to_string() } else { "Unable to delete plot point.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Updates an existing plot point in the database. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `book_id` - The ID of the book +/// * `plot_point_id` - The ID of the plot point to update +/// * `encrypted_plot_point_name` - The new encrypted title +/// * `plot_point_hashed_name` - The new hashed title +/// * `plot_point_summary` - The new summary +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the plot point was updated, false otherwise. +/// Errors if the update fails. +pub fn update_plot_point( + conn: &Connection, user_id: &str, book_id: &str, plot_point_id: &str, + encrypted_plot_point_name: &str, plot_point_hashed_name: &str, plot_point_summary: &str, + last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute("UPDATE book_plot_points SET title=?1, hashed_title=?2, summary=?3, last_update=?4 WHERE author_id=?5 AND book_id=?6 AND plot_point_id=?7", params![encrypted_plot_point_name, plot_point_hashed_name, plot_point_summary, last_update, user_id, book_id, plot_point_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le point d'intrigue.".to_string() } else { "Unable to update plot point.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Fetches all plot points for a book with complete information for synchronization. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `book_id` - The ID of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of complete plot point records. +/// Errors if the plot points cannot be retrieved. +pub fn fetch_book_plot_points(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT plot_point_id, title, hashed_title, summary, linked_incident_id, author_id, book_id, last_update FROM book_plot_points WHERE author_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les points d'intrigue.".to_string() } else { "Unable to retrieve plot points.".to_string() }))?; + + let plot_points = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookPlotPointsTable { + plot_point_id: query_row.get(0)?, + title: query_row.get(1)?, + hashed_title: query_row.get(2)?, + summary: query_row.get(3)?, + linked_incident_id: query_row.get(4)?, + author_id: query_row.get(5)?, + book_id: query_row.get(6)?, + last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les points d'intrigue.".to_string() } else { "Unable to retrieve plot points.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les points d'intrigue.".to_string() } else { "Unable to retrieve plot points.".to_string() }))?; + + Ok(plot_points) +} + +/// Fetches all synced plot points for a user across all books. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced plot point records with minimal information. +/// Errors if the synced plot points cannot be retrieved. +pub fn fetch_synced_plot_points(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT plot_point_id, book_id, title, last_update FROM book_plot_points WHERE author_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les points d'intrigue synchronisés.".to_string() } else { "Unable to retrieve synced plot points.".to_string() }))?; + + let synced_plot_points = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedPlotPointResult { + plot_point_id: query_row.get(0)?, + book_id: query_row.get(1)?, + title: query_row.get(2)?, + last_update: query_row.get(3)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les points d'intrigue synchronisés.".to_string() } else { "Unable to retrieve synced plot points.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les points d'intrigue synchronisés.".to_string() } else { "Unable to retrieve synced plot points.".to_string() }))?; + + Ok(synced_plot_points) +} + +/// Inserts a plot point during synchronization from remote data. +/// * `conn` - Database connection +/// * `plot_point_id` - The unique ID of the plot point +/// * `title` - The encrypted title +/// * `hashed_title` - The hashed title for duplicate checking +/// * `summary` - The encrypted summary (can be null) +/// * `linked_incident_id` - The ID of the linked incident (can be null) +/// * `author_id` - The ID of the author +/// * `book_id` - The ID of the book +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the plot point was inserted, false otherwise. +/// Errors if the plot point cannot be inserted. +pub fn insert_sync_plot_point( + conn: &Connection, plot_point_id: &str, title: &str, hashed_title: &str, + summary: Option<&str>, linked_incident_id: Option<&str>, author_id: &str, + book_id: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute("INSERT INTO book_plot_points (plot_point_id, title, hashed_title, summary, linked_incident_id, author_id, book_id, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![plot_point_id, title, hashed_title, summary, linked_incident_id, author_id, book_id, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le point d'intrigue.".to_string() } else { "Unable to insert plot point.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Fetches complete plot point data by its ID. +/// * `conn` - Database connection +/// * `plot_point_id` - The ID of the plot point to retrieve +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array containing the plot point data (empty if not found). +/// Errors if the plot point cannot be retrieved. +pub fn fetch_complete_plot_point_by_id(conn: &Connection, plot_point_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT plot_point_id, title, hashed_title, summary, linked_incident_id, author_id, book_id, last_update FROM book_plot_points WHERE plot_point_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le point d'intrigue complet.".to_string() } else { "Unable to retrieve complete plot point.".to_string() }))?; + + let plot_point = statement + .query_map(params![plot_point_id], |query_row| { + Ok(BookPlotPointsTable { + plot_point_id: query_row.get(0)?, + title: query_row.get(1)?, + hashed_title: query_row.get(2)?, + summary: query_row.get(3)?, + linked_incident_id: query_row.get(4)?, + author_id: query_row.get(5)?, + book_id: query_row.get(6)?, + last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le point d'intrigue complet.".to_string() } else { "Unable to retrieve complete plot point.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le point d'intrigue complet.".to_string() } else { "Unable to retrieve complete plot point.".to_string() }))?; + + Ok(plot_point) +} + +/// Checks if a plot point exists in the database. +/// * `conn` - Database connection +/// * `user_id` - The ID of the user/author +/// * `book_id` - The ID of the book +/// * `plot_point_id` - The ID of the plot point to check +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the plot point exists, false otherwise. +/// Errors if the existence check fails. +pub fn plot_point_exist(conn: &Connection, user_id: &str, book_id: &str, plot_point_id: &str, lang: Lang) -> AppResult { + let existing_plot_point: Option = conn + .query_row("SELECT 1 FROM book_plot_points WHERE author_id =?1 AND book_id =?2 AND plot_point_id =?3", params![user_id, book_id, plot_point_id], |query_row| query_row.get(0)) + .optional() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du point de intrigue.".to_string() } else { "Unable to check plot point existence.".to_string() }))?; + + Ok(existing_plot_point.is_some()) +} diff --git a/src-tauri/src/domains/plotpoint/service.rs b/src-tauri/src/domains/plotpoint/service.rs new file mode 100644 index 0000000..3e54b2a --- /dev/null +++ b/src-tauri/src/domains/plotpoint/service.rs @@ -0,0 +1,130 @@ +use rusqlite::Connection; + +use crate::crypto::encryption::{decrypt_data_with_user_key, encrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::chapter::repo::ActChapterQuery; +use crate::domains::plotpoint::repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::AppResult; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +/// Represents the story details associated with a plot point. +pub struct PlotPointStory { + pub plot_title: String, + pub plot_summary: String, + pub chapter_summary: String, + pub chapter_goal: String, +} + +/// Represents a plot point with its properties and associated chapters. +pub struct PlotPointProps { + pub plot_point_id: String, + pub title: String, + pub summary: String, + pub linked_incident_id: Option, + pub chapters: Vec, +} + +/// Represents a synced plot point with minimal information. +pub struct SyncedPlotPoint { + pub id: String, + pub name: String, + pub last_update: i64, +} + +/// Retrieves all plot points for a specific book with their associated chapters. +/// Decrypts plot point titles and summaries using the user's encryption key. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `act_chapters` - Array of act chapters to associate with plot points +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns a list of plot point properties with their associated chapters. +/// Errors if the plot points cannot be retrieved or decrypted. +pub fn get_plot_points( + conn: &Connection, user_id: &str, book_id: &str, + act_chapters: &[ActChapterQuery], lang: Lang, +) -> AppResult> { + let plot_point_query_results: Vec = repo::fetch_all_plot_points(conn, user_id, book_id, lang)?; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let mut plot_points: Vec = Vec::new(); + + if !plot_point_query_results.is_empty() { + for plot_point_row in &plot_point_query_results { + let mut associated_chapters: Vec = Vec::new(); + + for chapter in act_chapters { + if chapter.plot_point_id.as_deref() == Some(&plot_point_row.plot_point_id) { + associated_chapters.push(ActChapterQuery { + chapter_info_id: chapter.chapter_info_id, + chapter_id: chapter.chapter_id.clone(), + title: chapter.title.clone(), + chapter_order: chapter.chapter_order, + act_id: chapter.act_id, + incident_id: chapter.incident_id.clone(), + plot_point_id: chapter.plot_point_id.clone(), + summary: chapter.summary.clone(), + goal: chapter.goal.clone(), + }); + } + } + + let decrypted_title: String = if !plot_point_row.title.is_empty() { decrypt_data_with_user_key(&plot_point_row.title, &user_encryption_key)? } else { String::new() }; + let decrypted_summary: String = if !plot_point_row.summary.is_empty() { decrypt_data_with_user_key(&plot_point_row.summary, &user_encryption_key)? } else { String::new() }; + + plot_points.push(PlotPointProps { + plot_point_id: plot_point_row.plot_point_id.clone(), + title: decrypted_title, + summary: decrypted_summary, + linked_incident_id: plot_point_row.linked_incident_id.clone(), + chapters: associated_chapters, + }); + } + } + + Ok(plot_points) +} + +/// Creates a new plot point for a book, encrypting the name before storage. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `incident_id` - The identifier of the linked incident +/// * `name` - The name/title of the plot point +/// * `lang` - The language for error messages ("fr" or "en") +/// * `existing_plot_point_id` - Optional existing plot point ID to use instead of generating a new one +/// Returns the unique identifier of the created plot point. +/// Errors if the plot point already exists or cannot be added. +pub fn add_new_plot_point( + conn: &Connection, user_id: &str, book_id: &str, incident_id: &str, + name: &str, lang: Lang, existing_plot_point_id: Option<&str>, +) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let encrypted_name: String = encrypt_data_with_user_key(name, &user_encryption_key)?; + let hashed_name: String = hash_element(name); + let plot_point_id: String = create_unique_id(existing_plot_point_id); + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_new_plot_point(conn, &plot_point_id, user_id, book_id, &encrypted_name, &hashed_name, incident_id, last_update, lang) +} + +/// Removes a plot point from the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `plot_id` - The unique identifier of the plot point to remove +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the plot point was successfully deleted, false otherwise. +/// Errors if the plot point cannot be deleted. +pub fn remove_plot_point( + conn: &Connection, user_id: &str, book_id: &str, plot_id: &str, + deleted_at: i64, lang: Lang, +) -> AppResult { + let deleted: bool = repo::delete_plot_point(conn, user_id, plot_id, lang)?; + if deleted { + tombstone_repo::insert(conn, book_id, "book_plot_points", plot_id, None, user_id, deleted_at, lang)?; + } + Ok(deleted) +} diff --git a/src-tauri/src/domains/series/commands.rs b/src-tauri/src/domains/series/commands.rs new file mode 100644 index 0000000..3deec31 --- /dev/null +++ b/src-tauri/src/domains/series/commands.rs @@ -0,0 +1,164 @@ +use serde::Deserialize; +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::series::service; +use crate::error::AppError; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +#[tauri::command] +pub fn get_series_list(db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_series_list(conn, &user_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSeriesDetailData { + pub series_id: String, +} + +#[tauri::command] +pub fn get_series_detail(data: GetSeriesDetailData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_series_detail(conn, &user_id, &data.series_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSeriesData { + pub name: String, + pub description: String, + pub book_ids: Option>, +} + +#[tauri::command] +pub fn create_series(data: CreateSeriesData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::create_series(conn, &user_id, &data.name, &data.description, lang, data.book_ids.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSeriesData { + pub series_id: String, + pub name: String, + pub description: String, +} + +#[tauri::command] +pub fn update_series(data: UpdateSeriesData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::update_series(conn, &user_id, &data.series_id, &data.name, &data.description, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSeriesData { + pub series_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn delete_series(data: DeleteSeriesData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_series(conn, &user_id, &data.series_id, data.deleted_at, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSeriesBooksData { + pub series_id: String, +} + +#[tauri::command] +pub fn get_series_books(data: GetSeriesBooksData, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_series_books(conn, &user_id, &data.series_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddBookToSeriesData { + pub series_id: String, + pub book_id: String, +} + +#[tauri::command] +pub fn add_book_to_series(data: AddBookToSeriesData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + let books = service::get_series_books(conn, &user_id, &data.series_id, lang)?; + let next_order = books.len() as i64 + 1; + service::add_book_to_series(conn, &user_id, &data.series_id, &data.book_id, next_order, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveBookFromSeriesData { + pub series_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn remove_book_from_series(data: RemoveBookFromSeriesData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::remove_book_from_series(conn, &user_id, &data.series_id, &data.book_id, data.deleted_at, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReorderSeriesBooksData { + pub series_id: String, + pub book_ids: Vec, +} + +#[tauri::command] +pub fn reorder_series_books(data: ReorderSeriesBooksData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + let books_order: Vec = data.book_ids.into_iter().enumerate().map(|(index, book_id)| service::BooksOrderPost { + book_id, + order: (index + 1) as i64, + }).collect(); + service::update_books_order(conn, &user_id, &data.series_id, &books_order, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSeriesForBookData { + pub book_id: String, +} + +#[tauri::command] +pub fn get_series_for_book(data: GetSeriesForBookData, db: State, session: State) -> Result, AppError> { + let (_user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&_user_id)?; + service::get_series_id_for_book(conn, &data.book_id, lang) +} diff --git a/src-tauri/src/domains/series/mod.rs b/src-tauri/src/domains/series/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/series/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/series/repo.rs b/src-tauri/src/domains/series/repo.rs new file mode 100644 index 0000000..f1b7e44 --- /dev/null +++ b/src-tauri/src/domains/series/repo.rs @@ -0,0 +1,530 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct SeriesResult { + pub series_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub description: Option, + pub cover_image: Option, + pub last_update: i64, +} + +pub struct SeriesBookResult { + pub series_id: String, + pub book_id: String, + pub book_order: i64, + pub title: String, + pub cover_image: Option, +} + +pub struct SeriesListItem { + pub series_id: String, + pub name: String, + pub description: Option, + pub cover_image: Option, + pub book_count: i64, + pub book_ids: Option, +} + +pub struct SeriesTableResult { + pub series_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub description: Option, + pub cover_image: Option, + pub last_update: i64, +} + +pub struct SeriesBooksTableResult { + pub series_id: String, + pub book_id: String, + pub book_order: i64, + pub last_update: i64, +} + +pub struct SyncedSeriesResult { + pub series_id: String, + pub name: String, + pub description: Option, + pub last_update: i64, +} + +pub struct SyncedSeriesBookResult { + pub series_id: String, + pub book_id: String, + pub book_order: i64, + pub last_update: i64, +} + +pub struct BookOrderItem { + pub book_id: String, + pub order: i64, +} + +/// Fetches all series for a user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of series with book counts. +pub fn fetch_user_series(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT series.series_id, series.name, series.description, series.cover_image, COUNT(series_books.book_id) AS book_count, GROUP_CONCAT(series_books.book_id) AS book_ids FROM book_series series LEFT JOIN series_books ON series.series_id = series_books.series_id WHERE series.user_id = ?1 GROUP BY series.series_id, series.last_update ORDER BY series.last_update DESC") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les séries.".to_string() } else { "Unable to retrieve series.".to_string() }))?; + + let series = statement + .query_map(params![user_id], |query_row| { + Ok(SeriesListItem { + series_id: query_row.get(0)?, name: query_row.get(1)?, + description: query_row.get(2)?, cover_image: query_row.get(3)?, + book_count: query_row.get(4)?, book_ids: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les séries.".to_string() } else { "Unable to retrieve series.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les séries.".to_string() } else { "Unable to retrieve series.".to_string() }))?; + + Ok(series) +} + +/// Fetches a single series by its ID. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the series result or None if not found. +pub fn fetch_series_by_id(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT series_id, user_id, name, hashed_name, description, cover_image, last_update FROM book_series WHERE series_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la série.".to_string() } else { "Unable to retrieve series.".to_string() }))?; + + let series = statement + .query_row(params![series_id, user_id], |query_row| { + Ok(SeriesResult { + series_id: query_row.get(0)?, user_id: query_row.get(1)?, + name: query_row.get(2)?, hashed_name: query_row.get(3)?, + description: query_row.get(4)?, cover_image: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }); + + match series { + Ok(row) => Ok(Some(row)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la série.".to_string() } else { "Unable to retrieve series.".to_string() })), + } +} + +/// Inserts a new series. +/// * `conn` - Database connection +/// * `series_id` - The unique identifier for the new series +/// * `user_id` - The unique identifier of the user +/// * `name` - The encrypted name +/// * `hashed_name` - The hashed name for duplicate detection +/// * `description` - The encrypted description (nullable) +/// * `last_update` - The creation timestamp +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the series ID if successful. +pub fn insert_series( + conn: &Connection, series_id: &str, user_id: &str, name: &str, hashed_name: &str, + description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_series (series_id, user_id, name, hashed_name, description, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![series_id, user_id, name, hashed_name, description, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de créer la série.".to_string() } else { "Unable to create series.".to_string() }))?; + + if insert_result > 0 { + Ok(series_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de la création de la série.".to_string() } else { "Error creating series.".to_string() })) + } +} + +/// Updates an existing series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `name` - The encrypted name +/// * `hashed_name` - The hashed name +/// * `description` - The encrypted description (nullable) +/// * `last_update` - The update timestamp +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful. +pub fn update_series( + conn: &Connection, user_id: &str, series_id: &str, name: &str, hashed_name: &str, + description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE book_series SET name = ?1, hashed_name = ?2, description = ?3, last_update = ?4 WHERE series_id = ?5 AND user_id = ?6", + params![name, hashed_name, description, last_update, series_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour la série.".to_string() } else { "Unable to update series.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Deletes a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion was successful. +pub fn delete_series(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM book_series WHERE series_id = ?1 AND user_id = ?2", params![series_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer la série.".to_string() } else { "Unable to delete series.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Fetches all books in a series with their order. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of books in the series. +pub fn fetch_series_books(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sb.series_id, sb.book_id, sb.book_order, b.title, b.cover_image FROM series_books sb INNER JOIN erit_books b ON sb.book_id = b.book_id WHERE sb.series_id = ?1 AND b.author_id = ?2 ORDER BY sb.book_order") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres de la série.".to_string() } else { "Unable to retrieve series books.".to_string() }))?; + + let books = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesBookResult { + series_id: query_row.get(0)?, book_id: query_row.get(1)?, + book_order: query_row.get(2)?, title: query_row.get(3)?, + cover_image: query_row.get(4)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres de la série.".to_string() } else { "Unable to retrieve series books.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres de la série.".to_string() } else { "Unable to retrieve series books.".to_string() }))?; + + Ok(books) +} + +/// Adds a book to a series. +/// * `conn` - Database connection +/// * `series_id` - The unique identifier of the series +/// * `book_id` - The unique identifier of the book +/// * `book_order` - The order of the book in the series +/// * `last_update` - The timestamp +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the addition was successful. +pub fn add_book_to_series(conn: &Connection, series_id: &str, book_id: &str, book_order: i64, last_update: i64, lang: Lang) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_books (series_id, book_id, book_order, last_update) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(series_id, book_id) DO UPDATE SET book_order = excluded.book_order, last_update = excluded.last_update", + params![series_id, book_id, book_order, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le livre à la série.".to_string() } else { "Unable to add book to series.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Removes a book from a series. +/// * `conn` - Database connection +/// * `series_id` - The unique identifier of the series +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the removal was successful. +pub fn remove_book_from_series(conn: &Connection, series_id: &str, book_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM series_books WHERE series_id = ?1 AND book_id = ?2", params![series_id, book_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de retirer le livre de la série.".to_string() } else { "Unable to remove book from series.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Updates the order of books in a series. +/// * `conn` - Database connection +/// * `series_id` - The unique identifier of the series +/// * `books_order` - An array of BookOrderItem with book_id and order +/// * `last_update` - The timestamp +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful. +pub fn update_books_order(conn: &Connection, series_id: &str, books_order: &[BookOrderItem], last_update: i64, lang: Lang) -> AppResult { + for book_order in books_order { + conn.execute( + "UPDATE series_books SET book_order = ?1, last_update = ?2 WHERE series_id = ?3 AND book_id = ?4", + params![book_order.order, last_update, series_id, book_order.book_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de réordonner les livres.".to_string() } else { "Unable to reorder books.".to_string() }))?; + } + + Ok(true) +} + +/// Checks if a series exists for a user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the series exists. +pub fn is_series_exist(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_series WHERE series_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de la série.".to_string() } else { "Unable to check series existence.".to_string() }))?; + + let exists = statement + .query_row(params![series_id, user_id], |_query_row| Ok(true)); + + match exists { + Ok(_) => Ok(true), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de la série.".to_string() } else { "Unable to check series existence.".to_string() })), + } +} + +/// Gets the series ID for a book if it belongs to one. +/// * `conn` - Database connection +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the series ID or None. +pub fn get_series_id_for_book(conn: &Connection, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT series_id FROM series_books WHERE book_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier la série du livre.".to_string() } else { "Unable to check book series.".to_string() }))?; + + let result = statement + .query_row(params![book_id], |query_row| query_row.get::<_, String>(0)); + + match result { + Ok(series_id) => Ok(Some(series_id)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier la série du livre.".to_string() } else { "Unable to check book series.".to_string() })), + } +} + +/// Fetches a series table row for sync purposes. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array containing the series table row. +pub fn fetch_series_table_for_sync(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT series_id, user_id, name, hashed_name, description, cover_image, last_update FROM book_series WHERE series_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la série pour sync.".to_string() } else { "Unable to retrieve series for sync.".to_string() }))?; + + let series = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesTableResult { + series_id: query_row.get(0)?, user_id: query_row.get(1)?, + name: query_row.get(2)?, hashed_name: query_row.get(3)?, + description: query_row.get(4)?, cover_image: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la série pour sync.".to_string() } else { "Unable to retrieve series for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la série pour sync.".to_string() } else { "Unable to retrieve series for sync.".to_string() }))?; + + Ok(series) +} + +/// Fetches all series-books relationships for sync. +/// * `conn` - Database connection +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of series-books table rows. +pub fn fetch_series_books_table(conn: &Connection, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT series_id, book_id, book_order, last_update FROM series_books WHERE series_id = ?1 ORDER BY book_order") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres de la série pour sync.".to_string() } else { "Unable to retrieve series books for sync.".to_string() }))?; + + let books = statement + .query_map(params![series_id], |query_row| { + Ok(SeriesBooksTableResult { + series_id: query_row.get(0)?, book_id: query_row.get(1)?, + book_order: query_row.get(2)?, last_update: query_row.get(3)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres de la série pour sync.".to_string() } else { "Unable to retrieve series books for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres de la série pour sync.".to_string() } else { "Unable to retrieve series books for sync.".to_string() }))?; + + Ok(books) +} + +/// Fetches all series for a user for sync comparison. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced series results. +pub fn fetch_synced_series(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT series_id, name, description, last_update FROM book_series WHERE user_id = ?1 ORDER BY last_update DESC") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les séries pour sync.".to_string() } else { "Unable to retrieve series for sync.".to_string() }))?; + + let series = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSeriesResult { + series_id: query_row.get(0)?, name: query_row.get(1)?, + description: query_row.get(2)?, last_update: query_row.get(3)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les séries pour sync.".to_string() } else { "Unable to retrieve series for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les séries pour sync.".to_string() } else { "Unable to retrieve series for sync.".to_string() }))?; + + Ok(series) +} + +/// Fetches all series-books relationships for a user for sync comparison. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced series book results. +pub fn fetch_synced_series_books(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sb.series_id, sb.book_id, sb.book_order, sb.last_update FROM series_books sb INNER JOIN book_series bs ON sb.series_id = bs.series_id WHERE bs.user_id = ?1 ORDER BY sb.book_order") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres de séries pour sync.".to_string() } else { "Unable to retrieve series books for sync.".to_string() }))?; + + let books = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSeriesBookResult { + series_id: query_row.get(0)?, book_id: query_row.get(1)?, + book_order: query_row.get(2)?, last_update: query_row.get(3)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres de séries pour sync.".to_string() } else { "Unable to retrieve series books for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les livres de séries pour sync.".to_string() } else { "Unable to retrieve series books for sync.".to_string() }))?; + + Ok(books) +} + +/// Fetches a complete series by ID for sync. +/// * `conn` - Database connection +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array containing the series. +pub fn fetch_complete_series_by_id(conn: &Connection, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT series_id, user_id, name, hashed_name, description, cover_image, last_update FROM book_series WHERE series_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la série complète.".to_string() } else { "Unable to retrieve complete series.".to_string() }))?; + + let series = statement + .query_map(params![series_id], |query_row| { + Ok(SeriesTableResult { + series_id: query_row.get(0)?, user_id: query_row.get(1)?, + name: query_row.get(2)?, hashed_name: query_row.get(3)?, + description: query_row.get(4)?, cover_image: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la série complète.".to_string() } else { "Unable to retrieve complete series.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer la série complète.".to_string() } else { "Unable to retrieve complete series.".to_string() }))?; + + Ok(series) +} + +/// Inserts a series for sync purposes. +/// * `conn` - Database connection +/// * `series_id` - The unique identifier of the series +/// * `user_id` - The unique identifier of the user +/// * `name` - The encrypted name +/// * `hashed_name` - The hashed name +/// * `description` - The encrypted description (nullable) +/// * `cover_image` - The cover image (nullable) +/// * `last_update` - The sync timestamp +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion was successful. +pub fn insert_sync_series( + conn: &Connection, series_id: &str, user_id: &str, name: &str, hashed_name: &str, + description: Option<&str>, cover_image: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO book_series (series_id, user_id, name, hashed_name, description, cover_image, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(series_id) DO UPDATE SET name = excluded.name, hashed_name = excluded.hashed_name, description = excluded.description, cover_image = excluded.cover_image, last_update = excluded.last_update", + params![series_id, user_id, name, hashed_name, description, cover_image, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer la série pour sync.".to_string() } else { "Unable to insert series for sync.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Updates a series for sync purposes. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `name` - The encrypted name +/// * `hashed_name` - The hashed name +/// * `description` - The encrypted description (nullable) +/// * `cover_image` - The cover image (nullable) +/// * `last_update` - The sync timestamp +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful. +pub fn update_sync_series( + conn: &Connection, user_id: &str, series_id: &str, name: &str, hashed_name: &str, + description: Option<&str>, cover_image: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE book_series SET name = ?1, hashed_name = ?2, description = ?3, cover_image = ?4, last_update = ?5 WHERE series_id = ?6 AND user_id = ?7", + params![name, hashed_name, description, cover_image, last_update, series_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour la série pour sync.".to_string() } else { "Unable to update series for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a series-book relationship for sync. +/// * `conn` - Database connection +/// * `series_id` - The unique identifier of the series +/// * `book_id` - The unique identifier of the book +/// * `book_order` - The order of the book +/// * `last_update` - The sync timestamp +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion was successful. +pub fn insert_sync_series_book(conn: &Connection, series_id: &str, book_id: &str, book_order: i64, last_update: i64, lang: Lang) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_books (series_id, book_id, book_order, last_update) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(series_id, book_id) DO UPDATE SET book_order = excluded.book_order, last_update = excluded.last_update", + params![series_id, book_id, book_order, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer la liaison série-livre pour sync.".to_string() } else { "Unable to insert series-book for sync.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Checks if a series-book relationship exists. +/// * `conn` - Database connection +/// * `series_id` - The unique identifier of the series +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the relationship exists. +pub fn is_series_book_exist(conn: &Connection, series_id: &str, book_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM series_books WHERE series_id = ?1 AND book_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier la liaison série-livre.".to_string() } else { "Unable to check series-book.".to_string() }))?; + + let exists = statement + .query_row(params![series_id, book_id], |_query_row| Ok(true)); + + match exists { + Ok(_) => Ok(true), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier la liaison série-livre.".to_string() } else { "Unable to check series-book.".to_string() })), + } +} + +/// Checks if a series exists for a user (alias for is_series_exist). +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the series exists. +pub fn series_exists(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult { + is_series_exist(conn, user_id, series_id, lang) +} diff --git a/src-tauri/src/domains/series/service.rs b/src-tauri/src/domains/series/service.rs new file mode 100644 index 0000000..cc02dca --- /dev/null +++ b/src-tauri/src/domains/series/service.rs @@ -0,0 +1,299 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::series::repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::{AppError, AppResult}; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesProps { + pub id: String, + pub name: String, + pub description: String, + pub cover_image: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesDetailProps { + pub id: String, + pub name: String, + pub description: String, + pub cover_image: Option, + pub books: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesBookProps { + pub book_id: String, + pub title: String, + pub order: i64, + pub cover_image: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesListItemProps { + pub id: String, + pub name: String, + pub description: String, + pub cover_image: Option, + pub book_count: i64, + pub book_ids: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BooksOrderPost { + pub book_id: String, + pub order: i64, +} + +/// Gets the list of all series for a user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the list of series with decrypted names and descriptions. +pub fn get_series_list(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let user_key: String = get_user_encryption_key(user_id)?; + let series_results: Vec = repo::fetch_user_series(conn, user_id, lang)?; + + let mut series_list: Vec = Vec::with_capacity(series_results.len()); + for series_item in series_results { + let decrypted_name: String = decrypt_data_with_user_key(&series_item.name, &user_key)?; + let decrypted_description: String = if let Some(ref description) = series_item.description { decrypt_data_with_user_key(description, &user_key)? } else { String::new() }; + let book_ids: Vec = if let Some(ref book_ids_str) = series_item.book_ids { book_ids_str.split(',').map(|s| s.to_string()).collect() } else { vec![] }; + + series_list.push(SeriesListItemProps { + id: series_item.series_id, + name: decrypted_name, + description: decrypted_description, + cover_image: series_item.cover_image, + book_count: series_item.book_count, + book_ids, + }); + } + + Ok(series_list) +} + +/// Gets the detail of a series including its books. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the series detail with decrypted data. +pub fn get_series_detail(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + + let series_result: Option = repo::fetch_series_by_id(conn, user_id, series_id, lang)?; + let series_result: repo::SeriesResult = series_result.ok_or_else(|| { + AppError::Internal(if lang == Lang::Fr { "Série non trouvée.".to_string() } else { "Series not found.".to_string() }) + })?; + + let books_result: Vec = repo::fetch_series_books(conn, user_id, series_id, lang)?; + + let mut books: Vec = Vec::with_capacity(books_result.len()); + for book in books_result { + let decrypted_title: String = decrypt_data_with_user_key(&book.title, &user_key)?; + books.push(SeriesBookProps { + book_id: book.book_id, + title: decrypted_title, + order: book.book_order, + cover_image: book.cover_image, + }); + } + + let decrypted_name: String = decrypt_data_with_user_key(&series_result.name, &user_key)?; + let decrypted_description: String = if let Some(ref description) = series_result.description { decrypt_data_with_user_key(description, &user_key)? } else { String::new() }; + + Ok(SeriesDetailProps { + id: series_result.series_id, + name: decrypted_name, + description: decrypted_description, + cover_image: series_result.cover_image, + books, + }) +} + +/// Creates a new series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `name` - The name of the series +/// * `description` - The description of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// * `book_ids` - Optional array of book IDs to add to the series +/// Returns the created series ID. +pub fn create_series(conn: &Connection, user_id: &str, name: &str, description: &str, lang: Lang, book_ids: Option<&[String]>) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let series_id: String = create_unique_id(None); + + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let hashed_name: String = hash_element(name); + let encrypted_description: Option = if description.is_empty() { None } else { Some(encrypt_data_with_user_key(description, &user_key)?) }; + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_series(conn, &series_id, user_id, &encrypted_name, &hashed_name, encrypted_description.as_deref(), last_update, lang)?; + + if let Some(book_ids) = book_ids { + for (index, book_id) in book_ids.iter().enumerate() { + let book_order: i64 = (index as i64) + 1; + repo::add_book_to_series(conn, &series_id, book_id, book_order, last_update, lang)?; + } + } + + Ok(series_id) +} + +/// Updates an existing series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `name` - The name of the series +/// * `description` - The description of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful. +pub fn update_series(conn: &Connection, user_id: &str, series_id: &str, name: &str, description: &str, lang: Lang) -> AppResult { + let exists: bool = repo::is_series_exist(conn, user_id, series_id, lang)?; + if !exists { + return Err(AppError::Internal(if lang == Lang::Fr { "Série non trouvée.".to_string() } else { "Series not found.".to_string() })); + } + + let user_key: String = get_user_encryption_key(user_id)?; + + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let hashed_name: String = hash_element(name); + let encrypted_description: Option = if description.is_empty() { None } else { Some(encrypt_data_with_user_key(description, &user_key)?) }; + let last_update: i64 = timestamp_in_seconds(); + + repo::update_series(conn, user_id, series_id, &encrypted_name, &hashed_name, encrypted_description.as_deref(), last_update, lang) +} + +/// Deletes a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion was successful. +pub fn delete_series(conn: &Connection, user_id: &str, series_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let exists: bool = repo::is_series_exist(conn, user_id, series_id, lang)?; + if !exists { + return Err(AppError::Internal(if lang == Lang::Fr { "Série non trouvée.".to_string() } else { "Series not found.".to_string() })); + } + + let deleted: bool = repo::delete_series(conn, user_id, series_id, lang)?; + if deleted { + tombstone_repo::insert(conn, series_id, "book_series", series_id, None, user_id, deleted_at, lang)?; + } + + Ok(deleted) +} + +/// Adds a book to a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `book_id` - The unique identifier of the book +/// * `order` - The order of the book in the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the addition was successful. +pub fn add_book_to_series(conn: &Connection, user_id: &str, series_id: &str, book_id: &str, order: i64, lang: Lang) -> AppResult { + let exists: bool = repo::is_series_exist(conn, user_id, series_id, lang)?; + if !exists { + return Err(AppError::Internal(if lang == Lang::Fr { "Série non trouvée.".to_string() } else { "Series not found.".to_string() })); + } + + let last_update: i64 = timestamp_in_seconds(); + repo::add_book_to_series(conn, series_id, book_id, order, last_update, lang) +} + +/// Removes a book from a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `book_id` - The unique identifier of the book +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the removal was successful. +pub fn remove_book_from_series(conn: &Connection, user_id: &str, series_id: &str, book_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let exists: bool = repo::is_series_exist(conn, user_id, series_id, lang)?; + if !exists { + return Err(AppError::Internal(if lang == Lang::Fr { "Série non trouvée.".to_string() } else { "Series not found.".to_string() })); + } + + let deleted: bool = repo::remove_book_from_series(conn, series_id, book_id, lang)?; + if deleted { + let entity_id: String = format!("{}_{}", series_id, book_id); + tombstone_repo::insert(conn, &entity_id, "series_books", &entity_id, None, user_id, deleted_at, lang)?; + } + + Ok(deleted) +} + +/// Updates the order of books in a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `books_order` - An array of BooksOrderPost with book_id and order +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful. +pub fn update_books_order(conn: &Connection, user_id: &str, series_id: &str, books_order: &[BooksOrderPost], lang: Lang) -> AppResult { + let exists: bool = repo::is_series_exist(conn, user_id, series_id, lang)?; + if !exists { + return Err(AppError::Internal(if lang == Lang::Fr { "Série non trouvée.".to_string() } else { "Series not found.".to_string() })); + } + + let repo_books_order: Vec = books_order.iter().map(|item| repo::BookOrderItem { + book_id: item.book_id.clone(), + order: item.order, + }).collect(); + let last_update: i64 = timestamp_in_seconds(); + + repo::update_books_order(conn, series_id, &repo_books_order, last_update, lang) +} + +/// Gets the series ID for a book if it belongs to one. +/// * `conn` - Database connection +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the series ID or None. +pub fn get_series_id_for_book(conn: &Connection, book_id: &str, lang: Lang) -> AppResult> { + repo::get_series_id_for_book(conn, book_id, lang) +} + +/// Gets only the books of a series (without series details). +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the list of books in the series. +pub fn get_series_books(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let user_key: String = get_user_encryption_key(user_id)?; + + let exists: bool = repo::is_series_exist(conn, user_id, series_id, lang)?; + if !exists { + return Err(AppError::Internal(if lang == Lang::Fr { "Série non trouvée.".to_string() } else { "Series not found.".to_string() })); + } + + let books_result: Vec = repo::fetch_series_books(conn, user_id, series_id, lang)?; + + let mut books: Vec = Vec::with_capacity(books_result.len()); + for book in books_result { + let decrypted_title: String = decrypt_data_with_user_key(&book.title, &user_key)?; + books.push(SeriesBookProps { + book_id: book.book_id, + title: decrypted_title, + order: book.book_order, + cover_image: book.cover_image, + }); + } + + Ok(books) +} diff --git a/src-tauri/src/domains/series_character/commands.rs b/src-tauri/src/domains/series_character/commands.rs new file mode 100644 index 0000000..3204c89 --- /dev/null +++ b/src-tauri/src/domains/series_character/commands.rs @@ -0,0 +1,108 @@ +use serde::Deserialize; +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::series_character::service; +use crate::error::AppError; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSeriesCharacterListData { pub series_id: String } + +#[tauri::command] +pub fn get_series_character_list(data: GetSeriesCharacterListData, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_character_list(conn, &user_id, &data.series_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSeriesCharacterAttributesData { pub character_id: String } + +#[tauri::command] +pub fn get_series_character_attributes(data: GetSeriesCharacterAttributesData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_character_attributes(conn, &user_id, &data.character_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesCharacterData { + pub series_id: String, + pub character: service::SeriesCharacterPropsPost, +} + +#[tauri::command] +pub fn add_series_character(data: AddSeriesCharacterData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_new_character(conn, &user_id, &data.character, &data.series_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSeriesCharacterData { + pub character: service::SeriesCharacterPropsPost, +} + +#[tauri::command] +pub fn update_series_character(data: UpdateSeriesCharacterData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::update_character(conn, &user_id, &data.character, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSeriesCharacterData { pub character_id: String, pub deleted_at: i64 } + +#[tauri::command] +pub fn delete_series_character(data: DeleteSeriesCharacterData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_character(conn, &user_id, &data.character_id, data.deleted_at, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesCharacterAttributeData { + pub character_id: String, + pub r#type: String, + pub name: String, +} + +#[tauri::command] +pub fn add_series_character_attribute(data: AddSeriesCharacterAttributeData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_new_attribute(conn, &data.character_id, &user_id, &data.r#type, &data.name, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSeriesCharacterAttributeData { pub attribute_id: String, pub deleted_at: i64 } + +#[tauri::command] +pub fn delete_series_character_attribute(data: DeleteSeriesCharacterAttributeData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_attribute(conn, &user_id, &data.attribute_id, data.deleted_at, lang) +} diff --git a/src-tauri/src/domains/series_character/mod.rs b/src-tauri/src/domains/series_character/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/series_character/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/series_character/repo.rs b/src-tauri/src/domains/series_character/repo.rs new file mode 100644 index 0000000..4063504 --- /dev/null +++ b/src-tauri/src/domains/series_character/repo.rs @@ -0,0 +1,746 @@ +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct SeriesCharacterResult { + pub character_id: String, + pub first_name: String, + pub last_name: String, + pub nickname: Option, + pub age: Option, + pub gender: Option, + pub species: Option, + pub nationality: Option, + pub status: Option, + pub title: String, + pub category: String, + pub image: String, + pub role: String, + pub biography: String, + pub history: String, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, +} + +pub struct SeriesCharacterAttributeResult { + pub attr_id: String, + pub attribute_name: String, + pub attribute_value: String, +} + +pub struct SeriesCharactersTableResult { + pub character_id: String, + pub series_id: String, + pub user_id: String, + pub first_name: String, + pub last_name: Option, + pub nickname: Option, + pub age: Option, + pub gender: Option, + pub species: Option, + pub nationality: Option, + pub status: Option, + pub title: Option, + pub category: String, + pub image: Option, + pub role: Option, + pub biography: Option, + pub history: Option, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, + pub last_update: i64, +} + +pub struct SeriesCharacterAttributesTableResult { + pub attr_id: String, + pub character_id: String, + pub user_id: String, + pub attribute_name: String, + pub attribute_value: String, + pub last_update: i64, +} + +pub struct SyncedSeriesCharacterResult { + pub character_id: String, + pub series_id: String, + pub first_name: String, + pub last_update: i64, +} + +pub struct SyncedSeriesCharacterAttributeResult { + pub attr_id: String, + pub character_id: String, + pub attribute_name: String, + pub last_update: i64, +} + +/// Fetches all characters for a specific series owned by the user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of character results. +pub fn fetch_characters(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT character_id, first_name, last_name, nickname, age, gender, species, nationality, status, title, category, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color FROM series_characters WHERE series_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages de la s\u{00e9}rie.".to_string() } else { "Unable to retrieve series characters.".to_string() }))?; + + let characters = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesCharacterResult { + character_id: query_row.get(0)?, first_name: query_row.get(1)?, + last_name: query_row.get(2)?, nickname: query_row.get(3)?, + age: query_row.get(4)?, gender: query_row.get(5)?, + species: query_row.get(6)?, nationality: query_row.get(7)?, + status: query_row.get(8)?, title: query_row.get(9)?, + category: query_row.get(10)?, image: query_row.get(11)?, + role: query_row.get(12)?, biography: query_row.get(13)?, + history: query_row.get(14)?, speech_pattern: query_row.get(15)?, + catchphrase: query_row.get(16)?, residence: query_row.get(17)?, + notes: query_row.get(18)?, color: query_row.get(19)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages de la s\u{00e9}rie.".to_string() } else { "Unable to retrieve series characters.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages de la s\u{00e9}rie.".to_string() } else { "Unable to retrieve series characters.".to_string() }))?; + + Ok(characters) +} + +/// Adds a new character to a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character +/// * `encrypted_name` - The encrypted first name +/// * `encrypted_last_name` - The encrypted last name +/// * `encrypted_nickname` - The encrypted nickname +/// * `encrypted_age` - The encrypted age +/// * `encrypted_gender` - The encrypted gender +/// * `encrypted_species` - The encrypted species +/// * `encrypted_nationality` - The encrypted nationality +/// * `encrypted_status` - The encrypted status +/// * `encrypted_title` - The encrypted title +/// * `encrypted_category` - The encrypted category +/// * `encrypted_image` - The encrypted image +/// * `encrypted_role` - The encrypted role +/// * `encrypted_biography` - The encrypted biography +/// * `encrypted_history` - The encrypted history +/// * `encrypted_speech_pattern` - The encrypted speech pattern +/// * `encrypted_catchphrase` - The encrypted catchphrase +/// * `encrypted_residence` - The encrypted residence +/// * `encrypted_notes` - The encrypted notes +/// * `encrypted_color` - The encrypted color +/// * `series_id` - The unique identifier of the series +/// * `last_update` - The timestamp for the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the character ID if insertion was successful. +pub fn add_new_character( + conn: &Connection, user_id: &str, character_id: &str, encrypted_name: &str, + encrypted_last_name: Option<&str>, encrypted_nickname: Option<&str>, encrypted_age: Option<&str>, + encrypted_gender: Option<&str>, encrypted_species: Option<&str>, encrypted_nationality: Option<&str>, + encrypted_status: Option<&str>, encrypted_title: Option<&str>, encrypted_category: Option<&str>, + encrypted_image: Option<&str>, encrypted_role: Option<&str>, encrypted_biography: Option<&str>, + encrypted_history: Option<&str>, encrypted_speech_pattern: Option<&str>, encrypted_catchphrase: Option<&str>, + encrypted_residence: Option<&str>, encrypted_notes: Option<&str>, encrypted_color: Option<&str>, + series_id: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_characters (character_id, series_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, category, title, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)", + params![character_id, series_id, user_id, encrypted_name, encrypted_last_name, encrypted_nickname, encrypted_age, encrypted_gender, encrypted_species, encrypted_nationality, encrypted_status, encrypted_category, encrypted_title, encrypted_image, encrypted_role, encrypted_biography, encrypted_history, encrypted_speech_pattern, encrypted_catchphrase, encrypted_residence, encrypted_notes, encrypted_color, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le personnage.".to_string() } else { "Unable to add character.".to_string() }))?; + + if insert_result > 0 { + Ok(character_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de l'ajout du personnage.".to_string() } else { "Error adding character.".to_string() })) + } +} + +/// Inserts a new attribute for a series character. +/// * `conn` - Database connection +/// * `attribute_id` - The unique identifier of the attribute +/// * `character_id` - The unique identifier of the character +/// * `user_id` - The unique identifier of the user +/// * `attribute_type` - The attribute type/name +/// * `name` - The attribute value +/// * `last_update` - The timestamp for the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the attribute ID if insertion was successful. +pub fn insert_attribute( + conn: &Connection, attribute_id: &str, character_id: &str, user_id: &str, + attribute_type: &str, name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_characters_attributes (attr_id, character_id, user_id, attribute_name, attribute_value, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![attribute_id, character_id, user_id, attribute_type, name, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter l'attribut.".to_string() } else { "Unable to add attribute.".to_string() }))?; + + if insert_result > 0 { + Ok(attribute_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de l'ajout de l'attribut.".to_string() } else { "Error adding attribute.".to_string() })) + } +} + +/// Updates an existing series character's information. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character +/// * `encrypted_name` - The encrypted first name +/// * `encrypted_last_name` - The encrypted last name +/// * `encrypted_nickname` - The encrypted nickname +/// * `encrypted_age` - The encrypted age +/// * `encrypted_gender` - The encrypted gender +/// * `encrypted_species` - The encrypted species +/// * `encrypted_nationality` - The encrypted nationality +/// * `encrypted_status` - The encrypted status +/// * `encrypted_title` - The encrypted title +/// * `encrypted_category` - The encrypted category +/// * `encrypted_image` - The encrypted image +/// * `encrypted_role` - The encrypted role +/// * `encrypted_biography` - The encrypted biography +/// * `encrypted_history` - The encrypted history +/// * `encrypted_speech_pattern` - The encrypted speech pattern +/// * `encrypted_catchphrase` - The encrypted catchphrase +/// * `encrypted_residence` - The encrypted residence +/// * `encrypted_notes` - The encrypted notes +/// * `encrypted_color` - The encrypted color +/// * `last_update` - The timestamp for the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful, false otherwise. +pub fn update_character( + conn: &Connection, user_id: &str, character_id: &str, encrypted_name: &str, + encrypted_last_name: Option<&str>, encrypted_nickname: Option<&str>, encrypted_age: Option<&str>, + encrypted_gender: Option<&str>, encrypted_species: Option<&str>, encrypted_nationality: Option<&str>, + encrypted_status: Option<&str>, encrypted_title: Option<&str>, encrypted_category: Option<&str>, + encrypted_image: Option<&str>, encrypted_role: Option<&str>, encrypted_biography: Option<&str>, + encrypted_history: Option<&str>, encrypted_speech_pattern: Option<&str>, encrypted_catchphrase: Option<&str>, + encrypted_residence: Option<&str>, encrypted_notes: Option<&str>, encrypted_color: Option<&str>, + last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_characters SET first_name = ?1, last_name = ?2, nickname = ?3, age = ?4, gender = ?5, species = ?6, nationality = ?7, status = ?8, title = ?9, category = ?10, image = ?11, role = ?12, biography = ?13, history = ?14, speech_pattern = ?15, catchphrase = ?16, residence = ?17, notes = ?18, color = ?19, last_update = ?20 WHERE character_id = ?21 AND user_id = ?22", + params![encrypted_name, encrypted_last_name, encrypted_nickname, encrypted_age, encrypted_gender, encrypted_species, encrypted_nationality, encrypted_status, encrypted_title, encrypted_category, encrypted_image, encrypted_role, encrypted_biography, encrypted_history, encrypted_speech_pattern, encrypted_catchphrase, encrypted_residence, encrypted_notes, encrypted_color, last_update, character_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre \u{00e0} jour le personnage.".to_string() } else { "Unable to update character.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Deletes a series character and all its related data via CASCADE. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion was successful, false otherwise. +pub fn delete_character(conn: &Connection, user_id: &str, character_id: &str, lang: Lang) -> AppResult { + // Delete attributes first + conn.execute("DELETE FROM series_characters_attributes WHERE character_id = ?1 AND user_id = ?2", params![character_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le personnage.".to_string() } else { "Unable to delete character.".to_string() }))?; + + // Delete character + let delete_result = conn + .execute("DELETE FROM series_characters WHERE character_id = ?1 AND user_id = ?2", params![character_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le personnage.".to_string() } else { "Unable to delete character.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Deletes an attribute from a series character. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `attribute_id` - The unique identifier of the attribute +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion was successful, false otherwise. +pub fn delete_attribute(conn: &Connection, user_id: &str, attribute_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM series_characters_attributes WHERE attr_id = ?1 AND user_id = ?2", params![attribute_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer l'attribut.".to_string() } else { "Unable to delete attribute.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Fetches all attributes for a specific series character. +/// * `conn` - Database connection +/// * `character_id` - The unique identifier of the character +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of attribute results. +pub fn fetch_attributes(conn: &Connection, character_id: &str, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT attr_id, attribute_name, attribute_value FROM series_characters_attributes WHERE character_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs.".to_string() } else { "Unable to retrieve attributes.".to_string() }))?; + + let attributes = statement + .query_map(params![character_id, user_id], |query_row| { + Ok(SeriesCharacterAttributeResult { attr_id: query_row.get(0)?, attribute_name: query_row.get(1)?, attribute_value: query_row.get(2)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs.".to_string() } else { "Unable to retrieve attributes.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs.".to_string() } else { "Unable to retrieve attributes.".to_string() }))?; + + Ok(attributes) +} + +/// Checks if a series character exists. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the character exists, false otherwise. +pub fn is_character_exist(conn: &Connection, user_id: &str, character_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM series_characters WHERE character_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence du personnage.".to_string() } else { "Unable to check character existence.".to_string() }))?; + + let existence_check = statement + .query_row(params![character_id, user_id], |_query_row| Ok(true)) + .optional() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence du personnage.".to_string() } else { "Unable to check character existence.".to_string() }))?; + + Ok(existence_check.is_some()) +} + +/// Fetches all characters for a series for sync. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of full character table results. +pub fn fetch_series_characters_table(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT character_id, series_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, title, category, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update FROM series_characters WHERE series_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages pour sync.".to_string() } else { "Unable to retrieve characters for sync.".to_string() }))?; + + let characters = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesCharactersTableResult { + character_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, first_name: query_row.get(3)?, + last_name: query_row.get(4)?, nickname: query_row.get(5)?, + age: query_row.get(6)?, gender: query_row.get(7)?, + species: query_row.get(8)?, nationality: query_row.get(9)?, + status: query_row.get(10)?, title: query_row.get(11)?, + category: query_row.get(12)?, image: query_row.get(13)?, + role: query_row.get(14)?, biography: query_row.get(15)?, + history: query_row.get(16)?, speech_pattern: query_row.get(17)?, + catchphrase: query_row.get(18)?, residence: query_row.get(19)?, + notes: query_row.get(20)?, color: query_row.get(21)?, + last_update: query_row.get(22)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages pour sync.".to_string() } else { "Unable to retrieve characters for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages pour sync.".to_string() } else { "Unable to retrieve characters for sync.".to_string() }))?; + + Ok(characters) +} + +/// Fetches all attributes for a character for sync. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of full attribute table results. +pub fn fetch_series_character_attributes_table(conn: &Connection, user_id: &str, character_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT attr_id, character_id, user_id, attribute_name, attribute_value, last_update FROM series_characters_attributes WHERE character_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs pour sync.".to_string() } else { "Unable to retrieve attributes for sync.".to_string() }))?; + + let attributes = statement + .query_map(params![character_id, user_id], |query_row| { + Ok(SeriesCharacterAttributesTableResult { + attr_id: query_row.get(0)?, character_id: query_row.get(1)?, + user_id: query_row.get(2)?, attribute_name: query_row.get(3)?, + attribute_value: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs pour sync.".to_string() } else { "Unable to retrieve attributes for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs pour sync.".to_string() } else { "Unable to retrieve attributes for sync.".to_string() }))?; + + Ok(attributes) +} + +/// Fetches all series characters for a user for sync comparison. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced character results. +pub fn fetch_synced_series_characters(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT character_id, series_id, first_name, last_update FROM series_characters WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages de s\u{00e9}rie pour sync.".to_string() } else { "Unable to retrieve series characters for sync.".to_string() }))?; + + let characters = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSeriesCharacterResult { character_id: query_row.get(0)?, series_id: query_row.get(1)?, first_name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages de s\u{00e9}rie pour sync.".to_string() } else { "Unable to retrieve series characters for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages de s\u{00e9}rie pour sync.".to_string() } else { "Unable to retrieve series characters for sync.".to_string() }))?; + + Ok(characters) +} + +/// Fetches all series character attributes for a user for sync comparison. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced attribute results. +pub fn fetch_synced_series_character_attributes(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT attr_id, character_id, attribute_name, last_update FROM series_characters_attributes WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs de personnage pour sync.".to_string() } else { "Unable to retrieve character attributes for sync.".to_string() }))?; + + let attributes = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSeriesCharacterAttributeResult { attr_id: query_row.get(0)?, character_id: query_row.get(1)?, attribute_name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs de personnage pour sync.".to_string() } else { "Unable to retrieve character attributes for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs de personnage pour sync.".to_string() } else { "Unable to retrieve character attributes for sync.".to_string() }))?; + + Ok(attributes) +} + +/// Fetches a complete character by ID for sync. +/// * `conn` - Database connection +/// * `character_id` - The unique identifier of the character +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of full character table results. +pub fn fetch_complete_character_by_id(conn: &Connection, character_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT character_id, series_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, title, category, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update FROM series_characters WHERE character_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer le personnage complet.".to_string() } else { "Unable to retrieve complete character.".to_string() }))?; + + let characters = statement + .query_map(params![character_id], |query_row| { + Ok(SeriesCharactersTableResult { + character_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, first_name: query_row.get(3)?, + last_name: query_row.get(4)?, nickname: query_row.get(5)?, + age: query_row.get(6)?, gender: query_row.get(7)?, + species: query_row.get(8)?, nationality: query_row.get(9)?, + status: query_row.get(10)?, title: query_row.get(11)?, + category: query_row.get(12)?, image: query_row.get(13)?, + role: query_row.get(14)?, biography: query_row.get(15)?, + history: query_row.get(16)?, speech_pattern: query_row.get(17)?, + catchphrase: query_row.get(18)?, residence: query_row.get(19)?, + notes: query_row.get(20)?, color: query_row.get(21)?, + last_update: query_row.get(22)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer le personnage complet.".to_string() } else { "Unable to retrieve complete character.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer le personnage complet.".to_string() } else { "Unable to retrieve complete character.".to_string() }))?; + + Ok(characters) +} + +/// Fetches a complete character attribute by ID for sync. +/// * `conn` - Database connection +/// * `attr_id` - The unique identifier of the attribute +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of full attribute table results. +pub fn fetch_complete_attribute_by_id(conn: &Connection, attr_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT attr_id, character_id, user_id, attribute_name, attribute_value, last_update FROM series_characters_attributes WHERE attr_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer l'attribut complet.".to_string() } else { "Unable to retrieve complete attribute.".to_string() }))?; + + let attributes = statement + .query_map(params![attr_id], |query_row| { + Ok(SeriesCharacterAttributesTableResult { + attr_id: query_row.get(0)?, character_id: query_row.get(1)?, + user_id: query_row.get(2)?, attribute_name: query_row.get(3)?, + attribute_value: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer l'attribut complet.".to_string() } else { "Unable to retrieve complete attribute.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer l'attribut complet.".to_string() } else { "Unable to retrieve complete attribute.".to_string() }))?; + + Ok(attributes) +} + +/// Inserts a series character for sync. +/// * `conn` - Database connection +/// * `character_id` - The unique identifier of the character +/// * `series_id` - The unique identifier of the series +/// * `user_id` - The unique identifier of the user +/// * `first_name` - The character's first name +/// * `last_name` - The character's last name +/// * `nickname` - The character's nickname +/// * `age` - The character's age +/// * `gender` - The character's gender +/// * `species` - The character's species +/// * `nationality` - The character's nationality +/// * `status` - The character's status +/// * `category` - The character's category +/// * `title` - The character's title +/// * `image` - The character's image +/// * `role` - The character's role +/// * `biography` - The character's biography +/// * `history` - The character's history +/// * `speech_pattern` - The character's speech pattern +/// * `catchphrase` - The character's catchphrase +/// * `residence` - The character's residence +/// * `notes` - The character's notes +/// * `color` - The character's color +/// * `last_update` - The timestamp for the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion was successful, false otherwise. +pub fn insert_sync_series_character( + conn: &Connection, character_id: &str, series_id: &str, user_id: &str, first_name: &str, + last_name: Option<&str>, nickname: Option<&str>, age: Option<&str>, gender: Option<&str>, + species: Option<&str>, nationality: Option<&str>, status: Option<&str>, category: &str, + title: Option<&str>, image: Option<&str>, role: Option<&str>, biography: Option<&str>, + history: Option<&str>, speech_pattern: Option<&str>, catchphrase: Option<&str>, + residence: Option<&str>, notes: Option<&str>, color: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_characters (character_id, series_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, category, title, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23) ON CONFLICT(character_id) DO UPDATE SET first_name = excluded.first_name, last_name = excluded.last_name, nickname = excluded.nickname, age = excluded.age, gender = excluded.gender, species = excluded.species, nationality = excluded.nationality, status = excluded.status, category = excluded.category, title = excluded.title, image = excluded.image, role = excluded.role, biography = excluded.biography, history = excluded.history, speech_pattern = excluded.speech_pattern, catchphrase = excluded.catchphrase, residence = excluded.residence, notes = excluded.notes, color = excluded.color, last_update = excluded.last_update", + params![character_id, series_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, category, title, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ins\u{00e9}rer le personnage pour sync.".to_string() } else { "Unable to insert character for sync.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Updates a series character for sync. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character +/// * `first_name` - The character's first name +/// * `last_name` - The character's last name +/// * `nickname` - The character's nickname +/// * `age` - The character's age +/// * `gender` - The character's gender +/// * `species` - The character's species +/// * `nationality` - The character's nationality +/// * `status` - The character's status +/// * `category` - The character's category +/// * `title` - The character's title +/// * `image` - The character's image +/// * `role` - The character's role +/// * `biography` - The character's biography +/// * `history` - The character's history +/// * `speech_pattern` - The character's speech pattern +/// * `catchphrase` - The character's catchphrase +/// * `residence` - The character's residence +/// * `notes` - The character's notes +/// * `color` - The character's color +/// * `last_update` - The timestamp for the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful, false otherwise. +pub fn update_sync_series_character( + conn: &Connection, user_id: &str, character_id: &str, first_name: &str, + last_name: Option<&str>, nickname: Option<&str>, age: Option<&str>, gender: Option<&str>, + species: Option<&str>, nationality: Option<&str>, status: Option<&str>, category: &str, + title: Option<&str>, image: Option<&str>, role: Option<&str>, biography: Option<&str>, + history: Option<&str>, speech_pattern: Option<&str>, catchphrase: Option<&str>, + residence: Option<&str>, notes: Option<&str>, color: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_characters SET first_name = ?1, last_name = ?2, nickname = ?3, age = ?4, gender = ?5, species = ?6, nationality = ?7, status = ?8, category = ?9, title = ?10, image = ?11, role = ?12, biography = ?13, history = ?14, speech_pattern = ?15, catchphrase = ?16, residence = ?17, notes = ?18, color = ?19, last_update = ?20 WHERE character_id = ?21 AND user_id = ?22", + params![first_name, last_name, nickname, age, gender, species, nationality, status, category, title, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update, character_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre \u{00e0} jour le personnage pour sync.".to_string() } else { "Unable to update character for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a series character attribute for sync. +/// * `conn` - Database connection +/// * `attr_id` - The unique identifier of the attribute +/// * `character_id` - The unique identifier of the character +/// * `user_id` - The unique identifier of the user +/// * `attribute_name` - The attribute name +/// * `attribute_value` - The attribute value +/// * `last_update` - The timestamp for the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion was successful, false otherwise. +pub fn insert_sync_series_character_attribute( + conn: &Connection, attr_id: &str, character_id: &str, user_id: &str, + attribute_name: &str, attribute_value: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_characters_attributes (attr_id, character_id, user_id, attribute_name, attribute_value, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(attr_id) DO UPDATE SET attribute_name = excluded.attribute_name, attribute_value = excluded.attribute_value, last_update = excluded.last_update", + params![attr_id, character_id, user_id, attribute_name, attribute_value, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ins\u{00e9}rer l'attribut pour sync.".to_string() } else { "Unable to insert attribute for sync.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Checks if a series character attribute exists. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `attr_id` - The unique identifier of the attribute +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the attribute exists, false otherwise. +pub fn is_attribute_exist(conn: &Connection, user_id: &str, attr_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM series_characters_attributes WHERE attr_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence de l'attribut.".to_string() } else { "Unable to check attribute existence.".to_string() }))?; + + let existence_check = statement + .query_row(params![attr_id, user_id], |_query_row| Ok(true)) + .optional() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence de l'attribut.".to_string() } else { "Unable to check attribute existence.".to_string() }))?; + + Ok(existence_check.is_some()) +} + +/// Updates a series character attribute for sync. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `attr_id` - The unique identifier of the attribute +/// * `attribute_name` - The attribute name +/// * `attribute_value` - The attribute value +/// * `last_update` - The timestamp for the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful, false otherwise. +pub fn update_sync_series_character_attribute( + conn: &Connection, user_id: &str, attr_id: &str, attribute_name: &str, + attribute_value: &str, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_characters_attributes SET attribute_name = ?1, attribute_value = ?2, last_update = ?3 WHERE attr_id = ?4 AND user_id = ?5", + params![attribute_name, attribute_value, last_update, attr_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre \u{00e0} jour l'attribut pour sync.".to_string() } else { "Unable to update attribute for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Fetches all characters for a series for sync (without user filter). +/// * `conn` - Database connection +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of full character table results. +pub fn fetch_characters_table_for_sync(conn: &Connection, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT character_id, series_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, title, category, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update FROM series_characters WHERE series_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages pour sync.".to_string() } else { "Unable to retrieve characters for sync.".to_string() }))?; + + let characters = statement + .query_map(params![series_id], |query_row| { + Ok(SeriesCharactersTableResult { + character_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, first_name: query_row.get(3)?, + last_name: query_row.get(4)?, nickname: query_row.get(5)?, + age: query_row.get(6)?, gender: query_row.get(7)?, + species: query_row.get(8)?, nationality: query_row.get(9)?, + status: query_row.get(10)?, title: query_row.get(11)?, + category: query_row.get(12)?, image: query_row.get(13)?, + role: query_row.get(14)?, biography: query_row.get(15)?, + history: query_row.get(16)?, speech_pattern: query_row.get(17)?, + catchphrase: query_row.get(18)?, residence: query_row.get(19)?, + notes: query_row.get(20)?, color: query_row.get(21)?, + last_update: query_row.get(22)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages pour sync.".to_string() } else { "Unable to retrieve characters for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les personnages pour sync.".to_string() } else { "Unable to retrieve characters for sync.".to_string() }))?; + + Ok(characters) +} + +/// Fetches all character attributes for a series for sync (without user filter). +/// * `conn` - Database connection +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of full attribute table results. +pub fn fetch_character_attributes_table_for_sync(conn: &Connection, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sca.attr_id, sca.character_id, sca.user_id, sca.attribute_name, sca.attribute_value, sca.last_update FROM series_characters_attributes sca INNER JOIN series_characters sc ON sca.character_id = sc.character_id WHERE sc.series_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs pour sync.".to_string() } else { "Unable to retrieve attributes for sync.".to_string() }))?; + + let attributes = statement + .query_map(params![series_id], |query_row| { + Ok(SeriesCharacterAttributesTableResult { + attr_id: query_row.get(0)?, character_id: query_row.get(1)?, + user_id: query_row.get(2)?, attribute_name: query_row.get(3)?, + attribute_value: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs pour sync.".to_string() } else { "Unable to retrieve attributes for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs pour sync.".to_string() } else { "Unable to retrieve attributes for sync.".to_string() }))?; + + Ok(attributes) +} + +/// Fetches all characters for a series (alias for fetch_series_characters_table). +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of full character table results. +pub fn fetch_series_characters(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + fetch_series_characters_table(conn, user_id, series_id, lang) +} + +/// Fetches all character attributes for a series by series ID. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of full attribute table results. +pub fn fetch_series_character_attributes_by_series_id(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sca.attr_id, sca.character_id, sca.user_id, sca.attribute_name, sca.attribute_value, sca.last_update FROM series_characters_attributes sca INNER JOIN series_characters sc ON sca.character_id = sc.character_id WHERE sc.series_id = ?1 AND sc.user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs par s\u{00e9}rie.".to_string() } else { "Unable to retrieve attributes by series.".to_string() }))?; + + let attributes = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesCharacterAttributesTableResult { + attr_id: query_row.get(0)?, character_id: query_row.get(1)?, + user_id: query_row.get(2)?, attribute_name: query_row.get(3)?, + attribute_value: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs par s\u{00e9}rie.".to_string() } else { "Unable to retrieve attributes by series.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les attributs par s\u{00e9}rie.".to_string() } else { "Unable to retrieve attributes by series.".to_string() }))?; + + Ok(attributes) +} + +/// Checks if a series character exists (alias for is_character_exist). +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the character exists, false otherwise. +pub fn series_character_exists(conn: &Connection, user_id: &str, character_id: &str, lang: Lang) -> AppResult { + is_character_exist(conn, user_id, character_id, lang) +} + +/// Checks if a series character attribute exists (alias for is_attribute_exist). +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `attr_id` - The unique identifier of the attribute +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the attribute exists, false otherwise. +pub fn series_character_attribute_exists(conn: &Connection, user_id: &str, attr_id: &str, lang: Lang) -> AppResult { + is_attribute_exist(conn, user_id, attr_id, lang) +} diff --git a/src-tauri/src/domains/series_character/service.rs b/src-tauri/src/domains/series_character/service.rs new file mode 100644 index 0000000..79b8c68 --- /dev/null +++ b/src-tauri/src/domains/series_character/service.rs @@ -0,0 +1,349 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::series_character::repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::{AppError, AppResult}; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesCharacterPropsPost { + pub id: Option, + pub name: String, + pub last_name: String, + pub nickname: String, + pub age: Option, + pub gender: String, + pub species: String, + pub nationality: String, + pub status: String, + pub category: String, + pub title: String, + pub image: String, + pub physical: Vec, + pub psychological: Vec, + pub relations: Vec, + pub skills: Vec, + pub weaknesses: Vec, + pub strengths: Vec, + pub goals: Vec, + pub motivations: Vec, + pub arc: Vec, + pub secrets: Vec, + pub fears: Vec, + pub flaws: Vec, + pub beliefs: Vec, + pub conflicts: Vec, + pub quotes: Vec, + pub distinguishing_marks: Vec, + pub items: Vec, + pub affiliations: Vec, + pub role: String, + pub biography: Option, + pub history: Option, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, +} + +#[derive(Deserialize)] +pub struct NameItem { + pub name: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesCharacterListProps { + pub id: String, + pub name: String, + pub last_name: String, + pub nickname: String, + pub age: Option, + pub gender: String, + pub species: String, + pub nationality: String, + pub status: String, + pub title: String, + pub category: String, + pub image: String, + pub role: String, + pub biography: String, + pub history: String, + pub speech_pattern: String, + pub catchphrase: String, + pub residence: String, + pub notes: String, + pub color: String, +} + +#[derive(Serialize)] +pub struct SeriesAttribute { + pub id: String, + pub name: String, +} + +#[derive(Serialize)] +pub struct CharacterAttributesResponse { + pub attributes: Vec, +} + +/// Retrieves a list of characters for a specific series owned by a user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages +/// Returns a list of decrypted series character properties. +pub fn get_character_list(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let characters: Vec = repo::fetch_characters(conn, user_id, series_id, lang)?; + + if characters.is_empty() { + return Ok(vec![]); + } + + let user_key: String = get_user_encryption_key(user_id)?; + + let mut character_list: Vec = Vec::with_capacity(characters.len()); + for character in characters { + let decrypted_name: String = if !character.first_name.is_empty() { decrypt_data_with_user_key(&character.first_name, &user_key)? } else { String::new() }; + let decrypted_last_name: String = if !character.last_name.is_empty() { decrypt_data_with_user_key(&character.last_name, &user_key)? } else { String::new() }; + let decrypted_nickname: String = if let Some(ref nickname) = character.nickname { decrypt_data_with_user_key(nickname, &user_key)? } else { String::new() }; + let decrypted_age: Option = if let Some(ref age) = character.age { Some(decrypt_data_with_user_key(age, &user_key)?.parse::().unwrap_or(0)) } else { None }; + let decrypted_gender: String = if let Some(ref gender) = character.gender { decrypt_data_with_user_key(gender, &user_key)? } else { String::new() }; + let decrypted_species: String = if let Some(ref species) = character.species { decrypt_data_with_user_key(species, &user_key)? } else { String::new() }; + let decrypted_nationality: String = if let Some(ref nationality) = character.nationality { decrypt_data_with_user_key(nationality, &user_key)? } else { String::new() }; + let decrypted_status: String = if let Some(ref status) = character.status { decrypt_data_with_user_key(status, &user_key)? } else { "alive".to_string() }; + let decrypted_title: String = if !character.title.is_empty() { decrypt_data_with_user_key(&character.title, &user_key)? } else { String::new() }; + let decrypted_category: String = if !character.category.is_empty() { decrypt_data_with_user_key(&character.category, &user_key)? } else { String::new() }; + let decrypted_image: String = if !character.image.is_empty() { decrypt_data_with_user_key(&character.image, &user_key)? } else { String::new() }; + let decrypted_role: String = if !character.role.is_empty() { decrypt_data_with_user_key(&character.role, &user_key)? } else { String::new() }; + let decrypted_biography: String = if !character.biography.is_empty() { decrypt_data_with_user_key(&character.biography, &user_key)? } else { String::new() }; + let decrypted_history: String = if !character.history.is_empty() { decrypt_data_with_user_key(&character.history, &user_key)? } else { String::new() }; + let decrypted_speech_pattern: String = if let Some(ref speech_pattern) = character.speech_pattern { decrypt_data_with_user_key(speech_pattern, &user_key)? } else { String::new() }; + let decrypted_catchphrase: String = if let Some(ref catchphrase) = character.catchphrase { decrypt_data_with_user_key(catchphrase, &user_key)? } else { String::new() }; + let decrypted_residence: String = if let Some(ref residence) = character.residence { decrypt_data_with_user_key(residence, &user_key)? } else { String::new() }; + let decrypted_notes: String = if let Some(ref notes) = character.notes { decrypt_data_with_user_key(notes, &user_key)? } else { String::new() }; + let decrypted_color: String = if let Some(ref color) = character.color { decrypt_data_with_user_key(color, &user_key)? } else { String::new() }; + + character_list.push(SeriesCharacterListProps { + id: character.character_id, + name: decrypted_name, + last_name: decrypted_last_name, + nickname: decrypted_nickname, + age: decrypted_age, + gender: decrypted_gender, + species: decrypted_species, + nationality: decrypted_nationality, + status: decrypted_status, + title: decrypted_title, + category: decrypted_category, + image: decrypted_image, + role: decrypted_role, + biography: decrypted_biography, + history: decrypted_history, + speech_pattern: decrypted_speech_pattern, + catchphrase: decrypted_catchphrase, + residence: decrypted_residence, + notes: decrypted_notes, + color: decrypted_color, + }); + } + + Ok(character_list) +} + +/// Adds a new character to a series with all its attributes. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character` - The character data to create +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages +/// Returns the newly created character's ID. +pub fn add_new_character(conn: &Connection, user_id: &str, character: &SeriesCharacterPropsPost, series_id: &str, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let character_id: String = create_unique_id(None); + let last_update: i64 = timestamp_in_seconds(); + let encrypted_name: String = encrypt_data_with_user_key(&character.name, &user_key)?; + let encrypted_last_name: Option = if !character.last_name.is_empty() { Some(encrypt_data_with_user_key(&character.last_name, &user_key)?) } else { None }; + let encrypted_nickname: Option = if !character.nickname.is_empty() { Some(encrypt_data_with_user_key(&character.nickname, &user_key)?) } else { None }; + let encrypted_age: Option = if let Some(age) = character.age { Some(encrypt_data_with_user_key(&age.to_string(), &user_key)?) } else { None }; + let encrypted_gender: Option = if !character.gender.is_empty() { Some(encrypt_data_with_user_key(&character.gender, &user_key)?) } else { None }; + let encrypted_species: Option = if !character.species.is_empty() { Some(encrypt_data_with_user_key(&character.species, &user_key)?) } else { None }; + let encrypted_nationality: Option = if !character.nationality.is_empty() { Some(encrypt_data_with_user_key(&character.nationality, &user_key)?) } else { None }; + let encrypted_status: Option = if !character.status.is_empty() { Some(encrypt_data_with_user_key(&character.status, &user_key)?) } else { None }; + let encrypted_title: Option = if !character.title.is_empty() { Some(encrypt_data_with_user_key(&character.title, &user_key)?) } else { None }; + let encrypted_category: Option = if !character.category.is_empty() { Some(encrypt_data_with_user_key(&character.category, &user_key)?) } else { None }; + let encrypted_image: Option = if !character.image.is_empty() { Some(encrypt_data_with_user_key(&character.image, &user_key)?) } else { None }; + let encrypted_role: Option = if !character.role.is_empty() { Some(encrypt_data_with_user_key(&character.role, &user_key)?) } else { None }; + let encrypted_biography: Option = if let Some(ref biography) = character.biography { if !biography.is_empty() { Some(encrypt_data_with_user_key(biography, &user_key)?) } else { None } } else { None }; + let encrypted_history: Option = if let Some(ref history) = character.history { if !history.is_empty() { Some(encrypt_data_with_user_key(history, &user_key)?) } else { None } } else { None }; + let encrypted_speech_pattern: Option = if let Some(ref speech_pattern) = character.speech_pattern { if !speech_pattern.is_empty() { Some(encrypt_data_with_user_key(speech_pattern, &user_key)?) } else { None } } else { None }; + let encrypted_catchphrase: Option = if let Some(ref catchphrase) = character.catchphrase { if !catchphrase.is_empty() { Some(encrypt_data_with_user_key(catchphrase, &user_key)?) } else { None } } else { None }; + let encrypted_residence: Option = if let Some(ref residence) = character.residence { if !residence.is_empty() { Some(encrypt_data_with_user_key(residence, &user_key)?) } else { None } } else { None }; + let encrypted_notes: Option = if let Some(ref notes) = character.notes { if !notes.is_empty() { Some(encrypt_data_with_user_key(notes, &user_key)?) } else { None } } else { None }; + let encrypted_color: Option = if let Some(ref color) = character.color { if !color.is_empty() { Some(encrypt_data_with_user_key(color, &user_key)?) } else { None } } else { None }; + + repo::add_new_character( + conn, user_id, &character_id, &encrypted_name, + encrypted_last_name.as_deref(), encrypted_nickname.as_deref(), encrypted_age.as_deref(), + encrypted_gender.as_deref(), encrypted_species.as_deref(), encrypted_nationality.as_deref(), + encrypted_status.as_deref(), encrypted_title.as_deref(), encrypted_category.as_deref(), + encrypted_image.as_deref(), encrypted_role.as_deref(), encrypted_biography.as_deref(), + encrypted_history.as_deref(), encrypted_speech_pattern.as_deref(), encrypted_catchphrase.as_deref(), + encrypted_residence.as_deref(), encrypted_notes.as_deref(), encrypted_color.as_deref(), + series_id, last_update, lang, + )?; + + // Insert array attributes + for attribute_item in &character.physical { add_new_attribute(conn, &character_id, user_id, "physical", &attribute_item.name, lang)?; } + for attribute_item in &character.psychological { add_new_attribute(conn, &character_id, user_id, "psychological", &attribute_item.name, lang)?; } + for attribute_item in &character.relations { add_new_attribute(conn, &character_id, user_id, "relations", &attribute_item.name, lang)?; } + for attribute_item in &character.skills { add_new_attribute(conn, &character_id, user_id, "skills", &attribute_item.name, lang)?; } + for attribute_item in &character.weaknesses { add_new_attribute(conn, &character_id, user_id, "weaknesses", &attribute_item.name, lang)?; } + for attribute_item in &character.strengths { add_new_attribute(conn, &character_id, user_id, "strengths", &attribute_item.name, lang)?; } + for attribute_item in &character.goals { add_new_attribute(conn, &character_id, user_id, "goals", &attribute_item.name, lang)?; } + for attribute_item in &character.motivations { add_new_attribute(conn, &character_id, user_id, "motivations", &attribute_item.name, lang)?; } + for attribute_item in &character.arc { add_new_attribute(conn, &character_id, user_id, "arc", &attribute_item.name, lang)?; } + for attribute_item in &character.secrets { add_new_attribute(conn, &character_id, user_id, "secrets", &attribute_item.name, lang)?; } + for attribute_item in &character.fears { add_new_attribute(conn, &character_id, user_id, "fears", &attribute_item.name, lang)?; } + for attribute_item in &character.flaws { add_new_attribute(conn, &character_id, user_id, "flaws", &attribute_item.name, lang)?; } + for attribute_item in &character.beliefs { add_new_attribute(conn, &character_id, user_id, "beliefs", &attribute_item.name, lang)?; } + for attribute_item in &character.conflicts { add_new_attribute(conn, &character_id, user_id, "conflicts", &attribute_item.name, lang)?; } + for attribute_item in &character.quotes { add_new_attribute(conn, &character_id, user_id, "quotes", &attribute_item.name, lang)?; } + for attribute_item in &character.distinguishing_marks { add_new_attribute(conn, &character_id, user_id, "distinguishingMarks", &attribute_item.name, lang)?; } + for attribute_item in &character.items { add_new_attribute(conn, &character_id, user_id, "items", &attribute_item.name, lang)?; } + for attribute_item in &character.affiliations { add_new_attribute(conn, &character_id, user_id, "affiliations", &attribute_item.name, lang)?; } + + Ok(character_id) +} + +/// Updates an existing character's information and attributes. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character` - The updated character data +/// * `lang` - The language for error messages +/// Returns true if the update was successful. +pub fn update_character(conn: &Connection, user_id: &str, character: &SeriesCharacterPropsPost, lang: Lang) -> AppResult { + let character_id: &str = character.id.as_deref().ok_or_else(|| { + AppError::Internal(if lang == Lang::Fr { "ID du personnage requis.".to_string() } else { "Character ID required.".to_string() }) + })?; + + let exists: bool = repo::is_character_exist(conn, user_id, character_id, lang)?; + if !exists { + return Err(AppError::Internal(if lang == Lang::Fr { "Personnage non trouvé.".to_string() } else { "Character not found.".to_string() })); + } + + let user_key: String = get_user_encryption_key(user_id)?; + let last_update: i64 = timestamp_in_seconds(); + let encrypted_name: String = encrypt_data_with_user_key(&character.name, &user_key)?; + let encrypted_last_name: Option = if !character.last_name.is_empty() { Some(encrypt_data_with_user_key(&character.last_name, &user_key)?) } else { None }; + let encrypted_nickname: Option = if !character.nickname.is_empty() { Some(encrypt_data_with_user_key(&character.nickname, &user_key)?) } else { None }; + let encrypted_age: Option = if let Some(age) = character.age { Some(encrypt_data_with_user_key(&age.to_string(), &user_key)?) } else { None }; + let encrypted_gender: Option = if !character.gender.is_empty() { Some(encrypt_data_with_user_key(&character.gender, &user_key)?) } else { None }; + let encrypted_species: Option = if !character.species.is_empty() { Some(encrypt_data_with_user_key(&character.species, &user_key)?) } else { None }; + let encrypted_nationality: Option = if !character.nationality.is_empty() { Some(encrypt_data_with_user_key(&character.nationality, &user_key)?) } else { None }; + let encrypted_status: Option = if !character.status.is_empty() { Some(encrypt_data_with_user_key(&character.status, &user_key)?) } else { None }; + let encrypted_title: Option = if !character.title.is_empty() { Some(encrypt_data_with_user_key(&character.title, &user_key)?) } else { None }; + let encrypted_category: Option = if !character.category.is_empty() { Some(encrypt_data_with_user_key(&character.category, &user_key)?) } else { None }; + let encrypted_image: Option = if !character.image.is_empty() { Some(encrypt_data_with_user_key(&character.image, &user_key)?) } else { None }; + let encrypted_role: Option = if !character.role.is_empty() { Some(encrypt_data_with_user_key(&character.role, &user_key)?) } else { None }; + let encrypted_biography: Option = if let Some(ref biography) = character.biography { if !biography.is_empty() { Some(encrypt_data_with_user_key(biography, &user_key)?) } else { None } } else { None }; + let encrypted_history: Option = if let Some(ref history) = character.history { if !history.is_empty() { Some(encrypt_data_with_user_key(history, &user_key)?) } else { None } } else { None }; + let encrypted_speech_pattern: Option = if let Some(ref speech_pattern) = character.speech_pattern { if !speech_pattern.is_empty() { Some(encrypt_data_with_user_key(speech_pattern, &user_key)?) } else { None } } else { None }; + let encrypted_catchphrase: Option = if let Some(ref catchphrase) = character.catchphrase { if !catchphrase.is_empty() { Some(encrypt_data_with_user_key(catchphrase, &user_key)?) } else { None } } else { None }; + let encrypted_residence: Option = if let Some(ref residence) = character.residence { if !residence.is_empty() { Some(encrypt_data_with_user_key(residence, &user_key)?) } else { None } } else { None }; + let encrypted_notes: Option = if let Some(ref notes) = character.notes { if !notes.is_empty() { Some(encrypt_data_with_user_key(notes, &user_key)?) } else { None } } else { None }; + let encrypted_color: Option = if let Some(ref color) = character.color { if !color.is_empty() { Some(encrypt_data_with_user_key(color, &user_key)?) } else { None } } else { None }; + + repo::update_character( + conn, user_id, character_id, &encrypted_name, + encrypted_last_name.as_deref(), encrypted_nickname.as_deref(), encrypted_age.as_deref(), + encrypted_gender.as_deref(), encrypted_species.as_deref(), encrypted_nationality.as_deref(), + encrypted_status.as_deref(), encrypted_title.as_deref(), encrypted_category.as_deref(), + encrypted_image.as_deref(), encrypted_role.as_deref(), encrypted_biography.as_deref(), + encrypted_history.as_deref(), encrypted_speech_pattern.as_deref(), encrypted_catchphrase.as_deref(), + encrypted_residence.as_deref(), encrypted_notes.as_deref(), encrypted_color.as_deref(), + last_update, lang, + ) +} + +/// Deletes a character from a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if the deletion was successful. +pub fn delete_character(conn: &Connection, user_id: &str, character_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let exists: bool = repo::is_character_exist(conn, user_id, character_id, lang)?; + if !exists { + return Err(AppError::Internal(if lang == Lang::Fr { "Personnage non trouvé.".to_string() } else { "Character not found.".to_string() })); + } + let deleted: bool = repo::delete_character(conn, user_id, character_id, lang)?; + if deleted { + tombstone_repo::insert(conn, character_id, "series_characters", character_id, None, user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Adds a new attribute to a character. +/// * `conn` - Database connection +/// * `character_id` - The unique identifier of the character +/// * `user_id` - The unique identifier of the user +/// * `attribute_type` - The attribute type +/// * `name` - The attribute value +/// * `lang` - The language for error messages +/// Returns the attribute ID. +pub fn add_new_attribute(conn: &Connection, character_id: &str, user_id: &str, attribute_type: &str, name: &str, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let attribute_id: String = create_unique_id(None); + let last_update: i64 = timestamp_in_seconds(); + let encrypted_type: String = encrypt_data_with_user_key(attribute_type, &user_key)?; + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + + repo::insert_attribute(conn, &attribute_id, character_id, user_id, &encrypted_type, &encrypted_name, last_update, lang)?; + + Ok(attribute_id) +} + +/// Deletes an attribute from a character. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `attribute_id` - The unique identifier of the attribute +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if the deletion was successful. +pub fn delete_attribute(conn: &Connection, user_id: &str, attribute_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_attribute(conn, user_id, attribute_id, lang)?; + if deleted { + tombstone_repo::insert(conn, attribute_id, "series_characters_attributes", attribute_id, None, user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Gets all attributes for a character. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `character_id` - The unique identifier of the character +/// * `lang` - The language for error messages +/// Returns the character's attributes. +pub fn get_character_attributes(conn: &Connection, user_id: &str, character_id: &str, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let attributes_result: Vec = repo::fetch_attributes(conn, character_id, user_id, lang)?; + + let mut attributes: Vec = Vec::with_capacity(attributes_result.len()); + for attr in attributes_result { + let decrypted_name: String = decrypt_data_with_user_key(&attr.attribute_value, &user_key)?; + attributes.push(SeriesAttribute { + id: attr.attr_id, + name: decrypted_name, + }); + } + + Ok(CharacterAttributesResponse { attributes }) +} diff --git a/src-tauri/src/domains/series_location/commands.rs b/src-tauri/src/domains/series_location/commands.rs new file mode 100644 index 0000000..4156f41 --- /dev/null +++ b/src-tauri/src/domains/series_location/commands.rs @@ -0,0 +1,99 @@ +use serde::Deserialize; +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::series_location::service; +use crate::error::AppError; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSeriesLocationListData { pub series_id: String } + +#[tauri::command] +pub fn get_series_location_list(data: GetSeriesLocationListData, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_location_list(conn, &user_id, &data.series_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesLocationSectionData { pub series_id: String, pub name: String } + +#[tauri::command] +pub fn add_series_location_section(data: AddSeriesLocationSectionData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_location_section(conn, &user_id, &data.series_id, &data.name, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesLocationElementData { pub location_id: String, pub name: String, pub description: Option } + +#[tauri::command] +pub fn add_series_location_element(data: AddSeriesLocationElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_element(conn, &user_id, &data.location_id, &data.name, lang, data.description.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesLocationSubElementData { pub element_id: String, pub name: String, pub description: Option } + +#[tauri::command] +pub fn add_series_location_sub_element(data: AddSeriesLocationSubElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_sub_element(conn, &user_id, &data.element_id, &data.name, lang, data.description.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSeriesLocationData { pub location_id: String, pub deleted_at: i64 } + +#[tauri::command] +pub fn delete_series_location(data: DeleteSeriesLocationData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_location(conn, &user_id, &data.location_id, data.deleted_at, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSeriesLocationElementData { pub element_id: String, pub deleted_at: i64 } + +#[tauri::command] +pub fn delete_series_location_element(data: DeleteSeriesLocationElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_element(conn, &user_id, &data.element_id, data.deleted_at, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSeriesLocationSubElementData { pub sub_element_id: String, pub deleted_at: i64 } + +#[tauri::command] +pub fn delete_series_location_sub_element(data: DeleteSeriesLocationSubElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_sub_element(conn, &user_id, &data.sub_element_id, data.deleted_at, lang) +} diff --git a/src-tauri/src/domains/series_location/mod.rs b/src-tauri/src/domains/series_location/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/series_location/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/series_location/repo.rs b/src-tauri/src/domains/series_location/repo.rs new file mode 100644 index 0000000..e221e22 --- /dev/null +++ b/src-tauri/src/domains/series_location/repo.rs @@ -0,0 +1,699 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct SeriesLocationResult { + pub loc_id: String, + pub loc_name: String, +} + +pub struct SeriesLocationElementResult { + pub element_id: String, + pub location_id: String, + pub element_name: String, + pub element_description: String, +} + +pub struct SeriesLocationSubElementResult { + pub sub_element_id: String, + pub element_id: String, + pub sub_elem_name: String, + pub sub_elem_description: String, +} + +pub struct SeriesLocationsTableResult { + pub loc_id: String, + pub series_id: String, + pub user_id: String, + pub loc_name: String, + pub loc_original_name: String, + pub last_update: i64, +} + +pub struct SeriesLocationElementsTableResult { + pub element_id: String, + pub location_id: String, + pub user_id: String, + pub element_name: String, + pub original_name: String, + pub element_description: Option, + pub last_update: i64, +} + +pub struct SeriesLocationSubElementsTableResult { + pub sub_element_id: String, + pub element_id: String, + pub user_id: String, + pub sub_elem_name: String, + pub original_name: String, + pub sub_elem_description: Option, + pub last_update: i64, +} + +pub struct SyncedSeriesLocationResult { + pub loc_id: String, + pub series_id: String, + pub loc_name: String, + pub last_update: i64, +} + +pub struct SyncedSeriesLocationElementResult { + pub element_id: String, + pub location_id: String, + pub element_name: String, + pub last_update: i64, +} + +pub struct SyncedSeriesLocationSubElementResult { + pub sub_element_id: String, + pub element_id: String, + pub sub_elem_name: String, + pub last_update: i64, +} + +/// Fetches all locations for a series. +pub fn fetch_locations(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT loc_id, loc_name FROM series_locations WHERE user_id = ?1 AND series_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux.".to_string() } else { "Unable to retrieve locations.".to_string() }))?; + + let locations = statement + .query_map(params![user_id, series_id], |query_row| { + Ok(SeriesLocationResult { loc_id: query_row.get(0)?, loc_name: query_row.get(1)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux.".to_string() } else { "Unable to retrieve locations.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux.".to_string() } else { "Unable to retrieve locations.".to_string() }))?; + + Ok(locations) +} + +/// Fetches all elements for a location. +pub fn fetch_elements(conn: &Connection, user_id: &str, location_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, location_id, element_name, element_description FROM series_location_elements WHERE user_id = ?1 AND location_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments.".to_string() } else { "Unable to retrieve elements.".to_string() }))?; + + let elements = statement + .query_map(params![user_id, location_id], |query_row| { + Ok(SeriesLocationElementResult { element_id: query_row.get(0)?, location_id: query_row.get(1)?, element_name: query_row.get(2)?, element_description: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments.".to_string() } else { "Unable to retrieve elements.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments.".to_string() } else { "Unable to retrieve elements.".to_string() }))?; + + Ok(elements) +} + +/// Fetches all sub-elements for an element. +pub fn fetch_sub_elements(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sub_element_id, element_id, sub_elem_name, sub_elem_description FROM series_location_sub_elements WHERE user_id = ?1 AND element_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments.".to_string() } else { "Unable to retrieve sub-elements.".to_string() }))?; + + let sub_elements = statement + .query_map(params![user_id, element_id], |query_row| { + Ok(SeriesLocationSubElementResult { sub_element_id: query_row.get(0)?, element_id: query_row.get(1)?, sub_elem_name: query_row.get(2)?, sub_elem_description: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments.".to_string() } else { "Unable to retrieve sub-elements.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments.".to_string() } else { "Unable to retrieve sub-elements.".to_string() }))?; + + Ok(sub_elements) +} + +/// Inserts a new location section. +pub fn insert_location( + conn: &Connection, location_id: &str, series_id: &str, user_id: &str, + encrypted_name: &str, original_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_locations (loc_id, series_id, user_id, loc_name, loc_original_name, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![location_id, series_id, user_id, encrypted_name, original_name, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le lieu.".to_string() } else { "Unable to add location.".to_string() }))?; + + if insert_result > 0 { + Ok(location_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Erreur lors de l'ajout du lieu.".to_string() } else { "Error adding location.".to_string() })) + } +} + +/// Inserts a new element. +pub fn insert_element( + conn: &Connection, element_id: &str, location_id: &str, user_id: &str, + encrypted_name: &str, original_name: &str, description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_location_elements (element_id, location_id, user_id, element_name, original_name, element_description, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![element_id, location_id, user_id, encrypted_name, original_name, description, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter l'élément.".to_string() } else { "Unable to add element.".to_string() }))?; + + if insert_result > 0 { + Ok(element_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Erreur lors de l'ajout de l'élément.".to_string() } else { "Error adding element.".to_string() })) + } +} + +/// Inserts a new sub-element. +pub fn insert_sub_element( + conn: &Connection, sub_element_id: &str, element_id: &str, user_id: &str, + encrypted_name: &str, original_name: &str, description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_location_sub_elements (sub_element_id, element_id, user_id, sub_elem_name, original_name, sub_elem_description, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![sub_element_id, element_id, user_id, encrypted_name, original_name, description, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le sous-élément.".to_string() } else { "Unable to add sub-element.".to_string() }))?; + + if insert_result > 0 { + Ok(sub_element_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Erreur lors de l'ajout du sous-élément.".to_string() } else { "Error adding sub-element.".to_string() })) + } +} + +/// Deletes a location section. +pub fn delete_location(conn: &Connection, user_id: &str, location_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM series_locations WHERE loc_id = ?1 AND user_id = ?2", params![location_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le lieu.".to_string() } else { "Unable to delete location.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Deletes an element. +pub fn delete_element(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM series_location_elements WHERE element_id = ?1 AND user_id = ?2", params![element_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer l'élément.".to_string() } else { "Unable to delete element.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Deletes a sub-element. +pub fn delete_sub_element(conn: &Connection, user_id: &str, sub_element_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM series_location_sub_elements WHERE sub_element_id = ?1 AND user_id = ?2", params![sub_element_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le sous-élément.".to_string() } else { "Unable to delete sub-element.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Updates a location's name. +pub fn update_location(conn: &Connection, user_id: &str, location_id: &str, encrypted_name: &str, original_name: &str, last_update: i64, lang: Lang) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_locations SET loc_name = ?1, loc_original_name = ?2, last_update = ?3 WHERE loc_id = ?4 AND user_id = ?5", + params![encrypted_name, original_name, last_update, location_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le lieu.".to_string() } else { "Unable to update location.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Fetches all locations for a series for sync. +pub fn fetch_series_locations_table(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT loc_id, series_id, user_id, loc_name, loc_original_name, last_update FROM series_locations WHERE series_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux pour sync.".to_string() } else { "Unable to retrieve locations for sync.".to_string() }))?; + + let locations = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesLocationsTableResult { + loc_id: query_row.get(0)?, series_id: query_row.get(1)?, user_id: query_row.get(2)?, + loc_name: query_row.get(3)?, loc_original_name: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux pour sync.".to_string() } else { "Unable to retrieve locations for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux pour sync.".to_string() } else { "Unable to retrieve locations for sync.".to_string() }))?; + + Ok(locations) +} + +/// Fetches all elements for a location for sync. +pub fn fetch_series_location_elements_table(conn: &Connection, user_id: &str, location_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, location_id, user_id, element_name, original_name, element_description, last_update FROM series_location_elements WHERE location_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location elements for sync.".to_string() }))?; + + let elements = statement + .query_map(params![location_id, user_id], |query_row| { + Ok(SeriesLocationElementsTableResult { + element_id: query_row.get(0)?, location_id: query_row.get(1)?, user_id: query_row.get(2)?, + element_name: query_row.get(3)?, original_name: query_row.get(4)?, + element_description: query_row.get(5)?, last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location elements for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location elements for sync.".to_string() }))?; + + Ok(elements) +} + +/// Fetches all sub-elements for an element for sync. +pub fn fetch_series_location_sub_elements_table(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sub_element_id, element_id, user_id, sub_elem_name, original_name, sub_elem_description, last_update FROM series_location_sub_elements WHERE element_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments pour sync.".to_string() } else { "Unable to retrieve sub-elements for sync.".to_string() }))?; + + let sub_elements = statement + .query_map(params![element_id, user_id], |query_row| { + Ok(SeriesLocationSubElementsTableResult { + sub_element_id: query_row.get(0)?, element_id: query_row.get(1)?, user_id: query_row.get(2)?, + sub_elem_name: query_row.get(3)?, original_name: query_row.get(4)?, + sub_elem_description: query_row.get(5)?, last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments pour sync.".to_string() } else { "Unable to retrieve sub-elements for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments pour sync.".to_string() } else { "Unable to retrieve sub-elements for sync.".to_string() }))?; + + Ok(sub_elements) +} + +/// Fetches all series locations for a user for sync comparison. +pub fn fetch_synced_series_locations(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT loc_id, series_id, loc_name, last_update FROM series_locations WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux de série pour sync.".to_string() } else { "Unable to retrieve series locations for sync.".to_string() }))?; + + let locations = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSeriesLocationResult { loc_id: query_row.get(0)?, series_id: query_row.get(1)?, loc_name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux de série pour sync.".to_string() } else { "Unable to retrieve series locations for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux de série pour sync.".to_string() } else { "Unable to retrieve series locations for sync.".to_string() }))?; + + Ok(locations) +} + +/// Fetches all series location elements for a user for sync comparison. +pub fn fetch_synced_series_location_elements(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, location_id, element_name, last_update FROM series_location_elements WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location elements for sync.".to_string() }))?; + + let elements = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSeriesLocationElementResult { element_id: query_row.get(0)?, location_id: query_row.get(1)?, element_name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location elements for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location elements for sync.".to_string() }))?; + + Ok(elements) +} + +/// Fetches all series location sub-elements for a user for sync comparison. +pub fn fetch_synced_series_location_sub_elements(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sub_element_id, element_id, sub_elem_name, last_update FROM series_location_sub_elements WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location sub-elements for sync.".to_string() }))?; + + let sub_elements = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSeriesLocationSubElementResult { sub_element_id: query_row.get(0)?, element_id: query_row.get(1)?, sub_elem_name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location sub-elements for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location sub-elements for sync.".to_string() }))?; + + Ok(sub_elements) +} + +/// Fetches a complete location by ID for sync. +pub fn fetch_complete_location_by_id(conn: &Connection, location_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT loc_id, series_id, user_id, loc_name, loc_original_name, last_update FROM series_locations WHERE loc_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lieu complet.".to_string() } else { "Unable to retrieve complete location.".to_string() }))?; + + let locations = statement + .query_map(params![location_id], |query_row| { + Ok(SeriesLocationsTableResult { + loc_id: query_row.get(0)?, series_id: query_row.get(1)?, user_id: query_row.get(2)?, + loc_name: query_row.get(3)?, loc_original_name: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lieu complet.".to_string() } else { "Unable to retrieve complete location.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lieu complet.".to_string() } else { "Unable to retrieve complete location.".to_string() }))?; + + Ok(locations) +} + +/// Fetches a complete location element by ID for sync. +pub fn fetch_complete_location_element_by_id(conn: &Connection, element_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, location_id, user_id, element_name, original_name, element_description, last_update FROM series_location_elements WHERE element_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'élément de lieu complet.".to_string() } else { "Unable to retrieve complete location element.".to_string() }))?; + + let elements = statement + .query_map(params![element_id], |query_row| { + Ok(SeriesLocationElementsTableResult { + element_id: query_row.get(0)?, location_id: query_row.get(1)?, user_id: query_row.get(2)?, + element_name: query_row.get(3)?, original_name: query_row.get(4)?, + element_description: query_row.get(5)?, last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'élément de lieu complet.".to_string() } else { "Unable to retrieve complete location element.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'élément de lieu complet.".to_string() } else { "Unable to retrieve complete location element.".to_string() }))?; + + Ok(elements) +} + +/// Fetches a complete location sub-element by ID for sync. +pub fn fetch_complete_location_sub_element_by_id(conn: &Connection, sub_element_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sub_element_id, element_id, user_id, sub_elem_name, original_name, sub_elem_description, last_update FROM series_location_sub_elements WHERE sub_element_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sous-élément complet.".to_string() } else { "Unable to retrieve complete sub-element.".to_string() }))?; + + let sub_elements = statement + .query_map(params![sub_element_id], |query_row| { + Ok(SeriesLocationSubElementsTableResult { + sub_element_id: query_row.get(0)?, element_id: query_row.get(1)?, user_id: query_row.get(2)?, + sub_elem_name: query_row.get(3)?, original_name: query_row.get(4)?, + sub_elem_description: query_row.get(5)?, last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sous-élément complet.".to_string() } else { "Unable to retrieve complete sub-element.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sous-élément complet.".to_string() } else { "Unable to retrieve complete sub-element.".to_string() }))?; + + Ok(sub_elements) +} + +/// Checks if a location exists. +pub fn is_location_exist(conn: &Connection, user_id: &str, location_id: &str, lang: Lang) -> AppResult { + let exists: bool = conn + .prepare("SELECT 1 FROM series_locations WHERE loc_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du lieu.".to_string() } else { "Unable to check location existence.".to_string() }))? + .exists(params![location_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du lieu.".to_string() } else { "Unable to check location existence.".to_string() }))?; + + Ok(exists) +} + +/// Checks if a location element exists. +pub fn is_location_element_exist(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult { + let exists: bool = conn + .prepare("SELECT 1 FROM series_location_elements WHERE element_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'élément.".to_string() } else { "Unable to check element existence.".to_string() }))? + .exists(params![element_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'élément.".to_string() } else { "Unable to check element existence.".to_string() }))?; + + Ok(exists) +} + +/// Checks if a location sub-element exists. +pub fn is_location_sub_element_exist(conn: &Connection, user_id: &str, sub_element_id: &str, lang: Lang) -> AppResult { + let exists: bool = conn + .prepare("SELECT 1 FROM series_location_sub_elements WHERE sub_element_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du sous-élément.".to_string() } else { "Unable to check sub-element existence.".to_string() }))? + .exists(params![sub_element_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du sous-élément.".to_string() } else { "Unable to check sub-element existence.".to_string() }))?; + + Ok(exists) +} + +/// Inserts a series location for sync. +pub fn insert_sync_location( + conn: &Connection, location_id: &str, series_id: &str, user_id: &str, + loc_name: &str, loc_original_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_locations (loc_id, series_id, user_id, loc_name, loc_original_name, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(loc_id) DO UPDATE SET loc_name = excluded.loc_name, loc_original_name = excluded.loc_original_name, last_update = excluded.last_update", + params![location_id, series_id, user_id, loc_name, loc_original_name, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le lieu pour sync.".to_string() } else { "Unable to insert location for sync.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Updates a series location for sync. +pub fn update_sync_location(conn: &Connection, user_id: &str, location_id: &str, loc_name: &str, loc_original_name: &str, last_update: i64, lang: Lang) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_locations SET loc_name = ?1, loc_original_name = ?2, last_update = ?3 WHERE loc_id = ?4 AND user_id = ?5", + params![loc_name, loc_original_name, last_update, location_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le lieu pour sync.".to_string() } else { "Unable to update location for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a series location element for sync. +pub fn insert_sync_location_element( + conn: &Connection, element_id: &str, location_id: &str, user_id: &str, + element_name: &str, original_name: &str, element_description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_location_elements (element_id, location_id, user_id, element_name, original_name, element_description, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(element_id) DO UPDATE SET element_name = excluded.element_name, original_name = excluded.original_name, element_description = excluded.element_description, last_update = excluded.last_update", + params![element_id, location_id, user_id, element_name, original_name, element_description, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer l'élément de lieu pour sync.".to_string() } else { "Unable to insert location element for sync.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Updates a series location element for sync. +pub fn update_sync_location_element( + conn: &Connection, user_id: &str, element_id: &str, element_name: &str, + original_name: &str, element_description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_location_elements SET element_name = ?1, original_name = ?2, element_description = ?3, last_update = ?4 WHERE element_id = ?5 AND user_id = ?6", + params![element_name, original_name, element_description, last_update, element_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour l'élément de lieu pour sync.".to_string() } else { "Unable to update location element for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a series location sub-element for sync. +pub fn insert_sync_location_sub_element( + conn: &Connection, sub_element_id: &str, element_id: &str, user_id: &str, + sub_elem_name: &str, original_name: &str, sub_elem_description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_location_sub_elements (sub_element_id, element_id, user_id, sub_elem_name, original_name, sub_elem_description, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(sub_element_id) DO UPDATE SET sub_elem_name = excluded.sub_elem_name, original_name = excluded.original_name, sub_elem_description = excluded.sub_elem_description, last_update = excluded.last_update", + params![sub_element_id, element_id, user_id, sub_elem_name, original_name, sub_elem_description, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le sous-élément pour sync.".to_string() } else { "Unable to insert sub-element for sync.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Updates a series location sub-element for sync. +pub fn update_sync_location_sub_element( + conn: &Connection, user_id: &str, sub_element_id: &str, sub_elem_name: &str, + original_name: &str, sub_elem_description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_location_sub_elements SET sub_elem_name = ?1, original_name = ?2, sub_elem_description = ?3, last_update = ?4 WHERE sub_element_id = ?5 AND user_id = ?6", + params![sub_elem_name, original_name, sub_elem_description, last_update, sub_element_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le sous-élément pour sync.".to_string() } else { "Unable to update sub-element for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Fetches all locations for a series for sync (without user filter). +pub fn fetch_locations_table_for_sync(conn: &Connection, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT loc_id, series_id, user_id, loc_name, loc_original_name, last_update FROM series_locations WHERE series_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux pour sync.".to_string() } else { "Unable to retrieve locations for sync.".to_string() }))?; + + let locations = statement + .query_map(params![series_id], |query_row| { + Ok(SeriesLocationsTableResult { + loc_id: query_row.get(0)?, series_id: query_row.get(1)?, user_id: query_row.get(2)?, + loc_name: query_row.get(3)?, loc_original_name: query_row.get(4)?, last_update: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux pour sync.".to_string() } else { "Unable to retrieve locations for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les lieux pour sync.".to_string() } else { "Unable to retrieve locations for sync.".to_string() }))?; + + Ok(locations) +} + +/// Fetches all location elements for a series for sync (without user filter). +pub fn fetch_location_elements_table_for_sync(conn: &Connection, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sle.element_id, sle.location_id, sle.user_id, sle.element_name, sle.original_name, sle.element_description, sle.last_update FROM series_location_elements sle INNER JOIN series_locations sl ON sle.location_id = sl.loc_id WHERE sl.series_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location elements for sync.".to_string() }))?; + + let elements = statement + .query_map(params![series_id], |query_row| { + Ok(SeriesLocationElementsTableResult { + element_id: query_row.get(0)?, location_id: query_row.get(1)?, user_id: query_row.get(2)?, + element_name: query_row.get(3)?, original_name: query_row.get(4)?, + element_description: query_row.get(5)?, last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location elements for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location elements for sync.".to_string() }))?; + + Ok(elements) +} + +/// Fetches all location sub-elements for a series for sync (without user filter). +pub fn fetch_location_sub_elements_table_for_sync(conn: &Connection, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT slse.sub_element_id, slse.element_id, slse.user_id, slse.sub_elem_name, slse.original_name, slse.sub_elem_description, slse.last_update FROM series_location_sub_elements slse INNER JOIN series_location_elements sle ON slse.element_id = sle.element_id INNER JOIN series_locations sl ON sle.location_id = sl.loc_id WHERE sl.series_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location sub-elements for sync.".to_string() }))?; + + let sub_elements = statement + .query_map(params![series_id], |query_row| { + Ok(SeriesLocationSubElementsTableResult { + sub_element_id: query_row.get(0)?, element_id: query_row.get(1)?, user_id: query_row.get(2)?, + sub_elem_name: query_row.get(3)?, original_name: query_row.get(4)?, + sub_elem_description: query_row.get(5)?, last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location sub-elements for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu pour sync.".to_string() } else { "Unable to retrieve location sub-elements for sync.".to_string() }))?; + + Ok(sub_elements) +} + +/// Fetches all locations for a series (alias for fetch_series_locations_table). +pub fn fetch_series_locations(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + fetch_series_locations_table(conn, user_id, series_id, lang) +} + +/// Fetches all location elements for a series by series ID. +pub fn fetch_series_location_elements_by_series_id(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT sle.element_id, sle.location_id, sle.user_id, sle.element_name, sle.original_name, sle.element_description, sle.last_update FROM series_location_elements sle INNER JOIN series_locations sl ON sle.location_id = sl.loc_id WHERE sl.series_id = ?1 AND sl.user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu par série.".to_string() } else { "Unable to retrieve location elements by series.".to_string() }))?; + + let elements = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesLocationElementsTableResult { + element_id: query_row.get(0)?, location_id: query_row.get(1)?, user_id: query_row.get(2)?, + element_name: query_row.get(3)?, original_name: query_row.get(4)?, + element_description: query_row.get(5)?, last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu par série.".to_string() } else { "Unable to retrieve location elements by series.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de lieu par série.".to_string() } else { "Unable to retrieve location elements by series.".to_string() }))?; + + Ok(elements) +} + +/// Fetches all location sub-elements for a series by series ID. +pub fn fetch_series_location_sub_elements_by_series_id(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT slse.sub_element_id, slse.element_id, slse.user_id, slse.sub_elem_name, slse.original_name, slse.sub_elem_description, slse.last_update FROM series_location_sub_elements slse INNER JOIN series_location_elements sle ON slse.element_id = sle.element_id INNER JOIN series_locations sl ON sle.location_id = sl.loc_id WHERE sl.series_id = ?1 AND sl.user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu par série.".to_string() } else { "Unable to retrieve location sub-elements by series.".to_string() }))?; + + let sub_elements = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesLocationSubElementsTableResult { + sub_element_id: query_row.get(0)?, element_id: query_row.get(1)?, user_id: query_row.get(2)?, + sub_elem_name: query_row.get(3)?, original_name: query_row.get(4)?, + sub_elem_description: query_row.get(5)?, last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu par série.".to_string() } else { "Unable to retrieve location sub-elements by series.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sous-éléments de lieu par série.".to_string() } else { "Unable to retrieve location sub-elements by series.".to_string() }))?; + + Ok(sub_elements) +} + +/// Checks if a series location exists (alias for is_location_exist). +pub fn series_location_exists(conn: &Connection, user_id: &str, location_id: &str, lang: Lang) -> AppResult { + is_location_exist(conn, user_id, location_id, lang) +} + +/// Checks if a series location element exists (alias for is_location_element_exist). +pub fn series_location_element_exists(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult { + is_location_element_exist(conn, user_id, element_id, lang) +} + +/// Checks if a series location sub-element exists (alias for is_location_sub_element_exist). +pub fn series_location_sub_element_exists(conn: &Connection, user_id: &str, sub_element_id: &str, lang: Lang) -> AppResult { + is_location_sub_element_exist(conn, user_id, sub_element_id, lang) +} + +/// Inserts a series location for sync (alias with compatible signature). +pub fn insert_sync_series_location( + conn: &Connection, location_id: &str, series_id: &str, user_id: &str, + loc_name: &str, loc_original_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + insert_sync_location(conn, location_id, series_id, user_id, loc_name, loc_original_name, last_update, lang) +} + +/// Updates a series location for sync (without original_name). +pub fn update_sync_series_location(conn: &Connection, location_id: &str, user_id: &str, loc_name: &str, last_update: i64, lang: Lang) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_locations SET loc_name = ?1, last_update = ?2 WHERE loc_id = ?3 AND user_id = ?4", + params![loc_name, last_update, location_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le lieu série pour sync.".to_string() } else { "Unable to update series location for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a series location element for sync (alias with compatible signature). +pub fn insert_sync_series_location_element( + conn: &Connection, element_id: &str, location_id: &str, user_id: &str, + element_name: &str, original_name: &str, element_description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + insert_sync_location_element(conn, element_id, location_id, user_id, element_name, original_name, element_description, last_update, lang) +} + +/// Updates a series location element for sync (without original_name). +pub fn update_sync_series_location_element(conn: &Connection, element_id: &str, user_id: &str, element_name: &str, element_description: Option<&str>, last_update: i64, lang: Lang) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_location_elements SET element_name = ?1, element_description = ?2, last_update = ?3 WHERE element_id = ?4 AND user_id = ?5", + params![element_name, element_description, last_update, element_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour l'élément de lieu série pour sync.".to_string() } else { "Unable to update series location element for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a series location sub-element for sync (alias with compatible signature). +pub fn insert_sync_series_location_sub_element( + conn: &Connection, sub_element_id: &str, element_id: &str, user_id: &str, + sub_elem_name: &str, original_name: &str, sub_elem_description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + insert_sync_location_sub_element(conn, sub_element_id, element_id, user_id, sub_elem_name, original_name, sub_elem_description, last_update, lang) +} + +/// Updates a series location sub-element for sync (without original_name). +pub fn update_sync_series_location_sub_element(conn: &Connection, sub_element_id: &str, user_id: &str, sub_elem_name: &str, sub_elem_description: Option<&str>, last_update: i64, lang: Lang) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_location_sub_elements SET sub_elem_name = ?1, sub_elem_description = ?2, last_update = ?3 WHERE sub_element_id = ?4 AND user_id = ?5", + params![sub_elem_name, sub_elem_description, last_update, sub_element_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le sous-élément série pour sync.".to_string() } else { "Unable to update series sub-element for sync.".to_string() }))?; + + Ok(update_result > 0) +} diff --git a/src-tauri/src/domains/series_location/service.rs b/src-tauri/src/domains/series_location/service.rs new file mode 100644 index 0000000..c39cf9f --- /dev/null +++ b/src-tauri/src/domains/series_location/service.rs @@ -0,0 +1,178 @@ +use rusqlite::Connection; +use serde::Serialize; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::series_location::repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::AppResult; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +#[derive(Serialize)] +pub struct SeriesLocationSubElementProps { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesLocationElementProps { + pub id: String, + pub name: String, + pub description: String, + pub sub_elements: Vec, +} + +#[derive(Serialize)] +pub struct SeriesLocationListProps { + pub id: String, + pub name: String, + pub elements: Vec, +} + +/// Retrieves all locations for a series with their elements and sub-elements. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages +/// Returns the list of locations. +pub fn get_location_list(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let user_key: String = get_user_encryption_key(user_id)?; + let locations_result: Vec = repo::fetch_locations(conn, user_id, series_id, lang)?; + + let mut location_list: Vec = Vec::with_capacity(locations_result.len()); + for loc in locations_result { + let elements_result: Vec = repo::fetch_elements(conn, user_id, &loc.loc_id, lang)?; + + let mut elements: Vec = Vec::with_capacity(elements_result.len()); + for elem in elements_result { + let sub_elements_result: Vec = repo::fetch_sub_elements(conn, user_id, &elem.element_id, lang)?; + + let mut sub_elements: Vec = Vec::with_capacity(sub_elements_result.len()); + for sub in sub_elements_result { + sub_elements.push(SeriesLocationSubElementProps { + id: sub.sub_element_id, + name: if sub.sub_elem_name.is_empty() { String::new() } else { decrypt_data_with_user_key(&sub.sub_elem_name, &user_key)? }, + description: if sub.sub_elem_description.is_empty() { String::new() } else { decrypt_data_with_user_key(&sub.sub_elem_description, &user_key)? }, + }); + } + + elements.push(SeriesLocationElementProps { + id: elem.element_id, + name: if elem.element_name.is_empty() { String::new() } else { decrypt_data_with_user_key(&elem.element_name, &user_key)? }, + description: if elem.element_description.is_empty() { String::new() } else { decrypt_data_with_user_key(&elem.element_description, &user_key)? }, + sub_elements, + }); + } + + location_list.push(SeriesLocationListProps { + id: loc.loc_id, + name: if loc.loc_name.is_empty() { String::new() } else { decrypt_data_with_user_key(&loc.loc_name, &user_key)? }, + elements, + }); + } + + Ok(location_list) +} + +/// Adds a new location section to a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `name` - The name of the location +/// * `lang` - The language for error messages +/// Returns the new location ID. +pub fn add_location_section(conn: &Connection, user_id: &str, series_id: &str, name: &str, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let location_id: String = create_unique_id(None); + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let original_name: String = hash_element(name); + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_location(conn, &location_id, series_id, user_id, &encrypted_name, &original_name, last_update, lang) +} + +/// Adds a new element to a location. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `location_id` - The unique identifier of the location +/// * `name` - The name of the element +/// * `lang` - The language for error messages +/// * `description` - The description of the element (optional) +/// Returns the new element ID. +pub fn add_element(conn: &Connection, user_id: &str, location_id: &str, name: &str, lang: Lang, description: Option<&str>) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let element_id: String = create_unique_id(None); + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let original_name: String = hash_element(name); + let encrypted_description: Option = if let Some(desc) = description { Some(encrypt_data_with_user_key(desc, &user_key)?) } else { None }; + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_element(conn, &element_id, location_id, user_id, &encrypted_name, &original_name, encrypted_description.as_deref(), last_update, lang) +} + +/// Adds a new sub-element to an element. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `element_id` - The unique identifier of the element +/// * `name` - The name of the sub-element +/// * `lang` - The language for error messages +/// * `description` - The description of the sub-element (optional) +/// Returns the new sub-element ID. +pub fn add_sub_element(conn: &Connection, user_id: &str, element_id: &str, name: &str, lang: Lang, description: Option<&str>) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let sub_element_id: String = create_unique_id(None); + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let original_name: String = hash_element(name); + let encrypted_description: Option = if let Some(desc) = description { Some(encrypt_data_with_user_key(desc, &user_key)?) } else { None }; + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_sub_element(conn, &sub_element_id, element_id, user_id, &encrypted_name, &original_name, encrypted_description.as_deref(), last_update, lang) +} + +/// Deletes a location section. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `location_id` - The unique identifier of the location +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if successful. +pub fn delete_location(conn: &Connection, user_id: &str, location_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_location(conn, user_id, location_id, lang)?; + if deleted { + tombstone_repo::insert(conn, location_id, "series_locations", location_id, None, user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Deletes an element. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `element_id` - The unique identifier of the element +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if successful. +pub fn delete_element(conn: &Connection, user_id: &str, element_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_element(conn, user_id, element_id, lang)?; + if deleted { + tombstone_repo::insert(conn, element_id, "series_location_elements", element_id, None, user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Deletes a sub-element. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `sub_element_id` - The unique identifier of the sub-element +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if successful. +pub fn delete_sub_element(conn: &Connection, user_id: &str, sub_element_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_sub_element(conn, user_id, sub_element_id, lang)?; + if deleted { + tombstone_repo::insert(conn, sub_element_id, "series_location_sub_elements", sub_element_id, None, user_id, deleted_at, lang)?; + } + Ok(deleted) +} diff --git a/src-tauri/src/domains/series_spell/commands.rs b/src-tauri/src/domains/series_spell/commands.rs new file mode 100644 index 0000000..13add05 --- /dev/null +++ b/src-tauri/src/domains/series_spell/commands.rs @@ -0,0 +1,141 @@ +use serde::Deserialize; +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::series_spell::service; +use crate::error::AppError; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSeriesSpellListData { pub series_id: String } + +#[tauri::command] +pub fn get_series_spell_list(data: GetSeriesSpellListData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_spell_list(conn, &user_id, &data.series_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSeriesSpellDetailData { pub spell_id: String } + +#[tauri::command] +pub fn get_series_spell_detail(data: GetSeriesSpellDetailData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_spell_detail(conn, &user_id, &data.spell_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesSpellData { + pub series_id: String, + pub name: String, + pub description: Option, + pub appearance: Option, + pub tags: Option>, + pub power_level: Option, + pub components: Option, + pub limitations: Option, + pub notes: Option, +} + +#[tauri::command] +pub fn add_series_spell(data: AddSeriesSpellData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_spell( + conn, &user_id, &data.series_id, &data.name, lang, + data.description.as_deref(), data.appearance.as_deref(), data.tags, + data.power_level.as_deref(), data.components.as_deref(), + data.limitations.as_deref(), data.notes.as_deref(), + ) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSeriesSpellData { + pub spell_id: String, + pub name: String, + pub description: Option, + pub appearance: Option, + pub tags: Option>, + pub power_level: Option, + pub components: Option, + pub limitations: Option, + pub notes: Option, +} + +#[tauri::command] +pub fn update_series_spell(data: UpdateSeriesSpellData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::update_spell( + conn, &user_id, &data.spell_id, &data.name, lang, + data.description.as_deref(), data.appearance.as_deref(), data.tags, + data.power_level.as_deref(), data.components.as_deref(), + data.limitations.as_deref(), data.notes.as_deref(), + ) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSeriesSpellData { pub spell_id: String, pub deleted_at: i64 } + +#[tauri::command] +pub fn delete_series_spell(data: DeleteSeriesSpellData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_spell(conn, &user_id, &data.spell_id, data.deleted_at, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesSpellTagData { pub series_id: String, pub name: String, pub color: Option } + +#[tauri::command] +pub fn add_series_spell_tag(data: AddSeriesSpellTagData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_tag(conn, &user_id, &data.series_id, &data.name, lang, data.color.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSeriesSpellTagData { pub tag_id: String, pub name: String, pub color: Option } + +#[tauri::command] +pub fn update_series_spell_tag(data: UpdateSeriesSpellTagData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::update_tag(conn, &user_id, &data.tag_id, &data.name, lang, data.color.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSeriesSpellTagData { pub tag_id: String, pub deleted_at: i64 } + +#[tauri::command] +pub fn delete_series_spell_tag(data: DeleteSeriesSpellTagData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_tag(conn, &user_id, &data.tag_id, data.deleted_at, lang) +} diff --git a/src-tauri/src/domains/series_spell/mod.rs b/src-tauri/src/domains/series_spell/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/series_spell/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/series_spell/repo.rs b/src-tauri/src/domains/series_spell/repo.rs new file mode 100644 index 0000000..b4122a9 --- /dev/null +++ b/src-tauri/src/domains/series_spell/repo.rs @@ -0,0 +1,599 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct SeriesSpellResult { + pub spell_id: String, + pub series_id: String, + pub name: String, + pub description: String, + pub appearance: String, + pub tags: String, + pub power_level: Option, + pub components: Option, + pub limitations: Option, + pub notes: Option, +} + +pub struct SeriesSpellTagResult { + pub tag_id: String, + pub name: String, + pub color: Option, +} + +pub struct SeriesSpellsTableResult { + pub spell_id: String, + pub series_id: String, + pub user_id: String, + pub name: String, + pub name_hash: String, + pub description: Option, + pub appearance: Option, + pub tags: Option, + pub power_level: Option, + pub components: Option, + pub limitations: Option, + pub notes: Option, + pub last_update: i64, +} + +pub struct SeriesSpellTagsTableResult { + pub tag_id: String, + pub series_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub color: Option, + pub last_update: i64, +} + +pub struct SyncedSeriesSpellResult { + pub spell_id: String, + pub series_id: String, + pub name: String, + pub last_update: i64, +} + +pub struct SyncedSeriesSpellTagResult { + pub tag_id: String, + pub series_id: String, + pub name: String, + pub last_update: i64, +} + +/// Fetches all spells for a specific series. +pub fn fetch_spells(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, series_id, name, description, appearance, tags, power_level, components, limitations, notes FROM series_spells WHERE user_id=?1 AND series_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts.".to_string() } else { "Unable to retrieve spells.".to_string() }))?; + + let spells = statement + .query_map(params![user_id, series_id], |query_row| { + Ok(SeriesSpellResult { + spell_id: query_row.get(0)?, series_id: query_row.get(1)?, + name: query_row.get(2)?, description: query_row.get(3)?, + appearance: query_row.get(4)?, tags: query_row.get(5)?, + power_level: query_row.get(6)?, components: query_row.get(7)?, + limitations: query_row.get(8)?, notes: query_row.get(9)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts.".to_string() } else { "Unable to retrieve spells.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts.".to_string() } else { "Unable to retrieve spells.".to_string() }))?; + + Ok(spells) +} + +/// Fetches a single spell by its ID. +pub fn fetch_spell_by_id(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, series_id, name, description, appearance, tags, power_level, components, limitations, notes FROM series_spells WHERE user_id=?1 AND spell_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort.".to_string() } else { "Unable to retrieve spell.".to_string() }))?; + + let spell = statement + .query_row(params![user_id, spell_id], |query_row| { + Ok(SeriesSpellResult { + spell_id: query_row.get(0)?, series_id: query_row.get(1)?, + name: query_row.get(2)?, description: query_row.get(3)?, + appearance: query_row.get(4)?, tags: query_row.get(5)?, + power_level: query_row.get(6)?, components: query_row.get(7)?, + limitations: query_row.get(8)?, notes: query_row.get(9)?, + }) + }); + + match spell { + Ok(spell) => Ok(Some(spell)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort.".to_string() } else { "Unable to retrieve spell.".to_string() })), + } +} + +/// Inserts a new spell. +pub fn insert_spell( + conn: &Connection, spell_id: &str, series_id: &str, user_id: &str, name: &str, name_hash: &str, + description: &str, appearance: &str, tags: &str, power_level: Option<&str>, components: Option<&str>, + limitations: Option<&str>, notes: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_spells (spell_id, series_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![spell_id, series_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le sort.".to_string() } else { "Unable to add spell.".to_string() }))?; + + if insert_result > 0 { + Ok(spell_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Erreur lors de l'ajout du sort.".to_string() } else { "Error adding spell.".to_string() })) + } +} + +/// Updates an existing spell. +pub fn update_spell( + conn: &Connection, user_id: &str, spell_id: &str, name: &str, name_hash: &str, description: &str, + appearance: &str, tags: &str, power_level: Option<&str>, components: Option<&str>, + limitations: Option<&str>, notes: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_spells SET name=?1, name_hash=?2, description=?3, appearance=?4, tags=?5, power_level=?6, components=?7, limitations=?8, notes=?9, last_update=?10 WHERE spell_id=?11 AND user_id=?12", + params![name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update, spell_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le sort.".to_string() } else { "Unable to update spell.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Deletes a spell. +pub fn delete_spell(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM series_spells WHERE spell_id=?1 AND user_id=?2", params![spell_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le sort.".to_string() } else { "Unable to delete spell.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Fetches all spell tags for a series. +pub fn fetch_tags(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT tag_id, name, color FROM series_spell_tags WHERE user_id=?1 AND series_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags.".to_string() } else { "Unable to retrieve tags.".to_string() }))?; + + let tags = statement + .query_map(params![user_id, series_id], |query_row| { + Ok(SeriesSpellTagResult { tag_id: query_row.get(0)?, name: query_row.get(1)?, color: query_row.get(2)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags.".to_string() } else { "Unable to retrieve tags.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags.".to_string() } else { "Unable to retrieve tags.".to_string() }))?; + + Ok(tags) +} + +/// Inserts a new spell tag. +pub fn insert_tag( + conn: &Connection, tag_id: &str, series_id: &str, user_id: &str, name: &str, + hashed_name: &str, color: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_spell_tags (tag_id, series_id, user_id, name, hashed_name, color, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![tag_id, series_id, user_id, name, hashed_name, color, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le tag.".to_string() } else { "Unable to add tag.".to_string() }))?; + + if insert_result > 0 { + Ok(tag_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Erreur lors de l'ajout du tag.".to_string() } else { "Error adding tag.".to_string() })) + } +} + +/// Updates an existing spell tag. +pub fn update_tag( + conn: &Connection, user_id: &str, tag_id: &str, name: &str, + hashed_name: &str, color: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_spell_tags SET name=?1, hashed_name=?2, color=?3, last_update=?4 WHERE tag_id=?5 AND user_id=?6", + params![name, hashed_name, color, last_update, tag_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le tag.".to_string() } else { "Unable to update tag.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Deletes a spell tag. +pub fn delete_tag(conn: &Connection, user_id: &str, tag_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM series_spell_tags WHERE tag_id=?1 AND user_id=?2", params![tag_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le tag.".to_string() } else { "Unable to delete tag.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Checks if a spell exists. +pub fn is_spell_exist(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM series_spells WHERE spell_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du sort.".to_string() } else { "Unable to check spell existence.".to_string() }))?; + + let result = statement.query_row(params![spell_id, user_id], |_query_row| Ok(())); + + match result { + Ok(_) => Ok(true), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du sort.".to_string() } else { "Unable to check spell existence.".to_string() })), + } +} + +/// Fetches all spells for a series for sync. +pub fn fetch_series_spells_table(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, series_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update FROM series_spells WHERE series_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts pour sync.".to_string() } else { "Unable to retrieve spells for sync.".to_string() }))?; + + let spells = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesSpellsTableResult { + spell_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + name_hash: query_row.get(4)?, description: query_row.get(5)?, + appearance: query_row.get(6)?, tags: query_row.get(7)?, + power_level: query_row.get(8)?, components: query_row.get(9)?, + limitations: query_row.get(10)?, notes: query_row.get(11)?, + last_update: query_row.get(12)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts pour sync.".to_string() } else { "Unable to retrieve spells for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts pour sync.".to_string() } else { "Unable to retrieve spells for sync.".to_string() }))?; + + Ok(spells) +} + +/// Fetches all spell tags for a series for sync. +pub fn fetch_series_spell_tags_table(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT tag_id, series_id, user_id, name, hashed_name, color, last_update FROM series_spell_tags WHERE series_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sort pour sync.".to_string() } else { "Unable to retrieve spell tags for sync.".to_string() }))?; + + let tags = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesSpellTagsTableResult { + tag_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + hashed_name: query_row.get(4)?, color: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sort pour sync.".to_string() } else { "Unable to retrieve spell tags for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sort pour sync.".to_string() } else { "Unable to retrieve spell tags for sync.".to_string() }))?; + + Ok(tags) +} + +/// Fetches all series spells for a user for sync comparison. +pub fn fetch_synced_series_spells(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, series_id, name, last_update FROM series_spells WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts de série pour sync.".to_string() } else { "Unable to retrieve series spells for sync.".to_string() }))?; + + let spells = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSeriesSpellResult { spell_id: query_row.get(0)?, series_id: query_row.get(1)?, name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts de série pour sync.".to_string() } else { "Unable to retrieve series spells for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts de série pour sync.".to_string() } else { "Unable to retrieve series spells for sync.".to_string() }))?; + + Ok(spells) +} + +/// Fetches all series spell tags for a user for sync comparison. +pub fn fetch_synced_series_spell_tags(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT tag_id, series_id, name, last_update FROM series_spell_tags WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sort pour sync.".to_string() } else { "Unable to retrieve spell tags for sync.".to_string() }))?; + + let tags = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSeriesSpellTagResult { tag_id: query_row.get(0)?, series_id: query_row.get(1)?, name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sort pour sync.".to_string() } else { "Unable to retrieve spell tags for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sort pour sync.".to_string() } else { "Unable to retrieve spell tags for sync.".to_string() }))?; + + Ok(tags) +} + +/// Fetches a complete spell by ID for sync. +pub fn fetch_spell_table_by_id(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, series_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update FROM series_spells WHERE spell_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort complet.".to_string() } else { "Unable to retrieve complete spell.".to_string() }))?; + + let spell = statement + .query_row(params![spell_id, user_id], |query_row| { + Ok(SeriesSpellsTableResult { + spell_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + name_hash: query_row.get(4)?, description: query_row.get(5)?, + appearance: query_row.get(6)?, tags: query_row.get(7)?, + power_level: query_row.get(8)?, components: query_row.get(9)?, + limitations: query_row.get(10)?, notes: query_row.get(11)?, + last_update: query_row.get(12)?, + }) + }); + + match spell { + Ok(spell) => Ok(Some(spell)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort complet.".to_string() } else { "Unable to retrieve complete spell.".to_string() })), + } +} + +/// Fetches a complete spell tag by ID for sync. +pub fn fetch_spell_tag_table_by_id(conn: &Connection, user_id: &str, tag_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT tag_id, series_id, user_id, name, hashed_name, color, last_update FROM series_spell_tags WHERE tag_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le tag complet.".to_string() } else { "Unable to retrieve complete tag.".to_string() }))?; + + let tag = statement + .query_row(params![tag_id, user_id], |query_row| { + Ok(SeriesSpellTagsTableResult { + tag_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + hashed_name: query_row.get(4)?, color: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }); + + match tag { + Ok(tag) => Ok(Some(tag)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le tag complet.".to_string() } else { "Unable to retrieve complete tag.".to_string() })), + } +} + +/// Checks if a spell tag exists. +pub fn is_spell_tag_exist(conn: &Connection, user_id: &str, tag_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM series_spell_tags WHERE tag_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du tag.".to_string() } else { "Unable to check tag existence.".to_string() }))?; + + let result = statement.query_row(params![tag_id, user_id], |_query_row| Ok(())); + + match result { + Ok(_) => Ok(true), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du tag.".to_string() } else { "Unable to check tag existence.".to_string() })), + } +} + +/// Inserts a series spell for sync. +pub fn insert_sync_spell( + conn: &Connection, spell_id: &str, series_id: &str, user_id: &str, name: &str, name_hash: &str, + description: &str, appearance: &str, tags: &str, power_level: Option<&str>, components: Option<&str>, + limitations: Option<&str>, notes: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_spells (spell_id, series_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) ON CONFLICT(spell_id) DO UPDATE SET name = excluded.name, name_hash = excluded.name_hash, description = excluded.description, appearance = excluded.appearance, tags = excluded.tags, power_level = excluded.power_level, components = excluded.components, limitations = excluded.limitations, notes = excluded.notes, last_update = excluded.last_update", + params![spell_id, series_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le sort pour sync.".to_string() } else { "Unable to insert spell for sync.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Updates a series spell for sync. +pub fn update_sync_spell( + conn: &Connection, user_id: &str, spell_id: &str, name: &str, name_hash: &str, description: &str, + appearance: &str, tags: &str, power_level: Option<&str>, components: Option<&str>, + limitations: Option<&str>, notes: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_spells SET name = ?1, name_hash = ?2, description = ?3, appearance = ?4, tags = ?5, power_level = ?6, components = ?7, limitations = ?8, notes = ?9, last_update = ?10 WHERE spell_id = ?11 AND user_id = ?12", + params![name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update, spell_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le sort pour sync.".to_string() } else { "Unable to update spell for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a series spell tag for sync. +pub fn insert_sync_spell_tag( + conn: &Connection, tag_id: &str, series_id: &str, user_id: &str, name: &str, + hashed_name: &str, color: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_spell_tags (tag_id, series_id, user_id, name, hashed_name, color, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(tag_id) DO UPDATE SET name = excluded.name, hashed_name = excluded.hashed_name, color = excluded.color, last_update = excluded.last_update", + params![tag_id, series_id, user_id, name, hashed_name, color, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le tag pour sync.".to_string() } else { "Unable to insert tag for sync.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Updates a series spell tag for sync. +pub fn update_sync_spell_tag( + conn: &Connection, user_id: &str, tag_id: &str, name: &str, + hashed_name: &str, color: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_spell_tags SET name = ?1, hashed_name = ?2, color = ?3, last_update = ?4 WHERE tag_id = ?5 AND user_id = ?6", + params![name, hashed_name, color, last_update, tag_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le tag pour sync.".to_string() } else { "Unable to update tag for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Fetches all spells for a series for sync (without user filter). +pub fn fetch_spells_table_for_sync(conn: &Connection, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, series_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update FROM series_spells WHERE series_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts pour sync.".to_string() } else { "Unable to retrieve spells for sync.".to_string() }))?; + + let spells = statement + .query_map(params![series_id], |query_row| { + Ok(SeriesSpellsTableResult { + spell_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + name_hash: query_row.get(4)?, description: query_row.get(5)?, + appearance: query_row.get(6)?, tags: query_row.get(7)?, + power_level: query_row.get(8)?, components: query_row.get(9)?, + limitations: query_row.get(10)?, notes: query_row.get(11)?, + last_update: query_row.get(12)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts pour sync.".to_string() } else { "Unable to retrieve spells for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts pour sync.".to_string() } else { "Unable to retrieve spells for sync.".to_string() }))?; + + Ok(spells) +} + +/// Fetches all spell tags for a series for sync (without user filter). +pub fn fetch_spell_tags_table_for_sync(conn: &Connection, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT tag_id, series_id, user_id, name, hashed_name, color, last_update FROM series_spell_tags WHERE series_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sort pour sync.".to_string() } else { "Unable to retrieve spell tags for sync.".to_string() }))?; + + let tags = statement + .query_map(params![series_id], |query_row| { + Ok(SeriesSpellTagsTableResult { + tag_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + hashed_name: query_row.get(4)?, color: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sort pour sync.".to_string() } else { "Unable to retrieve spell tags for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sort pour sync.".to_string() } else { "Unable to retrieve spell tags for sync.".to_string() }))?; + + Ok(tags) +} + +/// Fetches all spells for a series (alias for fetch_series_spells_table). +pub fn fetch_series_spells(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + fetch_series_spells_table(conn, user_id, series_id, lang) +} + +/// Fetches all spell tags for a series (alias for fetch_series_spell_tags_table). +pub fn fetch_series_spell_tags(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + fetch_series_spell_tags_table(conn, user_id, series_id, lang) +} + +/// Checks if a series spell exists (alias for is_spell_exist). +pub fn series_spell_exists(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult { + is_spell_exist(conn, user_id, spell_id, lang) +} + +/// Checks if a series spell tag exists (alias for is_spell_tag_exist). +pub fn series_spell_tag_exists(conn: &Connection, user_id: &str, tag_id: &str, lang: Lang) -> AppResult { + is_spell_tag_exist(conn, user_id, tag_id, lang) +} + +/// Fetches a complete spell by ID for sync (array format). +pub fn fetch_complete_spell_by_id(conn: &Connection, spell_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, series_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update FROM series_spells WHERE spell_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort complet.".to_string() } else { "Unable to retrieve complete spell.".to_string() }))?; + + let spells = statement + .query_map(params![spell_id], |query_row| { + Ok(SeriesSpellsTableResult { + spell_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + name_hash: query_row.get(4)?, description: query_row.get(5)?, + appearance: query_row.get(6)?, tags: query_row.get(7)?, + power_level: query_row.get(8)?, components: query_row.get(9)?, + limitations: query_row.get(10)?, notes: query_row.get(11)?, + last_update: query_row.get(12)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort complet.".to_string() } else { "Unable to retrieve complete spell.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort complet.".to_string() } else { "Unable to retrieve complete spell.".to_string() }))?; + + Ok(spells) +} + +/// Fetches a complete spell tag by ID for sync (array format). +pub fn fetch_complete_spell_tag_by_id(conn: &Connection, tag_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT tag_id, series_id, user_id, name, hashed_name, color, last_update FROM series_spell_tags WHERE tag_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le tag complet.".to_string() } else { "Unable to retrieve complete tag.".to_string() }))?; + + let tags = statement + .query_map(params![tag_id], |query_row| { + Ok(SeriesSpellTagsTableResult { + tag_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + hashed_name: query_row.get(4)?, color: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le tag complet.".to_string() } else { "Unable to retrieve complete tag.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le tag complet.".to_string() } else { "Unable to retrieve complete tag.".to_string() }))?; + + Ok(tags) +} + +/// Inserts a series spell for sync (alias with compatible signature). +pub fn insert_sync_series_spell( + conn: &Connection, spell_id: &str, series_id: &str, user_id: &str, name: &str, name_hash: &str, + description: &str, appearance: &str, tags: &str, power_level: Option<&str>, components: Option<&str>, + limitations: Option<&str>, notes: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + insert_sync_spell(conn, spell_id, series_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update, lang) +} + +/// Updates a series spell for sync (simplified signature). +pub fn update_sync_series_spell( + conn: &Connection, spell_id: &str, user_id: &str, name: &str, description: &str, appearance: &str, + tags: &str, power_level: Option<&str>, components: Option<&str>, limitations: Option<&str>, + notes: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_spells SET name = ?1, description = ?2, appearance = ?3, tags = ?4, power_level = ?5, components = ?6, limitations = ?7, notes = ?8, last_update = ?9 WHERE spell_id = ?10 AND user_id = ?11", + params![name, description, appearance, tags, power_level, components, limitations, notes, last_update, spell_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le sort série pour sync.".to_string() } else { "Unable to update series spell for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a series spell tag for sync (alias with compatible signature). +pub fn insert_sync_series_spell_tag( + conn: &Connection, tag_id: &str, series_id: &str, user_id: &str, name: &str, + hashed_name: &str, color: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + insert_sync_spell_tag(conn, tag_id, series_id, user_id, name, hashed_name, color, last_update, lang) +} + +/// Updates a series spell tag for sync (simplified signature). +pub fn update_sync_series_spell_tag( + conn: &Connection, tag_id: &str, user_id: &str, name: &str, + color: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_spell_tags SET name = ?1, color = ?2, last_update = ?3 WHERE tag_id = ?4 AND user_id = ?5", + params![name, color, last_update, tag_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le tag série pour sync.".to_string() } else { "Unable to update series tag for sync.".to_string() }))?; + + Ok(update_result > 0) +} diff --git a/src-tauri/src/domains/series_spell/service.rs b/src-tauri/src/domains/series_spell/service.rs new file mode 100644 index 0000000..c000711 --- /dev/null +++ b/src-tauri/src/domains/series_spell/service.rs @@ -0,0 +1,284 @@ +use rusqlite::Connection; +use serde::Serialize; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::series_spell::repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::{AppError, AppResult}; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +#[derive(Serialize)] +pub struct SeriesSpellTagProps { + pub id: String, + pub name: String, + pub color: Option, +} + +#[derive(Serialize)] +pub struct SeriesSpellListProps { + pub id: String, + pub name: String, + pub description: String, + pub tags: Vec, +} + +#[derive(Serialize)] +pub struct SeriesSpellListResponse { + pub spells: Vec, + pub tags: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesSpellDetailProps { + pub id: String, + pub name: String, + pub description: String, + pub appearance: String, + pub tags: Vec, + pub power_level: Option, + pub components: Option, + pub limitations: Option, + pub notes: Option, +} + +/// Retrieves all spells and tags for a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages +/// Returns the list of spells and tags. +pub fn get_spell_list(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let spells_result: Vec = repo::fetch_spells(conn, user_id, series_id, lang)?; + let tags_result: Vec = repo::fetch_tags(conn, user_id, series_id, lang)?; + + let mut spells: Vec = Vec::with_capacity(spells_result.len()); + for spell in spells_result { + let decrypted_name: String = if spell.name.is_empty() { String::new() } else { decrypt_data_with_user_key(&spell.name, &user_key)? }; + let decrypted_description: String = if spell.description.is_empty() { String::new() } else { decrypt_data_with_user_key(&spell.description, &user_key)? }; + let decrypted_tags: Vec = if spell.tags.is_empty() { + vec![] + } else { + let decrypted_tags_string: String = decrypt_data_with_user_key(&spell.tags, &user_key)?; + serde_json::from_str(&decrypted_tags_string).unwrap_or_default() + }; + + spells.push(SeriesSpellListProps { + id: spell.spell_id, + name: decrypted_name, + description: decrypted_description, + tags: decrypted_tags, + }); + } + + let mut tags: Vec = Vec::with_capacity(tags_result.len()); + for tag in tags_result { + let decrypted_name: String = if tag.name.is_empty() { String::new() } else { decrypt_data_with_user_key(&tag.name, &user_key)? }; + + tags.push(SeriesSpellTagProps { + id: tag.tag_id, + name: decrypted_name, + color: tag.color, + }); + } + + Ok(SeriesSpellListResponse { spells, tags }) +} + +/// Retrieves the details of a specific spell. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `lang` - The language for error messages +/// Returns the spell details. +pub fn get_spell_detail(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let spell: Option = repo::fetch_spell_by_id(conn, user_id, spell_id, lang)?; + + let spell: repo::SeriesSpellResult = spell.ok_or_else(|| { + AppError::Internal(if lang == Lang::Fr { "Sort non trouvé.".to_string() } else { "Spell not found.".to_string() }) + })?; + + let decrypted_name: String = if spell.name.is_empty() { String::new() } else { decrypt_data_with_user_key(&spell.name, &user_key)? }; + let decrypted_description: String = if spell.description.is_empty() { String::new() } else { decrypt_data_with_user_key(&spell.description, &user_key)? }; + let decrypted_appearance: String = if spell.appearance.is_empty() { String::new() } else { decrypt_data_with_user_key(&spell.appearance, &user_key)? }; + let decrypted_tags: Vec = if spell.tags.is_empty() { + vec![] + } else { + let decrypted_tags_string: String = decrypt_data_with_user_key(&spell.tags, &user_key)?; + serde_json::from_str(&decrypted_tags_string).unwrap_or_default() + }; + let decrypted_power_level: Option = if let Some(ref power_level) = spell.power_level { Some(decrypt_data_with_user_key(power_level, &user_key)?) } else { None }; + let decrypted_components: Option = if let Some(ref components) = spell.components { Some(decrypt_data_with_user_key(components, &user_key)?) } else { None }; + let decrypted_limitations: Option = if let Some(ref limitations) = spell.limitations { Some(decrypt_data_with_user_key(limitations, &user_key)?) } else { None }; + let decrypted_notes: Option = if let Some(ref notes) = spell.notes { Some(decrypt_data_with_user_key(notes, &user_key)?) } else { None }; + + Ok(SeriesSpellDetailProps { + id: spell.spell_id, + name: decrypted_name, + description: decrypted_description, + appearance: decrypted_appearance, + tags: decrypted_tags, + power_level: decrypted_power_level, + components: decrypted_components, + limitations: decrypted_limitations, + notes: decrypted_notes, + }) +} + +/// Adds a new spell to a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `name` - The spell name +/// * `lang` - The language for error messages +/// * `description` - The spell description +/// * `appearance` - The spell appearance +/// * `tags` - The spell tags +/// * `power_level` - The spell power level +/// * `components` - The spell components +/// * `limitations` - The spell limitations +/// * `notes` - The spell notes +/// Returns the new spell ID. +pub fn add_spell( + conn: &Connection, user_id: &str, series_id: &str, name: &str, lang: Lang, + description: Option<&str>, appearance: Option<&str>, tags: Option>, + power_level: Option<&str>, components: Option<&str>, limitations: Option<&str>, notes: Option<&str>, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let spell_id: String = create_unique_id(None); + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let name_hash: String = hash_element(name); + let encrypted_description: String = if let Some(description) = description { encrypt_data_with_user_key(description, &user_key)? } else { String::new() }; + let encrypted_appearance: String = if let Some(appearance) = appearance { encrypt_data_with_user_key(appearance, &user_key)? } else { String::new() }; + let tags_json: String = serde_json::to_string(&tags.unwrap_or_default()).unwrap_or_else(|_| "[]".to_string()); + let encrypted_tags: String = encrypt_data_with_user_key(&tags_json, &user_key)?; + let encrypted_power_level: Option = if let Some(power_level) = power_level { Some(encrypt_data_with_user_key(power_level, &user_key)?) } else { None }; + let encrypted_components: Option = if let Some(components) = components { Some(encrypt_data_with_user_key(components, &user_key)?) } else { None }; + let encrypted_limitations: Option = if let Some(limitations) = limitations { Some(encrypt_data_with_user_key(limitations, &user_key)?) } else { None }; + let encrypted_notes: Option = if let Some(notes) = notes { Some(encrypt_data_with_user_key(notes, &user_key)?) } else { None }; + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_spell( + conn, &spell_id, series_id, user_id, &encrypted_name, &name_hash, + &encrypted_description, &encrypted_appearance, &encrypted_tags, + encrypted_power_level.as_deref(), encrypted_components.as_deref(), + encrypted_limitations.as_deref(), encrypted_notes.as_deref(), last_update, lang, + )?; + + Ok(spell_id) +} + +/// Updates an existing spell. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `name` - The spell name +/// * `lang` - The language for error messages +/// * `description` - The spell description +/// * `appearance` - The spell appearance +/// * `tags` - The spell tags +/// * `power_level` - The spell power level +/// * `components` - The spell components +/// * `limitations` - The spell limitations +/// * `notes` - The spell notes +/// Returns true if successful. +pub fn update_spell( + conn: &Connection, user_id: &str, spell_id: &str, name: &str, lang: Lang, + description: Option<&str>, appearance: Option<&str>, tags: Option>, + power_level: Option<&str>, components: Option<&str>, limitations: Option<&str>, notes: Option<&str>, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let name_hash: String = hash_element(name); + let encrypted_description: String = if let Some(description) = description { encrypt_data_with_user_key(description, &user_key)? } else { String::new() }; + let encrypted_appearance: String = if let Some(appearance) = appearance { encrypt_data_with_user_key(appearance, &user_key)? } else { String::new() }; + let tags_json: String = serde_json::to_string(&tags.unwrap_or_default()).unwrap_or_else(|_| "[]".to_string()); + let encrypted_tags: String = encrypt_data_with_user_key(&tags_json, &user_key)?; + let encrypted_power_level: Option = if let Some(power_level) = power_level { Some(encrypt_data_with_user_key(power_level, &user_key)?) } else { None }; + let encrypted_components: Option = if let Some(components) = components { Some(encrypt_data_with_user_key(components, &user_key)?) } else { None }; + let encrypted_limitations: Option = if let Some(limitations) = limitations { Some(encrypt_data_with_user_key(limitations, &user_key)?) } else { None }; + let encrypted_notes: Option = if let Some(notes) = notes { Some(encrypt_data_with_user_key(notes, &user_key)?) } else { None }; + let last_update: i64 = timestamp_in_seconds(); + + repo::update_spell( + conn, user_id, spell_id, &encrypted_name, &name_hash, + &encrypted_description, &encrypted_appearance, &encrypted_tags, + encrypted_power_level.as_deref(), encrypted_components.as_deref(), + encrypted_limitations.as_deref(), encrypted_notes.as_deref(), last_update, lang, + ) +} + +/// Deletes a spell. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if successful. +pub fn delete_spell(conn: &Connection, user_id: &str, spell_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_spell(conn, user_id, spell_id, lang)?; + if deleted { + tombstone_repo::insert(conn, spell_id, "series_spells", spell_id, None, user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Adds a new tag to a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `name` - The name of the tag +/// * `lang` - The language for error messages +/// * `color` - The color of the tag (optional) +/// Returns the new tag ID. +pub fn add_tag( + conn: &Connection, user_id: &str, series_id: &str, name: &str, lang: Lang, color: Option<&str>, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let tag_id: String = create_unique_id(None); + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let hashed_name: String = hash_element(name); + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_tag(conn, &tag_id, series_id, user_id, &encrypted_name, &hashed_name, color, last_update, lang)?; + + Ok(tag_id) +} + +/// Updates an existing tag. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `tag_id` - The unique identifier of the tag +/// * `name` - The new name of the tag +/// * `lang` - The language for error messages +/// * `color` - The new color of the tag (optional) +/// Returns true if successful. +pub fn update_tag( + conn: &Connection, user_id: &str, tag_id: &str, name: &str, lang: Lang, color: Option<&str>, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let hashed_name: String = hash_element(name); + let last_update: i64 = timestamp_in_seconds(); + + repo::update_tag(conn, user_id, tag_id, &encrypted_name, &hashed_name, color, last_update, lang) +} + +/// Deletes a tag. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `tag_id` - The unique identifier of the tag +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if successful. +pub fn delete_tag(conn: &Connection, user_id: &str, tag_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_tag(conn, user_id, tag_id, lang)?; + if deleted { + tombstone_repo::insert(conn, tag_id, "series_spell_tags", tag_id, None, user_id, deleted_at, lang)?; + } + Ok(deleted) +} diff --git a/src-tauri/src/domains/series_sync/commands.rs b/src-tauri/src/domains/series_sync/commands.rs new file mode 100644 index 0000000..37f347e --- /dev/null +++ b/src-tauri/src/domains/series_sync/commands.rs @@ -0,0 +1,55 @@ +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::book::service::CompleteSeries; +use crate::domains::series_sync::service; +use crate::error::AppError; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +#[tauri::command] +pub fn upload_series_to_server(series_id: String, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_complete_series_for_upload(conn, &user_id, &series_id, lang) +} + +#[tauri::command] +pub fn sync_save_series(data: CompleteSeries, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::save_complete_series(conn, &user_id, &data, lang) +} + +#[tauri::command] +pub fn sync_series_to_client(data: CompleteSeries, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::sync_series_from_server_to_client(conn, &user_id, &data, lang) +} + +#[tauri::command] +pub fn sync_series_to_server(series_id: String, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_complete_series_for_upload(conn, &user_id, &series_id, lang) +} + +#[tauri::command] +pub fn series_sync_upload(data: service::SeriesSyncUploadPayload, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::upload_field_to_series(conn, &user_id, &data, lang) +} diff --git a/src-tauri/src/domains/series_sync/mod.rs b/src-tauri/src/domains/series_sync/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/series_sync/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/series_sync/repo.rs b/src-tauri/src/domains/series_sync/repo.rs new file mode 100644 index 0000000..b147e05 --- /dev/null +++ b/src-tauri/src/domains/series_sync/repo.rs @@ -0,0 +1,210 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct BookElementSeriesLink { + pub series_id: Option, +} + +const CHARACTER_ALLOWED_FIELDS: &[&str] = &["first_name", "last_name", "nickname", "age", "gender", "species", "nationality", "status", "title", "category", "role", "biography", "history", "speech_pattern", "catchphrase", "residence", "notes", "color"]; + +const WORLD_ALLOWED_FIELDS: &[&str] = &["name", "history", "politics", "economy", "religion", "languages"]; + +const LOCATION_ALLOWED_FIELDS: &[&str] = &["name"]; + +const LOCATION_BOOK_ALLOWED_FIELDS: &[&str] = &["loc_name"]; + +const SPELL_ALLOWED_FIELDS: &[&str] = &["name", "description", "type", "level", "range", "duration", "cost", "effect", "components", "notes"]; + +/// Gets the series element ID linked to a book character. +pub fn get_character_series_link(conn: &Connection, user_id: &str, character_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT series_character_id AS series_id FROM book_characters WHERE character_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lien série du personnage.".to_string() } else { "Unable to retrieve character series link.".to_string() }))?; + + let result = statement + .query_row(params![character_id, user_id], |query_row| { + Ok(BookElementSeriesLink { series_id: query_row.get(0)? }) + }); + + match result { + Ok(link) => Ok(link.series_id), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lien série du personnage.".to_string() } else { "Unable to retrieve character series link.".to_string() })), + } +} + +/// Gets the series element ID linked to a book world. +pub fn get_world_series_link(conn: &Connection, user_id: &str, world_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT series_world_id AS series_id FROM book_world WHERE world_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lien série du monde.".to_string() } else { "Unable to retrieve world series link.".to_string() }))?; + + let result = statement + .query_row(params![world_id, user_id], |query_row| { + Ok(BookElementSeriesLink { series_id: query_row.get(0)? }) + }); + + match result { + Ok(link) => Ok(link.series_id), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lien série du monde.".to_string() } else { "Unable to retrieve world series link.".to_string() })), + } +} + +/// Gets the series element ID linked to a book location. +pub fn get_location_series_link(conn: &Connection, user_id: &str, location_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT series_location_id AS series_id FROM book_location WHERE loc_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lien série du lieu.".to_string() } else { "Unable to retrieve location series link.".to_string() }))?; + + let result = statement + .query_row(params![location_id, user_id], |query_row| { + Ok(BookElementSeriesLink { series_id: query_row.get(0)? }) + }); + + match result { + Ok(link) => Ok(link.series_id), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lien série du lieu.".to_string() } else { "Unable to retrieve location series link.".to_string() })), + } +} + +/// Gets the series element ID linked to a book spell. +pub fn get_spell_series_link(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT series_spell_id AS series_id FROM book_spells WHERE spell_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lien série du sort.".to_string() } else { "Unable to retrieve spell series link.".to_string() }))?; + + let result = statement + .query_row(params![spell_id, user_id], |query_row| { + Ok(BookElementSeriesLink { series_id: query_row.get(0)? }) + }); + + match result { + Ok(link) => Ok(link.series_id), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(_) => Err(AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le lien série du sort.".to_string() } else { "Unable to retrieve spell series link.".to_string() })), + } +} + +/// Updates a field in series_characters table. +pub fn update_series_character_field(conn: &Connection, user_id: &str, series_character_id: &str, field: &str, encrypted_value: &str, last_update: i64, lang: Lang) -> AppResult { + if !CHARACTER_ALLOWED_FIELDS.contains(&field) { + return Err(AppError::Validation(if lang == Lang::Fr { format!("Champ non autorisé: {}", field) } else { format!("Field not allowed: {}", field) })); + } + + let query = format!("UPDATE series_characters SET {} = ?1, last_update = ?2 WHERE character_id = ?3 AND user_id = ?4", field); + + let update_result = conn + .execute(&query, params![encrypted_value, last_update, series_character_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le personnage série.".to_string() } else { "Unable to update series character.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Updates a field in all book_characters linked to a series character. +pub fn update_linked_book_characters_field(conn: &Connection, user_id: &str, series_character_id: &str, field: &str, encrypted_value: &str, last_update: i64, lang: Lang) -> AppResult { + if !CHARACTER_ALLOWED_FIELDS.contains(&field) { + return Err(AppError::Validation(if lang == Lang::Fr { format!("Champ non autorisé: {}", field) } else { format!("Field not allowed: {}", field) })); + } + + let query = format!("UPDATE book_characters SET {} = ?1, last_update = ?2 WHERE series_character_id = ?3 AND user_id = ?4", field); + + let update_result = conn + .execute(&query, params![encrypted_value, last_update, series_character_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour les personnages liés.".to_string() } else { "Unable to update linked characters.".to_string() }))?; + + Ok(update_result) +} + +/// Updates a field in series_worlds table. +pub fn update_series_world_field(conn: &Connection, user_id: &str, series_world_id: &str, field: &str, encrypted_value: &str, last_update: i64, lang: Lang) -> AppResult { + if !WORLD_ALLOWED_FIELDS.contains(&field) { + return Err(AppError::Validation(if lang == Lang::Fr { format!("Champ non autorisé: {}", field) } else { format!("Field not allowed: {}", field) })); + } + + let query = format!("UPDATE series_worlds SET {} = ?1, last_update = ?2 WHERE world_id = ?3 AND user_id = ?4", field); + + let update_result = conn + .execute(&query, params![encrypted_value, last_update, series_world_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le monde série.".to_string() } else { "Unable to update series world.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Updates a field in all book_world linked to a series world. +pub fn update_linked_book_worlds_field(conn: &Connection, user_id: &str, series_world_id: &str, field: &str, encrypted_value: &str, last_update: i64, lang: Lang) -> AppResult { + if !WORLD_ALLOWED_FIELDS.contains(&field) { + return Err(AppError::Validation(if lang == Lang::Fr { format!("Champ non autorisé: {}", field) } else { format!("Field not allowed: {}", field) })); + } + + let query = format!("UPDATE book_world SET {} = ?1, last_update = ?2 WHERE series_world_id = ?3 AND user_id = ?4", field); + + let update_result = conn + .execute(&query, params![encrypted_value, last_update, series_world_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour les mondes liés.".to_string() } else { "Unable to update linked worlds.".to_string() }))?; + + Ok(update_result) +} + +/// Updates a field in series_locations table. +pub fn update_series_location_field(conn: &Connection, user_id: &str, series_location_id: &str, field: &str, encrypted_value: &str, last_update: i64, lang: Lang) -> AppResult { + if !LOCATION_ALLOWED_FIELDS.contains(&field) { + return Err(AppError::Validation(if lang == Lang::Fr { format!("Champ non autorisé: {}", field) } else { format!("Field not allowed: {}", field) })); + } + + let query = format!("UPDATE series_locations SET {} = ?1, last_update = ?2 WHERE location_id = ?3 AND user_id = ?4", field); + + let update_result = conn + .execute(&query, params![encrypted_value, last_update, series_location_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le lieu série.".to_string() } else { "Unable to update series location.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Updates a field in all book_location linked to a series location. +pub fn update_linked_book_locations_field(conn: &Connection, user_id: &str, series_location_id: &str, field: &str, encrypted_value: &str, last_update: i64, lang: Lang) -> AppResult { + if !LOCATION_BOOK_ALLOWED_FIELDS.contains(&field) { + return Err(AppError::Validation(if lang == Lang::Fr { format!("Champ non autorisé: {}", field) } else { format!("Field not allowed: {}", field) })); + } + + let query = format!("UPDATE book_location SET {} = ?1, last_update = ?2 WHERE series_location_id = ?3 AND user_id = ?4", field); + + let update_result = conn + .execute(&query, params![encrypted_value, last_update, series_location_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour les lieux liés.".to_string() } else { "Unable to update linked locations.".to_string() }))?; + + Ok(update_result) +} + +/// Updates a field in series_spells table. +pub fn update_series_spell_field(conn: &Connection, user_id: &str, series_spell_id: &str, field: &str, encrypted_value: &str, last_update: i64, lang: Lang) -> AppResult { + if !SPELL_ALLOWED_FIELDS.contains(&field) { + return Err(AppError::Validation(if lang == Lang::Fr { format!("Champ non autorisé: {}", field) } else { format!("Field not allowed: {}", field) })); + } + + let query = format!("UPDATE series_spells SET {} = ?1, last_update = ?2 WHERE spell_id = ?3 AND user_id = ?4", field); + + let update_result = conn + .execute(&query, params![encrypted_value, last_update, series_spell_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le sort série.".to_string() } else { "Unable to update series spell.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Updates a field in all book_spells linked to a series spell. +pub fn update_linked_book_spells_field(conn: &Connection, user_id: &str, series_spell_id: &str, field: &str, encrypted_value: &str, last_update: i64, lang: Lang) -> AppResult { + if !SPELL_ALLOWED_FIELDS.contains(&field) { + return Err(AppError::Validation(if lang == Lang::Fr { format!("Champ non autorisé: {}", field) } else { format!("Field not allowed: {}", field) })); + } + + let query = format!("UPDATE book_spells SET {} = ?1, last_update = ?2 WHERE series_spell_id = ?3 AND user_id = ?4", field); + + let update_result = conn + .execute(&query, params![encrypted_value, last_update, series_spell_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour les sorts liés.".to_string() } else { "Unable to update linked spells.".to_string() }))?; + + Ok(update_result) +} diff --git a/src-tauri/src/domains/series_sync/service.rs b/src-tauri/src/domains/series_sync/service.rs new file mode 100644 index 0000000..4f41a1f --- /dev/null +++ b/src-tauri/src/domains/series_sync/service.rs @@ -0,0 +1,764 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::book::repo as book_repo; +use crate::domains::book::service::{ + SeriesTable, SeriesBooksTable, SeriesCharactersTable, SeriesCharacterAttributesTable, + SeriesWorldsTable, SeriesWorldElementsTable, SeriesLocationsTable, SeriesLocationElementsTable, + SeriesLocationSubElementsTable, SeriesSpellsTable, SeriesSpellTagsTable, CompleteSeries, +}; +use crate::domains::series::repo as series_repo; +use crate::domains::series_character::repo as series_character_repo; +use crate::domains::series_location::repo as series_location_repo; +use crate::domains::series_spell::repo as series_spell_repo; +use crate::domains::series_sync::repo as series_sync_repo; +use crate::domains::series_world::repo as series_world_repo; +use crate::error::AppResult; +use crate::helpers::timestamp_in_seconds; +use crate::shared::types::Lang; + +// ===== STRUCTS ===== + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesSyncUploadPayload { + pub sync_type: String, + pub book_element_id: String, + pub field: String, + pub value: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesSyncResult { + pub success: bool, + pub updated_count: usize, +} + +// ===== FUNCTIONS ===== + +/// Uploads a field value from a book element to its linked series element, +/// then propagates the change to all other book elements linked to the same series element. +pub fn upload_field_to_series(conn: &Connection, user_id: &str, payload: &SeriesSyncUploadPayload, lang: Lang) -> AppResult { + let sync_type: &str = &payload.sync_type; + let book_element_id: &str = &payload.book_element_id; + let field: &str = &payload.field; + let value: &str = &payload.value; + + // 1. Get the series element ID linked to the book element + let series_element_id: Option = get_series_link(conn, user_id, sync_type, book_element_id, lang)?; + if series_element_id.is_none() { + return Ok(SeriesSyncResult { success: false, updated_count: 0 }); + } + let series_element_id: String = series_element_id.unwrap(); + + // 2. Encrypt the value + let user_key: String = get_user_encryption_key(user_id)?; + let encrypted_value: String = encrypt_data_with_user_key(value, &user_key)?; + + // 3. Map the frontend field name to the database column name + let db_column: String = map_field_to_db_column(sync_type, field); + + // 4. Update the series element + let last_update: i64 = timestamp_in_seconds(); + let series_updated: bool = update_series_element(conn, user_id, sync_type, &series_element_id, &db_column, &encrypted_value, last_update, lang)?; + if !series_updated { + return Ok(SeriesSyncResult { success: false, updated_count: 0 }); + } + + // 5. Map series field to book field (they may differ) + let book_field: String = map_series_field_to_book_field(sync_type, &db_column); + + // 6. Update all linked book elements + let updated_count: usize = update_linked_book_elements(conn, user_id, sync_type, &series_element_id, &book_field, &encrypted_value, last_update, lang)?; + + Ok(SeriesSyncResult { success: true, updated_count }) +} + +/// Gets the series element ID linked to a book element. +fn get_series_link(conn: &Connection, user_id: &str, sync_type: &str, book_element_id: &str, lang: Lang) -> AppResult> { + match sync_type { + "character" => series_sync_repo::get_character_series_link(conn, user_id, book_element_id, lang), + "world" => series_sync_repo::get_world_series_link(conn, user_id, book_element_id, lang), + "location" => series_sync_repo::get_location_series_link(conn, user_id, book_element_id, lang), + "spell" => series_sync_repo::get_spell_series_link(conn, user_id, book_element_id, lang), + _ => Ok(None), + } +} + +/// Maps frontend field names to database column names. +fn map_field_to_db_column(sync_type: &str, field: &str) -> String { + match sync_type { + "character" => match field { + "firstName" => "first_name".to_string(), + "lastName" => "last_name".to_string(), + "speechPattern" => "speech_pattern".to_string(), + _ => field.to_string(), + }, + "location" => match field { + "locName" => "loc_name".to_string(), + _ => field.to_string(), + }, + "spell" => match field { + "powerLevel" => "power_level".to_string(), + _ => field.to_string(), + }, + _ => field.to_string(), + } +} + +/// Updates a field in the series element. +fn update_series_element(conn: &Connection, user_id: &str, sync_type: &str, series_element_id: &str, field: &str, encrypted_value: &str, last_update: i64, lang: Lang) -> AppResult { + match sync_type { + "character" => series_sync_repo::update_series_character_field(conn, user_id, series_element_id, field, encrypted_value, last_update, lang), + "world" => series_sync_repo::update_series_world_field(conn, user_id, series_element_id, field, encrypted_value, last_update, lang), + "location" => series_sync_repo::update_series_location_field(conn, user_id, series_element_id, field, encrypted_value, last_update, lang), + "spell" => series_sync_repo::update_series_spell_field(conn, user_id, series_element_id, field, encrypted_value, last_update, lang), + _ => Ok(false), + } +} + +/// Updates all book elements linked to a series element. +fn update_linked_book_elements(conn: &Connection, user_id: &str, sync_type: &str, series_element_id: &str, field: &str, encrypted_value: &str, last_update: i64, lang: Lang) -> AppResult { + match sync_type { + "character" => series_sync_repo::update_linked_book_characters_field(conn, user_id, series_element_id, field, encrypted_value, last_update, lang), + "world" => series_sync_repo::update_linked_book_worlds_field(conn, user_id, series_element_id, field, encrypted_value, last_update, lang), + "location" => series_sync_repo::update_linked_book_locations_field(conn, user_id, series_element_id, field, encrypted_value, last_update, lang), + "spell" => series_sync_repo::update_linked_book_spells_field(conn, user_id, series_element_id, field, encrypted_value, last_update, lang), + _ => Ok(0), + } +} + +/// Maps series field names to book field names (they may differ). +fn map_series_field_to_book_field(sync_type: &str, series_field: &str) -> String { + match sync_type { + "location" => match series_field { + "name" => "loc_name".to_string(), + _ => series_field.to_string(), + }, + _ => series_field.to_string(), + } +} + +// ===== SYNC METHODS ===== + +/// Gets a complete series with all data decrypted for upload to server. +pub fn get_complete_series_for_upload(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + + // Fetch all series data + let series_data = series_repo::fetch_series_table_for_sync(conn, user_id, series_id, lang)?; + let series_books_data = series_repo::fetch_series_books_table(conn, series_id, lang)?; + let characters_data = series_character_repo::fetch_series_characters_table(conn, user_id, series_id, lang)?; + let character_attributes_data = series_character_repo::fetch_series_character_attributes_by_series_id(conn, user_id, series_id, lang)?; + let worlds_data = series_world_repo::fetch_series_worlds_table(conn, user_id, series_id, lang)?; + let world_elements_data = series_world_repo::fetch_series_world_elements_by_series_id(conn, user_id, series_id, lang)?; + let locations_data = series_location_repo::fetch_series_locations_table(conn, user_id, series_id, lang)?; + let location_elements_data = series_location_repo::fetch_series_location_elements_by_series_id(conn, user_id, series_id, lang)?; + let location_sub_elements_data = series_location_repo::fetch_series_location_sub_elements_by_series_id(conn, user_id, series_id, lang)?; + let spells_data = series_spell_repo::fetch_series_spells_table(conn, user_id, series_id, lang)?; + let spell_tags_data = series_spell_repo::fetch_series_spell_tags_table(conn, user_id, series_id, lang)?; + + // Decrypt series + let mut series: Vec = Vec::with_capacity(series_data.len()); + for s in series_data { + series.push(SeriesTable { + series_id: s.series_id, + user_id: s.user_id, + name: decrypt_data_with_user_key(&s.name, &user_key)?, + hashed_name: s.hashed_name, + description: if let Some(ref val) = s.description { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + cover_image: if let Some(ref val) = s.cover_image { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + last_update: s.last_update, + }); + } + + // Series books (no encryption) + let mut series_books: Vec = Vec::with_capacity(series_books_data.len()); + for sb in series_books_data { + series_books.push(SeriesBooksTable { + series_id: sb.series_id, + book_id: sb.book_id, + book_order: sb.book_order, + last_update: sb.last_update, + }); + } + + // Decrypt characters + let mut series_characters: Vec = Vec::with_capacity(characters_data.len()); + for c in characters_data { + let decrypted_age: Option = if let Some(ref val) = c.age { + let decrypted: String = decrypt_data_with_user_key(val, &user_key)?; + decrypted.parse::().ok() + } else { + None + }; + series_characters.push(SeriesCharactersTable { + character_id: c.character_id, + series_id: c.series_id, + user_id: c.user_id, + first_name: decrypt_data_with_user_key(&c.first_name, &user_key)?, + last_name: if let Some(ref val) = c.last_name { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + nickname: if let Some(ref val) = c.nickname { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + age: decrypted_age, + gender: if let Some(ref val) = c.gender { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + species: if let Some(ref val) = c.species { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + nationality: if let Some(ref val) = c.nationality { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + status: if let Some(ref val) = c.status { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + title: if let Some(ref val) = c.title { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + category: decrypt_data_with_user_key(&c.category, &user_key)?, + image: if let Some(ref val) = c.image { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + role: if let Some(ref val) = c.role { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + biography: if let Some(ref val) = c.biography { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + history: if let Some(ref val) = c.history { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + speech_pattern: if let Some(ref val) = c.speech_pattern { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + catchphrase: if let Some(ref val) = c.catchphrase { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + residence: if let Some(ref val) = c.residence { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + notes: if let Some(ref val) = c.notes { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + color: if let Some(ref val) = c.color { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + last_update: c.last_update, + }); + } + + // Decrypt character attributes + let mut series_character_attributes: Vec = Vec::with_capacity(character_attributes_data.len()); + for a in character_attributes_data { + series_character_attributes.push(SeriesCharacterAttributesTable { + attr_id: a.attr_id, + character_id: a.character_id, + user_id: a.user_id, + attribute_name: decrypt_data_with_user_key(&a.attribute_name, &user_key)?, + attribute_value: decrypt_data_with_user_key(&a.attribute_value, &user_key)?, + last_update: a.last_update, + }); + } + + // Decrypt worlds + let mut series_worlds: Vec = Vec::with_capacity(worlds_data.len()); + for w in worlds_data { + series_worlds.push(SeriesWorldsTable { + world_id: w.world_id, + series_id: w.series_id, + user_id: w.user_id, + name: decrypt_data_with_user_key(&w.name, &user_key)?, + hashed_name: w.hashed_name, + history: if let Some(ref val) = w.history { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + politics: if let Some(ref val) = w.politics { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + economy: if let Some(ref val) = w.economy { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + religion: if let Some(ref val) = w.religion { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + languages: if let Some(ref val) = w.languages { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + last_update: w.last_update, + }); + } + + // Decrypt world elements + let mut series_world_elements: Vec = Vec::with_capacity(world_elements_data.len()); + for e in world_elements_data { + series_world_elements.push(SeriesWorldElementsTable { + element_id: e.element_id, + world_id: e.world_id, + user_id: e.user_id, + element_type: e.element_type, + name: decrypt_data_with_user_key(&e.name, &user_key)?, + original_name: e.original_name, + description: if let Some(ref val) = e.description { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + last_update: e.last_update, + }); + } + + // Decrypt locations + let mut series_locations: Vec = Vec::with_capacity(locations_data.len()); + for l in locations_data { + series_locations.push(SeriesLocationsTable { + loc_id: l.loc_id, + series_id: l.series_id, + user_id: l.user_id, + loc_name: decrypt_data_with_user_key(&l.loc_name, &user_key)?, + loc_original_name: l.loc_original_name, + last_update: l.last_update, + }); + } + + // Decrypt location elements + let mut series_location_elements: Vec = Vec::with_capacity(location_elements_data.len()); + for e in location_elements_data { + series_location_elements.push(SeriesLocationElementsTable { + element_id: e.element_id, + location_id: e.location_id, + user_id: e.user_id, + element_name: decrypt_data_with_user_key(&e.element_name, &user_key)?, + original_name: e.original_name, + element_description: if let Some(ref val) = e.element_description { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + last_update: e.last_update, + }); + } + + // Decrypt location sub-elements + let mut series_location_sub_elements: Vec = Vec::with_capacity(location_sub_elements_data.len()); + for se in location_sub_elements_data { + series_location_sub_elements.push(SeriesLocationSubElementsTable { + sub_element_id: se.sub_element_id, + element_id: se.element_id, + user_id: se.user_id, + sub_elem_name: decrypt_data_with_user_key(&se.sub_elem_name, &user_key)?, + original_name: se.original_name, + sub_elem_description: if let Some(ref val) = se.sub_elem_description { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + last_update: se.last_update, + }); + } + + // Decrypt spells + let mut series_spells: Vec = Vec::with_capacity(spells_data.len()); + for s in spells_data { + series_spells.push(SeriesSpellsTable { + spell_id: s.spell_id, + series_id: s.series_id, + user_id: s.user_id, + name: decrypt_data_with_user_key(&s.name, &user_key)?, + name_hash: s.name_hash, + description: if let Some(ref val) = s.description { decrypt_data_with_user_key(val, &user_key)? } else { String::new() }, + appearance: if let Some(ref val) = s.appearance { decrypt_data_with_user_key(val, &user_key)? } else { String::new() }, + tags: if let Some(ref val) = s.tags { decrypt_data_with_user_key(val, &user_key)? } else { String::new() }, + power_level: if let Some(ref val) = s.power_level { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + components: if let Some(ref val) = s.components { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + limitations: if let Some(ref val) = s.limitations { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + notes: if let Some(ref val) = s.notes { Some(decrypt_data_with_user_key(val, &user_key)?) } else { None }, + last_update: s.last_update, + }); + } + + // Decrypt spell tags + let mut series_spell_tags: Vec = Vec::with_capacity(spell_tags_data.len()); + for t in spell_tags_data { + series_spell_tags.push(SeriesSpellTagsTable { + tag_id: t.tag_id, + series_id: t.series_id, + user_id: t.user_id, + name: decrypt_data_with_user_key(&t.name, &user_key)?, + hashed_name: t.hashed_name, + color: t.color, + last_update: t.last_update, + }); + } + + Ok(CompleteSeries { + series, + series_books, + series_characters, + series_character_attributes, + series_worlds, + series_world_elements, + series_locations, + series_location_elements, + series_location_sub_elements, + series_spells, + series_spell_tags, + }) +} + +/// Saves a complete series downloaded from the server to the local database. +/// Encrypts all data before storing. +pub fn save_complete_series(conn: &Connection, user_id: &str, complete_series: &CompleteSeries, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + + // Save series + for s in &complete_series.series { + let encrypted_name: String = encrypt_data_with_user_key(&s.name, &user_key)?; + let encrypted_description: Option = if let Some(ref val) = s.description { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_cover_image: Option = if let Some(ref val) = s.cover_image { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let success: bool = series_repo::insert_sync_series(conn, &s.series_id, user_id, &encrypted_name, &s.hashed_name, encrypted_description.as_deref(), encrypted_cover_image.as_deref(), s.last_update, lang)?; + if !success { return Ok(false); } + } + + // Save series books (only if the book exists locally) + for sb in &complete_series.series_books { + let book_exists: bool = book_repo::is_book_exist(conn, user_id, &sb.book_id, lang); + if !book_exists { continue; } + + let success: bool = series_repo::insert_sync_series_book(conn, &sb.series_id, &sb.book_id, sb.book_order, sb.last_update, lang)?; + if !success { return Ok(false); } + } + + // Save characters + for character in &complete_series.series_characters { + let enc_first_name: String = encrypt_data_with_user_key(&character.first_name, &user_key)?; + let enc_last_name: Option = if let Some(ref val) = character.last_name { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_nickname: Option = if let Some(ref val) = character.nickname { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_age: Option = if let Some(age) = character.age { Some(encrypt_data_with_user_key(&age.to_string(), &user_key)?) } else { None }; + let enc_gender: Option = if let Some(ref val) = character.gender { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_species: Option = if let Some(ref val) = character.species { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_nationality: Option = if let Some(ref val) = character.nationality { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_status: Option = if let Some(ref val) = character.status { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_title: Option = if let Some(ref val) = character.title { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_category: String = encrypt_data_with_user_key(&character.category, &user_key)?; + let enc_image: Option = if let Some(ref val) = character.image { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_role: Option = if let Some(ref val) = character.role { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_biography: Option = if let Some(ref val) = character.biography { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_history: Option = if let Some(ref val) = character.history { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_speech_pattern: Option = if let Some(ref val) = character.speech_pattern { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_catchphrase: Option = if let Some(ref val) = character.catchphrase { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_residence: Option = if let Some(ref val) = character.residence { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_notes: Option = if let Some(ref val) = character.notes { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_color: Option = if let Some(ref val) = character.color { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let success: bool = series_character_repo::insert_sync_series_character( + conn, &character.character_id, &character.series_id, user_id, &enc_first_name, + enc_last_name.as_deref(), enc_nickname.as_deref(), enc_age.as_deref(), enc_gender.as_deref(), + enc_species.as_deref(), enc_nationality.as_deref(), enc_status.as_deref(), &enc_category, + enc_title.as_deref(), enc_image.as_deref(), enc_role.as_deref(), enc_biography.as_deref(), + enc_history.as_deref(), enc_speech_pattern.as_deref(), enc_catchphrase.as_deref(), + enc_residence.as_deref(), enc_notes.as_deref(), enc_color.as_deref(), character.last_update, lang, + )?; + if !success { return Ok(false); } + } + + // Save character attributes + for attr in &complete_series.series_character_attributes { + let encrypted_name: String = encrypt_data_with_user_key(&attr.attribute_name, &user_key)?; + let encrypted_value: String = encrypt_data_with_user_key(&attr.attribute_value, &user_key)?; + + let success: bool = series_character_repo::insert_sync_series_character_attribute( + conn, &attr.attr_id, &attr.character_id, user_id, &encrypted_name, &encrypted_value, attr.last_update, lang, + )?; + if !success { return Ok(false); } + } + + // Save worlds + for world in &complete_series.series_worlds { + let encrypted_name: String = encrypt_data_with_user_key(&world.name, &user_key)?; + let encrypted_history: Option = if let Some(ref val) = world.history { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_politics: Option = if let Some(ref val) = world.politics { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_economy: Option = if let Some(ref val) = world.economy { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_religion: Option = if let Some(ref val) = world.religion { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_languages: Option = if let Some(ref val) = world.languages { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let success: bool = series_world_repo::insert_sync_series_world( + conn, &world.world_id, &world.series_id, user_id, &encrypted_name, &world.hashed_name, + encrypted_history.as_deref(), encrypted_politics.as_deref(), encrypted_economy.as_deref(), + encrypted_religion.as_deref(), encrypted_languages.as_deref(), world.last_update, lang, + )?; + if !success { return Ok(false); } + } + + // Save world elements + for element in &complete_series.series_world_elements { + let encrypted_name: String = encrypt_data_with_user_key(&element.name, &user_key)?; + let encrypted_description: Option = if let Some(ref val) = element.description { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let success: bool = series_world_repo::insert_sync_series_world_element( + conn, &element.element_id, &element.world_id, user_id, element.element_type, + &encrypted_name, &element.original_name, encrypted_description.as_deref(), element.last_update, lang, + )?; + if !success { return Ok(false); } + } + + // Save locations + for location in &complete_series.series_locations { + let encrypted_name: String = encrypt_data_with_user_key(&location.loc_name, &user_key)?; + + let success: bool = series_location_repo::insert_sync_series_location( + conn, &location.loc_id, &location.series_id, user_id, &encrypted_name, + &location.loc_original_name, location.last_update, lang, + )?; + if !success { return Ok(false); } + } + + // Save location elements + for element in &complete_series.series_location_elements { + let encrypted_name: String = encrypt_data_with_user_key(&element.element_name, &user_key)?; + let encrypted_description: Option = if let Some(ref val) = element.element_description { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let success: bool = series_location_repo::insert_sync_series_location_element( + conn, &element.element_id, &element.location_id, user_id, &encrypted_name, + &element.original_name, encrypted_description.as_deref(), element.last_update, lang, + )?; + if !success { return Ok(false); } + } + + // Save location sub-elements + for sub_element in &complete_series.series_location_sub_elements { + let encrypted_name: String = encrypt_data_with_user_key(&sub_element.sub_elem_name, &user_key)?; + let encrypted_description: Option = if let Some(ref val) = sub_element.sub_elem_description { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let success: bool = series_location_repo::insert_sync_series_location_sub_element( + conn, &sub_element.sub_element_id, &sub_element.element_id, user_id, &encrypted_name, + &sub_element.original_name, encrypted_description.as_deref(), sub_element.last_update, lang, + )?; + if !success { return Ok(false); } + } + + // Save spells + for spell in &complete_series.series_spells { + let encrypted_name: String = encrypt_data_with_user_key(&spell.name, &user_key)?; + let encrypted_description: String = encrypt_data_with_user_key(&spell.description, &user_key)?; + let encrypted_appearance: String = encrypt_data_with_user_key(&spell.appearance, &user_key)?; + let encrypted_tags: String = encrypt_data_with_user_key(&spell.tags, &user_key)?; + let encrypted_power_level: Option = if let Some(ref val) = spell.power_level { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_components: Option = if let Some(ref val) = spell.components { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_limitations: Option = if let Some(ref val) = spell.limitations { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_notes: Option = if let Some(ref val) = spell.notes { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let success: bool = series_spell_repo::insert_sync_series_spell( + conn, &spell.spell_id, &spell.series_id, user_id, &encrypted_name, &spell.name_hash, + &encrypted_description, &encrypted_appearance, &encrypted_tags, + encrypted_power_level.as_deref(), encrypted_components.as_deref(), + encrypted_limitations.as_deref(), encrypted_notes.as_deref(), spell.last_update, lang, + )?; + if !success { return Ok(false); } + } + + // Save spell tags + for tag in &complete_series.series_spell_tags { + let encrypted_name: String = encrypt_data_with_user_key(&tag.name, &user_key)?; + + let success: bool = series_spell_repo::insert_sync_series_spell_tag( + conn, &tag.tag_id, &tag.series_id, user_id, &encrypted_name, &tag.hashed_name, + tag.color.as_deref(), tag.last_update, lang, + )?; + if !success { return Ok(false); } + } + + Ok(true) +} + +/// Synchronizes a series from server to client, updating existing records or inserting new ones. +pub fn sync_series_from_server_to_client(conn: &Connection, user_id: &str, complete_series: &CompleteSeries, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + + // Sync series + for s in &complete_series.series { + let encrypted_name: String = encrypt_data_with_user_key(&s.name, &user_key)?; + let encrypted_description: Option = if let Some(ref val) = s.description { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_cover_image: Option = if let Some(ref val) = s.cover_image { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let exists: bool = series_repo::series_exists(conn, user_id, &s.series_id, lang)?; + if exists { + let success: bool = series_repo::update_sync_series(conn, user_id, &s.series_id, &encrypted_name, &s.hashed_name, encrypted_description.as_deref(), encrypted_cover_image.as_deref(), s.last_update, lang)?; + if !success { return Ok(false); } + } else { + let success: bool = series_repo::insert_sync_series(conn, &s.series_id, user_id, &encrypted_name, &s.hashed_name, encrypted_description.as_deref(), encrypted_cover_image.as_deref(), s.last_update, lang)?; + if !success { return Ok(false); } + } + } + + // Sync series books (only if the book exists locally) + for sb in &complete_series.series_books { + let book_exists: bool = book_repo::is_book_exist(conn, user_id, &sb.book_id, lang); + if !book_exists { continue; } + + let success: bool = series_repo::insert_sync_series_book(conn, &sb.series_id, &sb.book_id, sb.book_order, sb.last_update, lang)?; + if !success { return Ok(false); } + } + + // Sync characters + for character in &complete_series.series_characters { + let enc_first_name: String = encrypt_data_with_user_key(&character.first_name, &user_key)?; + let enc_last_name: Option = if let Some(ref val) = character.last_name { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_nickname: Option = if let Some(ref val) = character.nickname { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_age: Option = if let Some(age) = character.age { Some(encrypt_data_with_user_key(&age.to_string(), &user_key)?) } else { None }; + let enc_gender: Option = if let Some(ref val) = character.gender { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_species: Option = if let Some(ref val) = character.species { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_nationality: Option = if let Some(ref val) = character.nationality { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_status: Option = if let Some(ref val) = character.status { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_title: Option = if let Some(ref val) = character.title { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_category: String = encrypt_data_with_user_key(&character.category, &user_key)?; + let enc_image: Option = if let Some(ref val) = character.image { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_role: Option = if let Some(ref val) = character.role { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_biography: Option = if let Some(ref val) = character.biography { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_history: Option = if let Some(ref val) = character.history { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_speech_pattern: Option = if let Some(ref val) = character.speech_pattern { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_catchphrase: Option = if let Some(ref val) = character.catchphrase { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_residence: Option = if let Some(ref val) = character.residence { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_notes: Option = if let Some(ref val) = character.notes { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let enc_color: Option = if let Some(ref val) = character.color { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let exists: bool = series_character_repo::series_character_exists(conn, user_id, &character.character_id, lang)?; + if exists { + let success: bool = series_character_repo::update_sync_series_character( + conn, user_id, &character.character_id, &enc_first_name, + enc_last_name.as_deref(), enc_nickname.as_deref(), enc_age.as_deref(), enc_gender.as_deref(), + enc_species.as_deref(), enc_nationality.as_deref(), enc_status.as_deref(), &enc_category, + enc_title.as_deref(), enc_image.as_deref(), enc_role.as_deref(), enc_biography.as_deref(), + enc_history.as_deref(), enc_speech_pattern.as_deref(), enc_catchphrase.as_deref(), + enc_residence.as_deref(), enc_notes.as_deref(), enc_color.as_deref(), character.last_update, lang, + )?; + if !success { return Ok(false); } + } else { + let success: bool = series_character_repo::insert_sync_series_character( + conn, &character.character_id, &character.series_id, user_id, &enc_first_name, + enc_last_name.as_deref(), enc_nickname.as_deref(), enc_age.as_deref(), enc_gender.as_deref(), + enc_species.as_deref(), enc_nationality.as_deref(), enc_status.as_deref(), &enc_category, + enc_title.as_deref(), enc_image.as_deref(), enc_role.as_deref(), enc_biography.as_deref(), + enc_history.as_deref(), enc_speech_pattern.as_deref(), enc_catchphrase.as_deref(), + enc_residence.as_deref(), enc_notes.as_deref(), enc_color.as_deref(), character.last_update, lang, + )?; + if !success { return Ok(false); } + } + } + + // Sync character attributes + for attr in &complete_series.series_character_attributes { + let encrypted_name: String = encrypt_data_with_user_key(&attr.attribute_name, &user_key)?; + let encrypted_value: String = encrypt_data_with_user_key(&attr.attribute_value, &user_key)?; + + let exists: bool = series_character_repo::series_character_attribute_exists(conn, user_id, &attr.attr_id, lang)?; + if exists { + let success: bool = series_character_repo::update_sync_series_character_attribute(conn, user_id, &attr.attr_id, &encrypted_name, &encrypted_value, attr.last_update, lang)?; + if !success { return Ok(false); } + } else { + let success: bool = series_character_repo::insert_sync_series_character_attribute(conn, &attr.attr_id, &attr.character_id, user_id, &encrypted_name, &encrypted_value, attr.last_update, lang)?; + if !success { return Ok(false); } + } + } + + // Sync worlds + for world in &complete_series.series_worlds { + let encrypted_name: String = encrypt_data_with_user_key(&world.name, &user_key)?; + let encrypted_history: Option = if let Some(ref val) = world.history { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_politics: Option = if let Some(ref val) = world.politics { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_economy: Option = if let Some(ref val) = world.economy { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_religion: Option = if let Some(ref val) = world.religion { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_languages: Option = if let Some(ref val) = world.languages { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let exists: bool = series_world_repo::series_world_exists(conn, user_id, &world.world_id, lang)?; + if exists { + let success: bool = series_world_repo::update_sync_series_world( + conn, &world.world_id, user_id, &encrypted_name, + encrypted_history.as_deref(), encrypted_politics.as_deref(), encrypted_economy.as_deref(), + encrypted_religion.as_deref(), encrypted_languages.as_deref(), world.last_update, lang, + )?; + if !success { return Ok(false); } + } else { + let success: bool = series_world_repo::insert_sync_series_world( + conn, &world.world_id, &world.series_id, user_id, &encrypted_name, &world.hashed_name, + encrypted_history.as_deref(), encrypted_politics.as_deref(), encrypted_economy.as_deref(), + encrypted_religion.as_deref(), encrypted_languages.as_deref(), world.last_update, lang, + )?; + if !success { return Ok(false); } + } + } + + // Sync world elements + for element in &complete_series.series_world_elements { + let encrypted_name: String = encrypt_data_with_user_key(&element.name, &user_key)?; + let encrypted_description: Option = if let Some(ref val) = element.description { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let exists: bool = series_world_repo::series_world_element_exists(conn, user_id, &element.element_id, lang)?; + if exists { + let success: bool = series_world_repo::update_sync_series_world_element(conn, &element.element_id, user_id, &encrypted_name, encrypted_description.as_deref(), element.last_update, lang)?; + if !success { return Ok(false); } + } else { + let success: bool = series_world_repo::insert_sync_series_world_element( + conn, &element.element_id, &element.world_id, user_id, element.element_type, + &encrypted_name, &element.original_name, encrypted_description.as_deref(), element.last_update, lang, + )?; + if !success { return Ok(false); } + } + } + + // Sync locations + for location in &complete_series.series_locations { + let encrypted_name: String = encrypt_data_with_user_key(&location.loc_name, &user_key)?; + + let exists: bool = series_location_repo::series_location_exists(conn, user_id, &location.loc_id, lang)?; + if exists { + let success: bool = series_location_repo::update_sync_series_location(conn, &location.loc_id, user_id, &encrypted_name, location.last_update, lang)?; + if !success { return Ok(false); } + } else { + let success: bool = series_location_repo::insert_sync_series_location( + conn, &location.loc_id, &location.series_id, user_id, &encrypted_name, + &location.loc_original_name, location.last_update, lang, + )?; + if !success { return Ok(false); } + } + } + + // Sync location elements + for element in &complete_series.series_location_elements { + let encrypted_name: String = encrypt_data_with_user_key(&element.element_name, &user_key)?; + let encrypted_description: Option = if let Some(ref val) = element.element_description { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let exists: bool = series_location_repo::series_location_element_exists(conn, user_id, &element.element_id, lang)?; + if exists { + let success: bool = series_location_repo::update_sync_series_location_element(conn, &element.element_id, user_id, &encrypted_name, encrypted_description.as_deref(), element.last_update, lang)?; + if !success { return Ok(false); } + } else { + let success: bool = series_location_repo::insert_sync_series_location_element( + conn, &element.element_id, &element.location_id, user_id, &encrypted_name, + &element.original_name, encrypted_description.as_deref(), element.last_update, lang, + )?; + if !success { return Ok(false); } + } + } + + // Sync location sub-elements + for sub_element in &complete_series.series_location_sub_elements { + let encrypted_name: String = encrypt_data_with_user_key(&sub_element.sub_elem_name, &user_key)?; + let encrypted_description: Option = if let Some(ref val) = sub_element.sub_elem_description { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let exists: bool = series_location_repo::series_location_sub_element_exists(conn, user_id, &sub_element.sub_element_id, lang)?; + if exists { + let success: bool = series_location_repo::update_sync_series_location_sub_element(conn, &sub_element.sub_element_id, user_id, &encrypted_name, encrypted_description.as_deref(), sub_element.last_update, lang)?; + if !success { return Ok(false); } + } else { + let success: bool = series_location_repo::insert_sync_series_location_sub_element( + conn, &sub_element.sub_element_id, &sub_element.element_id, user_id, &encrypted_name, + &sub_element.original_name, encrypted_description.as_deref(), sub_element.last_update, lang, + )?; + if !success { return Ok(false); } + } + } + + // Sync spells + for spell in &complete_series.series_spells { + let encrypted_name: String = encrypt_data_with_user_key(&spell.name, &user_key)?; + let encrypted_description: String = encrypt_data_with_user_key(&spell.description, &user_key)?; + let encrypted_appearance: String = encrypt_data_with_user_key(&spell.appearance, &user_key)?; + let encrypted_tags: String = encrypt_data_with_user_key(&spell.tags, &user_key)?; + let encrypted_power_level: Option = if let Some(ref val) = spell.power_level { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_components: Option = if let Some(ref val) = spell.components { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_limitations: Option = if let Some(ref val) = spell.limitations { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + let encrypted_notes: Option = if let Some(ref val) = spell.notes { Some(encrypt_data_with_user_key(val, &user_key)?) } else { None }; + + let exists: bool = series_spell_repo::series_spell_exists(conn, user_id, &spell.spell_id, lang)?; + if exists { + let success: bool = series_spell_repo::update_sync_series_spell( + conn, &spell.spell_id, user_id, &encrypted_name, &encrypted_description, + &encrypted_appearance, &encrypted_tags, encrypted_power_level.as_deref(), + encrypted_components.as_deref(), encrypted_limitations.as_deref(), + encrypted_notes.as_deref(), spell.last_update, lang, + )?; + if !success { return Ok(false); } + } else { + let success: bool = series_spell_repo::insert_sync_series_spell( + conn, &spell.spell_id, &spell.series_id, user_id, &encrypted_name, &spell.name_hash, + &encrypted_description, &encrypted_appearance, &encrypted_tags, + encrypted_power_level.as_deref(), encrypted_components.as_deref(), + encrypted_limitations.as_deref(), encrypted_notes.as_deref(), spell.last_update, lang, + )?; + if !success { return Ok(false); } + } + } + + // Sync spell tags + for tag in &complete_series.series_spell_tags { + let encrypted_name: String = encrypt_data_with_user_key(&tag.name, &user_key)?; + + let exists: bool = series_spell_repo::series_spell_tag_exists(conn, user_id, &tag.tag_id, lang)?; + if exists { + let success: bool = series_spell_repo::update_sync_series_spell_tag(conn, &tag.tag_id, user_id, &encrypted_name, tag.color.as_deref(), tag.last_update, lang)?; + if !success { return Ok(false); } + } else { + let success: bool = series_spell_repo::insert_sync_series_spell_tag( + conn, &tag.tag_id, &tag.series_id, user_id, &encrypted_name, &tag.hashed_name, + tag.color.as_deref(), tag.last_update, lang, + )?; + if !success { return Ok(false); } + } + } + + Ok(true) +} diff --git a/src-tauri/src/domains/series_world/commands.rs b/src-tauri/src/domains/series_world/commands.rs new file mode 100644 index 0000000..b6004fc --- /dev/null +++ b/src-tauri/src/domains/series_world/commands.rs @@ -0,0 +1,83 @@ +use serde::Deserialize; +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::series_world::service; +use crate::error::AppError; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSeriesWorldListData { pub series_id: String } + +#[tauri::command] +pub fn get_series_world_list(data: GetSeriesWorldListData, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_world_list(conn, &user_id, &data.series_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesWorldData { pub series_id: String, pub name: String } + +#[tauri::command] +pub fn add_series_world(data: AddSeriesWorldData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_world(conn, &user_id, &data.series_id, &data.name, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSeriesWorldData { + pub world_id: String, + pub world: service::SeriesWorldUpdateProps, +} + +#[tauri::command] +pub fn update_series_world(data: UpdateSeriesWorldData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::update_world(conn, &user_id, &data.world_id, data.world, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesWorldElementData { + pub world_id: String, + pub element_type: i64, + pub name: String, + pub description: Option, +} + +#[tauri::command] +pub fn add_series_world_element(data: AddSeriesWorldElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_element(conn, &user_id, &data.world_id, data.element_type, &data.name, lang, data.description.as_deref()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSeriesWorldElementData { pub element_id: String, pub deleted_at: i64 } + +#[tauri::command] +pub fn delete_series_world_element(data: DeleteSeriesWorldElementData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_element(conn, &user_id, &data.element_id, data.deleted_at, lang) +} diff --git a/src-tauri/src/domains/series_world/mod.rs b/src-tauri/src/domains/series_world/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/series_world/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/series_world/repo.rs b/src-tauri/src/domains/series_world/repo.rs new file mode 100644 index 0000000..754a6b3 --- /dev/null +++ b/src-tauri/src/domains/series_world/repo.rs @@ -0,0 +1,499 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct SeriesWorldResult { + pub world_id: String, + pub world_name: String, + pub history: Option, + pub politics: Option, + pub economy: Option, + pub religion: Option, + pub languages: Option, + pub element_id: Option, + pub element_name: Option, + pub element_description: Option, + pub element_type: Option, +} + +pub struct SeriesWorldsTableResult { + pub world_id: String, + pub series_id: String, + pub user_id: String, + pub name: String, + pub hashed_name: String, + pub history: Option, + pub politics: Option, + pub economy: Option, + pub religion: Option, + pub languages: Option, + pub last_update: i64, +} + +pub struct SeriesWorldElementsTableResult { + pub element_id: String, + pub world_id: String, + pub user_id: String, + pub element_type: i64, + pub name: String, + pub original_name: String, + pub description: Option, + pub last_update: i64, +} + +pub struct SyncedSeriesWorldResult { + pub world_id: String, + pub series_id: String, + pub name: String, + pub last_update: i64, +} + +pub struct SyncedSeriesWorldElementResult { + pub element_id: String, + pub world_id: String, + pub name: String, + pub last_update: i64, +} + +/// Checks if a world with the given hashed name already exists for a user and series. +pub fn check_world_exist(conn: &Connection, user_id: &str, series_id: &str, world_name: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT world_id FROM series_worlds WHERE user_id=?1 AND series_id=?2 AND hashed_name=?3") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du monde.".to_string() } else { "Unable to verify world existence.".to_string() }))?; + + let exists = statement + .exists(params![user_id, series_id, world_name]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du monde.".to_string() } else { "Unable to verify world existence.".to_string() }))?; + + Ok(exists) +} + +/// Inserts a new world into the series. +pub fn insert_new_world(conn: &Connection, world_id: &str, user_id: &str, series_id: &str, encrypted_name: &str, hashed_name: &str, last_update: i64, lang: Lang) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_worlds (world_id, user_id, series_id, name, hashed_name, last_update) VALUES (?1,?2,?3,?4,?5,?6)", + params![world_id, user_id, series_id, encrypted_name, hashed_name, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le monde.".to_string() } else { "Unable to add world.".to_string() }))?; + + if insert_result > 0 { + Ok(world_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Erreur lors de l'ajout du monde.".to_string() } else { "Error adding world.".to_string() })) + } +} + +/// Fetches all worlds and their elements for a given series. +pub fn fetch_worlds(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT world.world_id AS world_id, world.name AS world_name, world.history, world.politics, world.economy, world.religion, world.languages, element.element_id AS element_id, element.name AS element_name, element.description AS element_description, element.element_type FROM series_worlds AS world LEFT JOIN series_world_elements AS element ON world.world_id = element.world_id WHERE world.user_id = ?1 AND world.series_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes.".to_string() } else { "Unable to retrieve worlds.".to_string() }))?; + + let worlds = statement + .query_map(params![user_id, series_id], |query_row| { + Ok(SeriesWorldResult { + world_id: query_row.get(0)?, world_name: query_row.get(1)?, + history: query_row.get(2)?, politics: query_row.get(3)?, + economy: query_row.get(4)?, religion: query_row.get(5)?, + languages: query_row.get(6)?, element_id: query_row.get(7)?, + element_name: query_row.get(8)?, element_description: query_row.get(9)?, + element_type: query_row.get(10)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes.".to_string() } else { "Unable to retrieve worlds.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes.".to_string() } else { "Unable to retrieve worlds.".to_string() }))?; + + Ok(worlds) +} + +/// Updates a world's information. +pub fn update_world( + conn: &Connection, user_id: &str, world_id: &str, encrypted_name: &str, hashed_name: &str, + history: Option<&str>, politics: Option<&str>, economy: Option<&str>, religion: Option<&str>, + languages: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_worlds SET name=?1, hashed_name=?2, history=?3, politics=?4, economy=?5, religion=?6, languages=?7, last_update=?8 WHERE world_id=?9 AND user_id=?10", + params![encrypted_name, hashed_name, history, politics, economy, religion, languages, last_update, world_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le monde.".to_string() } else { "Unable to update world.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a new element for a world. +pub fn insert_element( + conn: &Connection, element_id: &str, world_id: &str, user_id: &str, element_type: i64, + encrypted_name: &str, original_name: &str, description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_world_elements (element_id, world_id, user_id, element_type, name, original_name, description, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + params![element_id, world_id, user_id, element_type, encrypted_name, original_name, description, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter l'élément.".to_string() } else { "Unable to add element.".to_string() }))?; + + if insert_result > 0 { + Ok(element_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Erreur lors de l'ajout de l'élément.".to_string() } else { "Error adding element.".to_string() })) + } +} + +/// Deletes an element from a world. +pub fn delete_element(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute( + "DELETE FROM series_world_elements WHERE element_id=?1 AND user_id=?2", + params![element_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer l'élément.".to_string() } else { "Unable to delete element.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Fetches all worlds for a series for sync. +pub fn fetch_series_worlds_table(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT world_id, series_id, user_id, name, hashed_name, history, politics, economy, religion, languages, last_update FROM series_worlds WHERE series_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes pour sync.".to_string() } else { "Unable to retrieve worlds for sync.".to_string() }))?; + + let worlds = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesWorldsTableResult { + world_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + hashed_name: query_row.get(4)?, history: query_row.get(5)?, + politics: query_row.get(6)?, economy: query_row.get(7)?, + religion: query_row.get(8)?, languages: query_row.get(9)?, + last_update: query_row.get(10)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes pour sync.".to_string() } else { "Unable to retrieve worlds for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes pour sync.".to_string() } else { "Unable to retrieve worlds for sync.".to_string() }))?; + + Ok(worlds) +} + +/// Fetches all elements for a world for sync. +pub fn fetch_series_world_elements_table(conn: &Connection, user_id: &str, world_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, world_id, user_id, element_type, name, original_name, description, last_update FROM series_world_elements WHERE world_id = ?1 AND user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde pour sync.".to_string() } else { "Unable to retrieve world elements for sync.".to_string() }))?; + + let elements = statement + .query_map(params![world_id, user_id], |query_row| { + Ok(SeriesWorldElementsTableResult { + element_id: query_row.get(0)?, world_id: query_row.get(1)?, + user_id: query_row.get(2)?, element_type: query_row.get(3)?, + name: query_row.get(4)?, original_name: query_row.get(5)?, + description: query_row.get(6)?, last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde pour sync.".to_string() } else { "Unable to retrieve world elements for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde pour sync.".to_string() } else { "Unable to retrieve world elements for sync.".to_string() }))?; + + Ok(elements) +} + +/// Fetches all series worlds for a user for sync comparison. +pub fn fetch_synced_series_worlds(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT world_id, series_id, name, last_update FROM series_worlds WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes de série pour sync.".to_string() } else { "Unable to retrieve series worlds for sync.".to_string() }))?; + + let worlds = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSeriesWorldResult { world_id: query_row.get(0)?, series_id: query_row.get(1)?, name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes de série pour sync.".to_string() } else { "Unable to retrieve series worlds for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes de série pour sync.".to_string() } else { "Unable to retrieve series worlds for sync.".to_string() }))?; + + Ok(worlds) +} + +/// Fetches all series world elements for a user for sync comparison. +pub fn fetch_synced_series_world_elements(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, world_id, name, last_update FROM series_world_elements WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde pour sync.".to_string() } else { "Unable to retrieve world elements for sync.".to_string() }))?; + + let elements = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSeriesWorldElementResult { element_id: query_row.get(0)?, world_id: query_row.get(1)?, name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde pour sync.".to_string() } else { "Unable to retrieve world elements for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde pour sync.".to_string() } else { "Unable to retrieve world elements for sync.".to_string() }))?; + + Ok(elements) +} + +/// Fetches a complete world by ID for sync. +pub fn fetch_complete_world_by_id(conn: &Connection, world_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT world_id, series_id, user_id, name, hashed_name, history, politics, economy, religion, languages, last_update FROM series_worlds WHERE world_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le monde complet.".to_string() } else { "Unable to retrieve complete world.".to_string() }))?; + + let worlds = statement + .query_map(params![world_id], |query_row| { + Ok(SeriesWorldsTableResult { + world_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + hashed_name: query_row.get(4)?, history: query_row.get(5)?, + politics: query_row.get(6)?, economy: query_row.get(7)?, + religion: query_row.get(8)?, languages: query_row.get(9)?, + last_update: query_row.get(10)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le monde complet.".to_string() } else { "Unable to retrieve complete world.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le monde complet.".to_string() } else { "Unable to retrieve complete world.".to_string() }))?; + + Ok(worlds) +} + +/// Fetches a complete world element by ID for sync. +pub fn fetch_complete_world_element_by_id(conn: &Connection, element_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, world_id, user_id, element_type, name, original_name, description, last_update FROM series_world_elements WHERE element_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'élément de monde complet.".to_string() } else { "Unable to retrieve complete world element.".to_string() }))?; + + let elements = statement + .query_map(params![element_id], |query_row| { + Ok(SeriesWorldElementsTableResult { + element_id: query_row.get(0)?, world_id: query_row.get(1)?, + user_id: query_row.get(2)?, element_type: query_row.get(3)?, + name: query_row.get(4)?, original_name: query_row.get(5)?, + description: query_row.get(6)?, last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'élément de monde complet.".to_string() } else { "Unable to retrieve complete world element.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer l'élément de monde complet.".to_string() } else { "Unable to retrieve complete world element.".to_string() }))?; + + Ok(elements) +} + +/// Checks if a world exists. +pub fn is_world_exist(conn: &Connection, user_id: &str, world_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM series_worlds WHERE world_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du monde.".to_string() } else { "Unable to check world existence.".to_string() }))?; + + let exists = statement + .exists(params![world_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du monde.".to_string() } else { "Unable to check world existence.".to_string() }))?; + + Ok(exists) +} + +/// Checks if a world element exists. +pub fn is_world_element_exist(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM series_world_elements WHERE element_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'élément.".to_string() } else { "Unable to check element existence.".to_string() }))?; + + let exists = statement + .exists(params![element_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence de l'élément.".to_string() } else { "Unable to check element existence.".to_string() }))?; + + Ok(exists) +} + +/// Inserts a series world for sync. +pub fn insert_sync_world( + conn: &Connection, world_id: &str, series_id: &str, user_id: &str, name: &str, hashed_name: &str, + history: Option<&str>, politics: Option<&str>, economy: Option<&str>, religion: Option<&str>, + languages: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_worlds (world_id, series_id, user_id, name, hashed_name, history, politics, economy, religion, languages, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ON CONFLICT(world_id) DO UPDATE SET name = excluded.name, hashed_name = excluded.hashed_name, history = excluded.history, politics = excluded.politics, economy = excluded.economy, religion = excluded.religion, languages = excluded.languages, last_update = excluded.last_update", + params![world_id, series_id, user_id, name, hashed_name, history, politics, economy, religion, languages, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le monde pour sync.".to_string() } else { "Unable to insert world for sync.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Updates a series world for sync. +pub fn update_sync_world( + conn: &Connection, user_id: &str, world_id: &str, name: &str, hashed_name: &str, + history: Option<&str>, politics: Option<&str>, economy: Option<&str>, religion: Option<&str>, + languages: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_worlds SET name = ?1, hashed_name = ?2, history = ?3, politics = ?4, economy = ?5, religion = ?6, languages = ?7, last_update = ?8 WHERE world_id = ?9 AND user_id = ?10", + params![name, hashed_name, history, politics, economy, religion, languages, last_update, world_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le monde pour sync.".to_string() } else { "Unable to update world for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a series world element for sync. +pub fn insert_sync_world_element( + conn: &Connection, element_id: &str, world_id: &str, user_id: &str, element_type: i64, + name: &str, original_name: &str, description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO series_world_elements (element_id, world_id, user_id, element_type, name, original_name, description, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(element_id) DO UPDATE SET element_type = excluded.element_type, name = excluded.name, original_name = excluded.original_name, description = excluded.description, last_update = excluded.last_update", + params![element_id, world_id, user_id, element_type, name, original_name, description, last_update], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer l'élément de monde pour sync.".to_string() } else { "Unable to insert world element for sync.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Updates a series world element for sync. +pub fn update_sync_world_element( + conn: &Connection, user_id: &str, element_id: &str, element_type: i64, name: &str, + original_name: &str, description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_world_elements SET element_type = ?1, name = ?2, original_name = ?3, description = ?4, last_update = ?5 WHERE element_id = ?6 AND user_id = ?7", + params![element_type, name, original_name, description, last_update, element_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour l'élément de monde pour sync.".to_string() } else { "Unable to update world element for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Fetches all worlds for a series for sync (without user filter). +pub fn fetch_worlds_table_for_sync(conn: &Connection, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT world_id, series_id, user_id, name, hashed_name, history, politics, economy, religion, languages, last_update FROM series_worlds WHERE series_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes pour sync.".to_string() } else { "Unable to retrieve worlds for sync.".to_string() }))?; + + let worlds = statement + .query_map(params![series_id], |query_row| { + Ok(SeriesWorldsTableResult { + world_id: query_row.get(0)?, series_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + hashed_name: query_row.get(4)?, history: query_row.get(5)?, + politics: query_row.get(6)?, economy: query_row.get(7)?, + religion: query_row.get(8)?, languages: query_row.get(9)?, + last_update: query_row.get(10)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes pour sync.".to_string() } else { "Unable to retrieve worlds for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les mondes pour sync.".to_string() } else { "Unable to retrieve worlds for sync.".to_string() }))?; + + Ok(worlds) +} + +/// Fetches all world elements for a series for sync (via series_worlds join). +pub fn fetch_world_elements_table_for_sync(conn: &Connection, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT swe.element_id, swe.world_id, swe.user_id, swe.element_type, swe.name, swe.original_name, swe.description, swe.last_update FROM series_world_elements swe INNER JOIN series_worlds sw ON swe.world_id = sw.world_id WHERE sw.series_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde pour sync.".to_string() } else { "Unable to retrieve world elements for sync.".to_string() }))?; + + let elements = statement + .query_map(params![series_id], |query_row| { + Ok(SeriesWorldElementsTableResult { + element_id: query_row.get(0)?, world_id: query_row.get(1)?, + user_id: query_row.get(2)?, element_type: query_row.get(3)?, + name: query_row.get(4)?, original_name: query_row.get(5)?, + description: query_row.get(6)?, last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde pour sync.".to_string() } else { "Unable to retrieve world elements for sync.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde pour sync.".to_string() } else { "Unable to retrieve world elements for sync.".to_string() }))?; + + Ok(elements) +} + +/// Fetches all worlds for a series (alias for fetch_series_worlds_table). +pub fn fetch_series_worlds(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + fetch_series_worlds_table(conn, user_id, series_id, lang) +} + +/// Fetches all world elements for a series by series ID. +pub fn fetch_series_world_elements_by_series_id(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT swe.element_id, swe.world_id, swe.user_id, swe.element_type, swe.name, swe.original_name, swe.description, swe.last_update FROM series_world_elements swe INNER JOIN series_worlds sw ON swe.world_id = sw.world_id WHERE sw.series_id = ?1 AND sw.user_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde par série.".to_string() } else { "Unable to retrieve world elements by series.".to_string() }))?; + + let elements = statement + .query_map(params![series_id, user_id], |query_row| { + Ok(SeriesWorldElementsTableResult { + element_id: query_row.get(0)?, world_id: query_row.get(1)?, + user_id: query_row.get(2)?, element_type: query_row.get(3)?, + name: query_row.get(4)?, original_name: query_row.get(5)?, + description: query_row.get(6)?, last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde par série.".to_string() } else { "Unable to retrieve world elements by series.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les éléments de monde par série.".to_string() } else { "Unable to retrieve world elements by series.".to_string() }))?; + + Ok(elements) +} + +/// Checks if a series world exists (alias for is_world_exist). +pub fn series_world_exists(conn: &Connection, user_id: &str, world_id: &str, lang: Lang) -> AppResult { + is_world_exist(conn, user_id, world_id, lang) +} + +/// Checks if a series world element exists (alias for is_world_element_exist). +pub fn series_world_element_exists(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult { + is_world_element_exist(conn, user_id, element_id, lang) +} + +/// Inserts a series world for sync (alias with compatible signature). +pub fn insert_sync_series_world( + conn: &Connection, world_id: &str, series_id: &str, user_id: &str, name: &str, hashed_name: &str, + history: Option<&str>, politics: Option<&str>, economy: Option<&str>, religion: Option<&str>, + languages: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + insert_sync_world(conn, world_id, series_id, user_id, name, hashed_name, history, politics, economy, religion, languages, last_update, lang) +} + +/// Updates a series world for sync (without hashed_name). +pub fn update_sync_series_world( + conn: &Connection, world_id: &str, user_id: &str, name: &str, history: Option<&str>, + politics: Option<&str>, economy: Option<&str>, religion: Option<&str>, languages: Option<&str>, + last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_worlds SET name = ?1, history = ?2, politics = ?3, economy = ?4, religion = ?5, languages = ?6, last_update = ?7 WHERE world_id = ?8 AND user_id = ?9", + params![name, history, politics, economy, religion, languages, last_update, world_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le monde série pour sync.".to_string() } else { "Unable to update series world for sync.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Inserts a series world element for sync (alias with compatible signature). +pub fn insert_sync_series_world_element( + conn: &Connection, element_id: &str, world_id: &str, user_id: &str, element_type: i64, + name: &str, original_name: &str, description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + insert_sync_world_element(conn, element_id, world_id, user_id, element_type, name, original_name, description, last_update, lang) +} + +/// Updates a series world element for sync (without element_type and original_name). +pub fn update_sync_series_world_element(conn: &Connection, element_id: &str, user_id: &str, name: &str, description: Option<&str>, last_update: i64, lang: Lang) -> AppResult { + let update_result = conn + .execute( + "UPDATE series_world_elements SET name = ?1, description = ?2, last_update = ?3 WHERE element_id = ?4 AND user_id = ?5", + params![name, description, last_update, element_id, user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour l'élément de monde série pour sync.".to_string() } else { "Unable to update series world element for sync.".to_string() }))?; + + Ok(update_result > 0) +} diff --git a/src-tauri/src/domains/series_world/service.rs b/src-tauri/src/domains/series_world/service.rs new file mode 100644 index 0000000..d3ecd85 --- /dev/null +++ b/src-tauri/src/domains/series_world/service.rs @@ -0,0 +1,215 @@ +use std::collections::HashMap; + +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::series_world::repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::{AppError, AppResult}; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +#[derive(Serialize)] +pub struct SeriesWorldElementProps { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesWorldListProps { + pub id: String, + pub name: String, + pub history: String, + pub politics: String, + pub economy: String, + pub religion: String, + pub languages: String, + pub laws: Vec, + pub biomes: Vec, + pub issues: Vec, + pub customs: Vec, + pub kingdoms: Vec, + pub climate: Vec, + pub resources: Vec, + pub wildlife: Vec, + pub arts: Vec, + pub ethnic_groups: Vec, + pub social_classes: Vec, + pub important_characters: Vec, +} + +#[derive(Deserialize)] +pub struct SeriesWorldUpdateProps { + pub name: String, + pub history: Option, + pub politics: Option, + pub economy: Option, + pub religion: Option, + pub languages: Option, +} + +/// Retrieves all worlds and their elements for a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `lang` - The language for error messages +/// Returns the list of worlds with their categorized elements. +pub fn get_world_list(conn: &Connection, user_id: &str, series_id: &str, lang: Lang) -> AppResult> { + let user_key: String = get_user_encryption_key(user_id)?; + let worlds_result: Vec = repo::fetch_worlds(conn, user_id, series_id, lang)?; + + let mut worlds_map: HashMap = HashMap::new(); + let mut insertion_order: Vec = Vec::new(); + + for row in &worlds_result { + if !worlds_map.contains_key(&row.world_id) { + insertion_order.push(row.world_id.clone()); + worlds_map.insert(row.world_id.clone(), SeriesWorldListProps { + id: row.world_id.clone(), + name: if row.world_name.is_empty() { String::new() } else { decrypt_data_with_user_key(&row.world_name, &user_key)? }, + history: if let Some(ref history) = row.history { decrypt_data_with_user_key(history, &user_key)? } else { String::new() }, + politics: if let Some(ref politics) = row.politics { decrypt_data_with_user_key(politics, &user_key)? } else { String::new() }, + economy: if let Some(ref economy) = row.economy { decrypt_data_with_user_key(economy, &user_key)? } else { String::new() }, + religion: if let Some(ref religion) = row.religion { decrypt_data_with_user_key(religion, &user_key)? } else { String::new() }, + languages: if let Some(ref languages) = row.languages { decrypt_data_with_user_key(languages, &user_key)? } else { String::new() }, + laws: Vec::new(), + biomes: Vec::new(), + issues: Vec::new(), + customs: Vec::new(), + kingdoms: Vec::new(), + climate: Vec::new(), + resources: Vec::new(), + wildlife: Vec::new(), + arts: Vec::new(), + ethnic_groups: Vec::new(), + social_classes: Vec::new(), + important_characters: Vec::new(), + }); + } + + if let Some(ref element_id) = row.element_id { + let world = worlds_map.get_mut(&row.world_id).unwrap(); + let element = SeriesWorldElementProps { + id: element_id.clone(), + name: if let Some(ref element_name) = row.element_name { decrypt_data_with_user_key(element_name, &user_key)? } else { String::new() }, + description: if let Some(ref element_description) = row.element_description { decrypt_data_with_user_key(element_description, &user_key)? } else { String::new() }, + }; + + if let Some(element_type) = row.element_type { + match element_type { + 0 => world.laws.push(element), + 1 => world.biomes.push(element), + 2 => world.issues.push(element), + 3 => world.customs.push(element), + 4 => world.kingdoms.push(element), + 5 => world.climate.push(element), + 6 => world.resources.push(element), + 7 => world.wildlife.push(element), + 8 => world.arts.push(element), + 9 => world.ethnic_groups.push(element), + 10 => world.social_classes.push(element), + 11 => world.important_characters.push(element), + _ => {} + } + } + } + } + + let worlds_list: Vec = insertion_order + .into_iter() + .filter_map(|world_id| worlds_map.remove(&world_id)) + .collect(); + + Ok(worlds_list) +} + +/// Adds a new world to a series. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `series_id` - The unique identifier of the series +/// * `name` - The name of the world +/// * `lang` - The language for error messages +/// Returns the new world ID. +/// Errors if a world with the same name already exists. +pub fn add_world(conn: &Connection, user_id: &str, series_id: &str, name: &str, lang: Lang) -> AppResult { + let hashed_name: String = hash_element(name); + + let exists: bool = repo::check_world_exist(conn, user_id, series_id, &hashed_name, lang)?; + if exists { + return Err(AppError::Internal(if lang == Lang::Fr { "Un monde avec ce nom existe déjà.".to_string() } else { "A world with this name already exists.".to_string() })); + } + + let user_key: String = get_user_encryption_key(user_id)?; + let world_id: String = create_unique_id(None); + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_new_world(conn, &world_id, user_id, series_id, &encrypted_name, &hashed_name, last_update, lang)?; + Ok(world_id) +} + +/// Updates a world's information. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `world_id` - The unique identifier of the world +/// * `world` - The updated world data +/// * `lang` - The language for error messages +/// Returns true if successful. +pub fn update_world(conn: &Connection, user_id: &str, world_id: &str, world: SeriesWorldUpdateProps, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let encrypted_name: String = encrypt_data_with_user_key(&world.name, &user_key)?; + let hashed_name: String = hash_element(&world.name); + let encrypted_history: Option = if let Some(ref history) = world.history { Some(encrypt_data_with_user_key(history, &user_key)?) } else { None }; + let encrypted_politics: Option = if let Some(ref politics) = world.politics { Some(encrypt_data_with_user_key(politics, &user_key)?) } else { None }; + let encrypted_economy: Option = if let Some(ref economy) = world.economy { Some(encrypt_data_with_user_key(economy, &user_key)?) } else { None }; + let encrypted_religion: Option = if let Some(ref religion) = world.religion { Some(encrypt_data_with_user_key(religion, &user_key)?) } else { None }; + let encrypted_languages: Option = if let Some(ref languages) = world.languages { Some(encrypt_data_with_user_key(languages, &user_key)?) } else { None }; + let last_update: i64 = timestamp_in_seconds(); + + repo::update_world( + conn, user_id, world_id, &encrypted_name, &hashed_name, + encrypted_history.as_deref(), encrypted_politics.as_deref(), + encrypted_economy.as_deref(), encrypted_religion.as_deref(), + encrypted_languages.as_deref(), last_update, lang, + ) +} + +/// Adds a new element to a world. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `world_id` - The unique identifier of the world +/// * `element_type` - The type of element (0-11) +/// * `name` - The name of the element +/// * `lang` - The language for error messages +/// * `description` - The description of the element (optional) +/// Returns the new element ID. +pub fn add_element(conn: &Connection, user_id: &str, world_id: &str, element_type: i64, name: &str, lang: Lang, description: Option<&str>) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let element_id: String = create_unique_id(None); + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let original_name: String = hash_element(name); + let encrypted_description: Option = if let Some(description_value) = description { Some(encrypt_data_with_user_key(description_value, &user_key)?) } else { None }; + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_element(conn, &element_id, world_id, user_id, element_type, &encrypted_name, &original_name, encrypted_description.as_deref(), last_update, lang)?; + Ok(element_id) +} + +/// Deletes an element from a world. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `element_id` - The unique identifier of the element +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages +/// Returns true if successful. +pub fn delete_element(conn: &Connection, user_id: &str, element_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_element(conn, user_id, element_id, lang)?; + if deleted { + tombstone_repo::insert(conn, element_id, "series_world_elements", element_id, None, user_id, deleted_at, lang)?; + } + Ok(deleted) +} diff --git a/src-tauri/src/domains/spell/commands.rs b/src-tauri/src/domains/spell/commands.rs new file mode 100644 index 0000000..64a6a92 --- /dev/null +++ b/src-tauri/src/domains/spell/commands.rs @@ -0,0 +1,175 @@ +use serde::Deserialize; +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::spell::service; +use crate::error::AppError; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSpellListData { + pub book_id: String, + pub enabled: bool, +} + +#[tauri::command] +pub fn get_spell_list(data: GetSpellListData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_spell_list(conn, &user_id, &data.book_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSpellTagsData { + pub book_id: String, +} + +#[tauri::command] +pub fn get_spell_tags(data: GetSpellTagsData, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_spell_tags(conn, &user_id, &data.book_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSpellDetailData { + pub spell_id: String, +} + +#[tauri::command] +pub fn get_spell_detail(data: GetSpellDetailData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_spell_detail(conn, &user_id, &data.spell_id, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpellData { + pub name: String, + pub description: String, + pub appearance: String, + pub tags: Vec, + pub power_level: Option, + pub components: Option, + pub limitations: Option, + pub notes: Option, + pub series_spell_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSpellData { + pub book_id: String, + pub spell: SpellData, +} + +#[tauri::command] +pub fn create_spell(data: CreateSpellData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_spell( + conn, &user_id, &data.book_id, &data.spell.name, &data.spell.description, &data.spell.appearance, + data.spell.tags, data.spell.power_level.as_deref(), data.spell.components.as_deref(), + data.spell.limitations.as_deref(), data.spell.notes.as_deref(), None, lang, data.spell.series_spell_id.as_deref(), + ) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSpellData { + pub spell_id: String, + pub spell: SpellData, +} + +#[tauri::command] +pub fn update_spell(data: UpdateSpellData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::update_spell( + conn, &user_id, &data.spell_id, &data.spell.name, &data.spell.description, &data.spell.appearance, + data.spell.tags, data.spell.power_level.as_deref(), data.spell.components.as_deref(), + data.spell.limitations.as_deref(), data.spell.notes.as_deref(), lang, data.spell.series_spell_id.as_deref(), + ) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSpellData { + pub spell_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn delete_spell(data: DeleteSpellData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_spell(conn, &user_id, &data.book_id, &data.spell_id, data.deleted_at, lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSpellTagData { + pub book_id: String, + pub name: String, + pub color: Option, + pub id: Option, +} + +#[tauri::command] +pub fn create_spell_tag(data: CreateSpellTagData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::add_spell_tag(conn, &user_id, &data.book_id, &data.name, data.color.as_deref(), data.id.as_deref(), lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSpellTagData { + pub tag_id: String, + pub name: String, + pub color: Option, +} + +#[tauri::command] +pub fn update_spell_tag(data: UpdateSpellTagData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::update_spell_tag(conn, &user_id, &data.tag_id, &data.name, data.color.as_deref(), lang) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSpellTagData { + pub tag_id: String, + pub book_id: String, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn delete_spell_tag(data: DeleteSpellTagData, db: State, session: State) -> Result { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::delete_spell_tag(conn, &user_id, &data.book_id, &data.tag_id, data.deleted_at, lang) +} diff --git a/src-tauri/src/domains/spell/mod.rs b/src-tauri/src/domains/spell/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/spell/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/spell/repo.rs b/src-tauri/src/domains/spell/repo.rs new file mode 100644 index 0000000..42d89ce --- /dev/null +++ b/src-tauri/src/domains/spell/repo.rs @@ -0,0 +1,362 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct SpellResult { + pub spell_id: String, + pub book_id: String, + pub name: String, + pub description: Option, + pub appearance: Option, + pub tags: Option, + pub power_level: Option, + pub components: Option, + pub limitations: Option, + pub notes: Option, + pub series_spell_id: Option, +} + +pub struct BookSpellsTable { + pub spell_id: String, + pub book_id: String, + pub user_id: String, + pub name: String, + pub name_hash: String, + pub description: Option, + pub appearance: Option, + pub tags: Option, + pub power_level: Option, + pub components: Option, + pub limitations: Option, + pub notes: Option, + pub last_update: i64, +} + +pub struct SyncedSpellResult { + pub spell_id: String, + pub book_id: String, + pub name: String, + pub last_update: i64, +} + +/// Fetches all spells for a specific book owned by the user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages +/// Returns an array of spell results. +pub fn fetch_spells(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, book_id, name, description, appearance, tags, power_level, components, limitations, notes, series_spell_id FROM book_spells WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts.".to_string() } else { "Unable to retrieve spells.".to_string() }))?; + + let spells = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(SpellResult { + spell_id: query_row.get(0)?, book_id: query_row.get(1)?, + name: query_row.get(2)?, description: query_row.get(3)?, + appearance: query_row.get(4)?, tags: query_row.get(5)?, + power_level: query_row.get(6)?, components: query_row.get(7)?, + limitations: query_row.get(8)?, notes: query_row.get(9)?, + series_spell_id: query_row.get(10)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts.".to_string() } else { "Unable to retrieve spells.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts.".to_string() } else { "Unable to retrieve spells.".to_string() }))?; + + Ok(spells) +} + +/// Fetches a single spell by its ID. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `lang` - The language for error messages +/// Returns the spell result or None if not found. +pub fn fetch_spell_by_id(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, book_id, name, description, appearance, tags, power_level, components, limitations, notes, series_spell_id FROM book_spells WHERE user_id=?1 AND spell_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort.".to_string() } else { "Unable to retrieve spell.".to_string() }))?; + + let spells = statement + .query_map(params![user_id, spell_id], |query_row| { + Ok(SpellResult { + spell_id: query_row.get(0)?, book_id: query_row.get(1)?, + name: query_row.get(2)?, description: query_row.get(3)?, + appearance: query_row.get(4)?, tags: query_row.get(5)?, + power_level: query_row.get(6)?, components: query_row.get(7)?, + limitations: query_row.get(8)?, notes: query_row.get(9)?, + series_spell_id: query_row.get(10)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort.".to_string() } else { "Unable to retrieve spell.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort.".to_string() } else { "Unable to retrieve spell.".to_string() }))?; + + Ok(spells.into_iter().next()) +} + +/// Inserts a new spell. +/// * `conn` - Database connection +/// * `spell_id` - The unique identifier for the new spell +/// * `book_id` - The unique identifier of the book +/// * `user_id` - The unique identifier of the user +/// * `name` - The encrypted name +/// * `name_hash` - The hashed name for duplicate detection +/// * `description` - The encrypted description +/// * `appearance` - The encrypted appearance +/// * `tags` - The encrypted JSON tags array +/// * `power_level` - The encrypted power level (nullable) +/// * `components` - The encrypted components (nullable) +/// * `limitations` - The encrypted limitations (nullable) +/// * `notes` - The encrypted notes (nullable) +/// * `lang` - The language for error messages +/// * `series_spell_id` - The optional series spell identifier +/// Returns the spell ID if successful. +pub fn insert_spell( + conn: &Connection, spell_id: &str, book_id: &str, user_id: &str, name: &str, + name_hash: &str, description: Option<&str>, appearance: Option<&str>, tags: &str, + power_level: Option<&str>, components: Option<&str>, limitations: Option<&str>, + notes: Option<&str>, last_update: i64, lang: Lang, series_spell_id: Option<&str>, +) -> AppResult { + let insert_result = match series_spell_id { + Some(series_id) => conn + .execute("INSERT INTO book_spells (spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, series_spell_id, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)", params![spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, series_id, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le sort.".to_string() } else { "Unable to add spell.".to_string() }))?, + None => conn + .execute("INSERT INTO book_spells (spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)", params![spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le sort.".to_string() } else { "Unable to add spell.".to_string() }))?, + }; + + if insert_result > 0 { + Ok(spell_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de l'ajout du sort.".to_string() } else { "Error adding spell.".to_string() })) + } +} + +/// Updates an existing spell. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `name` - The encrypted name +/// * `name_hash` - The hashed name +/// * `description` - The encrypted description +/// * `appearance` - The encrypted appearance +/// * `tags` - The encrypted JSON tags array +/// * `power_level` - The encrypted power level (nullable) +/// * `components` - The encrypted components (nullable) +/// * `limitations` - The encrypted limitations (nullable) +/// * `notes` - The encrypted notes (nullable) +/// * `lang` - The language for error messages +/// * `series_spell_id` - The optional series spell identifier +/// Returns true if the update was successful. +pub fn update_spell( + conn: &Connection, user_id: &str, spell_id: &str, name: &str, name_hash: &str, + description: Option<&str>, appearance: Option<&str>, tags: &str, power_level: Option<&str>, + components: Option<&str>, limitations: Option<&str>, notes: Option<&str>, last_update: i64, lang: Lang, + series_spell_id: Option<&str>, +) -> AppResult { + let update_result = match series_spell_id { + Some(series_id) => conn + .execute("UPDATE book_spells SET name=?1, name_hash=?2, description=?3, appearance=?4, tags=?5, power_level=?6, components=?7, limitations=?8, notes=?9, series_spell_id=?10, last_update=?11 WHERE spell_id=?12 AND user_id=?13", params![name, name_hash, description, appearance, tags, power_level, components, limitations, notes, series_id, last_update, spell_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le sort.".to_string() } else { "Unable to update spell.".to_string() }))?, + None => conn + .execute("UPDATE book_spells SET name=?1, name_hash=?2, description=?3, appearance=?4, tags=?5, power_level=?6, components=?7, limitations=?8, notes=?9, last_update=?10 WHERE spell_id=?11 AND user_id=?12", params![name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update, spell_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le sort.".to_string() } else { "Unable to update spell.".to_string() }))?, + }; + + Ok(update_result > 0) +} + +/// Deletes a spell. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `lang` - The language for error messages +/// Returns true if the deletion was successful. +pub fn delete_spell(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM book_spells WHERE spell_id=?1 AND user_id=?2", params![spell_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le sort.".to_string() } else { "Unable to delete spell.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Updates the tags field of a spell. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `tags` - The new encrypted JSON tags array +/// * `lang` - The language for error messages +/// Returns true if the update was successful. +pub fn update_spell_tags(conn: &Connection, user_id: &str, spell_id: &str, tags: &str, last_update: i64, lang: Lang) -> AppResult { + let update_result = conn + .execute("UPDATE book_spells SET tags=?1, last_update=?2 WHERE spell_id=?3 AND user_id=?4", params![tags, last_update, spell_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour les tags du sort.".to_string() } else { "Unable to update spell tags.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Fetches all spells for a book with full table data for sync. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages +/// Returns an array of book spells table records. +pub fn fetch_book_spells_table(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update FROM book_spells WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts.".to_string() } else { "Unable to retrieve spells.".to_string() }))?; + + let spells = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookSpellsTable { + spell_id: query_row.get(0)?, book_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + name_hash: query_row.get(4)?, description: query_row.get(5)?, + appearance: query_row.get(6)?, tags: query_row.get(7)?, + power_level: query_row.get(8)?, components: query_row.get(9)?, + limitations: query_row.get(10)?, notes: query_row.get(11)?, + last_update: query_row.get(12)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts.".to_string() } else { "Unable to retrieve spells.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts.".to_string() } else { "Unable to retrieve spells.".to_string() }))?; + + Ok(spells) +} + +/// Fetches a complete spell record by its ID. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `lang` - The language for error messages +/// Returns the spell table record or None. +pub fn fetch_spell_table_by_id(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update FROM book_spells WHERE user_id=?1 AND spell_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort.".to_string() } else { "Unable to retrieve spell.".to_string() }))?; + + let spells = statement + .query_map(params![user_id, spell_id], |query_row| { + Ok(BookSpellsTable { + spell_id: query_row.get(0)?, book_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + name_hash: query_row.get(4)?, description: query_row.get(5)?, + appearance: query_row.get(6)?, tags: query_row.get(7)?, + power_level: query_row.get(8)?, components: query_row.get(9)?, + limitations: query_row.get(10)?, notes: query_row.get(11)?, + last_update: query_row.get(12)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort.".to_string() } else { "Unable to retrieve spell.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le sort.".to_string() } else { "Unable to retrieve spell.".to_string() }))?; + + Ok(spells.into_iter().next()) +} + +/// Fetches all synced spells for a user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages +/// Returns an array of synced spell results. +pub fn fetch_synced_spells(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT spell_id, book_id, name, last_update FROM book_spells WHERE user_id=?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts synchronisés.".to_string() } else { "Unable to retrieve synced spells.".to_string() }))?; + + let spells = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSpellResult { + spell_id: query_row.get(0)?, book_id: query_row.get(1)?, + name: query_row.get(2)?, last_update: query_row.get(3)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts synchronisés.".to_string() } else { "Unable to retrieve synced spells.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les sorts synchronisés.".to_string() } else { "Unable to retrieve synced spells.".to_string() }))?; + + Ok(spells) +} + +/// Inserts or updates a spell from synchronization data. +/// * `conn` - Database connection +/// * `spell_id` - The unique identifier for the spell +/// * `book_id` - The unique identifier of the book +/// * `user_id` - The unique identifier of the user +/// * `name` - The encrypted name +/// * `name_hash` - The hashed name +/// * `description` - The encrypted description +/// * `appearance` - The encrypted appearance +/// * `tags` - The encrypted JSON tags array +/// * `power_level` - The encrypted power level (nullable) +/// * `components` - The encrypted components (nullable) +/// * `limitations` - The encrypted limitations (nullable) +/// * `notes` - The encrypted notes (nullable) +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages +/// Returns true if the insertion was successful. +pub fn insert_sync_spell( + conn: &Connection, spell_id: &str, book_id: &str, user_id: &str, name: &str, + name_hash: &str, description: Option<&str>, appearance: Option<&str>, tags: Option<&str>, + power_level: Option<&str>, components: Option<&str>, limitations: Option<&str>, + notes: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute("INSERT OR REPLACE INTO book_spells (spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)", params![spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le sort.".to_string() } else { "Unable to insert spell.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Checks if a spell exists. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `lang` - The language for error messages +/// Returns true if the spell exists. +pub fn is_spell_exist(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_spells WHERE spell_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du sort.".to_string() } else { "Unable to check spell existence.".to_string() }))?; + + let exists = statement + .exists(params![spell_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du sort.".to_string() } else { "Unable to check spell existence.".to_string() }))?; + + Ok(exists) +} + +/// Updates a spell with timestamp for sync. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `name` - The encrypted name +/// * `name_hash` - The hashed name +/// * `description` - The encrypted description +/// * `appearance` - The encrypted appearance +/// * `tags` - The encrypted JSON tags array +/// * `power_level` - The encrypted power level (nullable) +/// * `components` - The encrypted components (nullable) +/// * `limitations` - The encrypted limitations (nullable) +/// * `notes` - The encrypted notes (nullable) +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages +/// Returns true if the update was successful. +pub fn update_sync_spell( + conn: &Connection, user_id: &str, spell_id: &str, name: &str, name_hash: &str, + description: Option<&str>, appearance: Option<&str>, tags: Option<&str>, + power_level: Option<&str>, components: Option<&str>, limitations: Option<&str>, + notes: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute("UPDATE book_spells SET name=?1, name_hash=?2, description=?3, appearance=?4, tags=?5, power_level=?6, components=?7, limitations=?8, notes=?9, last_update=?10 WHERE spell_id=?11 AND user_id=?12", params![name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update, spell_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le sort.".to_string() } else { "Unable to update spell.".to_string() }))?; + + Ok(update_result > 0) +} diff --git a/src-tauri/src/domains/spell/service.rs b/src-tauri/src/domains/spell/service.rs new file mode 100644 index 0000000..1943635 --- /dev/null +++ b/src-tauri/src/domains/spell/service.rs @@ -0,0 +1,406 @@ +use std::collections::HashMap; + +use rusqlite::Connection; +use serde::Serialize; + +use crate::crypto::encryption::{decrypt_data_with_user_key, encrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::book::repo as book_repo; +use crate::domains::spell::repo; +use crate::domains::spell_tag::repo as spell_tag_repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::error::{AppError, AppResult}; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +#[derive(Serialize)] +pub struct SpellTagProps { + pub id: String, + pub name: String, + pub color: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpellProps { + pub id: String, + pub name: String, + pub description: String, + pub appearance: String, + pub tags: Vec, + pub power_level: Option, + pub components: Option, + pub limitations: Option, + pub notes: Option, + pub series_spell_id: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpellListItem { + pub id: String, + pub name: String, + pub description: String, + pub tags: Vec, + pub series_spell_id: Option, +} + +#[derive(Serialize)] +pub struct SpellListResponse { + pub enabled: bool, + pub spells: Vec, + pub tags: Vec, +} + +pub struct SyncedSpell { + pub id: String, + pub name: String, + pub last_update: i64, +} + +pub struct SyncedSpellTag { + pub id: String, + pub name: String, + pub last_update: i64, +} + +/// Retrieves all spell tags for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of spell tag props. +pub fn get_spell_tags(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let user_key: String = get_user_encryption_key(user_id)?; + let spell_tags: Vec = spell_tag_repo::fetch_spell_tags(conn, user_id, book_id, lang)?; + + let mut result: Vec = Vec::with_capacity(spell_tags.len()); + for tag in spell_tags { + result.push(SpellTagProps { + id: tag.tag_id, + name: decrypt_data_with_user_key(&tag.name, &user_key)?, + color: tag.color, + }); + } + Ok(result) +} + +/// Adds a new spell tag to a book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `name` - The name of the tag +/// * `color` - The optional color hex code +/// * `existing_tag_id` - Optional existing tag ID for sync +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the created spell tag props. +pub fn add_spell_tag( + conn: &Connection, user_id: &str, book_id: &str, name: &str, color: Option<&str>, + existing_tag_id: Option<&str>, lang: Lang, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let tag_id: String = create_unique_id(existing_tag_id); + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let name_hash: String = hash_element(name); + let last_update: i64 = timestamp_in_seconds(); + + spell_tag_repo::insert_spell_tag(conn, &tag_id, book_id, user_id, &encrypted_name, &name_hash, color, last_update, lang)?; + + Ok(SpellTagProps { + id: tag_id, + name: name.to_string(), + color: color.map(|c| c.to_string()), + }) +} + +/// Updates an existing spell tag. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `tag_id` - The unique identifier of the tag +/// * `name` - The new name of the tag +/// * `color` - The new optional color hex code +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful. +pub fn update_spell_tag( + conn: &Connection, user_id: &str, tag_id: &str, name: &str, color: Option<&str>, lang: Lang, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let name_hash: String = hash_element(name); + let last_update: i64 = timestamp_in_seconds(); + + spell_tag_repo::update_spell_tag(conn, user_id, tag_id, &encrypted_name, &name_hash, color, last_update, lang) +} + +/// Deletes a spell tag and removes its references from all spells in the book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `tag_id` - The unique identifier of the tag to delete +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion was successful. +pub fn delete_spell_tag( + conn: &Connection, user_id: &str, book_id: &str, tag_id: &str, deleted_at: i64, lang: Lang, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let spells: Vec = repo::fetch_spells(conn, user_id, book_id, lang)?; + let last_update: i64 = timestamp_in_seconds(); + + for spell in &spells { + let decrypted_tags: Option = if let Some(ref tags) = spell.tags { + Some(decrypt_data_with_user_key(tags, &user_key)?) + } else { + None + }; + + let tags_array: Vec = match decrypted_tags { + Some(ref tags_str) => serde_json::from_str(tags_str).unwrap_or_default(), + None => Vec::new(), + }; + + if tags_array.contains(&tag_id.to_string()) { + let updated_tags: Vec<&String> = tags_array.iter().filter(|t| t.as_str() != tag_id).collect(); + let serialized_tags: String = serde_json::to_string(&updated_tags).unwrap_or_else(|_| "[]".to_string()); + let encrypted_tags: String = encrypt_data_with_user_key(&serialized_tags, &user_key)?; + repo::update_spell_tags(conn, user_id, &spell.spell_id, &encrypted_tags, last_update, lang)?; + } + } + + let deleted: bool = spell_tag_repo::delete_spell_tag(conn, user_id, tag_id, lang)?; + if deleted { + let removal_id: String = create_unique_id(None); + tombstone_repo::insert(conn, &removal_id, "book_spell_tags", tag_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} + +/// Retrieves the spell list with tags for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the spell list response with enabled status, spells, and tags. +pub fn get_spell_list(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + + let book_tools: Option = book_repo::fetch_book_tools(conn, user_id, book_id, lang)?; + let enabled: bool = book_tools.map_or(false, |bt| bt.spells_enabled == 1); + + let spell_tags: Vec = spell_tag_repo::fetch_spell_tags(conn, user_id, book_id, lang)?; + let mut tags: Vec = Vec::with_capacity(spell_tags.len()); + let mut tag_map: HashMap)> = HashMap::new(); + + for tag in spell_tags { + let decrypted_name: String = decrypt_data_with_user_key(&tag.name, &user_key)?; + tag_map.insert(tag.tag_id.clone(), (decrypted_name.clone(), tag.color.clone())); + tags.push(SpellTagProps { + id: tag.tag_id, + name: decrypted_name, + color: tag.color, + }); + } + + let spell_results: Vec = repo::fetch_spells(conn, user_id, book_id, lang)?; + let mut spells: Vec = Vec::with_capacity(spell_results.len()); + + for spell in spell_results { + let decrypted_name: String = decrypt_data_with_user_key(&spell.name, &user_key)?; + let decrypted_description: Option = if let Some(ref description) = spell.description { + Some(decrypt_data_with_user_key(description, &user_key)?) + } else { + None + }; + let decrypted_tags: Option = if let Some(ref tags_str) = spell.tags { + Some(decrypt_data_with_user_key(tags_str, &user_key)?) + } else { + None + }; + + let tag_ids: Vec = match decrypted_tags { + Some(ref tags_str) => serde_json::from_str(tags_str).unwrap_or_default(), + None => Vec::new(), + }; + + let resolved_tags: Vec = tag_ids + .iter() + .filter_map(|tag_id| { + tag_map.get(tag_id).map(|(name, color)| SpellTagProps { + id: tag_id.clone(), + name: name.clone(), + color: color.clone(), + }) + }) + .collect(); + + let truncated_description: String = match decrypted_description { + Some(ref desc) if desc.len() > 150 => format!("{}...", &desc[..150]), + Some(ref desc) => desc.clone(), + None => String::new(), + }; + + spells.push(SpellListItem { + id: spell.spell_id, + name: decrypted_name, + description: truncated_description, + tags: resolved_tags, + series_spell_id: spell.series_spell_id, + }); + } + + Ok(SpellListResponse { enabled, spells, tags }) +} + +/// Retrieves the full details of a specific spell. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the spell props with all details. +pub fn get_spell_detail(conn: &Connection, user_id: &str, spell_id: &str, lang: Lang) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + + let spell: repo::SpellResult = repo::fetch_spell_by_id(conn, user_id, spell_id, lang)? + .ok_or_else(|| AppError::Internal(if lang == Lang::Fr { "Sort non trouvé.".to_string() } else { "Spell not found.".to_string() }))?; + + let decrypted_name: String = decrypt_data_with_user_key(&spell.name, &user_key)?; + let decrypted_description: String = if let Some(ref description) = spell.description { decrypt_data_with_user_key(description, &user_key)? } else { String::new() }; + let decrypted_appearance: String = if let Some(ref appearance) = spell.appearance { decrypt_data_with_user_key(appearance, &user_key)? } else { String::new() }; + let decrypted_tags: Option = if let Some(ref tags) = spell.tags { Some(decrypt_data_with_user_key(tags, &user_key)?) } else { None }; + + let tag_ids: Vec = match decrypted_tags { + Some(ref tags_str) => serde_json::from_str(tags_str).unwrap_or_default(), + None => Vec::new(), + }; + + Ok(SpellProps { + id: spell.spell_id, + name: decrypted_name, + description: decrypted_description, + appearance: decrypted_appearance, + tags: tag_ids, + power_level: if let Some(ref power_level) = spell.power_level { Some(decrypt_data_with_user_key(power_level, &user_key)?) } else { None }, + components: if let Some(ref components) = spell.components { Some(decrypt_data_with_user_key(components, &user_key)?) } else { None }, + limitations: if let Some(ref limitations) = spell.limitations { Some(decrypt_data_with_user_key(limitations, &user_key)?) } else { None }, + notes: if let Some(ref notes) = spell.notes { Some(decrypt_data_with_user_key(notes, &user_key)?) } else { None }, + series_spell_id: spell.series_spell_id, + }) +} + +/// Adds a new spell to a book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `name` - The name of the spell +/// * `description` - The description of the spell +/// * `appearance` - The appearance of the spell +/// * `tags` - The tag IDs array +/// * `power_level` - The optional power level +/// * `components` - The optional components +/// * `limitations` - The optional limitations +/// * `notes` - The optional notes +/// * `existing_spell_id` - Optional existing spell ID for sync +/// * `lang` - The language for error messages ("fr" or "en") +/// * `series_spell_id` - The optional series spell identifier +/// Returns the created spell props. +pub fn add_spell( + conn: &Connection, user_id: &str, book_id: &str, name: &str, description: &str, appearance: &str, + tags: Vec, power_level: Option<&str>, components: Option<&str>, limitations: Option<&str>, + notes: Option<&str>, existing_spell_id: Option<&str>, lang: Lang, series_spell_id: Option<&str>, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + let spell_id: String = create_unique_id(existing_spell_id); + + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let name_hash: String = hash_element(name); + let encrypted_description: String = encrypt_data_with_user_key(description, &user_key)?; + let encrypted_appearance: String = encrypt_data_with_user_key(appearance, &user_key)?; + let serialized_tags: String = serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string()); + let encrypted_tags: String = encrypt_data_with_user_key(&serialized_tags, &user_key)?; + let encrypted_power_level: Option = if let Some(power_level_val) = power_level { Some(encrypt_data_with_user_key(power_level_val, &user_key)?) } else { None }; + let encrypted_components: Option = if let Some(components_val) = components { Some(encrypt_data_with_user_key(components_val, &user_key)?) } else { None }; + let encrypted_limitations: Option = if let Some(limitations_val) = limitations { Some(encrypt_data_with_user_key(limitations_val, &user_key)?) } else { None }; + let encrypted_notes: Option = if let Some(notes_val) = notes { Some(encrypt_data_with_user_key(notes_val, &user_key)?) } else { None }; + let last_update: i64 = timestamp_in_seconds(); + + repo::insert_spell( + conn, &spell_id, book_id, user_id, &encrypted_name, &name_hash, + Some(&encrypted_description), Some(&encrypted_appearance), &encrypted_tags, + encrypted_power_level.as_deref(), encrypted_components.as_deref(), + encrypted_limitations.as_deref(), encrypted_notes.as_deref(), + last_update, lang, series_spell_id, + )?; + + Ok(SpellProps { + id: spell_id, + name: name.to_string(), + description: description.to_string(), + appearance: appearance.to_string(), + tags, + power_level: power_level.map(|s| s.to_string()), + components: components.map(|s| s.to_string()), + limitations: limitations.map(|s| s.to_string()), + notes: notes.map(|s| s.to_string()), + series_spell_id: series_spell_id.map(|s| s.to_string()), + }) +} + +/// Updates an existing spell. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `spell_id` - The unique identifier of the spell +/// * `name` - The name of the spell +/// * `description` - The description of the spell +/// * `appearance` - The appearance of the spell +/// * `tags` - The tag IDs array +/// * `power_level` - The optional power level +/// * `components` - The optional components +/// * `limitations` - The optional limitations +/// * `notes` - The optional notes +/// * `lang` - The language for error messages ("fr" or "en") +/// * `series_spell_id` - The optional series spell identifier +/// Returns true if the update was successful. +pub fn update_spell( + conn: &Connection, user_id: &str, spell_id: &str, name: &str, description: &str, appearance: &str, + tags: Vec, power_level: Option<&str>, components: Option<&str>, limitations: Option<&str>, + notes: Option<&str>, lang: Lang, series_spell_id: Option<&str>, +) -> AppResult { + let user_key: String = get_user_encryption_key(user_id)?; + + let encrypted_name: String = encrypt_data_with_user_key(name, &user_key)?; + let name_hash: String = hash_element(name); + let encrypted_description: String = encrypt_data_with_user_key(description, &user_key)?; + let encrypted_appearance: String = encrypt_data_with_user_key(appearance, &user_key)?; + let serialized_tags: String = serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string()); + let encrypted_tags: String = encrypt_data_with_user_key(&serialized_tags, &user_key)?; + let encrypted_power_level: Option = if let Some(power_level_val) = power_level { Some(encrypt_data_with_user_key(power_level_val, &user_key)?) } else { None }; + let encrypted_components: Option = if let Some(components_val) = components { Some(encrypt_data_with_user_key(components_val, &user_key)?) } else { None }; + let encrypted_limitations: Option = if let Some(limitations_val) = limitations { Some(encrypt_data_with_user_key(limitations_val, &user_key)?) } else { None }; + let encrypted_notes: Option = if let Some(notes_val) = notes { Some(encrypt_data_with_user_key(notes_val, &user_key)?) } else { None }; + let last_update: i64 = timestamp_in_seconds(); + + repo::update_spell( + conn, user_id, spell_id, &encrypted_name, &name_hash, + Some(&encrypted_description), Some(&encrypted_appearance), &encrypted_tags, + encrypted_power_level.as_deref(), encrypted_components.as_deref(), + encrypted_limitations.as_deref(), encrypted_notes.as_deref(), + last_update, lang, series_spell_id, + ) +} + +/// Deletes a spell. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `spell_id` - The unique identifier of the spell +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion was successful. +pub fn delete_spell(conn: &Connection, user_id: &str, book_id: &str, spell_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_spell(conn, user_id, spell_id, lang)?; + if deleted { + let removal_id: String = create_unique_id(None); + tombstone_repo::insert(conn, &removal_id, "book_spells", spell_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} diff --git a/src-tauri/src/domains/spell_tag/mod.rs b/src-tauri/src/domains/spell_tag/mod.rs new file mode 100644 index 0000000..5ca3bf0 --- /dev/null +++ b/src-tauri/src/domains/spell_tag/mod.rs @@ -0,0 +1 @@ +pub mod repo; diff --git a/src-tauri/src/domains/spell_tag/repo.rs b/src-tauri/src/domains/spell_tag/repo.rs new file mode 100644 index 0000000..d4e4827 --- /dev/null +++ b/src-tauri/src/domains/spell_tag/repo.rs @@ -0,0 +1,174 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct SpellTagResult { + pub tag_id: String, + pub book_id: String, + pub name: String, + pub color: Option, +} + +pub struct BookSpellTagsTable { + pub tag_id: String, + pub book_id: String, + pub user_id: String, + pub name: String, + pub name_hash: String, + pub color: Option, + pub last_update: i64, +} + +pub struct SyncedSpellTagResult { + pub tag_id: String, + pub book_id: String, + pub name: String, + pub last_update: i64, +} + +/// Fetches all spell tags for a specific book owned by the user. +pub fn fetch_spell_tags(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT tag_id, book_id, name, color FROM book_spell_tags WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sorts.".to_string() } else { "Unable to retrieve spell tags.".to_string() }))?; + + let rows = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(SpellTagResult { + tag_id: query_row.get(0)?, book_id: query_row.get(1)?, + name: query_row.get(2)?, color: query_row.get(3)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sorts.".to_string() } else { "Unable to retrieve spell tags.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sorts.".to_string() } else { "Unable to retrieve spell tags.".to_string() }))?; + + Ok(rows) +} + +/// Inserts a new spell tag. +pub fn insert_spell_tag(conn: &Connection, tag_id: &str, book_id: &str, user_id: &str, name: &str, name_hash: &str, color: Option<&str>, last_update: i64, lang: Lang) -> AppResult { + let insert_result = conn + .execute("INSERT INTO book_spell_tags (tag_id, book_id, user_id, name, name_hash, color, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7)", params![tag_id, book_id, user_id, name, name_hash, color, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le tag de sort.".to_string() } else { "Unable to add spell tag.".to_string() }))?; + + if insert_result > 0 { + Ok(tag_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Une erreur s'est produite lors de l'ajout du tag.".to_string() } else { "Error adding tag.".to_string() })) + } +} + +/// Updates an existing spell tag. +pub fn update_spell_tag(conn: &Connection, user_id: &str, tag_id: &str, name: &str, name_hash: &str, color: Option<&str>, last_update: i64, lang: Lang) -> AppResult { + let update_result = conn + .execute("UPDATE book_spell_tags SET name=?1, name_hash=?2, color=?3, last_update=?4 WHERE tag_id=?5 AND user_id=?6", params![name, name_hash, color, last_update, tag_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le tag de sort.".to_string() } else { "Unable to update spell tag.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Deletes a spell tag. +pub fn delete_spell_tag(conn: &Connection, user_id: &str, tag_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM book_spell_tags WHERE tag_id=?1 AND user_id=?2", params![tag_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer le tag de sort.".to_string() } else { "Unable to delete spell tag.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Fetches all spell tags for a book with full table data for sync. +pub fn fetch_book_spell_tags_table(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT tag_id, book_id, user_id, name, name_hash, color, last_update FROM book_spell_tags WHERE user_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sorts.".to_string() } else { "Unable to retrieve spell tags.".to_string() }))?; + + let rows = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookSpellTagsTable { + tag_id: query_row.get(0)?, book_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + name_hash: query_row.get(4)?, color: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sorts.".to_string() } else { "Unable to retrieve spell tags.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sorts.".to_string() } else { "Unable to retrieve spell tags.".to_string() }))?; + + Ok(rows) +} + +/// Fetches a complete spell tag record by its ID. +pub fn fetch_spell_tag_table_by_id(conn: &Connection, user_id: &str, tag_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT tag_id, book_id, user_id, name, name_hash, color, last_update FROM book_spell_tags WHERE user_id=?1 AND tag_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le tag de sort.".to_string() } else { "Unable to retrieve spell tag.".to_string() }))?; + + let spell_tags = statement + .query_map(params![user_id, tag_id], |query_row| { + Ok(BookSpellTagsTable { + tag_id: query_row.get(0)?, book_id: query_row.get(1)?, + user_id: query_row.get(2)?, name: query_row.get(3)?, + name_hash: query_row.get(4)?, color: query_row.get(5)?, + last_update: query_row.get(6)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le tag de sort.".to_string() } else { "Unable to retrieve spell tag.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer le tag de sort.".to_string() } else { "Unable to retrieve spell tag.".to_string() }))?; + + Ok(if spell_tags.is_empty() { None } else { Some(spell_tags.into_iter().next().unwrap()) }) +} + +/// Fetches all synced spell tags for a user. +pub fn fetch_synced_spell_tags(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT tag_id, book_id, name, last_update FROM book_spell_tags WHERE user_id=?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sorts synchronisés.".to_string() } else { "Unable to retrieve synced spell tags.".to_string() }))?; + + let rows = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedSpellTagResult { + tag_id: query_row.get(0)?, book_id: query_row.get(1)?, + name: query_row.get(2)?, last_update: query_row.get(3)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sorts synchronisés.".to_string() } else { "Unable to retrieve synced spell tags.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les tags de sorts synchronisés.".to_string() } else { "Unable to retrieve synced spell tags.".to_string() }))?; + + Ok(rows) +} + +/// Inserts or updates a spell tag from synchronization data. +pub fn insert_sync_spell_tag(conn: &Connection, tag_id: &str, book_id: &str, user_id: &str, name: &str, name_hash: &str, color: Option<&str>, last_update: i64, lang: Lang) -> AppResult { + let insert_result = conn + .execute("INSERT OR REPLACE INTO book_spell_tags (tag_id, book_id, user_id, name, name_hash, color, last_update) VALUES (?1,?2,?3,?4,?5,?6,?7)", params![tag_id, book_id, user_id, name, name_hash, color, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'insérer le tag de sort.".to_string() } else { "Unable to insert spell tag.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Checks if a spell tag exists. +pub fn is_spell_tag_exist(conn: &Connection, user_id: &str, tag_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_spell_tags WHERE tag_id=?1 AND user_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du tag.".to_string() } else { "Unable to check tag existence.".to_string() }))?; + + let exists = statement + .exists(params![tag_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier l'existence du tag.".to_string() } else { "Unable to check tag existence.".to_string() }))?; + + Ok(exists) +} + +/// Updates a spell tag with timestamp for sync. +pub fn update_sync_spell_tag(conn: &Connection, user_id: &str, tag_id: &str, name: &str, name_hash: &str, color: Option<&str>, last_update: i64, lang: Lang) -> AppResult { + let update_result = conn + .execute("UPDATE book_spell_tags SET name=?1, name_hash=?2, color=?3, last_update=?4 WHERE tag_id=?5 AND user_id=?6", params![name, name_hash, color, last_update, tag_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le tag de sort.".to_string() } else { "Unable to update spell tag.".to_string() }))?; + + Ok(update_result > 0) +} diff --git a/src-tauri/src/domains/sync/commands.rs b/src-tauri/src/domains/sync/commands.rs new file mode 100644 index 0000000..f70d820 --- /dev/null +++ b/src-tauri/src/domains/sync/commands.rs @@ -0,0 +1,22 @@ +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::sync::service; +use crate::error::AppError; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +#[tauri::command] +pub fn get_synced_series(db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + service::get_synced_series(conn, &user_id, lang) +} diff --git a/src-tauri/src/domains/sync/mod.rs b/src-tauri/src/domains/sync/mod.rs new file mode 100644 index 0000000..0d79657 --- /dev/null +++ b/src-tauri/src/domains/sync/mod.rs @@ -0,0 +1,2 @@ +pub mod commands; +pub mod service; diff --git a/src-tauri/src/domains/sync/service.rs b/src-tauri/src/domains/sync/service.rs new file mode 100644 index 0000000..33768bc --- /dev/null +++ b/src-tauri/src/domains/sync/service.rs @@ -0,0 +1,1289 @@ +use rusqlite::Connection; +use serde::Serialize; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::act::repo as act_repo; +use crate::domains::book::repo as book_repo; +use crate::domains::book::service::{ + BookActSummariesTable, BookAIGuideLineTable, BookChapterContentTable, BookChaptersTable, + BookChapterInfosTable, BookCharactersTable, BookCharactersAttributesTable, BookGuideLineTable, + BookIncidentsTable, BookIssuesTable, BookLocationTable, BookPlotPointsTable, BookSpellsTable, + BookSpellTagsTable, BookWorldTable, BookWorldElementsTable, CompleteBook, + LocationElementTable, LocationSubElementTable, BookSyncCompare, SyncedBookTools, SyncedIncident, SyncedPlotPoint, SyncedIssue, SyncedActSummary, + SyncedGuideLine, SyncedAIGuideLine, SyncedSpell, SyncedSpellTag, + SyncedSeries, SyncedSeriesBook, SyncedSeriesCharacter, SyncedSeriesCharacterAttribute, + SyncedSeriesWorld, SyncedSeriesWorldElement, SyncedSeriesLocation, + SyncedSeriesLocationElement, SyncedSeriesLocationSubElement, SyncedSeriesSpell, + SyncedSeriesSpellTag, +}; +use crate::domains::chapter::repo as chapter_repo; +use crate::domains::chapter_content::repo as chapter_content_repo; +use crate::domains::character::repo as character_repo; +use crate::domains::guideline::repo as guideline_repo; +use crate::domains::incident::repo as incident_repo; +use crate::domains::issue::repo as issue_repo; +use crate::domains::location::repo as location_repo; +use crate::domains::plotpoint::repo as plotpoint_repo; +use crate::domains::series::repo as series_repo; +use crate::domains::series_character::repo as series_character_repo; +use crate::domains::series_location::repo as series_location_repo; +use crate::domains::series_spell::repo as series_spell_repo; +use crate::domains::series_world::repo as series_world_repo; +use crate::domains::spell::repo as spell_repo; +use crate::domains::spell_tag::repo as spell_tag_repo; +use crate::domains::world::repo as world_repo; +use crate::error::AppResult; +use crate::shared::types::Lang; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedChapterContent { + pub id: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedChapterInfo { + pub id: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedCharacterAttribute { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedLocationElement { + pub id: String, + pub name: String, + pub last_update: i64, + pub sub_elements: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedLocationSubElement { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedWorldElement { + pub id: String, + pub name: String, + pub last_update: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedChapterFull { + pub id: String, + pub name: String, + pub last_update: i64, + pub contents: Vec, + pub info: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedCharacterFull { + pub id: String, + pub name: String, + pub last_update: i64, + pub attributes: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedLocationFull { + pub id: String, + pub name: String, + pub last_update: i64, + pub elements: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedWorldFull { + pub id: String, + pub name: String, + pub last_update: i64, + pub elements: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncedBookFull { + pub id: String, + pub book_type: String, + pub title: String, + pub sub_title: Option, + pub last_update: i64, + pub chapters: Vec, + pub characters: Vec, + pub locations: Vec, + pub worlds: Vec, + pub incidents: Vec, + pub plot_points: Vec, + pub issues: Vec, + pub act_summaries: Vec, + pub guide_line: Option, + pub ai_guide_line: Option, + pub book_tools: Option, + pub spells: Vec, + pub spell_tags: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncCharacterData { + pub first_name: String, + pub last_name: Option, + pub nickname: Option, + pub age: Option, + pub gender: Option, + pub species: Option, + pub nationality: Option, + pub status: Option, + pub category: String, + pub title: Option, + pub image: Option, + pub role: Option, + pub biography: Option, + pub history: Option, + pub speech_pattern: Option, + pub catchphrase: Option, + pub residence: Option, + pub notes: Option, + pub color: Option, +} + +/// Retrieves a complete book with all its associated entities for synchronization. +/// Decrypts all encrypted fields using the user's encryption key. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `sync_compare_data` - Object containing IDs of entities to retrieve for sync comparison +/// * `lang` - The language for error messages +/// Returns a CompleteBook object with all decrypted data. +pub fn get_complete_sync_book(conn: &Connection, user_id: &str, sync_compare_data: &BookSyncCompare, lang: Lang) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let mut decrypted_books: Vec = Vec::new(); + let mut decrypted_chapters: Vec = Vec::new(); + let mut decrypted_plot_points: Vec = Vec::new(); + let mut decrypted_incidents: Vec = Vec::new(); + let mut decrypted_chapter_contents: Vec = Vec::new(); + let mut decrypted_chapter_infos: Vec = Vec::new(); + let mut decrypted_characters: Vec = Vec::new(); + let mut decrypted_character_attributes: Vec = Vec::new(); + let mut decrypted_locations: Vec = Vec::new(); + let mut decrypted_location_elements: Vec = Vec::new(); + let mut decrypted_location_sub_elements: Vec = Vec::new(); + let mut decrypted_worlds: Vec = Vec::new(); + let mut decrypted_world_elements: Vec = Vec::new(); + let mut decrypted_act_summaries: Vec = Vec::new(); + let mut decrypted_guide_lines: Vec = Vec::new(); + let mut decrypted_ai_guide_lines: Vec = Vec::new(); + let mut decrypted_issues: Vec = Vec::new(); + let mut decrypted_spells: Vec = Vec::new(); + let mut decrypted_spell_tags: Vec = Vec::new(); + + let act_summary_ids: &Vec = &sync_compare_data.act_summaries; + let chapter_ids: &Vec = &sync_compare_data.chapters; + let plot_point_ids: &Vec = &sync_compare_data.plot_points; + let incident_ids: &Vec = &sync_compare_data.incidents; + let chapter_content_ids: &Vec = &sync_compare_data.chapter_contents; + let chapter_info_ids: &Vec = &sync_compare_data.chapter_infos; + let character_ids: &Vec = &sync_compare_data.characters; + let character_attribute_ids: &Vec = &sync_compare_data.character_attributes; + let location_ids: &Vec = &sync_compare_data.locations; + let location_element_ids: &Vec = &sync_compare_data.location_elements; + let location_sub_element_ids: &Vec = &sync_compare_data.location_sub_elements; + let world_ids: &Vec = &sync_compare_data.worlds; + let world_element_ids: &Vec = &sync_compare_data.world_elements; + let issue_ids: &Vec = &sync_compare_data.issues; + let spell_ids: &Vec = &sync_compare_data.spells; + let spell_tag_ids: &Vec = &sync_compare_data.spell_tags; + + if !act_summary_ids.is_empty() { + for act_summary_id in act_summary_ids { + let act_summary_results: Vec = act_repo::fetch_complete_act_summary_by_id(conn, act_summary_id, lang)?; + if !act_summary_results.is_empty() { + let act_summary_record = &act_summary_results[0]; + decrypted_act_summaries.push(BookActSummariesTable { + summary_id: act_summary_record.act_sum_id.clone(), + book_id: act_summary_record.book_id.clone(), + user_id: act_summary_record.user_id.clone(), + act_number: act_summary_record.act_index, + summary: if let Some(ref summary) = act_summary_record.summary { decrypt_data_with_user_key(summary, &user_encryption_key)? } else { String::new() }, + last_update: act_summary_record.last_update, + }); + } + } + } + + if !chapter_ids.is_empty() { + for chapter_id in chapter_ids { + let chapter_results: Vec = chapter_repo::fetch_complete_chapter_by_id(conn, chapter_id, lang)?; + if !chapter_results.is_empty() { + let chapter_record = &chapter_results[0]; + decrypted_chapters.push(BookChaptersTable { + chapter_id: chapter_record.chapter_id.clone(), + book_id: chapter_record.book_id.clone(), + user_id: chapter_record.author_id.clone(), + title: decrypt_data_with_user_key(&chapter_record.title, &user_encryption_key)?, + hashed_title: chapter_record.hashed_title.clone(), + chapter_order: chapter_record.chapter_order, + last_update: chapter_record.last_update, + }); + } + } + } + + if !plot_point_ids.is_empty() { + for plot_point_id in plot_point_ids { + let plot_point_results: Vec = plotpoint_repo::fetch_complete_plot_point_by_id(conn, plot_point_id, lang)?; + if !plot_point_results.is_empty() { + let plot_point_record = &plot_point_results[0]; + decrypted_plot_points.push(BookPlotPointsTable { + plot_point_id: plot_point_record.plot_point_id.clone(), + chapter_id: plot_point_record.linked_incident_id.clone().unwrap_or_default(), + user_id: plot_point_record.author_id.clone(), + name: decrypt_data_with_user_key(&plot_point_record.title, &user_encryption_key)?, + hashed_name: plot_point_record.hashed_title.clone(), + description: if let Some(ref summary) = plot_point_record.summary { Some(decrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None }, + plot_point_order: 0, + last_update: plot_point_record.last_update, + }); + } + } + } + + if !incident_ids.is_empty() { + for incident_id in incident_ids { + let incident_results: Vec = incident_repo::fetch_complete_incident_by_id(conn, incident_id, lang)?; + if !incident_results.is_empty() { + let incident_record = &incident_results[0]; + decrypted_incidents.push(BookIncidentsTable { + incident_id: incident_record.incident_id.clone(), + chapter_id: incident_record.book_id.clone(), + user_id: incident_record.author_id.clone(), + name: decrypt_data_with_user_key(&incident_record.title, &user_encryption_key)?, + hashed_name: incident_record.hashed_title.clone(), + description: if let Some(ref summary) = incident_record.summary { Some(decrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None }, + incident_order: 0, + last_update: incident_record.last_update, + }); + } + } + } + + if !chapter_content_ids.is_empty() { + for chapter_content_id in chapter_content_ids { + let chapter_content_results: Vec = chapter_content_repo::fetch_complete_chapter_content_by_id(conn, chapter_content_id, lang)?; + if !chapter_content_results.is_empty() { + let chapter_content_record = &chapter_content_results[0]; + decrypted_chapter_contents.push(BookChapterContentTable { + content_id: chapter_content_record.content_id.clone(), + chapter_id: chapter_content_record.chapter_id.clone(), + user_id: chapter_content_record.author_id.clone(), + content: if let Some(ref content) = chapter_content_record.content { Some(decrypt_data_with_user_key(content, &user_encryption_key)?) } else { None }, + version: chapter_content_record.version, + last_update: chapter_content_record.last_update, + }); + } + } + } + + if !chapter_info_ids.is_empty() { + for chapter_info_id in chapter_info_ids { + let chapter_info_results: Vec = chapter_repo::fetch_complete_chapter_info_by_id(conn, chapter_info_id, lang)?; + if !chapter_info_results.is_empty() { + let chapter_info_record = &chapter_info_results[0]; + decrypted_chapter_infos.push(BookChapterInfosTable { + chapter_id: chapter_info_record.chapter_id.clone(), + user_id: chapter_info_record.author_id.clone(), + summary: if let Some(ref summary) = chapter_info_record.summary { Some(decrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None }, + notes: if let Some(ref goal) = chapter_info_record.goal { Some(decrypt_data_with_user_key(goal, &user_encryption_key)?) } else { None }, + last_update: chapter_info_record.last_update, + }); + } + } + } + + if !character_ids.is_empty() { + for character_id in character_ids { + let character_results: Vec = character_repo::fetch_complete_character_by_id(conn, character_id, lang)?; + if !character_results.is_empty() { + let character_record = &character_results[0]; + decrypted_characters.push(BookCharactersTable { + character_id: character_record.character_id.clone(), + book_id: character_record.book_id.clone(), + user_id: character_record.user_id.clone(), + first_name: decrypt_data_with_user_key(&character_record.first_name, &user_encryption_key)?, + last_name: if let Some(ref last_name) = character_record.last_name { Some(decrypt_data_with_user_key(last_name, &user_encryption_key)?) } else { None }, + nickname: if let Some(ref nickname) = character_record.nickname { Some(decrypt_data_with_user_key(nickname, &user_encryption_key)?) } else { None }, + age: if let Some(ref age) = character_record.age { Some(decrypt_data_with_user_key(age, &user_encryption_key)?.parse().unwrap_or(0)) } else { None }, + gender: if let Some(ref gender) = character_record.gender { Some(decrypt_data_with_user_key(gender, &user_encryption_key)?) } else { None }, + species: if let Some(ref species) = character_record.species { Some(decrypt_data_with_user_key(species, &user_encryption_key)?) } else { None }, + nationality: if let Some(ref nationality) = character_record.nationality { Some(decrypt_data_with_user_key(nationality, &user_encryption_key)?) } else { None }, + status: if let Some(ref status) = character_record.status { Some(decrypt_data_with_user_key(status, &user_encryption_key)?) } else { None }, + title: if let Some(ref title) = character_record.title { Some(decrypt_data_with_user_key(title, &user_encryption_key)?) } else { None }, + category: decrypt_data_with_user_key(&character_record.category, &user_encryption_key)?, + image: if let Some(ref image) = character_record.image { Some(decrypt_data_with_user_key(image, &user_encryption_key)?) } else { None }, + role: if let Some(ref role) = character_record.role { Some(decrypt_data_with_user_key(role, &user_encryption_key)?) } else { None }, + biography: if let Some(ref biography) = character_record.biography { Some(decrypt_data_with_user_key(biography, &user_encryption_key)?) } else { None }, + history: if let Some(ref history) = character_record.history { Some(decrypt_data_with_user_key(history, &user_encryption_key)?) } else { None }, + speech_pattern: if let Some(ref speech_pattern) = character_record.speech_pattern { Some(decrypt_data_with_user_key(speech_pattern, &user_encryption_key)?) } else { None }, + catchphrase: if let Some(ref catchphrase) = character_record.catchphrase { Some(decrypt_data_with_user_key(catchphrase, &user_encryption_key)?) } else { None }, + residence: if let Some(ref residence) = character_record.residence { Some(decrypt_data_with_user_key(residence, &user_encryption_key)?) } else { None }, + notes: if let Some(ref notes) = character_record.notes { Some(decrypt_data_with_user_key(notes, &user_encryption_key)?) } else { None }, + color: if let Some(ref color) = character_record.color { Some(decrypt_data_with_user_key(color, &user_encryption_key)?) } else { None }, + last_update: character_record.last_update, + }); + } + } + } + + if !character_attribute_ids.is_empty() { + for character_attribute_id in character_attribute_ids { + let character_attribute_results: Vec = character_repo::fetch_complete_character_attribute_by_id(conn, character_attribute_id, lang)?; + if !character_attribute_results.is_empty() { + let character_attribute_record = &character_attribute_results[0]; + decrypted_character_attributes.push(BookCharactersAttributesTable { + attr_id: character_attribute_record.attr_id.clone(), + character_id: character_attribute_record.character_id.clone(), + user_id: character_attribute_record.user_id.clone(), + attribute_name: decrypt_data_with_user_key(&character_attribute_record.attribute_name, &user_encryption_key)?, + attribute_value: decrypt_data_with_user_key(&character_attribute_record.attribute_value, &user_encryption_key)?, + last_update: character_attribute_record.last_update, + }); + } + } + } + + if !location_ids.is_empty() { + for location_id in location_ids { + let location_results: Vec = location_repo::fetch_complete_location_by_id(conn, location_id, lang)?; + if !location_results.is_empty() { + let location_record = &location_results[0]; + decrypted_locations.push(BookLocationTable { + loc_id: location_record.loc_id.clone(), + book_id: location_record.book_id.clone(), + user_id: location_record.user_id.clone(), + loc_name: decrypt_data_with_user_key(&location_record.loc_name, &user_encryption_key)?, + loc_original_name: location_record.loc_original_name.clone(), + last_update: location_record.last_update, + }); + } + } + } + + if !location_element_ids.is_empty() { + for location_element_id in location_element_ids { + let location_element_results: Vec = location_repo::fetch_complete_location_element_by_id(conn, location_element_id, lang)?; + if !location_element_results.is_empty() { + let location_element_record = &location_element_results[0]; + decrypted_location_elements.push(LocationElementTable { + element_id: location_element_record.element_id.clone(), + location_id: location_element_record.location.clone(), + user_id: location_element_record.user_id.clone(), + element_name: decrypt_data_with_user_key(&location_element_record.element_name, &user_encryption_key)?, + original_name: location_element_record.original_name.clone(), + element_description: if let Some(ref element_description) = location_element_record.element_description { Some(decrypt_data_with_user_key(element_description, &user_encryption_key)?) } else { None }, + last_update: location_element_record.last_update, + }); + } + } + } + + if !location_sub_element_ids.is_empty() { + for location_sub_element_id in location_sub_element_ids { + let location_sub_element_results: Vec = location_repo::fetch_complete_location_sub_element_by_id(conn, location_sub_element_id, lang)?; + if !location_sub_element_results.is_empty() { + let location_sub_element_record = &location_sub_element_results[0]; + decrypted_location_sub_elements.push(LocationSubElementTable { + sub_element_id: location_sub_element_record.sub_element_id.clone(), + element_id: location_sub_element_record.element_id.clone(), + user_id: location_sub_element_record.user_id.clone(), + sub_elem_name: decrypt_data_with_user_key(&location_sub_element_record.sub_elem_name, &user_encryption_key)?, + original_name: location_sub_element_record.original_name.clone(), + sub_elem_description: if let Some(ref sub_elem_description) = location_sub_element_record.sub_elem_description { Some(decrypt_data_with_user_key(sub_elem_description, &user_encryption_key)?) } else { None }, + last_update: location_sub_element_record.last_update, + }); + } + } + } + + if !world_ids.is_empty() { + for world_id in world_ids { + let world_results: Vec = world_repo::fetch_complete_world_by_id(conn, world_id, lang)?; + if !world_results.is_empty() { + let world_record = &world_results[0]; + decrypted_worlds.push(BookWorldTable { + world_id: world_record.world_id.clone(), + book_id: world_record.book_id.clone(), + user_id: world_record.author_id.clone(), + name: decrypt_data_with_user_key(&world_record.name, &user_encryption_key)?, + hashed_name: world_record.hashed_name.clone(), + history: if let Some(ref history) = world_record.history { Some(decrypt_data_with_user_key(history, &user_encryption_key)?) } else { None }, + politics: if let Some(ref politics) = world_record.politics { Some(decrypt_data_with_user_key(politics, &user_encryption_key)?) } else { None }, + economy: if let Some(ref economy) = world_record.economy { Some(decrypt_data_with_user_key(economy, &user_encryption_key)?) } else { None }, + religion: if let Some(ref religion) = world_record.religion { Some(decrypt_data_with_user_key(religion, &user_encryption_key)?) } else { None }, + languages: if let Some(ref languages) = world_record.languages { Some(decrypt_data_with_user_key(languages, &user_encryption_key)?) } else { None }, + last_update: world_record.last_update, + }); + } + } + } + + if !world_element_ids.is_empty() { + for world_element_id in world_element_ids { + let world_element_results: Vec = world_repo::fetch_complete_world_element_by_id(conn, world_element_id, lang)?; + if !world_element_results.is_empty() { + let world_element_record = &world_element_results[0]; + decrypted_world_elements.push(BookWorldElementsTable { + element_id: world_element_record.element_id.clone(), + world_id: world_element_record.world_id.clone(), + user_id: world_element_record.user_id.clone(), + element_type: world_element_record.element_type, + name: decrypt_data_with_user_key(&world_element_record.name, &user_encryption_key)?, + original_name: world_element_record.original_name.clone(), + description: if let Some(ref description) = world_element_record.description { Some(decrypt_data_with_user_key(description, &user_encryption_key)?) } else { None }, + last_update: world_element_record.last_update, + }); + } + } + } + + if !issue_ids.is_empty() { + for issue_id in issue_ids { + let issue_results: Vec = issue_repo::fetch_complete_issue_by_id(conn, issue_id, lang)?; + if !issue_results.is_empty() { + let issue_record = &issue_results[0]; + decrypted_issues.push(BookIssuesTable { + issue_id: issue_record.issue_id.clone(), + chapter_id: issue_record.book_id.clone(), + user_id: issue_record.author_id.clone(), + name: decrypt_data_with_user_key(&issue_record.name, &user_encryption_key)?, + hashed_name: issue_record.hashed_issue_name.clone(), + description: None, + issue_order: 0, + last_update: issue_record.last_update, + }); + } + } + } + + if sync_compare_data.guide_line { + let guideline_results: Vec = guideline_repo::fetch_book_guide_line_table(conn, user_id, &sync_compare_data.id, lang)?; + if !guideline_results.is_empty() { + let guideline_record = &guideline_results[0]; + decrypted_guide_lines.push(BookGuideLineTable { + user_id: guideline_record.user_id.clone(), + book_id: guideline_record.book_id.clone(), + tone: if let Some(ref tone) = guideline_record.tone { Some(decrypt_data_with_user_key(tone, &user_encryption_key)?) } else { None }, + atmosphere: if let Some(ref atmosphere) = guideline_record.atmosphere { Some(decrypt_data_with_user_key(atmosphere, &user_encryption_key)?) } else { None }, + writing_style: if let Some(ref writing_style) = guideline_record.writing_style { Some(decrypt_data_with_user_key(writing_style, &user_encryption_key)?) } else { None }, + themes: if let Some(ref themes) = guideline_record.themes { Some(decrypt_data_with_user_key(themes, &user_encryption_key)?) } else { None }, + symbolism: if let Some(ref symbolism) = guideline_record.symbolism { Some(decrypt_data_with_user_key(symbolism, &user_encryption_key)?) } else { None }, + motifs: if let Some(ref motifs) = guideline_record.motifs { Some(decrypt_data_with_user_key(motifs, &user_encryption_key)?) } else { None }, + narrative_voice: if let Some(ref narrative_voice) = guideline_record.narrative_voice { Some(decrypt_data_with_user_key(narrative_voice, &user_encryption_key)?) } else { None }, + pacing: if let Some(ref pacing) = guideline_record.pacing { Some(decrypt_data_with_user_key(pacing, &user_encryption_key)?) } else { None }, + intended_audience: if let Some(ref intended_audience) = guideline_record.intended_audience { Some(decrypt_data_with_user_key(intended_audience, &user_encryption_key)?) } else { None }, + key_messages: if let Some(ref key_messages) = guideline_record.key_messages { Some(decrypt_data_with_user_key(key_messages, &user_encryption_key)?) } else { None }, + last_update: guideline_record.last_update, + }); + } + } + + if sync_compare_data.ai_guide_line { + let ai_guideline_results: Vec = guideline_repo::fetch_book_ai_guide_line(conn, user_id, &sync_compare_data.id, lang)?; + if !ai_guideline_results.is_empty() { + let ai_guideline_record = &ai_guideline_results[0]; + decrypted_ai_guide_lines.push(BookAIGuideLineTable { + user_id: ai_guideline_record.user_id.clone(), + book_id: ai_guideline_record.book_id.clone(), + global_resume: if let Some(ref global_resume) = ai_guideline_record.global_resume { Some(decrypt_data_with_user_key(global_resume, &user_encryption_key)?) } else { None }, + themes: if let Some(ref themes) = ai_guideline_record.themes { Some(decrypt_data_with_user_key(themes, &user_encryption_key)?) } else { None }, + verbe_tense: ai_guideline_record.verbe_tense, + narrative_type: ai_guideline_record.narrative_type, + langue: ai_guideline_record.langue, + dialogue_type: ai_guideline_record.dialogue_type, + tone: if let Some(ref tone) = ai_guideline_record.tone { Some(decrypt_data_with_user_key(tone, &user_encryption_key)?) } else { None }, + atmosphere: if let Some(ref atmosphere) = ai_guideline_record.atmosphere { Some(decrypt_data_with_user_key(atmosphere, &user_encryption_key)?) } else { None }, + current_resume: if let Some(ref current_resume) = ai_guideline_record.current_resume { Some(decrypt_data_with_user_key(current_resume, &user_encryption_key)?) } else { None }, + last_update: ai_guideline_record.last_update, + }); + } + } + + if !spell_tag_ids.is_empty() { + for spell_tag_id in spell_tag_ids { + let spell_tag_record: Option = spell_tag_repo::fetch_spell_tag_table_by_id(conn, user_id, spell_tag_id, lang)?; + if let Some(spell_tag_record) = spell_tag_record { + decrypted_spell_tags.push(BookSpellTagsTable { + tag_id: spell_tag_record.tag_id, + book_id: spell_tag_record.book_id, + user_id: spell_tag_record.user_id, + name: decrypt_data_with_user_key(&spell_tag_record.name, &user_encryption_key)?, + hashed_name: spell_tag_record.name_hash, + color: spell_tag_record.color, + last_update: spell_tag_record.last_update, + }); + } + } + } + + if !spell_ids.is_empty() { + for spell_id in spell_ids { + let spell_record: Option = spell_repo::fetch_spell_table_by_id(conn, user_id, spell_id, lang)?; + if let Some(spell_record) = spell_record { + decrypted_spells.push(BookSpellsTable { + spell_id: spell_record.spell_id, + book_id: spell_record.book_id, + user_id: spell_record.user_id, + name: decrypt_data_with_user_key(&spell_record.name, &user_encryption_key)?, + name_hash: spell_record.name_hash, + description: if let Some(ref description) = spell_record.description { decrypt_data_with_user_key(description, &user_encryption_key).ok().unwrap_or_default() } else { String::new() }, + appearance: if let Some(ref appearance) = spell_record.appearance { decrypt_data_with_user_key(appearance, &user_encryption_key).ok().unwrap_or_default() } else { String::new() }, + tags: if let Some(ref tags) = spell_record.tags { decrypt_data_with_user_key(tags, &user_encryption_key).ok().unwrap_or_default() } else { String::new() }, + power_level: if let Some(ref power_level) = spell_record.power_level { Some(decrypt_data_with_user_key(power_level, &user_encryption_key)?) } else { None }, + components: if let Some(ref components) = spell_record.components { Some(decrypt_data_with_user_key(components, &user_encryption_key)?) } else { None }, + limitations: if let Some(ref limitations) = spell_record.limitations { Some(decrypt_data_with_user_key(limitations, &user_encryption_key)?) } else { None }, + notes: if let Some(ref notes) = spell_record.notes { Some(decrypt_data_with_user_key(notes, &user_encryption_key)?) } else { None }, + last_update: spell_record.last_update, + }); + } + } + } + + let book_results: Vec = book_repo::fetch_complete_book_by_id(conn, &sync_compare_data.id, lang)?; + if !book_results.is_empty() { + let book_record = &book_results[0]; + decrypted_books.push(book_repo::EritBooksTable { + book_id: book_record.book_id.clone(), + book_type: book_record.book_type.clone(), + author_id: book_record.author_id.clone(), + title: decrypt_data_with_user_key(&book_record.title, &user_encryption_key)?, + hashed_title: book_record.hashed_title.clone(), + sub_title: if let Some(ref sub_title) = book_record.sub_title { Some(decrypt_data_with_user_key(sub_title, &user_encryption_key)?) } else { None }, + hashed_sub_title: book_record.hashed_sub_title.clone(), + summary: if let Some(ref summary) = book_record.summary { Some(decrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None }, + serie_id: book_record.serie_id, + desired_release_date: book_record.desired_release_date.clone(), + desired_word_count: book_record.desired_word_count, + words_count: book_record.words_count, + cover_image: if let Some(ref cover_image) = book_record.cover_image { Some(decrypt_data_with_user_key(cover_image, &user_encryption_key)?) } else { None }, + last_update: book_record.last_update, + }); + } + + let book_tools_result: Option = book_repo::fetch_book_tools(conn, user_id, &sync_compare_data.id, lang)?; + let book_tools: Vec = if let Some(book_tools_row) = book_tools_result { vec![book_tools_row] } else { vec![] }; + + Ok(CompleteBook { + erit_books: decrypted_books, + act_summaries: decrypted_act_summaries, + ai_guide_line: decrypted_ai_guide_lines, + chapters: decrypted_chapters, + chapter_contents: decrypted_chapter_contents, + chapter_infos: decrypted_chapter_infos, + characters: decrypted_characters, + character_attributes: decrypted_character_attributes, + guide_line: decrypted_guide_lines, + incidents: decrypted_incidents, + issues: decrypted_issues, + locations: decrypted_locations, + plot_points: decrypted_plot_points, + worlds: decrypted_worlds, + world_elements: decrypted_world_elements, + location_elements: decrypted_location_elements, + location_sub_elements: decrypted_location_sub_elements, + book_tools, + spells: decrypted_spells, + spell_tags: decrypted_spell_tags, + }) +} + +/// Synchronizes a complete book from the server to the local client database. +/// Encrypts all data before storing and handles both insert and update operations. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `complete_book` - The complete book data received from the server +/// * `lang` - The language for error messages +/// Returns true if sync was successful, false otherwise. +pub fn sync_book_from_server_to_client(conn: &Connection, user_id: &str, complete_book: &CompleteBook, lang: Lang) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + let server_act_summaries: &Vec = &complete_book.act_summaries; + let server_chapters: &Vec = &complete_book.chapters; + let server_plot_points: &Vec = &complete_book.plot_points; + let server_incidents: &Vec = &complete_book.incidents; + let server_chapter_contents: &Vec = &complete_book.chapter_contents; + let server_chapter_infos: &Vec = &complete_book.chapter_infos; + let server_characters: &Vec = &complete_book.characters; + let server_character_attributes: &Vec = &complete_book.character_attributes; + let server_locations: &Vec = &complete_book.locations; + let server_location_elements: &Vec = &complete_book.location_elements; + let server_location_sub_elements: &Vec = &complete_book.location_sub_elements; + let server_worlds: &Vec = &complete_book.worlds; + let server_world_elements: &Vec = &complete_book.world_elements; + let server_issues: &Vec = &complete_book.issues; + let server_guide_lines: &Vec = &complete_book.guide_line; + let server_ai_guide_lines: &Vec = &complete_book.ai_guide_line; + + let book_id: String = if !complete_book.erit_books.is_empty() { complete_book.erit_books[0].book_id.clone() } else { String::new() }; + + if !server_chapters.is_empty() { + for server_chapter in server_chapters { + let chapter_exists: bool = chapter_repo::is_chapter_exist(conn, user_id, &server_chapter.chapter_id, lang); + let encrypted_title: String = encrypt_data_with_user_key(&server_chapter.title, &user_encryption_key)?; + if chapter_exists { + let update_successful: bool = chapter_repo::update_chapter(conn, user_id, &server_chapter.chapter_id, &encrypted_title, &server_chapter.hashed_title, server_chapter.chapter_order, server_chapter.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = chapter_repo::insert_sync_chapter(conn, &server_chapter.chapter_id, &server_chapter.book_id, user_id, &encrypted_title, Some(&server_chapter.hashed_title), Some(0), Some(server_chapter.chapter_order), server_chapter.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_act_summaries.is_empty() { + for server_act_summary in server_act_summaries { + let act_summary_exists: bool = act_repo::act_summarize_exist(conn, user_id, &book_id, server_act_summary.act_number, lang)?; + let encrypted_summary: String = encrypt_data_with_user_key(if server_act_summary.summary.is_empty() { "" } else { &server_act_summary.summary }, &user_encryption_key)?; + if act_summary_exists { + let update_successful: bool = act_repo::update_act_summary(conn, user_id, &book_id, server_act_summary.act_number, &encrypted_summary, server_act_summary.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let _insert_result: String = act_repo::insert_act_summary(conn, &server_act_summary.summary_id, user_id, &book_id, server_act_summary.act_number, &encrypted_summary, server_act_summary.last_update, lang)?; + } + } + } + + if !server_plot_points.is_empty() { + for server_plot_point in server_plot_points { + let encrypted_title: String = encrypt_data_with_user_key(&server_plot_point.name, &user_encryption_key)?; + let encrypted_summary: String = encrypt_data_with_user_key(if let Some(ref description) = server_plot_point.description { description } else { "" }, &user_encryption_key)?; + let plot_point_exists: bool = plotpoint_repo::plot_point_exist(conn, user_id, &book_id, &server_plot_point.plot_point_id, lang)?; + if plot_point_exists { + let update_successful: bool = plotpoint_repo::update_plot_point(conn, user_id, &book_id, &server_plot_point.plot_point_id, &encrypted_title, &server_plot_point.hashed_name, &encrypted_summary, server_plot_point.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + if server_plot_point.chapter_id.is_empty() { return Ok(false); } + let insert_successful: bool = plotpoint_repo::insert_sync_plot_point(conn, &server_plot_point.plot_point_id, &encrypted_title, &server_plot_point.hashed_name, Some(&encrypted_summary), Some(&server_plot_point.chapter_id), user_id, &book_id, server_plot_point.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_incidents.is_empty() { + for server_incident in server_incidents { + let encrypted_title: String = encrypt_data_with_user_key(&server_incident.name, &user_encryption_key)?; + let encrypted_summary: String = encrypt_data_with_user_key(if let Some(ref description) = server_incident.description { description } else { "" }, &user_encryption_key)?; + let incident_exists: bool = incident_repo::incident_exist(conn, user_id, &book_id, &server_incident.incident_id, lang)?; + if incident_exists { + let update_successful: bool = incident_repo::update_incident(conn, user_id, &book_id, &server_incident.incident_id, &encrypted_title, &server_incident.hashed_name, &encrypted_summary, server_incident.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = incident_repo::insert_sync_incident(conn, &server_incident.incident_id, user_id, &book_id, &encrypted_title, &server_incident.hashed_name, Some(&encrypted_summary), server_incident.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_chapter_contents.is_empty() { + for server_chapter_content in server_chapter_contents { + let chapter_content_exists: bool = chapter_content_repo::is_chapter_content_exist(conn, user_id, &server_chapter_content.content_id, lang)?; + let encrypted_content: String = encrypt_data_with_user_key(if let Some(ref content) = server_chapter_content.content { content } else { "" }, &user_encryption_key)?; + if chapter_content_exists { + let update_successful: bool = chapter_content_repo::update_chapter_content(conn, user_id, &server_chapter_content.chapter_id, server_chapter_content.version, &encrypted_content, 0, server_chapter_content.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = chapter_content_repo::insert_sync_chapter_content(conn, &server_chapter_content.content_id, &server_chapter_content.chapter_id, user_id, server_chapter_content.version, Some(&encrypted_content), 0, 0, server_chapter_content.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_chapter_infos.is_empty() { + for server_chapter_info in server_chapter_infos { + let chapter_info_exists: bool = chapter_repo::is_chapter_info_exist(conn, user_id, &server_chapter_info.chapter_id, lang); + let encrypted_summary: String = encrypt_data_with_user_key(if let Some(ref summary) = server_chapter_info.summary { summary } else { "" }, &user_encryption_key)?; + let encrypted_goal: String = encrypt_data_with_user_key(if let Some(ref notes) = server_chapter_info.notes { notes } else { "" }, &user_encryption_key)?; + if chapter_info_exists { + let update_successful: bool = chapter_repo::update_chapter_infos(conn, user_id, &server_chapter_info.chapter_id, 0, &book_id, None, None, &encrypted_summary, Some(&encrypted_goal), server_chapter_info.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = chapter_repo::insert_sync_chapter_info(conn, "", &server_chapter_info.chapter_id, Some(0), None, None, &book_id, user_id, Some(&encrypted_summary), Some(&encrypted_goal), server_chapter_info.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_characters.is_empty() { + for server_character in server_characters { + let character_exists: bool = character_repo::is_character_exist(conn, user_id, &server_character.character_id, lang)?; + let character_data = character_repo::SyncCharacterData { + first_name: encrypt_data_with_user_key(&server_character.first_name, &user_encryption_key)?, + last_name: Some(encrypt_data_with_user_key(if let Some(ref last_name) = server_character.last_name { last_name } else { "" }, &user_encryption_key)?), + nickname: Some(encrypt_data_with_user_key(if let Some(ref nickname) = server_character.nickname { nickname } else { "" }, &user_encryption_key)?), + age: Some(encrypt_data_with_user_key(&server_character.age.map_or(String::new(), |age| age.to_string()), &user_encryption_key)?), + gender: Some(encrypt_data_with_user_key(if let Some(ref gender) = server_character.gender { gender } else { "" }, &user_encryption_key)?), + species: Some(encrypt_data_with_user_key(if let Some(ref species) = server_character.species { species } else { "" }, &user_encryption_key)?), + nationality: Some(encrypt_data_with_user_key(if let Some(ref nationality) = server_character.nationality { nationality } else { "" }, &user_encryption_key)?), + status: Some(encrypt_data_with_user_key(if let Some(ref status) = server_character.status { status } else { "alive" }, &user_encryption_key)?), + category: encrypt_data_with_user_key(&server_character.category, &user_encryption_key)?, + title: Some(encrypt_data_with_user_key(if let Some(ref title) = server_character.title { title } else { "" }, &user_encryption_key)?), + image: Some(encrypt_data_with_user_key(if let Some(ref image) = server_character.image { image } else { "" }, &user_encryption_key)?), + role: Some(encrypt_data_with_user_key(if let Some(ref role) = server_character.role { role } else { "" }, &user_encryption_key)?), + biography: Some(encrypt_data_with_user_key(if let Some(ref biography) = server_character.biography { biography } else { "" }, &user_encryption_key)?), + history: Some(encrypt_data_with_user_key(if let Some(ref history) = server_character.history { history } else { "" }, &user_encryption_key)?), + speech_pattern: Some(encrypt_data_with_user_key(if let Some(ref speech_pattern) = server_character.speech_pattern { speech_pattern } else { "" }, &user_encryption_key)?), + catchphrase: Some(encrypt_data_with_user_key(if let Some(ref catchphrase) = server_character.catchphrase { catchphrase } else { "" }, &user_encryption_key)?), + residence: Some(encrypt_data_with_user_key(if let Some(ref residence) = server_character.residence { residence } else { "" }, &user_encryption_key)?), + notes: Some(encrypt_data_with_user_key(if let Some(ref notes) = server_character.notes { notes } else { "" }, &user_encryption_key)?), + color: Some(encrypt_data_with_user_key(if let Some(ref color) = server_character.color { color } else { "" }, &user_encryption_key)?), + }; + if character_exists { + let update_successful: bool = character_repo::update_character(conn, user_id, &server_character.character_id, &character_repo::CharacterData { first_name: character_data.first_name, last_name: character_data.last_name, nickname: character_data.nickname, age: character_data.age, gender: character_data.gender, species: character_data.species, nationality: character_data.nationality, status: character_data.status, title: character_data.title, category: Some(character_data.category), image: character_data.image, role: character_data.role, biography: character_data.biography, history: character_data.history, speech_pattern: character_data.speech_pattern, catchphrase: character_data.catchphrase, residence: character_data.residence, notes: character_data.notes, color: character_data.color }, server_character.last_update, lang, None)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = character_repo::insert_sync_character(conn, &server_character.character_id, &book_id, user_id, &character_data, server_character.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_character_attributes.is_empty() { + for server_character_attribute in server_character_attributes { + let character_attribute_exists: bool = character_repo::is_character_attribute_exist(conn, user_id, &server_character_attribute.attr_id, lang)?; + let encrypted_attribute_name: String = encrypt_data_with_user_key(&server_character_attribute.attribute_name, &user_encryption_key)?; + let encrypted_attribute_value: String = encrypt_data_with_user_key(&server_character_attribute.attribute_value, &user_encryption_key)?; + if character_attribute_exists { + let update_successful: bool = character_repo::update_character_attribute(conn, user_id, &server_character_attribute.attr_id, &encrypted_attribute_name, &encrypted_attribute_value, server_character_attribute.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = character_repo::insert_sync_character_attribute(conn, &server_character_attribute.attr_id, &server_character_attribute.character_id, user_id, &encrypted_attribute_name, &encrypted_attribute_value, server_character_attribute.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_locations.is_empty() { + for server_location in server_locations { + let location_exists: bool = location_repo::is_location_exist(conn, user_id, &server_location.loc_id, lang)?; + let encrypted_location_name: String = encrypt_data_with_user_key(&server_location.loc_name, &user_encryption_key)?; + if location_exists { + let update_successful: bool = location_repo::update_location_section(conn, user_id, &server_location.loc_id, &encrypted_location_name, &server_location.loc_original_name, server_location.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = location_repo::insert_sync_location(conn, &server_location.loc_id, &book_id, user_id, &encrypted_location_name, &server_location.loc_original_name, server_location.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_location_elements.is_empty() { + for server_location_element in server_location_elements { + let location_element_exists: bool = location_repo::is_location_element_exist(conn, user_id, &server_location_element.element_id, lang)?; + let encrypted_element_name: String = encrypt_data_with_user_key(&server_location_element.element_name, &user_encryption_key)?; + let encrypted_element_description: String = encrypt_data_with_user_key(if let Some(ref element_description) = server_location_element.element_description { element_description } else { "" }, &user_encryption_key)?; + if location_element_exists { + let update_successful: bool = location_repo::update_location_element(conn, user_id, &server_location_element.element_id, &encrypted_element_name, &server_location_element.original_name, &encrypted_element_description, server_location_element.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = location_repo::insert_sync_location_element(conn, &server_location_element.element_id, &server_location_element.location_id, user_id, &encrypted_element_name, &server_location_element.original_name, Some(&encrypted_element_description), server_location_element.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_location_sub_elements.is_empty() { + for server_location_sub_element in server_location_sub_elements { + let location_sub_element_exists: bool = location_repo::is_location_sub_element_exist(conn, user_id, &server_location_sub_element.sub_element_id, lang)?; + let encrypted_sub_element_name: String = encrypt_data_with_user_key(&server_location_sub_element.sub_elem_name, &user_encryption_key)?; + let encrypted_sub_element_description: String = encrypt_data_with_user_key(if let Some(ref sub_elem_description) = server_location_sub_element.sub_elem_description { sub_elem_description } else { "" }, &user_encryption_key)?; + if location_sub_element_exists { + let update_successful: bool = location_repo::update_location_sub_element(conn, user_id, &server_location_sub_element.sub_element_id, &encrypted_sub_element_name, &server_location_sub_element.original_name, &encrypted_sub_element_description, server_location_sub_element.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = location_repo::insert_sync_location_sub_element(conn, &server_location_sub_element.sub_element_id, &server_location_sub_element.element_id, user_id, &encrypted_sub_element_name, &server_location_sub_element.original_name, Some(&encrypted_sub_element_description), server_location_sub_element.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_worlds.is_empty() { + for server_world in server_worlds { + let world_exists: bool = world_repo::world_exist(conn, user_id, &book_id, &server_world.world_id, lang)?; + let encrypted_name: String = encrypt_data_with_user_key(&server_world.name, &user_encryption_key)?; + let encrypted_history: String = encrypt_data_with_user_key(if let Some(ref history) = server_world.history { history } else { "" }, &user_encryption_key)?; + let encrypted_politics: String = encrypt_data_with_user_key(if let Some(ref politics) = server_world.politics { politics } else { "" }, &user_encryption_key)?; + let encrypted_economy: String = encrypt_data_with_user_key(if let Some(ref economy) = server_world.economy { economy } else { "" }, &user_encryption_key)?; + let encrypted_religion: String = encrypt_data_with_user_key(if let Some(ref religion) = server_world.religion { religion } else { "" }, &user_encryption_key)?; + let encrypted_languages: String = encrypt_data_with_user_key(if let Some(ref languages) = server_world.languages { languages } else { "" }, &user_encryption_key)?; + if world_exists { + let update_successful: bool = world_repo::update_world(conn, user_id, &server_world.world_id, &encrypted_name, &server_world.hashed_name, &encrypted_history, &encrypted_politics, &encrypted_economy, &encrypted_religion, &encrypted_languages, server_world.last_update, lang, None)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = world_repo::insert_sync_world(conn, &server_world.world_id, &encrypted_name, &server_world.hashed_name, user_id, &book_id, Some(&encrypted_history), Some(&encrypted_politics), Some(&encrypted_economy), Some(&encrypted_religion), Some(&encrypted_languages), server_world.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_world_elements.is_empty() { + for server_world_element in server_world_elements { + let world_element_exists: bool = world_repo::world_element_exist(conn, user_id, &server_world_element.world_id, &server_world_element.element_id, lang)?; + let encrypted_name: String = encrypt_data_with_user_key(&server_world_element.name, &user_encryption_key)?; + let encrypted_description: String = encrypt_data_with_user_key(if let Some(ref description) = server_world_element.description { description } else { "" }, &user_encryption_key)?; + if world_element_exists { + let update_successful: bool = world_repo::update_world_element(conn, user_id, &server_world_element.element_id, &encrypted_name, &encrypted_description, server_world_element.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = world_repo::insert_sync_world_element(conn, &server_world_element.element_id, &server_world_element.world_id, user_id, server_world_element.element_type, &encrypted_name, &server_world_element.original_name, Some(&encrypted_description), server_world_element.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_issues.is_empty() { + for server_issue in server_issues { + let issue_exists: bool = issue_repo::issue_exist(conn, user_id, &book_id, &server_issue.issue_id, lang)?; + let encrypted_name: String = encrypt_data_with_user_key(&server_issue.name, &user_encryption_key)?; + if issue_exists { + let update_successful: bool = issue_repo::update_issue(conn, user_id, &book_id, &server_issue.issue_id, &encrypted_name, &server_issue.hashed_name, server_issue.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = issue_repo::insert_sync_issue(conn, &server_issue.issue_id, user_id, &book_id, &encrypted_name, &server_issue.hashed_name, server_issue.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_guide_lines.is_empty() { + for server_guide_line in server_guide_lines { + let guide_line_exists: bool = guideline_repo::guide_line_exist(conn, user_id, &book_id, lang)?; + let encrypted_tone: Option = if let Some(ref tone) = server_guide_line.tone { Some(encrypt_data_with_user_key(tone, &user_encryption_key)?) } else { None }; + let encrypted_atmosphere: Option = if let Some(ref atmosphere) = server_guide_line.atmosphere { Some(encrypt_data_with_user_key(atmosphere, &user_encryption_key)?) } else { None }; + let encrypted_writing_style: Option = if let Some(ref writing_style) = server_guide_line.writing_style { Some(encrypt_data_with_user_key(writing_style, &user_encryption_key)?) } else { None }; + let encrypted_themes: Option = if let Some(ref themes) = server_guide_line.themes { Some(encrypt_data_with_user_key(themes, &user_encryption_key)?) } else { None }; + let encrypted_symbolism: Option = if let Some(ref symbolism) = server_guide_line.symbolism { Some(encrypt_data_with_user_key(symbolism, &user_encryption_key)?) } else { None }; + let encrypted_motifs: Option = if let Some(ref motifs) = server_guide_line.motifs { Some(encrypt_data_with_user_key(motifs, &user_encryption_key)?) } else { None }; + let encrypted_narrative_voice: Option = if let Some(ref narrative_voice) = server_guide_line.narrative_voice { Some(encrypt_data_with_user_key(narrative_voice, &user_encryption_key)?) } else { None }; + let encrypted_pacing: Option = if let Some(ref pacing) = server_guide_line.pacing { Some(encrypt_data_with_user_key(pacing, &user_encryption_key)?) } else { None }; + let encrypted_key_messages: Option = if let Some(ref key_messages) = server_guide_line.key_messages { Some(encrypt_data_with_user_key(key_messages, &user_encryption_key)?) } else { None }; + let encrypted_intended_audience: Option = if let Some(ref intended_audience) = server_guide_line.intended_audience { Some(encrypt_data_with_user_key(intended_audience, &user_encryption_key)?) } else { None }; + if guide_line_exists { + let update_successful: bool = guideline_repo::update_guide_line(conn, user_id, &book_id, encrypted_tone.as_deref(), encrypted_atmosphere.as_deref(), encrypted_writing_style.as_deref(), encrypted_themes.as_deref(), encrypted_symbolism.as_deref(), encrypted_motifs.as_deref(), encrypted_narrative_voice.as_deref(), encrypted_pacing.as_deref(), encrypted_key_messages.as_deref(), encrypted_intended_audience.as_deref(), server_guide_line.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = guideline_repo::insert_sync_guide_line(conn, user_id, &book_id, encrypted_tone.as_deref(), encrypted_atmosphere.as_deref(), encrypted_writing_style.as_deref(), encrypted_themes.as_deref(), encrypted_symbolism.as_deref(), encrypted_motifs.as_deref(), encrypted_narrative_voice.as_deref(), encrypted_pacing.as_deref(), encrypted_intended_audience.as_deref(), encrypted_key_messages.as_deref(), server_guide_line.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !server_ai_guide_lines.is_empty() { + for server_ai_guide_line in server_ai_guide_lines { + let ai_guide_line_exists: bool = guideline_repo::ai_guide_line_exist(conn, user_id, &book_id, lang)?; + let encrypted_global_resume: Option = if let Some(ref global_resume) = server_ai_guide_line.global_resume { Some(encrypt_data_with_user_key(global_resume, &user_encryption_key)?) } else { None }; + let encrypted_themes: Option = if let Some(ref themes) = server_ai_guide_line.themes { Some(encrypt_data_with_user_key(themes, &user_encryption_key)?) } else { None }; + let encrypted_tone: Option = if let Some(ref tone) = server_ai_guide_line.tone { Some(encrypt_data_with_user_key(tone, &user_encryption_key)?) } else { None }; + let encrypted_atmosphere: Option = if let Some(ref atmosphere) = server_ai_guide_line.atmosphere { Some(encrypt_data_with_user_key(atmosphere, &user_encryption_key)?) } else { None }; + let encrypted_current_resume: Option = if let Some(ref current_resume) = server_ai_guide_line.current_resume { Some(encrypt_data_with_user_key(current_resume, &user_encryption_key)?) } else { None }; + if ai_guide_line_exists { + let update_successful: bool = guideline_repo::insert_ai_guide_line(conn, user_id, &book_id, server_ai_guide_line.narrative_type, server_ai_guide_line.dialogue_type, encrypted_global_resume.as_deref(), encrypted_atmosphere.as_deref(), server_ai_guide_line.verbe_tense, server_ai_guide_line.langue, encrypted_themes.as_deref(), server_ai_guide_line.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = guideline_repo::insert_sync_ai_guide_line(conn, user_id, &book_id, encrypted_global_resume.as_deref(), encrypted_themes.as_deref(), server_ai_guide_line.verbe_tense, server_ai_guide_line.narrative_type, server_ai_guide_line.langue, server_ai_guide_line.dialogue_type, encrypted_tone.as_deref(), encrypted_atmosphere.as_deref(), encrypted_current_resume.as_deref(), server_ai_guide_line.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !complete_book.book_tools.is_empty() { + for server_book_tool in &complete_book.book_tools { + let success: bool = book_repo::insert_sync_book_tools(conn, &book_id, user_id, server_book_tool.characters_enabled, server_book_tool.worlds_enabled, server_book_tool.locations_enabled, server_book_tool.spells_enabled, server_book_tool.last_update, lang); + if !success { return Ok(false); } + } + } + + if !complete_book.spell_tags.is_empty() { + for server_spell_tag in &complete_book.spell_tags { + let spell_tag_exists: bool = spell_tag_repo::is_spell_tag_exist(conn, user_id, &server_spell_tag.tag_id, lang)?; + let encrypted_name: String = encrypt_data_with_user_key(&server_spell_tag.name, &user_encryption_key)?; + if spell_tag_exists { + let update_successful: bool = spell_tag_repo::update_sync_spell_tag(conn, user_id, &server_spell_tag.tag_id, &encrypted_name, &server_spell_tag.hashed_name, server_spell_tag.color.as_deref(), server_spell_tag.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = spell_tag_repo::insert_sync_spell_tag(conn, &server_spell_tag.tag_id, &server_spell_tag.book_id, user_id, &encrypted_name, &server_spell_tag.hashed_name, server_spell_tag.color.as_deref(), server_spell_tag.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + if !complete_book.spells.is_empty() { + for server_spell in &complete_book.spells { + let spell_exists: bool = spell_repo::is_spell_exist(conn, user_id, &server_spell.spell_id, lang)?; + let encrypted_name: String = encrypt_data_with_user_key(&server_spell.name, &user_encryption_key)?; + let encrypted_description: Option = if server_spell.description.is_empty() { None } else { Some(encrypt_data_with_user_key(&server_spell.description, &user_encryption_key)?) }; + let encrypted_appearance: Option = if server_spell.appearance.is_empty() { None } else { Some(encrypt_data_with_user_key(&server_spell.appearance, &user_encryption_key)?) }; + let encrypted_tags: Option = if server_spell.tags.is_empty() { None } else { Some(encrypt_data_with_user_key(&server_spell.tags, &user_encryption_key)?) }; + let encrypted_power_level: Option = if let Some(ref power_level) = server_spell.power_level { Some(encrypt_data_with_user_key(power_level, &user_encryption_key)?) } else { None }; + let encrypted_components: Option = if let Some(ref components) = server_spell.components { Some(encrypt_data_with_user_key(components, &user_encryption_key)?) } else { None }; + let encrypted_limitations: Option = if let Some(ref limitations) = server_spell.limitations { Some(encrypt_data_with_user_key(limitations, &user_encryption_key)?) } else { None }; + let encrypted_notes: Option = if let Some(ref notes) = server_spell.notes { Some(encrypt_data_with_user_key(notes, &user_encryption_key)?) } else { None }; + if spell_exists { + let update_successful: bool = spell_repo::update_sync_spell(conn, user_id, &server_spell.spell_id, &encrypted_name, &server_spell.name_hash, encrypted_description.as_deref(), encrypted_appearance.as_deref(), encrypted_tags.as_deref(), encrypted_power_level.as_deref(), encrypted_components.as_deref(), encrypted_limitations.as_deref(), encrypted_notes.as_deref(), server_spell.last_update, lang)?; + if !update_successful { return Ok(false); } + } else { + let insert_successful: bool = spell_repo::insert_sync_spell(conn, &server_spell.spell_id, &server_spell.book_id, user_id, &encrypted_name, &server_spell.name_hash, encrypted_description.as_deref(), encrypted_appearance.as_deref(), encrypted_tags.as_deref(), encrypted_power_level.as_deref(), encrypted_components.as_deref(), encrypted_limitations.as_deref(), encrypted_notes.as_deref(), server_spell.last_update, lang)?; + if !insert_successful { return Ok(false); } + } + } + } + + Ok(true) +} + +/// Retrieves all synced books for a user with their complete hierarchical data structure. +/// Fetches all related entities (chapters, characters, locations, etc.) and organizes them by book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages +/// Returns an array of SyncedBookFull objects with decrypted data. +pub fn get_synced_books(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + let all_books: Vec = book_repo::fetch_synced_books(conn, user_id, lang)?; + let all_chapters: Vec = chapter_repo::fetch_synced_chapters(conn, user_id, lang)?; + let all_chapter_contents: Vec = chapter_content_repo::fetch_synced_chapter_contents(conn, user_id, lang)?; + let all_chapter_infos: Vec = chapter_repo::fetch_synced_chapter_infos(conn, user_id, lang)?; + let all_characters: Vec = character_repo::fetch_synced_characters(conn, user_id, lang)?; + let all_character_attributes: Vec = character_repo::fetch_synced_character_attributes(conn, user_id, lang)?; + let all_locations: Vec = location_repo::fetch_synced_locations(conn, user_id, lang)?; + let all_location_elements: Vec = location_repo::fetch_synced_location_elements(conn, user_id, lang)?; + let all_location_sub_elements: Vec = location_repo::fetch_synced_location_sub_elements(conn, user_id, lang)?; + let all_worlds: Vec = world_repo::fetch_synced_worlds(conn, user_id, lang)?; + let all_world_elements: Vec = world_repo::fetch_synced_world_elements(conn, user_id, lang)?; + let all_incidents: Vec = incident_repo::fetch_synced_incidents(conn, user_id, lang)?; + let all_plot_points: Vec = plotpoint_repo::fetch_synced_plot_points(conn, user_id, lang)?; + let all_issues: Vec = issue_repo::fetch_synced_issues(conn, user_id, lang)?; + let all_act_summaries: Vec = act_repo::fetch_synced_act_summaries(conn, user_id, lang)?; + let all_guidelines: Vec = guideline_repo::fetch_synced_guide_line(conn, user_id, lang)?; + let all_ai_guidelines: Vec = guideline_repo::fetch_synced_ai_guide_line(conn, user_id, lang)?; + let all_spells: Vec = spell_repo::fetch_synced_spells(conn, user_id, lang)?; + let all_spell_tags: Vec = spell_tag_repo::fetch_synced_spell_tags(conn, user_id, lang)?; + + let mut synced_books: Vec = Vec::with_capacity(all_books.len()); + + for book_record in &all_books { + let current_book_id: &str = &book_record.book_id; + + let book_chapters: Vec = all_chapters.iter() + .filter(|chapter_record| chapter_record.book_id == current_book_id) + .map(|chapter_record| { + let current_chapter_id: &str = &chapter_record.chapter_id; + + let chapter_contents: Vec = all_chapter_contents.iter() + .filter(|content_record| content_record.chapter_id == current_chapter_id) + .map(|content_record| SyncedChapterContent { id: content_record.content_id.clone(), last_update: content_record.last_update }) + .collect(); + + let chapter_info: Option = all_chapter_infos.iter() + .find(|info_record| info_record.chapter_id.as_deref() == Some(current_chapter_id)) + .map(|info_record| SyncedChapterInfo { id: info_record.chapter_info_id.clone(), last_update: info_record.last_update }); + + SyncedChapterFull { + id: current_chapter_id.to_string(), + name: decrypt_data_with_user_key(&chapter_record.title, &user_encryption_key).unwrap_or_default(), + last_update: chapter_record.last_update, + contents: chapter_contents, + info: chapter_info, + } + }) + .collect(); + + let book_characters: Vec = all_characters.iter() + .filter(|character_record| character_record.book_id == current_book_id) + .map(|character_record| { + let current_character_id: &str = &character_record.character_id; + + let character_attributes: Vec = all_character_attributes.iter() + .filter(|attribute_record| attribute_record.character_id == current_character_id) + .map(|attribute_record| SyncedCharacterAttribute { + id: attribute_record.attr_id.clone(), + name: decrypt_data_with_user_key(&attribute_record.attribute_name, &user_encryption_key).unwrap_or_default(), + last_update: attribute_record.last_update, + }) + .collect(); + + SyncedCharacterFull { + id: current_character_id.to_string(), + name: decrypt_data_with_user_key(&character_record.first_name, &user_encryption_key).unwrap_or_default(), + last_update: character_record.last_update, + attributes: character_attributes, + } + }) + .collect(); + + let book_locations: Vec = all_locations.iter() + .filter(|location_record| location_record.book_id == current_book_id) + .map(|location_record| { + let current_location_id: &str = &location_record.loc_id; + + let location_elements: Vec = all_location_elements.iter() + .filter(|element_record| element_record.location == current_location_id) + .map(|element_record| { + let current_element_id: &str = &element_record.element_id; + + let location_sub_elements: Vec = all_location_sub_elements.iter() + .filter(|sub_element_record| sub_element_record.element_id == current_element_id) + .map(|sub_element_record| SyncedLocationSubElement { + id: sub_element_record.sub_element_id.clone(), + name: decrypt_data_with_user_key(&sub_element_record.sub_elem_name, &user_encryption_key).unwrap_or_default(), + last_update: sub_element_record.last_update, + }) + .collect(); + + SyncedLocationElement { + id: current_element_id.to_string(), + name: decrypt_data_with_user_key(&element_record.element_name, &user_encryption_key).unwrap_or_default(), + last_update: element_record.last_update, + sub_elements: location_sub_elements, + } + }) + .collect(); + + SyncedLocationFull { + id: current_location_id.to_string(), + name: decrypt_data_with_user_key(&location_record.loc_name, &user_encryption_key).unwrap_or_default(), + last_update: location_record.last_update, + elements: location_elements, + } + }) + .collect(); + + let book_worlds: Vec = all_worlds.iter() + .filter(|world_record| world_record.book_id == current_book_id) + .map(|world_record| { + let current_world_id: &str = &world_record.world_id; + + let world_elements: Vec = all_world_elements.iter() + .filter(|world_element_record| world_element_record.world_id == current_world_id) + .map(|world_element_record| SyncedWorldElement { + id: world_element_record.element_id.clone(), + name: decrypt_data_with_user_key(&world_element_record.name, &user_encryption_key).unwrap_or_default(), + last_update: world_element_record.last_update, + }) + .collect(); + + SyncedWorldFull { + id: current_world_id.to_string(), + name: decrypt_data_with_user_key(&world_record.name, &user_encryption_key).unwrap_or_default(), + last_update: world_record.last_update, + elements: world_elements, + } + }) + .collect(); + + let book_incidents: Vec = all_incidents.iter() + .filter(|incident_record| incident_record.book_id == current_book_id) + .map(|incident_record| SyncedIncident { id: incident_record.incident_id.clone(), name: decrypt_data_with_user_key(&incident_record.title, &user_encryption_key).unwrap_or_default(), last_update: incident_record.last_update }) + .collect(); + + let book_plot_points: Vec = all_plot_points.iter() + .filter(|plot_point_record| plot_point_record.book_id == current_book_id) + .map(|plot_point_record| SyncedPlotPoint { id: plot_point_record.plot_point_id.clone(), name: decrypt_data_with_user_key(&plot_point_record.title, &user_encryption_key).unwrap_or_default(), last_update: plot_point_record.last_update }) + .collect(); + + let book_issues: Vec = all_issues.iter() + .filter(|issue_record| issue_record.book_id == current_book_id) + .map(|issue_record| SyncedIssue { id: issue_record.issue_id.clone(), name: decrypt_data_with_user_key(&issue_record.name, &user_encryption_key).unwrap_or_default(), last_update: issue_record.last_update }) + .collect(); + + let book_act_summaries: Vec = all_act_summaries.iter() + .filter(|act_summary_record| act_summary_record.book_id == current_book_id) + .map(|act_summary_record| SyncedActSummary { id: act_summary_record.act_sum_id.clone(), last_update: act_summary_record.last_update }) + .collect(); + + let book_guide_line: Option = all_guidelines.iter() + .find(|guideline_item| guideline_item.book_id == current_book_id) + .map(|guideline_record| SyncedGuideLine { last_update: guideline_record.last_update }); + + let book_ai_guide_line: Option = all_ai_guidelines.iter() + .find(|ai_guideline_item| ai_guideline_item.book_id == current_book_id) + .map(|ai_guideline_record| SyncedAIGuideLine { last_update: ai_guideline_record.last_update }); + + let book_tools_query: AppResult> = book_repo::fetch_synced_book_tools(conn, user_id, current_book_id, lang); + let book_tools: Option = book_tools_query.ok().flatten().map(|book_tools_row| SyncedBookTools { + last_update: book_tools_row.last_update, + characters_enabled: book_tools_row.characters_enabled == 1, + worlds_enabled: book_tools_row.worlds_enabled == 1, + locations_enabled: book_tools_row.locations_enabled == 1, + spells_enabled: book_tools_row.spells_enabled == 1, + }); + + let book_spells: Vec = all_spells.iter() + .filter(|spell_record| spell_record.book_id == current_book_id) + .map(|spell_record| SyncedSpell { id: spell_record.spell_id.clone(), name: decrypt_data_with_user_key(&spell_record.name, &user_encryption_key).unwrap_or_default(), last_update: spell_record.last_update }) + .collect(); + + let book_spell_tags: Vec = all_spell_tags.iter() + .filter(|spell_tag_record| spell_tag_record.book_id == current_book_id) + .map(|spell_tag_record| SyncedSpellTag { id: spell_tag_record.tag_id.clone(), name: decrypt_data_with_user_key(&spell_tag_record.name, &user_encryption_key).unwrap_or_default(), last_update: spell_tag_record.last_update }) + .collect(); + + synced_books.push(SyncedBookFull { + id: current_book_id.to_string(), + book_type: book_record.book_type.clone(), + title: decrypt_data_with_user_key(&book_record.title, &user_encryption_key).unwrap_or_default(), + sub_title: if let Some(ref sub_title) = book_record.sub_title { decrypt_data_with_user_key(sub_title, &user_encryption_key).ok() } else { None }, + last_update: book_record.last_update, + chapters: book_chapters, + characters: book_characters, + locations: book_locations, + worlds: book_worlds, + incidents: book_incidents, + plot_points: book_plot_points, + issues: book_issues, + act_summaries: book_act_summaries, + guide_line: book_guide_line, + ai_guide_line: book_ai_guide_line, + book_tools, + spells: book_spells, + spell_tags: book_spell_tags, + }); + } + + Ok(synced_books) +} + +/// Retrieves all series for the current user with lightweight structure for sync comparison. +/// Returns synced series with nested characters, worlds, locations, spells, and spell tags. +/// All encrypted fields are decrypted before return. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages +/// Returns an array of synced series with all nested entities. +pub fn get_synced_series(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + let all_series: Vec = series_repo::fetch_synced_series(conn, user_id, lang)?; + let all_series_books: Vec = series_repo::fetch_synced_series_books(conn, user_id, lang)?; + let all_characters: Vec = series_character_repo::fetch_synced_series_characters(conn, user_id, lang)?; + let all_character_attributes: Vec = series_character_repo::fetch_synced_series_character_attributes(conn, user_id, lang)?; + let all_worlds: Vec = series_world_repo::fetch_synced_series_worlds(conn, user_id, lang)?; + let all_world_elements: Vec = series_world_repo::fetch_synced_series_world_elements(conn, user_id, lang)?; + let all_locations: Vec = series_location_repo::fetch_synced_series_locations(conn, user_id, lang)?; + let all_location_elements: Vec = series_location_repo::fetch_synced_series_location_elements(conn, user_id, lang)?; + let all_location_sub_elements: Vec = series_location_repo::fetch_synced_series_location_sub_elements(conn, user_id, lang)?; + let all_spells: Vec = series_spell_repo::fetch_synced_series_spells(conn, user_id, lang)?; + let all_spell_tags: Vec = series_spell_repo::fetch_synced_series_spell_tags(conn, user_id, lang)?; + + let mut synced_series_list: Vec = Vec::with_capacity(all_series.len()); + + for series in &all_series { + let series_id: &str = &series.series_id; + + let books: Vec = all_series_books.iter() + .filter(|sb| sb.series_id == series_id) + .map(|sb| SyncedSeriesBook { book_id: sb.book_id.clone(), order: sb.book_order, last_update: sb.last_update }) + .collect(); + + let characters: Vec = all_characters.iter() + .filter(|c| c.series_id == series_id) + .map(|c| SyncedSeriesCharacter { + id: c.character_id.clone(), + name: decrypt_data_with_user_key(&c.first_name, &user_encryption_key).unwrap_or_default(), + last_update: c.last_update, + attributes: all_character_attributes.iter() + .filter(|a| a.character_id == c.character_id) + .map(|a| SyncedSeriesCharacterAttribute { + id: a.attr_id.clone(), + name: decrypt_data_with_user_key(&a.attribute_name, &user_encryption_key).unwrap_or_default(), + last_update: a.last_update, + }) + .collect(), + }) + .collect(); + + let worlds: Vec = all_worlds.iter() + .filter(|w| w.series_id == series_id) + .map(|w| SyncedSeriesWorld { + id: w.world_id.clone(), + name: decrypt_data_with_user_key(&w.name, &user_encryption_key).unwrap_or_default(), + last_update: w.last_update, + elements: all_world_elements.iter() + .filter(|e| e.world_id == w.world_id) + .map(|e| SyncedSeriesWorldElement { + id: e.element_id.clone(), + name: decrypt_data_with_user_key(&e.name, &user_encryption_key).unwrap_or_default(), + last_update: e.last_update, + }) + .collect(), + }) + .collect(); + + let locations: Vec = all_locations.iter() + .filter(|l| l.series_id == series_id) + .map(|l| SyncedSeriesLocation { + id: l.loc_id.clone(), + name: decrypt_data_with_user_key(&l.loc_name, &user_encryption_key).unwrap_or_default(), + last_update: l.last_update, + elements: all_location_elements.iter() + .filter(|e| e.location_id == l.loc_id) + .map(|e| SyncedSeriesLocationElement { + id: e.element_id.clone(), + name: decrypt_data_with_user_key(&e.element_name, &user_encryption_key).unwrap_or_default(), + last_update: e.last_update, + sub_elements: all_location_sub_elements.iter() + .filter(|se| se.element_id == e.element_id) + .map(|se| SyncedSeriesLocationSubElement { + id: se.sub_element_id.clone(), + name: decrypt_data_with_user_key(&se.sub_elem_name, &user_encryption_key).unwrap_or_default(), + last_update: se.last_update, + }) + .collect(), + }) + .collect(), + }) + .collect(); + + let spells: Vec = all_spells.iter() + .filter(|s| s.series_id == series_id) + .map(|s| SyncedSeriesSpell { id: s.spell_id.clone(), name: decrypt_data_with_user_key(&s.name, &user_encryption_key).unwrap_or_default(), last_update: s.last_update }) + .collect(); + + let spell_tags: Vec = all_spell_tags.iter() + .filter(|t| t.series_id == series_id) + .map(|t| SyncedSeriesSpellTag { id: t.tag_id.clone(), name: decrypt_data_with_user_key(&t.name, &user_encryption_key).unwrap_or_default(), last_update: t.last_update }) + .collect(); + + synced_series_list.push(SyncedSeries { + id: series_id.to_string(), + name: decrypt_data_with_user_key(&series.name, &user_encryption_key).unwrap_or_default(), + description: if let Some(ref description) = series.description { decrypt_data_with_user_key(description, &user_encryption_key).ok() } else { None }, + last_update: series.last_update, + books, + characters, + worlds, + locations, + spells, + spell_tags, + }); + } + + Ok(synced_series_list) +} diff --git a/src-tauri/src/domains/tombstone/commands.rs b/src-tauri/src/domains/tombstone/commands.rs new file mode 100644 index 0000000..fbb70cf --- /dev/null +++ b/src-tauri/src/domains/tombstone/commands.rs @@ -0,0 +1,120 @@ +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::db::connection::DbManager; +use crate::domains::tombstone::repo; +use crate::domains::book::service as book_service; +use crate::domains::chapter::service as chapter_service; +use crate::domains::character::service as character_service; +use crate::domains::location::service as location_service; +use crate::domains::world::service as world_service; +use crate::domains::incident::service as incident_service; +use crate::domains::plotpoint::service as plotpoint_service; +use crate::domains::issue::service as issue_service; +use crate::domains::spell::service as spell_service; +use crate::domains::series::service as series_service; +use crate::domains::series_character::service as series_character_service; +use crate::domains::series_location::service as series_location_service; +use crate::domains::series_world::service as series_world_service; +use crate::domains::series_spell::service as series_spell_service; +use crate::error::AppError; +use crate::shared::session::SessionState; +use crate::shared::types::Lang; + +fn get_session(session: &State) -> Result<(String, Lang), AppError> { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string(); + let lang = session_guard.lang; + Ok((user_id, lang)) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TombstoneRecord { + pub table_name: String, + pub entity_id: String, + pub book_id: Option, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn get_tombstones_since(since: i64, db: State, session: State) -> Result, AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + let records = repo::get_deletions_since(conn, &user_id, since, lang)?; + Ok(records.into_iter().map(|record| TombstoneRecord { + table_name: record.table_name, + entity_id: record.entity_id, + book_id: record.book_id, + deleted_at: record.deleted_at, + }).collect()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TombstoneInput { + pub table_name: String, + pub entity_id: String, + pub book_id: Option, + pub deleted_at: i64, +} + +#[tauri::command] +pub fn apply_book_tombstones(tombstones: Vec, db: State, session: State) -> Result<(), AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + + for tombstone in &tombstones { + let book_id = tombstone.book_id.as_deref().unwrap_or(""); + match tombstone.table_name.as_str() { + "erit_books" => { let _ = book_service::remove_book(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "book_chapters" => { let _ = chapter_service::remove_chapter(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "book_chapter_infos" => { let _ = chapter_service::remove_chapter_information(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "book_characters" => { let _ = character_service::delete_character(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "book_characters_attributes" => { let _ = character_service::delete_attribute(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "book_location" => { let _ = location_service::delete_location_section(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "location_element" => { let _ = location_service::delete_location_element(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "location_sub_element" => { let _ = location_service::delete_location_sub_element(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "book_world_elements" => { let _ = world_service::remove_element_from_world(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "book_incidents" => { let _ = incident_service::remove_incident(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "book_plot_points" => { let _ = plotpoint_service::remove_plot_point(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "book_issues" => { let _ = issue_service::remove_issue(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "book_spells" => { let _ = spell_service::delete_spell(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "book_spell_tags" => { let _ = spell_service::delete_spell_tag(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + _ => {} + } + } + + Ok(()) +} + +#[tauri::command] +pub fn apply_series_tombstones(tombstones: Vec, db: State, session: State) -> Result<(), AppError> { + let (user_id, lang) = get_session(&session)?; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(&user_id)?; + + for tombstone in &tombstones { + match tombstone.table_name.as_str() { + "erit_series" => { let _ = series_service::delete_series(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "series_books" => { + if let Some(ref book_id) = tombstone.book_id { + let _ = series_service::remove_book_from_series(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); + } + } + "series_characters" => { let _ = series_character_service::delete_character(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "series_characters_attributes" => { let _ = series_character_service::delete_attribute(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "series_locations" => { let _ = series_location_service::delete_location(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "series_location_elements" => { let _ = series_location_service::delete_element(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "series_location_sub_elements" => { let _ = series_location_service::delete_sub_element(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "series_world_elements" => { let _ = series_world_service::delete_element(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "series_spells" => { let _ = series_spell_service::delete_spell(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + "series_spell_tags" => { let _ = series_spell_service::delete_tag(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); } + _ => {} + } + } + + Ok(()) +} diff --git a/src-tauri/src/domains/tombstone/mod.rs b/src-tauri/src/domains/tombstone/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/tombstone/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/tombstone/repo.rs b/src-tauri/src/domains/tombstone/repo.rs new file mode 100644 index 0000000..592cef1 --- /dev/null +++ b/src-tauri/src/domains/tombstone/repo.rs @@ -0,0 +1,125 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct RemovedItemRecord { + pub removal_id: String, + pub table_name: String, + pub entity_id: String, + pub book_id: Option, + pub user_id: String, + pub deleted_at: i64, +} + +/// Inserts a removal record into the database. +/// * `conn` - Database connection +/// * `removal_id` - The unique ID for this removal record +/// * `table_name` - The name of the table from which the item is deleted +/// * `entity_id` - The UUID of the deleted entity +/// * `book_id` - Book ID (None for series items) +/// * `user_id` - The user ID who owns the item +/// * `deleted_at` - Timestamp of deletion +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if inserted successfully. +pub fn insert( + conn: &Connection, removal_id: &str, table_name: &str, entity_id: &str, + book_id: Option<&str>, user_id: &str, deleted_at: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute( + "INSERT INTO removed_items (removal_id, table_name, entity_id, book_id, user_id, deleted_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(table_name, entity_id) DO UPDATE SET deleted_at = excluded.deleted_at", + params![removal_id, table_name, entity_id, book_id, user_id, deleted_at], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'enregistrer la suppression.".to_string() } else { "Unable to record deletion.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Retrieves deletions since a specific timestamp. +/// Used to get deletions that occurred since last sync. +/// * `conn` - Database connection +/// * `user_id` - The user ID +/// * `since` - Timestamp to get deletions after +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns array of removed item records. +pub fn get_deletions_since(conn: &Connection, user_id: &str, since: i64, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT removal_id, table_name, entity_id, book_id, user_id, deleted_at FROM removed_items WHERE user_id = ?1 AND deleted_at > ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les suppressions.".to_string() } else { "Unable to retrieve deletions.".to_string() }))?; + + let records = statement + .query_map(params![user_id, since], |query_row| { + Ok(RemovedItemRecord { + removal_id: query_row.get(0)?, table_name: query_row.get(1)?, + entity_id: query_row.get(2)?, book_id: query_row.get(3)?, + user_id: query_row.get(4)?, deleted_at: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les suppressions.".to_string() } else { "Unable to retrieve deletions.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les suppressions.".to_string() } else { "Unable to retrieve deletions.".to_string() }))?; + + Ok(records) +} + +/// Checks if an entity was previously deleted. +/// * `conn` - Database connection +/// * `table_name` - The table name +/// * `entity_id` - The entity ID +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the entity was deleted locally. +pub fn was_deleted(conn: &Connection, table_name: &str, entity_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM removed_items WHERE table_name = ?1 AND entity_id = ?2 LIMIT 1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de vérifier si l'élément a été supprimé.".to_string() } else { "Unable to check if item was deleted.".to_string() }))?; + + let exists = statement + .query_row(params![table_name, entity_id], |_query_row| Ok(true)) + .unwrap_or(false); + + Ok(exists) +} + +/// Retrieves all tracked deletions for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The user ID +/// * `book_id` - The book ID +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns array of removed item records for that book. +pub fn get_deletions_for_book(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT removal_id, table_name, entity_id, book_id, user_id, deleted_at FROM removed_items WHERE user_id = ?1 AND book_id = ?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les suppressions pour ce livre.".to_string() } else { "Unable to retrieve deletions for this book.".to_string() }))?; + + let records = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(RemovedItemRecord { + removal_id: query_row.get(0)?, table_name: query_row.get(1)?, + entity_id: query_row.get(2)?, book_id: query_row.get(3)?, + user_id: query_row.get(4)?, deleted_at: query_row.get(5)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les suppressions pour ce livre.".to_string() } else { "Unable to retrieve deletions for this book.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les suppressions pour ce livre.".to_string() } else { "Unable to retrieve deletions for this book.".to_string() }))?; + + Ok(records) +} + +/// Clears all deletion records for a user. +/// WARNING: Only use this when wiping user data completely. +/// * `conn` - Database connection +/// * `user_id` - The user ID +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if cleared successfully. +pub fn clear_all_for_user(conn: &Connection, user_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute( + "DELETE FROM removed_items WHERE user_id = ?1", + params![user_id], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer les enregistrements de suppression.".to_string() } else { "Unable to clear deletion records.".to_string() }))?; + + Ok(delete_result > 0) +} diff --git a/src-tauri/src/domains/tombstone/service.rs b/src-tauri/src/domains/tombstone/service.rs new file mode 100644 index 0000000..ed600ff --- /dev/null +++ b/src-tauri/src/domains/tombstone/service.rs @@ -0,0 +1,25 @@ +use rusqlite::Connection; + +use crate::domains::tombstone::repo; +use crate::error::AppResult; +use crate::helpers::create_unique_id; +use crate::shared::types::Lang; + +/// Records a deleted item for sync tracking. +/// Must be called BEFORE the actual deletion from the source table. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The book ID (None for series items) +/// * `table_name` - The name of the table from which the item is deleted +/// * `entity_id` - The UUID of the deleted entity +/// * `deleted_at` - The timestamp of deletion (from UI via timestamp_in_seconds()) +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the record was inserted successfully. +pub fn delete_tracker( + conn: &Connection, user_id: &str, book_id: Option<&str>, table_name: &str, + entity_id: &str, deleted_at: i64, lang: Lang, +) -> AppResult { + let removal_id: String = create_unique_id(None); + + repo::insert(conn, &removal_id, table_name, entity_id, book_id, user_id, deleted_at, lang) +} diff --git a/src-tauri/src/domains/upload/mod.rs b/src-tauri/src/domains/upload/mod.rs new file mode 100644 index 0000000..50aab12 --- /dev/null +++ b/src-tauri/src/domains/upload/mod.rs @@ -0,0 +1 @@ +pub mod service; diff --git a/src-tauri/src/domains/upload/service.rs b/src-tauri/src/domains/upload/service.rs new file mode 100644 index 0000000..62b1214 --- /dev/null +++ b/src-tauri/src/domains/upload/service.rs @@ -0,0 +1,371 @@ +use rusqlite::Connection; + +use crate::crypto::encryption::decrypt_data_with_user_key; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::act::repo as act_repo; +use crate::domains::book::repo as book_repo; +use crate::domains::book::service::{ + BookActSummariesTable, BookAIGuideLineTable, BookChapterContentTable, BookChapterInfosTable, + BookChaptersTable, BookCharactersAttributesTable, BookCharactersTable, BookGuideLineTable, + BookIncidentsTable, BookIssuesTable, BookLocationTable, BookPlotPointsTable, BookSpellsTable, + BookSpellTagsTable, BookWorldElementsTable, BookWorldTable, CompleteBook, + LocationElementTable, LocationSubElementTable, +}; +use crate::domains::chapter::repo as chapter_repo; +use crate::domains::chapter_content::repo as chapter_content_repo; +use crate::domains::character::repo as character_repo; +use crate::domains::guideline::repo as guideline_repo; +use crate::domains::incident::repo as incident_repo; +use crate::domains::issue::repo as issue_repo; +use crate::domains::location::repo as location_repo; +use crate::domains::plotpoint::repo as plotpoint_repo; +use crate::domains::spell::repo as spell_repo; +use crate::domains::spell_tag::repo as spell_tag_repo; +use crate::domains::world::repo as world_repo; +use crate::error::AppResult; +use crate::shared::types::Lang; + +/// Prepares a complete book with all related data for synchronization upload. +/// Fetches all book-related tables from the database, decrypts encrypted fields +/// using the user's encryption key, and returns a complete book object ready for sync. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user who owns the book +/// * `book_id` - The unique identifier of the book to upload +/// * `lang` - The language code for localization +/// Returns a CompleteBook object containing all decrypted book data. +pub fn upload_book_for_sync(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + let encrypted_books: Vec = book_repo::fetch_erit_books_table(conn, user_id, book_id, lang)?; + let encrypted_act_summaries: Vec = act_repo::fetch_book_act_summaries(conn, user_id, book_id, lang)?; + let encrypted_ai_guidelines: Vec = guideline_repo::fetch_book_ai_guide_line(conn, user_id, book_id, lang)?; + let encrypted_chapters: Vec = chapter_repo::fetch_book_chapters(conn, user_id, book_id, lang)?; + let encrypted_characters: Vec = character_repo::fetch_book_characters(conn, user_id, book_id, lang)?; + let encrypted_guidelines: Vec = guideline_repo::fetch_book_guide_line_table(conn, user_id, book_id, lang)?; + let encrypted_incidents: Vec = incident_repo::fetch_book_incidents(conn, user_id, book_id, lang)?; + let encrypted_issues: Vec = issue_repo::fetch_book_issues(conn, user_id, book_id, lang)?; + let encrypted_locations: Vec = location_repo::fetch_book_locations(conn, user_id, book_id, lang)?; + let encrypted_plot_points: Vec = plotpoint_repo::fetch_book_plot_points(conn, user_id, book_id, lang)?; + let encrypted_worlds: Vec = world_repo::fetch_book_worlds(conn, user_id, book_id, lang)?; + let book_tools_data: Option = book_repo::fetch_book_tools(conn, user_id, book_id, lang)?; + let encrypted_spells: Vec = spell_repo::fetch_book_spells_table(conn, user_id, book_id, lang)?; + let encrypted_spell_tags: Vec = spell_tag_repo::fetch_book_spell_tags_table(conn, user_id, book_id, lang)?; + + let mut nested_chapter_contents: Vec> = Vec::with_capacity(encrypted_chapters.len()); + let mut nested_chapter_infos: Vec> = Vec::with_capacity(encrypted_chapters.len()); + for chapter in &encrypted_chapters { + nested_chapter_contents.push(chapter_content_repo::fetch_book_chapter_contents(conn, user_id, &chapter.chapter_id, lang)?); + nested_chapter_infos.push(chapter_repo::fetch_book_chapter_infos(conn, user_id, &chapter.chapter_id, lang)?); + } + + let mut nested_character_attributes: Vec> = Vec::with_capacity(encrypted_characters.len()); + for character in &encrypted_characters { + nested_character_attributes.push(character_repo::fetch_book_characters_attributes(conn, user_id, &character.character_id, lang)?); + } + + let mut nested_world_elements: Vec> = Vec::with_capacity(encrypted_worlds.len()); + for world in &encrypted_worlds { + nested_world_elements.push(world_repo::fetch_book_world_elements(conn, user_id, &world.world_id, lang)?); + } + + let mut nested_location_elements: Vec> = Vec::with_capacity(encrypted_locations.len()); + for location in &encrypted_locations { + nested_location_elements.push(location_repo::fetch_location_elements(conn, user_id, &location.loc_id, lang)?); + } + + let encrypted_chapter_contents: Vec = nested_chapter_contents.into_iter().flatten().collect(); + let encrypted_chapter_infos: Vec = nested_chapter_infos.into_iter().flatten().collect(); + let encrypted_character_attributes: Vec = nested_character_attributes.into_iter().flatten().collect(); + let encrypted_world_elements: Vec = nested_world_elements.into_iter().flatten().collect(); + let encrypted_location_elements: Vec = nested_location_elements.into_iter().flatten().collect(); + + let mut nested_location_sub_elements: Vec> = Vec::with_capacity(encrypted_location_elements.len()); + for element in &encrypted_location_elements { + nested_location_sub_elements.push(location_repo::fetch_location_sub_elements(conn, user_id, &element.element_id, lang)?); + } + let encrypted_location_sub_elements: Vec = nested_location_sub_elements.into_iter().flatten().collect(); + + let mut erit_books: Vec = Vec::with_capacity(encrypted_books.len()); + for book in encrypted_books { + let decrypted_title: String = decrypt_data_with_user_key(&book.title, &user_encryption_key)?; + let decrypted_sub_title: Option = if let Some(ref sub_title) = book.sub_title { Some(decrypt_data_with_user_key(sub_title, &user_encryption_key)?) } else { None }; + let decrypted_summary: Option = if let Some(ref summary) = book.summary { Some(decrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None }; + let decrypted_cover_image: Option = if let Some(ref cover_image) = book.cover_image { Some(decrypt_data_with_user_key(cover_image, &user_encryption_key)?) } else { None }; + erit_books.push(book_repo::EritBooksTable { + title: decrypted_title, sub_title: decrypted_sub_title, + summary: decrypted_summary, cover_image: decrypted_cover_image, + ..book + }); + } + + let mut act_summaries: Vec = Vec::with_capacity(encrypted_act_summaries.len()); + for act_summary in encrypted_act_summaries { + let decrypted_summary: String = if let Some(ref summary) = act_summary.summary { decrypt_data_with_user_key(summary, &user_encryption_key)? } else { String::new() }; + act_summaries.push(BookActSummariesTable { + summary_id: act_summary.act_sum_id, book_id: act_summary.book_id, + user_id: act_summary.user_id, act_number: act_summary.act_index, + summary: decrypted_summary, last_update: act_summary.last_update, + }); + } + + let mut ai_guide_line: Vec = Vec::with_capacity(encrypted_ai_guidelines.len()); + for guide_line in encrypted_ai_guidelines { + let decrypted_global_resume: Option = if let Some(ref global_resume) = guide_line.global_resume { Some(decrypt_data_with_user_key(global_resume, &user_encryption_key)?) } else { None }; + let decrypted_themes: Option = if let Some(ref themes) = guide_line.themes { Some(decrypt_data_with_user_key(themes, &user_encryption_key)?) } else { None }; + let decrypted_tone: Option = if let Some(ref tone) = guide_line.tone { Some(decrypt_data_with_user_key(tone, &user_encryption_key)?) } else { None }; + let decrypted_atmosphere: Option = if let Some(ref atmosphere) = guide_line.atmosphere { Some(decrypt_data_with_user_key(atmosphere, &user_encryption_key)?) } else { None }; + let decrypted_current_resume: Option = if let Some(ref current_resume) = guide_line.current_resume { Some(decrypt_data_with_user_key(current_resume, &user_encryption_key)?) } else { None }; + ai_guide_line.push(BookAIGuideLineTable { + user_id: guide_line.user_id, book_id: guide_line.book_id, + global_resume: decrypted_global_resume, themes: decrypted_themes, + verbe_tense: guide_line.verbe_tense, narrative_type: guide_line.narrative_type, + langue: guide_line.langue, dialogue_type: guide_line.dialogue_type, + tone: decrypted_tone, atmosphere: decrypted_atmosphere, + current_resume: decrypted_current_resume, last_update: guide_line.last_update, + }); + } + + let mut chapters: Vec = Vec::with_capacity(encrypted_chapters.len()); + for chapter in encrypted_chapters { + let decrypted_title: String = decrypt_data_with_user_key(&chapter.title, &user_encryption_key)?; + chapters.push(BookChaptersTable { + chapter_id: chapter.chapter_id, book_id: chapter.book_id, + user_id: chapter.author_id, title: decrypted_title, + hashed_title: chapter.hashed_title, chapter_order: chapter.chapter_order, + last_update: chapter.last_update, + }); + } + + let mut chapter_contents: Vec = Vec::with_capacity(encrypted_chapter_contents.len()); + for chapter_content in encrypted_chapter_contents { + let decrypted_content: Option = if let Some(ref content) = chapter_content.content { Some(decrypt_data_with_user_key(content, &user_encryption_key)?) } else { None }; + chapter_contents.push(BookChapterContentTable { + content_id: chapter_content.content_id, chapter_id: chapter_content.chapter_id, + user_id: chapter_content.author_id, content: decrypted_content, + version: chapter_content.version, last_update: chapter_content.last_update, + }); + } + + let mut chapter_infos: Vec = Vec::with_capacity(encrypted_chapter_infos.len()); + for chapter_info in encrypted_chapter_infos { + let decrypted_summary: Option = if let Some(ref summary) = chapter_info.summary { Some(decrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None }; + let decrypted_goal: Option = if let Some(ref goal) = chapter_info.goal { Some(decrypt_data_with_user_key(goal, &user_encryption_key)?) } else { None }; + chapter_infos.push(BookChapterInfosTable { + chapter_id: chapter_info.chapter_id, user_id: chapter_info.author_id, + summary: decrypted_summary, notes: decrypted_goal, + last_update: chapter_info.last_update, + }); + } + + let mut characters: Vec = Vec::with_capacity(encrypted_characters.len()); + for character in encrypted_characters { + let decrypted_first_name: String = decrypt_data_with_user_key(&character.first_name, &user_encryption_key)?; + let decrypted_last_name: Option = if let Some(ref last_name) = character.last_name { Some(decrypt_data_with_user_key(last_name, &user_encryption_key)?) } else { None }; + let decrypted_nickname: Option = if let Some(ref nickname) = character.nickname { Some(decrypt_data_with_user_key(nickname, &user_encryption_key)?) } else { None }; + let decrypted_age: Option = if let Some(ref age) = character.age { Some(decrypt_data_with_user_key(age, &user_encryption_key)?.parse().unwrap_or(0)) } else { None }; + let decrypted_gender: Option = if let Some(ref gender) = character.gender { Some(decrypt_data_with_user_key(gender, &user_encryption_key)?) } else { None }; + let decrypted_species: Option = if let Some(ref species) = character.species { Some(decrypt_data_with_user_key(species, &user_encryption_key)?) } else { None }; + let decrypted_nationality: Option = if let Some(ref nationality) = character.nationality { Some(decrypt_data_with_user_key(nationality, &user_encryption_key)?) } else { None }; + let decrypted_status: Option = if let Some(ref status) = character.status { Some(decrypt_data_with_user_key(status, &user_encryption_key)?) } else { None }; + let decrypted_category: String = decrypt_data_with_user_key(&character.category, &user_encryption_key)?; + let decrypted_title: Option = if let Some(ref title) = character.title { Some(decrypt_data_with_user_key(title, &user_encryption_key)?) } else { None }; + let decrypted_role: Option = if let Some(ref role) = character.role { Some(decrypt_data_with_user_key(role, &user_encryption_key)?) } else { None }; + let decrypted_biography: Option = if let Some(ref biography) = character.biography { Some(decrypt_data_with_user_key(biography, &user_encryption_key)?) } else { None }; + let decrypted_history: Option = if let Some(ref history) = character.history { Some(decrypt_data_with_user_key(history, &user_encryption_key)?) } else { None }; + let decrypted_speech_pattern: Option = if let Some(ref speech_pattern) = character.speech_pattern { Some(decrypt_data_with_user_key(speech_pattern, &user_encryption_key)?) } else { None }; + let decrypted_catchphrase: Option = if let Some(ref catchphrase) = character.catchphrase { Some(decrypt_data_with_user_key(catchphrase, &user_encryption_key)?) } else { None }; + let decrypted_residence: Option = if let Some(ref residence) = character.residence { Some(decrypt_data_with_user_key(residence, &user_encryption_key)?) } else { None }; + let decrypted_notes: Option = if let Some(ref notes) = character.notes { Some(decrypt_data_with_user_key(notes, &user_encryption_key)?) } else { None }; + let decrypted_color: Option = if let Some(ref color) = character.color { Some(decrypt_data_with_user_key(color, &user_encryption_key)?) } else { None }; + characters.push(BookCharactersTable { + character_id: character.character_id, book_id: character.book_id, + user_id: character.user_id, first_name: decrypted_first_name, + last_name: decrypted_last_name, nickname: decrypted_nickname, + age: decrypted_age, gender: decrypted_gender, species: decrypted_species, + nationality: decrypted_nationality, status: decrypted_status, + title: decrypted_title, category: decrypted_category, + image: character.image, role: decrypted_role, + biography: decrypted_biography, history: decrypted_history, + speech_pattern: decrypted_speech_pattern, catchphrase: decrypted_catchphrase, + residence: decrypted_residence, notes: decrypted_notes, + color: decrypted_color, last_update: character.last_update, + }); + } + + let mut character_attributes: Vec = Vec::with_capacity(encrypted_character_attributes.len()); + for attribute in encrypted_character_attributes { + let decrypted_attribute_name: String = decrypt_data_with_user_key(&attribute.attribute_name, &user_encryption_key)?; + let decrypted_attribute_value: String = decrypt_data_with_user_key(&attribute.attribute_value, &user_encryption_key)?; + character_attributes.push(BookCharactersAttributesTable { + attr_id: attribute.attr_id, character_id: attribute.character_id, + user_id: attribute.user_id, attribute_name: decrypted_attribute_name, + attribute_value: decrypted_attribute_value, last_update: attribute.last_update, + }); + } + + let mut guide_line: Vec = Vec::with_capacity(encrypted_guidelines.len()); + for guide in encrypted_guidelines { + let decrypted_tone: Option = if let Some(ref tone) = guide.tone { Some(decrypt_data_with_user_key(tone, &user_encryption_key)?) } else { None }; + let decrypted_atmosphere: Option = if let Some(ref atmosphere) = guide.atmosphere { Some(decrypt_data_with_user_key(atmosphere, &user_encryption_key)?) } else { None }; + let decrypted_writing_style: Option = if let Some(ref writing_style) = guide.writing_style { Some(decrypt_data_with_user_key(writing_style, &user_encryption_key)?) } else { None }; + let decrypted_themes: Option = if let Some(ref themes) = guide.themes { Some(decrypt_data_with_user_key(themes, &user_encryption_key)?) } else { None }; + let decrypted_symbolism: Option = if let Some(ref symbolism) = guide.symbolism { Some(decrypt_data_with_user_key(symbolism, &user_encryption_key)?) } else { None }; + let decrypted_motifs: Option = if let Some(ref motifs) = guide.motifs { Some(decrypt_data_with_user_key(motifs, &user_encryption_key)?) } else { None }; + let decrypted_narrative_voice: Option = if let Some(ref narrative_voice) = guide.narrative_voice { Some(decrypt_data_with_user_key(narrative_voice, &user_encryption_key)?) } else { None }; + let decrypted_pacing: Option = if let Some(ref pacing) = guide.pacing { Some(decrypt_data_with_user_key(pacing, &user_encryption_key)?) } else { None }; + let decrypted_intended_audience: Option = if let Some(ref intended_audience) = guide.intended_audience { Some(decrypt_data_with_user_key(intended_audience, &user_encryption_key)?) } else { None }; + let decrypted_key_messages: Option = if let Some(ref key_messages) = guide.key_messages { Some(decrypt_data_with_user_key(key_messages, &user_encryption_key)?) } else { None }; + guide_line.push(BookGuideLineTable { + user_id: guide.user_id, book_id: guide.book_id, + tone: decrypted_tone, atmosphere: decrypted_atmosphere, + writing_style: decrypted_writing_style, themes: decrypted_themes, + symbolism: decrypted_symbolism, motifs: decrypted_motifs, + narrative_voice: decrypted_narrative_voice, pacing: decrypted_pacing, + intended_audience: decrypted_intended_audience, key_messages: decrypted_key_messages, + last_update: guide.last_update, + }); + } + + let mut incidents: Vec = Vec::with_capacity(encrypted_incidents.len()); + for incident in encrypted_incidents { + let decrypted_title: String = decrypt_data_with_user_key(&incident.title, &user_encryption_key)?; + let decrypted_summary: Option = if let Some(ref summary) = incident.summary { Some(decrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None }; + incidents.push(BookIncidentsTable { + incident_id: incident.incident_id, chapter_id: String::new(), + user_id: incident.author_id, name: decrypted_title, + hashed_name: incident.hashed_title, description: decrypted_summary, + incident_order: 0, last_update: incident.last_update, + }); + } + + let mut issues: Vec = Vec::with_capacity(encrypted_issues.len()); + for issue in encrypted_issues { + let decrypted_name: String = decrypt_data_with_user_key(&issue.name, &user_encryption_key)?; + issues.push(BookIssuesTable { + issue_id: issue.issue_id, chapter_id: String::new(), + user_id: issue.author_id, name: decrypted_name, + hashed_name: issue.hashed_issue_name, description: None, + issue_order: 0, last_update: issue.last_update, + }); + } + + let mut locations: Vec = Vec::with_capacity(encrypted_locations.len()); + for location in encrypted_locations { + let decrypted_loc_name: String = decrypt_data_with_user_key(&location.loc_name, &user_encryption_key)?; + locations.push(BookLocationTable { + loc_id: location.loc_id, book_id: location.book_id, + user_id: location.user_id, loc_name: decrypted_loc_name, + loc_original_name: location.loc_original_name, last_update: location.last_update, + }); + } + + let mut plot_points: Vec = Vec::with_capacity(encrypted_plot_points.len()); + for plot_point in encrypted_plot_points { + let decrypted_title: String = decrypt_data_with_user_key(&plot_point.title, &user_encryption_key)?; + let decrypted_summary: Option = if let Some(ref summary) = plot_point.summary { Some(decrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None }; + plot_points.push(BookPlotPointsTable { + plot_point_id: plot_point.plot_point_id, chapter_id: String::new(), + user_id: plot_point.author_id, name: decrypted_title, + hashed_name: plot_point.hashed_title, description: decrypted_summary, + plot_point_order: 0, last_update: plot_point.last_update, + }); + } + + let mut worlds: Vec = Vec::with_capacity(encrypted_worlds.len()); + for world in encrypted_worlds { + let decrypted_name: String = decrypt_data_with_user_key(&world.name, &user_encryption_key)?; + let decrypted_history: Option = if let Some(ref history) = world.history { Some(decrypt_data_with_user_key(history, &user_encryption_key)?) } else { None }; + let decrypted_politics: Option = if let Some(ref politics) = world.politics { Some(decrypt_data_with_user_key(politics, &user_encryption_key)?) } else { None }; + let decrypted_economy: Option = if let Some(ref economy) = world.economy { Some(decrypt_data_with_user_key(economy, &user_encryption_key)?) } else { None }; + let decrypted_religion: Option = if let Some(ref religion) = world.religion { Some(decrypt_data_with_user_key(religion, &user_encryption_key)?) } else { None }; + let decrypted_languages: Option = if let Some(ref languages) = world.languages { Some(decrypt_data_with_user_key(languages, &user_encryption_key)?) } else { None }; + worlds.push(BookWorldTable { + world_id: world.world_id, book_id: world.book_id, + user_id: world.author_id, name: decrypted_name, + hashed_name: world.hashed_name, history: decrypted_history, + politics: decrypted_politics, economy: decrypted_economy, + religion: decrypted_religion, languages: decrypted_languages, + last_update: world.last_update, + }); + } + + let mut world_elements: Vec = Vec::with_capacity(encrypted_world_elements.len()); + for world_element in encrypted_world_elements { + let decrypted_name: String = decrypt_data_with_user_key(&world_element.name, &user_encryption_key)?; + let decrypted_description: Option = if let Some(ref description) = world_element.description { Some(decrypt_data_with_user_key(description, &user_encryption_key)?) } else { None }; + world_elements.push(BookWorldElementsTable { + element_id: world_element.element_id, world_id: world_element.world_id, + user_id: world_element.user_id, element_type: world_element.element_type, + name: decrypted_name, original_name: world_element.original_name, + description: decrypted_description, last_update: world_element.last_update, + }); + } + + let mut location_elements: Vec = Vec::with_capacity(encrypted_location_elements.len()); + for location_element in encrypted_location_elements { + let decrypted_element_name: String = decrypt_data_with_user_key(&location_element.element_name, &user_encryption_key)?; + let decrypted_element_description: Option = if let Some(ref element_description) = location_element.element_description { Some(decrypt_data_with_user_key(element_description, &user_encryption_key)?) } else { None }; + location_elements.push(LocationElementTable { + element_id: location_element.element_id, location_id: location_element.location, + user_id: location_element.user_id, element_name: decrypted_element_name, + original_name: location_element.original_name, + element_description: decrypted_element_description, + last_update: location_element.last_update, + }); + } + + let mut location_sub_elements: Vec = Vec::with_capacity(encrypted_location_sub_elements.len()); + for location_sub_element in encrypted_location_sub_elements { + let decrypted_sub_elem_name: String = decrypt_data_with_user_key(&location_sub_element.sub_elem_name, &user_encryption_key)?; + let decrypted_sub_elem_description: Option = if let Some(ref sub_elem_description) = location_sub_element.sub_elem_description { Some(decrypt_data_with_user_key(sub_elem_description, &user_encryption_key)?) } else { None }; + location_sub_elements.push(LocationSubElementTable { + sub_element_id: location_sub_element.sub_element_id, + element_id: location_sub_element.element_id, + user_id: location_sub_element.user_id, sub_elem_name: decrypted_sub_elem_name, + original_name: location_sub_element.original_name, + sub_elem_description: decrypted_sub_elem_description, + last_update: location_sub_element.last_update, + }); + } + + let book_tools: Vec = if let Some(tools_data) = book_tools_data { vec![tools_data] } else { vec![] }; + + let mut spells: Vec = Vec::with_capacity(encrypted_spells.len()); + for spell in encrypted_spells { + let decrypted_name: String = decrypt_data_with_user_key(&spell.name, &user_encryption_key)?; + let decrypted_description: String = if let Some(ref description) = spell.description { decrypt_data_with_user_key(description, &user_encryption_key)? } else { String::new() }; + let decrypted_appearance: String = if let Some(ref appearance) = spell.appearance { decrypt_data_with_user_key(appearance, &user_encryption_key)? } else { String::new() }; + let decrypted_tags: String = if let Some(ref tags) = spell.tags { decrypt_data_with_user_key(tags, &user_encryption_key)? } else { String::new() }; + let decrypted_power_level: Option = if let Some(ref power_level) = spell.power_level { Some(decrypt_data_with_user_key(power_level, &user_encryption_key)?) } else { None }; + let decrypted_components: Option = if let Some(ref components) = spell.components { Some(decrypt_data_with_user_key(components, &user_encryption_key)?) } else { None }; + let decrypted_limitations: Option = if let Some(ref limitations) = spell.limitations { Some(decrypt_data_with_user_key(limitations, &user_encryption_key)?) } else { None }; + let decrypted_notes: Option = if let Some(ref notes) = spell.notes { Some(decrypt_data_with_user_key(notes, &user_encryption_key)?) } else { None }; + spells.push(BookSpellsTable { + spell_id: spell.spell_id, book_id: spell.book_id, + user_id: spell.user_id, name: decrypted_name, + name_hash: spell.name_hash, description: decrypted_description, + appearance: decrypted_appearance, tags: decrypted_tags, + power_level: decrypted_power_level, components: decrypted_components, + limitations: decrypted_limitations, notes: decrypted_notes, + last_update: spell.last_update, + }); + } + + let mut spell_tags: Vec = Vec::with_capacity(encrypted_spell_tags.len()); + for spell_tag in encrypted_spell_tags { + let decrypted_name: String = decrypt_data_with_user_key(&spell_tag.name, &user_encryption_key)?; + spell_tags.push(BookSpellTagsTable { + tag_id: spell_tag.tag_id, book_id: spell_tag.book_id, + user_id: spell_tag.user_id, name: decrypted_name, + hashed_name: spell_tag.name_hash, color: spell_tag.color, + last_update: spell_tag.last_update, + }); + } + + Ok(CompleteBook { + erit_books, act_summaries, ai_guide_line, chapters, chapter_contents, + chapter_infos, characters, character_attributes, guide_line, incidents, + issues, locations, plot_points, worlds, world_elements, location_elements, + location_sub_elements, book_tools, spells, spell_tags, + }) +} diff --git a/src-tauri/src/domains/user/commands.rs b/src-tauri/src/domains/user/commands.rs new file mode 100644 index 0000000..25c8c07 --- /dev/null +++ b/src-tauri/src/domains/user/commands.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::crypto::{encryption, key_manager}; +use crate::db::connection::DbManager; +use crate::db::schema; +use crate::domains::user::service; +use crate::error::AppError; +use crate::shared::session::SessionState; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitUserData { + pub user_id: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InitUserResult { + pub success: bool, + pub error: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncUserData { + pub user_id: String, + pub first_name: String, + pub last_name: String, + pub username: String, + pub email: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserInfoResponse { + pub id: String, + pub name: String, + pub last_name: String, + pub username: String, + pub email: String, + pub account_verified: bool, + pub author_name: String, + pub group_id: i64, + pub terms_accepted: bool, +} + +#[tauri::command] +pub fn init_user(data: InitUserData, db: State, session: State) -> Result { + let mut db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + db_manager.initialize(&data.user_id)?; + let conn = db_manager.get_connection(&data.user_id)?; + schema::initialize_schema(conn)?; + if cfg!(debug_assertions) { + schema::run_dev_queries(conn); + } else { + schema::run_migrations(conn)?; + } + + let mut session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + session_guard.user_id = Some(data.user_id.clone()); + + key_manager::set_last_user_id(&data.user_id)?; + + Ok(InitUserResult { success: true, error: None }) +} + +#[tauri::command] +pub fn db_initialize(user_id: String, encryption_key: String, db: State, session: State) -> Result { + let mut db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + db_manager.initialize(&user_id)?; + let conn = db_manager.get_connection(&user_id)?; + schema::initialize_schema(conn)?; + if cfg!(debug_assertions) { + schema::run_dev_queries(conn); + } else { + schema::run_migrations(conn)?; + } + + if key_manager::get_user_encryption_key(&user_id).is_err() { + let key = if encryption_key.is_empty() { + encryption::generate_user_encryption_key(&user_id)? + } else { + encryption_key + }; + key_manager::set_user_encryption_key(&user_id, &key)?; + } + + let mut session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + session_guard.user_id = Some(user_id); + + Ok(true) +} + +#[tauri::command] +pub fn get_token() -> Result, AppError> { + key_manager::get_token() +} + +#[tauri::command] +pub fn set_token(token: String) -> Result<(), AppError> { + key_manager::set_token(&token) +} + +#[tauri::command] +pub fn remove_token(db: State, session: State) -> Result<(), AppError> { + if let Ok(session_guard) = session.lock() { + if let Ok(user_id) = session_guard.get_user_id() { + if let Ok(mut db_manager) = db.lock() { + db_manager.close(user_id); + } + } + } + key_manager::remove_token() +} + +#[tauri::command] +pub fn get_user_encryption_key(user_id: String) -> Result, AppError> { + match key_manager::get_user_encryption_key(&user_id) { + Ok(key) => Ok(Some(key)), + Err(_) => Ok(None), + } +} + +#[tauri::command] +pub fn get_platform() -> String { + std::env::consts::OS.to_string() +} + +#[tauri::command] +pub fn get_user_info(db: State, session: State) -> Result { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?; + let lang = session_guard.lang; + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + let conn = db_manager.get_connection(user_id)?; + + let user_info = service::return_user_infos(conn, user_id, lang)?; + + Ok(UserInfoResponse { + id: user_info.id, + name: user_info.name, + last_name: user_info.last_name, + username: user_info.username, + email: user_info.email, + account_verified: user_info.account_verified, + author_name: user_info.author_name, + group_id: user_info.group_id, + terms_accepted: user_info.terms_accepted, + }) +} + +#[tauri::command] +pub fn sync_user(data: SyncUserData, db: State, session: State) -> Result { + let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?; + let lang = session_guard.lang; + drop(session_guard); + + let mut db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?; + db_manager.initialize(&data.user_id)?; + let conn = db_manager.get_connection(&data.user_id)?; + + service::add_user(conn, &data.user_id, &data.first_name, &data.last_name, &data.username, &data.email, "", lang)?; + Ok(true) +} diff --git a/src-tauri/src/domains/user/mod.rs b/src-tauri/src/domains/user/mod.rs new file mode 100644 index 0000000..2ea4548 --- /dev/null +++ b/src-tauri/src/domains/user/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/user/repo.rs b/src-tauri/src/domains/user/repo.rs new file mode 100644 index 0000000..9649287 --- /dev/null +++ b/src-tauri/src/domains/user/repo.rs @@ -0,0 +1,158 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct UserInfosQueryResponse { + pub first_name: String, + pub last_name: String, + pub username: String, + pub email: String, + pub plateform: String, + pub term_accepted: i64, + pub account_verified: i64, + pub author_name: Option, + pub rite_points: i64, + pub user_group: i64, +} + +pub struct UserAccountQuery { + pub first_name: Option, + pub last_name: Option, + pub username: String, + pub author_name: Option, + pub email: String, +} + +pub struct CredentialResponse { + pub valid: bool, + pub message: Option, + pub user: Option, +} + +pub struct UserResponse { + pub id: String, + pub name: String, + pub last_name: String, + pub username: String, + pub email: String, + pub account_verified: bool, +} + +pub struct GuideTourResult { + pub step_tour: String, +} + +/// Inserts a new user into the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier for the user +/// * `first_name` - The user's first name +/// * `last_name` - The user's last name +/// * `username` - The user's username +/// * `origin_username` - The original username from the source platform +/// * `email` - The user's email address +/// * `origin_email` - The original email from the source platform +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the user's UUID if insertion was successful. +/// Errors if the user cannot be registered. +pub fn insert_user( + conn: &Connection, user_id: &str, first_name: &str, last_name: &str, username: &str, + origin_username: &str, email: &str, origin_email: &str, lang: Lang, +) -> AppResult { + let reg_date = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|duration| duration.as_millis() as i64).unwrap_or(0); + + // Try INSERT first; if user already exists, UPDATE their info instead + let insert_result = conn.execute( + "INSERT OR IGNORE INTO erit_users (user_id, first_name, last_name, username, email, origin_email, origin_username, plateform, term_accepted, account_verified, reg_date) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)", + params![user_id, first_name, last_name, username, email, origin_email, origin_username, "desktop", 0, 1, reg_date], + ).map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'enregistrer l'utilisateur.".to_string() } else { "Unable to register user.".to_string() }))?; + + if insert_result == 0 { + conn.execute( + "UPDATE erit_users SET first_name = ?1, last_name = ?2, username = ?3, email = ?4, origin_email = ?5, origin_username = ?6 WHERE user_id = ?7", + params![first_name, last_name, username, email, origin_email, origin_username, user_id], + ).map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour l'utilisateur.".to_string() } else { "Unable to update user.".to_string() }))?; + } + + Ok(user_id.to_string()) +} + +/// Fetches user information from the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user to fetch +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the user information object. +/// Errors if the user is not found or cannot be retrieved. +pub fn fetch_user_infos(conn: &Connection, user_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT first_name, last_name, username, email, plateform, term_accepted, account_verified, author_name, erite_points AS rite_points, user_group FROM erit_users WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations utilisateur.".to_string() } else { "Unable to retrieve user information.".to_string() }))?; + + let user_info = statement + .query_row(params![user_id], |query_row| { + Ok(UserInfosQueryResponse { + first_name: query_row.get(0)?, last_name: query_row.get(1)?, + username: query_row.get(2)?, email: query_row.get(3)?, + plateform: query_row.get(4)?, term_accepted: query_row.get(5)?, + account_verified: query_row.get(6)?, author_name: query_row.get(7)?, + rite_points: query_row.get(8)?, user_group: query_row.get(9)?, + }) + }) + .map_err(|error| match error { + rusqlite::Error::QueryReturnedNoRows => AppError::NotFound(if lang == Lang::Fr { "Utilisateur non trouvé.".to_string() } else { "User not found.".to_string() }), + _ => AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations utilisateur.".to_string() } else { "Unable to retrieve user information.".to_string() }), + })?; + + Ok(user_info) +} + +/// Updates user information in the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user to update +/// * `first_name` - The new first name +/// * `last_name` - The new last name +/// * `username` - The new username +/// * `origin_username` - The original username from the source platform +/// * `email` - The new email address +/// * `origin_email` - The original email from the source platform +/// * `original_author_name` - The original author name +/// * `author_name` - The new author name +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful, false otherwise. +/// Errors if the update fails. +pub fn update_user_infos( + conn: &Connection, user_id: &str, first_name: &str, last_name: &str, username: &str, + origin_username: &str, email: &str, origin_email: &str, original_author_name: &str, author_name: &str, lang: Lang, +) -> AppResult { + let update_result = conn + .execute( + "UPDATE erit_users SET first_name = ?1, last_name = ?2, username = ?3, email = ?4, origin_username = ?5, origin_author_name = ?6, author_name = ?7 WHERE user_id = ?8 AND origin_email = ?9", + params![first_name, last_name, username, email, origin_username, original_author_name, author_name, user_id, origin_email], + ) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour les informations utilisateur.".to_string() } else { "Unable to update user information.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Fetches account information for a user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the user account information object. +/// Errors if the account is not found or cannot be retrieved. +pub fn fetch_account_information(conn: &Connection, user_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT first_name, last_name, username, author_name, email FROM erit_users WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations du compte.".to_string() } else { "Unable to retrieve account information.".to_string() }))?; + + let account_info = statement + .query_row(params![user_id], |query_row| { + Ok(UserAccountQuery { first_name: query_row.get(0)?, last_name: query_row.get(1)?, username: query_row.get(2)?, author_name: query_row.get(3)?, email: query_row.get(4)? }) + }) + .map_err(|error| match error { + rusqlite::Error::QueryReturnedNoRows => AppError::NotFound(if lang == Lang::Fr { "Compte non trouvé.".to_string() } else { "Account not found.".to_string() }), + _ => AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations du compte.".to_string() } else { "Unable to retrieve account information.".to_string() }), + })?; + + Ok(account_info) +} diff --git a/src-tauri/src/domains/user/service.rs b/src-tauri/src/domains/user/service.rs new file mode 100644 index 0000000..58d91ec --- /dev/null +++ b/src-tauri/src/domains/user/service.rs @@ -0,0 +1,156 @@ +use rusqlite::Connection; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::user::repo; +use crate::error::AppResult; +use crate::shared::types::Lang; + +pub struct UserAccount { + pub first_name: String, + pub last_name: String, + pub username: String, + pub author_name: String, + pub email: String, +} + +pub struct GuideTour { + pub key: String, + pub value: bool, +} + +pub struct BookSummary { + pub book_id: String, + pub title: String, + pub sub_title: Option, +} + +pub struct UserInfoResponse { + pub id: String, + pub name: String, + pub last_name: String, + pub username: String, + pub email: String, + pub account_verified: bool, + pub author_name: String, + pub group_id: i64, + pub terms_accepted: bool, + pub guide_tour: Vec, +} + +/// Retrieves complete user information including associated books. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user to fetch +/// * `lang` - The language for error messages +/// Returns the complete user information response. +/// Errors if the user is not found or decryption fails. +pub fn return_user_infos(conn: &Connection, user_id: &str, lang: Lang) -> AppResult { + let user_infos_data: repo::UserInfosQueryResponse = repo::fetch_user_infos(conn, user_id, lang)?; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + let first_name: String = decrypt_data_with_user_key(&user_infos_data.first_name, &user_encryption_key)?; + let last_name: String = decrypt_data_with_user_key(&user_infos_data.last_name, &user_encryption_key)?; + let username: String = decrypt_data_with_user_key(&user_infos_data.username, &user_encryption_key)?; + let email: String = decrypt_data_with_user_key(&user_infos_data.email, &user_encryption_key)?; + let account_verified: bool = user_infos_data.account_verified == 1; + let author_name: String = if let Some(ref author_name_val) = user_infos_data.author_name { decrypt_data_with_user_key(author_name_val, &user_encryption_key)? } else { String::new() }; + let group_id: i64 = user_infos_data.user_group; + let terms_accepted: bool = user_infos_data.term_accepted == 1; + let guide_tour_status: Vec = vec![]; + + Ok(UserInfoResponse { + id: user_id.to_string(), + name: first_name, + last_name, + username, + email, + account_verified, + author_name, + group_id, + terms_accepted, + guide_tour: guide_tour_status, + }) +} + +/// Creates a new user in the database with encrypted personal information. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier for the new user +/// * `first_name` - The user's first name (will be encrypted) +/// * `last_name` - The user's last name (will be encrypted) +/// * `username` - The user's username (will be encrypted and hashed) +/// * `email` - The user's email address (will be encrypted and hashed) +/// * `not_encrypt_password` - The user's password in plain text (unused in current implementation) +/// * `lang` - The preferred language for the user +/// Returns the created user's identifier. +/// Errors if encryption or insertion fails. +pub fn add_user( + conn: &Connection, user_id: &str, first_name: &str, last_name: &str, username: &str, + email: &str, _not_encrypt_password: &str, lang: Lang, +) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let encrypted_first_name: String = encrypt_data_with_user_key(first_name, &user_encryption_key)?; + let encrypted_last_name: String = encrypt_data_with_user_key(last_name, &user_encryption_key)?; + let encrypted_username: String = encrypt_data_with_user_key(username, &user_encryption_key)?; + let encrypted_email: String = encrypt_data_with_user_key(email, &user_encryption_key)?; + let hashed_email: String = hash_element(email); + let hashed_username: String = hash_element(username); + + repo::insert_user(conn, user_id, &encrypted_first_name, &encrypted_last_name, &encrypted_username, &hashed_username, &encrypted_email, &hashed_email, lang) +} + +/// Updates an existing user's profile information in the database. +/// * `conn` - Database connection +/// * `user_key` - The encryption key for the user's data +/// * `user_id` - The unique identifier of the user to update +/// * `first_name` - The updated first name (will be encrypted) +/// * `last_name` - The updated last name (will be encrypted) +/// * `username` - The updated username (will be encrypted and hashed) +/// * `email` - The updated email address (will be encrypted and hashed) +/// * `author_name` - The optional author/pen name (will be encrypted and hashed if provided) +/// * `lang` - The preferred language for the user +/// Returns true if the update was successful. +/// Errors if encryption or update fails. +pub fn update_user_infos( + conn: &Connection, user_key: &str, user_id: &str, first_name: &str, last_name: &str, + username: &str, email: &str, author_name: Option<&str>, lang: Lang, +) -> AppResult { + let encrypted_first_name: String = encrypt_data_with_user_key(first_name, user_key)?; + let encrypted_last_name: String = encrypt_data_with_user_key(last_name, user_key)?; + let encrypted_username: String = encrypt_data_with_user_key(username, user_key)?; + let encrypted_email: String = encrypt_data_with_user_key(email, user_key)?; + let hashed_email: String = hash_element(email); + let hashed_username: String = hash_element(username); + let mut encrypted_author_name: String = String::new(); + let mut hashed_author_name: String = String::new(); + if let Some(author_name_val) = author_name { + encrypted_author_name = encrypt_data_with_user_key(author_name_val, user_key)?; + hashed_author_name = hash_element(author_name_val); + } + + repo::update_user_infos(conn, user_id, &encrypted_first_name, &encrypted_last_name, &encrypted_username, &hashed_username, &encrypted_email, &hashed_email, &hashed_author_name, &encrypted_author_name, lang) +} + +/// Retrieves and decrypts the user's account information from the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages +/// Returns the decrypted user account information. +/// Errors if the user is not found or decryption fails. +pub fn get_user_account_information(conn: &Connection, user_id: &str, lang: Lang) -> AppResult { + let account_data: repo::UserAccountQuery = repo::fetch_account_information(conn, user_id, lang)?; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + + let decrypted_first_name: String = if let Some(ref first_name) = account_data.first_name { decrypt_data_with_user_key(first_name, &user_encryption_key)? } else { String::new() }; + let decrypted_last_name: String = if let Some(ref last_name) = account_data.last_name { decrypt_data_with_user_key(last_name, &user_encryption_key)? } else { String::new() }; + let decrypted_username: String = decrypt_data_with_user_key(&account_data.username, &user_encryption_key)?; + let decrypted_author_name: String = if let Some(ref author_name) = account_data.author_name { decrypt_data_with_user_key(author_name, &user_encryption_key)? } else { String::new() }; + let decrypted_email: String = decrypt_data_with_user_key(&account_data.email, &user_encryption_key)?; + + Ok(UserAccount { + first_name: decrypted_first_name, + last_name: decrypted_last_name, + username: decrypted_username, + author_name: decrypted_author_name, + email: decrypted_email, + }) +} diff --git a/src-tauri/src/domains/world/mod.rs b/src-tauri/src/domains/world/mod.rs new file mode 100644 index 0000000..5608c55 --- /dev/null +++ b/src-tauri/src/domains/world/mod.rs @@ -0,0 +1,2 @@ +pub mod repo; +pub mod service; diff --git a/src-tauri/src/domains/world/repo.rs b/src-tauri/src/domains/world/repo.rs new file mode 100644 index 0000000..5eda9a4 --- /dev/null +++ b/src-tauri/src/domains/world/repo.rs @@ -0,0 +1,513 @@ +use rusqlite::{params, Connection}; + +use crate::error::{AppError, AppResult}; +use crate::shared::types::Lang; + +pub struct BookWorldTable { + pub world_id: String, + pub name: String, + pub hashed_name: String, + pub author_id: String, + pub book_id: String, + pub history: Option, + pub politics: Option, + pub economy: Option, + pub religion: Option, + pub languages: Option, + pub last_update: i64, +} + +pub struct BookWorldElementsTable { + pub element_id: String, + pub world_id: String, + pub user_id: String, + pub element_type: i64, + pub name: String, + pub original_name: String, + pub description: Option, + pub last_update: i64, +} + +pub struct SyncedWorldResult { + pub world_id: String, + pub book_id: String, + pub name: String, + pub last_update: i64, +} + +pub struct SyncedWorldElementResult { + pub element_id: String, + pub world_id: String, + pub name: String, + pub last_update: i64, +} + +pub struct WorldQuery { + pub world_id: String, + pub world_name: String, + pub history: Option, + pub politics: Option, + pub economy: Option, + pub religion: Option, + pub languages: Option, + pub element_id: Option, + pub element_name: Option, + pub element_description: Option, + pub element_type: Option, + pub series_world_id: Option, +} + +pub struct WorldElementValue { + pub id: String, + pub name: String, + pub description: String, + pub element_type: i64, +} + +/// Checks if a world with the given name exists for a specific user and book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `world_name` - The hashed name of the world to check +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the world exists, false otherwise. +pub fn check_world_exist(conn: &Connection, user_id: &str, book_id: &str, world_name: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT world_id FROM book_world WHERE author_id=?1 AND book_id=?2 AND hashed_name=?3") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence du monde.".to_string() } else { "Unable to verify world existence.".to_string() }))?; + + let exists = statement + .exists(params![user_id, book_id, world_name]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence du monde.".to_string() } else { "Unable to verify world existence.".to_string() }))?; + + Ok(exists) +} + +/// Inserts a new world into the database. +/// * `conn` - Database connection +/// * `world_id` - The unique identifier for the new world +/// * `user_id` - The unique identifier of the author +/// * `book_id` - The unique identifier of the book +/// * `encrypted_name` - The encrypted name of the world +/// * `hashed_name` - The hashed name of the world for uniqueness checks +/// * `last_update` - The creation timestamp +/// * `lang` - The language for error messages ("fr" or "en") +/// * `series_world_id` - The optional series world identifier +/// Returns the world ID if insertion was successful. +pub fn insert_new_world( + conn: &Connection, world_id: &str, user_id: &str, book_id: &str, encrypted_name: &str, + hashed_name: &str, last_update: i64, lang: Lang, series_world_id: Option<&str>, +) -> AppResult { + let insert_result = if let Some(series_id) = series_world_id { + conn.execute("INSERT INTO book_world (world_id, author_id, book_id, name, hashed_name, series_world_id, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![world_id, user_id, book_id, encrypted_name, hashed_name, series_id, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le monde.".to_string() } else { "Unable to add world.".to_string() }))? + } else { + conn.execute("INSERT INTO book_world (world_id, author_id, book_id, name, hashed_name, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![world_id, user_id, book_id, encrypted_name, hashed_name, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter le monde.".to_string() } else { "Unable to add world.".to_string() }))? + }; + + if insert_result > 0 { + Ok(world_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Erreur lors de l'ajout du monde.".to_string() } else { "Error adding world.".to_string() })) + } +} + +/// Fetches all worlds and their elements for a specific user and book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of world query results with joined element data. +pub fn fetch_worlds(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT world.world_id AS world_id, world.name AS world_name, world.history, world.politics, world.economy, world.religion, world.languages, element.element_id AS element_id, element.name AS element_name, element.description AS element_description, element.element_type, world.series_world_id FROM book_world AS world LEFT JOIN book_world_elements AS element ON world.world_id=element.world_id WHERE world.author_id=?1 AND world.book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les mondes.".to_string() } else { "Unable to retrieve worlds.".to_string() }))?; + + let worlds = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(WorldQuery { + world_id: query_row.get(0)?, world_name: query_row.get(1)?, + history: query_row.get(2)?, politics: query_row.get(3)?, + economy: query_row.get(4)?, religion: query_row.get(5)?, + languages: query_row.get(6)?, element_id: query_row.get(7)?, + element_name: query_row.get(8)?, element_description: query_row.get(9)?, + element_type: query_row.get(10)?, series_world_id: query_row.get(11)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les mondes.".to_string() } else { "Unable to retrieve worlds.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les mondes.".to_string() } else { "Unable to retrieve worlds.".to_string() }))?; + + Ok(worlds) +} + +/// Updates a world's data in the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the author +/// * `world_id` - The unique identifier of the world to update +/// * `encrypt_name` - The new encrypted name +/// * `hashed_name` - The new hashed name +/// * `encrypt_history` - The new encrypted history +/// * `encrypt_politics` - The new encrypted politics +/// * `encrypt_economy` - The new encrypted economy +/// * `encrypt_religion` - The new encrypted religion +/// * `encrypt_languages` - The new encrypted languages +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// * `series_world_id` - The optional series world identifier +/// Returns true if the update was successful, false otherwise. +pub fn update_world( + conn: &Connection, user_id: &str, world_id: &str, encrypt_name: &str, hashed_name: &str, + encrypt_history: &str, encrypt_politics: &str, encrypt_economy: &str, encrypt_religion: &str, + encrypt_languages: &str, last_update: i64, lang: Lang, series_world_id: Option<&str>, +) -> AppResult { + let update_result = if let Some(series_id) = series_world_id { + conn.execute("UPDATE book_world SET name=?1, hashed_name=?2, history=?3, politics=?4, economy=?5, religion=?6, languages=?7, last_update=?8, series_world_id=?9 WHERE author_id=?10 AND world_id=?11", params![encrypt_name, hashed_name, encrypt_history, encrypt_politics, encrypt_economy, encrypt_religion, encrypt_languages, last_update, series_id, user_id, world_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre \u{00e0} jour le monde.".to_string() } else { "Unable to update world.".to_string() }))? + } else { + conn.execute("UPDATE book_world SET name=?1, hashed_name=?2, history=?3, politics=?4, economy=?5, religion=?6, languages=?7, last_update=?8 WHERE author_id=?9 AND world_id=?10", params![encrypt_name, hashed_name, encrypt_history, encrypt_politics, encrypt_economy, encrypt_religion, encrypt_languages, last_update, user_id, world_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre \u{00e0} jour le monde.".to_string() } else { "Unable to update world.".to_string() }))? + }; + + Ok(update_result > 0) +} + +/// Updates multiple world elements in the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `elements` - An array of world element values to update +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if all updates were successful, false otherwise. +pub fn update_world_elements(conn: &Connection, user_id: &str, elements: &[WorldElementValue], last_update: i64, lang: Lang) -> AppResult { + for element in elements { + let update_result = conn + .execute("UPDATE book_world_elements SET name=?1, description=?2, element_type=?3, last_update=?4 WHERE user_id=?5 AND element_id=?6", params![element.name, element.description, element.element_type, last_update, user_id, element.id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre \u{00e0} jour les \u{00e9}l\u{00e9}ments du monde.".to_string() } else { "Unable to update world elements.".to_string() }))?; + + if update_result == 0 { + return Ok(false); + } + } + + Ok(true) +} + +/// Checks if a world element with the given hashed name exists. +/// * `conn` - Database connection +/// * `world_num_id` - The unique identifier of the world +/// * `hashed_name` - The hashed name of the element to check +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the element exists, false otherwise. +pub fn check_element_exist(conn: &Connection, world_num_id: &str, hashed_name: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT element_id FROM book_world_elements WHERE world_id=?1 AND original_name=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence de l'\u{00e9}l\u{00e9}ment.".to_string() } else { "Unable to verify element existence.".to_string() }))?; + + let exists = statement + .exists(params![world_num_id, hashed_name]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence de l'\u{00e9}l\u{00e9}ment.".to_string() } else { "Unable to verify element existence.".to_string() }))?; + + Ok(exists) +} + +/// Inserts a new world element into the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `element_id` - The unique identifier for the new element +/// * `element_type` - The type of the element +/// * `world_id` - The unique identifier of the parent world +/// * `encrypted_name` - The encrypted name of the element +/// * `hashed_name` - The hashed name of the element for uniqueness checks +/// * `last_update` - The creation timestamp +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns the element ID if insertion was successful. +pub fn insert_new_element( + conn: &Connection, user_id: &str, element_id: &str, element_type: i64, world_id: &str, + encrypted_name: &str, hashed_name: &str, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute("INSERT INTO book_world_elements (element_id, world_id, user_id, name, original_name, element_type, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![element_id, world_id, user_id, encrypted_name, hashed_name, element_type, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ajouter l'\u{00e9}l\u{00e9}ment.".to_string() } else { "Unable to add element.".to_string() }))?; + + if insert_result > 0 { + Ok(element_id.to_string()) + } else { + Err(AppError::Internal(if lang == Lang::Fr { "Erreur lors de l'ajout de l'\u{00e9}l\u{00e9}ment.".to_string() } else { "Error adding element.".to_string() })) + } +} + +/// Deletes a world element from the database. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `element_id` - The unique identifier of the element to delete +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion was successful, false otherwise. +pub fn delete_element(conn: &Connection, user_id: &str, element_id: &str, lang: Lang) -> AppResult { + let delete_result = conn + .execute("DELETE FROM book_world_elements WHERE user_id=?1 AND element_id=?2", params![user_id, element_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de supprimer l'\u{00e9}l\u{00e9}ment.".to_string() } else { "Unable to delete element.".to_string() }))?; + + Ok(delete_result > 0) +} + +/// Fetches all worlds for a specific user and book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of book world table records. +pub fn fetch_book_worlds(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT world_id, name, hashed_name, author_id, book_id, history, politics, economy, religion, languages, last_update FROM book_world WHERE author_id=?1 AND book_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les mondes.".to_string() } else { "Unable to retrieve worlds.".to_string() }))?; + + let worlds = statement + .query_map(params![user_id, book_id], |query_row| { + Ok(BookWorldTable { + world_id: query_row.get(0)?, name: query_row.get(1)?, + hashed_name: query_row.get(2)?, author_id: query_row.get(3)?, + book_id: query_row.get(4)?, history: query_row.get(5)?, + politics: query_row.get(6)?, economy: query_row.get(7)?, + religion: query_row.get(8)?, languages: query_row.get(9)?, + last_update: query_row.get(10)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les mondes.".to_string() } else { "Unable to retrieve worlds.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les mondes.".to_string() } else { "Unable to retrieve worlds.".to_string() }))?; + + Ok(worlds) +} + +/// Fetches all elements for a specific world. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `world_id` - The unique identifier of the world +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of book world elements table records. +pub fn fetch_book_world_elements(conn: &Connection, user_id: &str, world_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, world_id, user_id, element_type, name, original_name, description, last_update FROM book_world_elements WHERE user_id=?1 AND world_id=?2") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les \u{00e9}l\u{00e9}ments du monde.".to_string() } else { "Unable to retrieve world elements.".to_string() }))?; + + let elements = statement + .query_map(params![user_id, world_id], |query_row| { + Ok(BookWorldElementsTable { + element_id: query_row.get(0)?, world_id: query_row.get(1)?, + user_id: query_row.get(2)?, element_type: query_row.get(3)?, + name: query_row.get(4)?, original_name: query_row.get(5)?, + description: query_row.get(6)?, last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les \u{00e9}l\u{00e9}ments du monde.".to_string() } else { "Unable to retrieve world elements.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les \u{00e9}l\u{00e9}ments du monde.".to_string() } else { "Unable to retrieve world elements.".to_string() }))?; + + Ok(elements) +} + +/// Fetches all synced worlds for a specific user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced world results. +pub fn fetch_synced_worlds(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT world_id, book_id, name, last_update FROM book_world WHERE author_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les mondes synchronis\u{00e9}s.".to_string() } else { "Unable to retrieve synced worlds.".to_string() }))?; + + let synced_worlds = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedWorldResult { world_id: query_row.get(0)?, book_id: query_row.get(1)?, name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les mondes synchronis\u{00e9}s.".to_string() } else { "Unable to retrieve synced worlds.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les mondes synchronis\u{00e9}s.".to_string() } else { "Unable to retrieve synced worlds.".to_string() }))?; + + Ok(synced_worlds) +} + +/// Fetches all synced world elements for a specific user. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of synced world element results. +pub fn fetch_synced_world_elements(conn: &Connection, user_id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, world_id, name, last_update FROM book_world_elements WHERE user_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les \u{00e9}l\u{00e9}ments de monde synchronis\u{00e9}s.".to_string() } else { "Unable to retrieve synced world elements.".to_string() }))?; + + let synced_elements = statement + .query_map(params![user_id], |query_row| { + Ok(SyncedWorldElementResult { element_id: query_row.get(0)?, world_id: query_row.get(1)?, name: query_row.get(2)?, last_update: query_row.get(3)? }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les \u{00e9}l\u{00e9}ments de monde synchronis\u{00e9}s.".to_string() } else { "Unable to retrieve synced world elements.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer les \u{00e9}l\u{00e9}ments de monde synchronis\u{00e9}s.".to_string() } else { "Unable to retrieve synced world elements.".to_string() }))?; + + Ok(synced_elements) +} + +/// Inserts a synced world into the database. +/// * `conn` - Database connection +/// * `world_id` - The unique identifier for the world +/// * `name` - The encrypted name of the world +/// * `hashed_name` - The hashed name of the world +/// * `author_id` - The unique identifier of the author +/// * `book_id` - The unique identifier of the book +/// * `history` - The encrypted history (optional) +/// * `politics` - The encrypted politics (optional) +/// * `economy` - The encrypted economy (optional) +/// * `religion` - The encrypted religion (optional) +/// * `languages` - The encrypted languages (optional) +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion was successful, false otherwise. +pub fn insert_sync_world( + conn: &Connection, world_id: &str, name: &str, hashed_name: &str, author_id: &str, + book_id: &str, history: Option<&str>, politics: Option<&str>, economy: Option<&str>, + religion: Option<&str>, languages: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute("INSERT INTO book_world (world_id, name, hashed_name, author_id, book_id, history, politics, economy, religion, languages, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![world_id, name, hashed_name, author_id, book_id, history, politics, economy, religion, languages, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ins\u{00e9}rer le monde.".to_string() } else { "Unable to insert world.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Inserts a synced world element into the database. +/// * `conn` - Database connection +/// * `element_id` - The unique identifier for the element +/// * `world_id` - The unique identifier of the parent world +/// * `user_id` - The unique identifier of the user +/// * `element_type` - The type of the element +/// * `name` - The encrypted name of the element +/// * `original_name` - The original hashed name +/// * `description` - The encrypted description (optional) +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the insertion was successful, false otherwise. +pub fn insert_sync_world_element( + conn: &Connection, element_id: &str, world_id: &str, user_id: &str, element_type: i64, + name: &str, original_name: &str, description: Option<&str>, last_update: i64, lang: Lang, +) -> AppResult { + let insert_result = conn + .execute("INSERT INTO book_world_elements (element_id, world_id, user_id, element_type, name, original_name, description, last_update) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![element_id, world_id, user_id, element_type, name, original_name, description, last_update]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible d'ins\u{00e9}rer l'\u{00e9}l\u{00e9}ment du monde.".to_string() } else { "Unable to insert world element.".to_string() }))?; + + Ok(insert_result > 0) +} + +/// Fetches a complete world by its ID. +/// * `conn` - Database connection +/// * `id` - The unique identifier of the world +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of book world table records. +pub fn fetch_complete_world_by_id(conn: &Connection, id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT world_id, name, hashed_name, author_id, book_id, history, politics, economy, religion, languages, last_update FROM book_world WHERE world_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer le monde complet.".to_string() } else { "Unable to retrieve complete world.".to_string() }))?; + + let worlds = statement + .query_map(params![id], |query_row| { + Ok(BookWorldTable { + world_id: query_row.get(0)?, name: query_row.get(1)?, + hashed_name: query_row.get(2)?, author_id: query_row.get(3)?, + book_id: query_row.get(4)?, history: query_row.get(5)?, + politics: query_row.get(6)?, economy: query_row.get(7)?, + religion: query_row.get(8)?, languages: query_row.get(9)?, + last_update: query_row.get(10)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer le monde complet.".to_string() } else { "Unable to retrieve complete world.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer le monde complet.".to_string() } else { "Unable to retrieve complete world.".to_string() }))?; + + Ok(worlds) +} + +/// Fetches a complete world element by its ID. +/// * `conn` - Database connection +/// * `id` - The unique identifier of the element +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns an array of book world elements table records. +pub fn fetch_complete_world_element_by_id(conn: &Connection, id: &str, lang: Lang) -> AppResult> { + let mut statement = conn + .prepare("SELECT element_id, world_id, user_id, element_type, name, original_name, description, last_update FROM book_world_elements WHERE element_id = ?1") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer l'\u{00e9}l\u{00e9}ment de monde complet.".to_string() } else { "Unable to retrieve complete world element.".to_string() }))?; + + let elements = statement + .query_map(params![id], |query_row| { + Ok(BookWorldElementsTable { + element_id: query_row.get(0)?, world_id: query_row.get(1)?, + user_id: query_row.get(2)?, element_type: query_row.get(3)?, + name: query_row.get(4)?, original_name: query_row.get(5)?, + description: query_row.get(6)?, last_update: query_row.get(7)?, + }) + }) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer l'\u{00e9}l\u{00e9}ment de monde complet.".to_string() } else { "Unable to retrieve complete world element.".to_string() }))? + .collect::, _>>() + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de r\u{00e9}cup\u{00e9}rer l'\u{00e9}l\u{00e9}ment de monde complet.".to_string() } else { "Unable to retrieve complete world element.".to_string() }))?; + + Ok(elements) +} + +/// Updates a single world element's name and description. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `element_id` - The unique identifier of the element to update +/// * `name` - The new encrypted name +/// * `description` - The new encrypted description +/// * `last_update` - The timestamp of the last update +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful, false otherwise. +pub fn update_world_element( + conn: &Connection, user_id: &str, element_id: &str, name: &str, description: &str, + last_update: i64, lang: Lang, +) -> AppResult { + let update_result = conn + .execute("UPDATE book_world_elements SET name = ?1, description = ?2, last_update = ?3 WHERE element_id = ?4 AND user_id = ?5", params![name, description, last_update, element_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre \u{00e0} jour l'\u{00e9}l\u{00e9}ment du monde.".to_string() } else { "Unable to update world element.".to_string() }))?; + + Ok(update_result > 0) +} + +/// Checks if a world exists for a specific user and book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `world_id` - The unique identifier of the world +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the world exists, false otherwise. +pub fn world_exist(conn: &Connection, user_id: &str, book_id: &str, world_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_world WHERE world_id=?1 AND author_id=?2 AND book_id=?3") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence du monde.".to_string() } else { "Unable to check world existence.".to_string() }))?; + + let exists = statement + .exists(params![world_id, user_id, book_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence du monde.".to_string() } else { "Unable to check world existence.".to_string() }))?; + + Ok(exists) +} + +/// Checks if a world element exists for a specific user and world. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `world_id` - The unique identifier of the world +/// * `element_id` - The unique identifier of the element +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the element exists, false otherwise. +pub fn world_element_exist(conn: &Connection, user_id: &str, world_id: &str, element_id: &str, lang: Lang) -> AppResult { + let mut statement = conn + .prepare("SELECT 1 FROM book_world_elements WHERE element_id=?1 AND world_id=?2 AND user_id=?3") + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence de l'\u{00e9}l\u{00e9}ment du monde.".to_string() } else { "Unable to check world element existence.".to_string() }))?; + + let exists = statement + .exists(params![element_id, world_id, user_id]) + .map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de v\u{00e9}rifier l'existence de l'\u{00e9}l\u{00e9}ment du monde.".to_string() } else { "Unable to check world element existence.".to_string() }))?; + + Ok(exists) +} diff --git a/src-tauri/src/domains/world/service.rs b/src-tauri/src/domains/world/service.rs new file mode 100644 index 0000000..3062098 --- /dev/null +++ b/src-tauri/src/domains/world/service.rs @@ -0,0 +1,298 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::crypto::encryption::{encrypt_data_with_user_key, decrypt_data_with_user_key, hash_element}; +use crate::crypto::key_manager::get_user_encryption_key; +use crate::domains::book::repo as book_repo; +use crate::domains::tombstone::repo as tombstone_repo; +use crate::domains::world::repo; +use crate::error::{AppError, AppResult}; +use crate::helpers::{create_unique_id, timestamp_in_seconds}; +use crate::shared::types::Lang; + +/// Represents a synced world with its elements for synchronization. +#[derive(Debug, Serialize, Deserialize)] +pub struct SyncedWorld { + pub id: String, + pub name: String, + pub last_update: i64, + pub elements: Vec, +} + +/// Represents a synced world element for synchronization. +#[derive(Debug, Serialize, Deserialize)] +pub struct SyncedWorldElement { + pub id: String, + pub name: String, + pub last_update: i64, +} + +/// Represents a single world element with its properties. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WorldElement { + pub id: String, + pub name: String, + pub description: String, + #[serde(rename = "type")] + pub element_type: Option, +} + +/// Represents a complete world with all its properties and elements. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorldProps { + pub id: String, + pub name: String, + pub history: String, + pub politics: String, + pub economy: String, + pub religion: String, + pub languages: String, + pub laws: Vec, + pub biomes: Vec, + pub issues: Vec, + pub customs: Vec, + pub kingdoms: Vec, + pub climate: Vec, + pub resources: Vec, + pub wildlife: Vec, + pub arts: Vec, + pub ethnic_groups: Vec, + pub social_classes: Vec, + pub important_characters: Vec, + pub series_world_id: Option, +} + +/// Response containing the list of worlds and whether the tool is enabled. +#[derive(Debug, Serialize, Deserialize)] +pub struct WorldListResponse { + pub worlds: Vec, + pub enabled: bool, +} + +/// Mapping of element type keys to their corresponding numeric type identifiers. +fn get_element_types(element_type: &str) -> i64 { + match element_type { + "laws" => 1, + "biomes" => 2, + "issues" => 3, + "customs" => 4, + "kingdoms" => 5, + "climate" => 6, + "resources" => 7, + "wildlife" => 8, + "arts" => 9, + "ethnicGroups" => 10, + "socialClasses" => 11, + "importantCharacters" => 12, + _ => 0, + } +} + +/// Pushes a world element into the correct category vector of a WorldProps. +/// * `world` - The world to push the element into +/// * `element_type` - The numeric type identifier +/// * `element` - The world element to push +fn push_element_to_world(world: &mut WorldProps, element_type: i64, element: WorldElement) { + match element_type { + 1 => world.laws.push(element), + 2 => world.biomes.push(element), + 3 => world.issues.push(element), + 4 => world.customs.push(element), + 5 => world.kingdoms.push(element), + 6 => world.climate.push(element), + 7 => world.resources.push(element), + 8 => world.wildlife.push(element), + 9 => world.arts.push(element), + 10 => world.ethnic_groups.push(element), + 11 => world.social_classes.push(element), + 12 => world.important_characters.push(element), + _ => {} + } +} + +/// Creates a new world for a book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user creating the world +/// * `book_id` - The unique identifier of the book to associate the world with +/// * `world_name` - The name of the new world +/// * `lang` - The language for error messages ("fr" or "en") +/// * `existing_world_id` - Optional existing world ID for syncing purposes +/// * `series_world_id` - Optional series world identifier +/// Returns the unique identifier of the newly created world. +/// Errors if a world with the same name already exists for this book. +pub fn add_new_world( + conn: &Connection, user_id: &str, book_id: &str, world_name: &str, lang: Lang, + existing_world_id: Option<&str>, series_world_id: Option<&str>, +) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let hashed_world_name: String = hash_element(world_name); + if existing_world_id.is_none() && repo::check_world_exist(conn, user_id, book_id, &hashed_world_name, lang)? { + return Err(AppError::Internal(if lang == Lang::Fr { format!("Tu as d\u{00e9}j\u{00e0} un monde {}.", world_name) } else { format!("You already have a world named {}.", world_name) })); + } + let encrypted_world_name: String = encrypt_data_with_user_key(world_name, &user_encryption_key)?; + let world_id: String = create_unique_id(existing_world_id); + let last_update: i64 = timestamp_in_seconds(); + repo::insert_new_world(conn, &world_id, user_id, book_id, &encrypted_world_name, &hashed_world_name, last_update, lang, series_world_id) +} + +/// Retrieves all worlds and their elements for a specific book. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns a WorldListResponse containing an array of WorldProps and enabled flag. +pub fn get_worlds(conn: &Connection, user_id: &str, book_id: &str, lang: Lang) -> AppResult { + let book_tools: Option = book_repo::fetch_book_tools(conn, user_id, book_id, lang)?; + let enabled: bool = book_tools.map_or(false, |book_tools_row| book_tools_row.worlds_enabled == 1); + + let world_query_results: Vec = repo::fetch_worlds(conn, user_id, book_id, lang)?; + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let mut worlds: Vec = Vec::new(); + + for query_row in &world_query_results { + let existing_world_index: Option = worlds.iter().position(|world| world.id == query_row.world_id); + + if let Some(index) = existing_world_index { + if let Some(element_type) = query_row.element_type { + let world_element: WorldElement = WorldElement { + id: query_row.element_id.clone().unwrap_or_default(), + name: if let Some(ref element_name) = query_row.element_name { decrypt_data_with_user_key(element_name, &user_encryption_key)? } else { String::new() }, + description: if let Some(ref element_description) = query_row.element_description { decrypt_data_with_user_key(element_description, &user_encryption_key)? } else { String::new() }, + element_type: None, + }; + push_element_to_world(&mut worlds[index], element_type, world_element); + } + } else { + let mut new_world: WorldProps = WorldProps { + id: query_row.world_id.clone(), + name: decrypt_data_with_user_key(&query_row.world_name, &user_encryption_key)?, + history: if let Some(ref history) = query_row.history { decrypt_data_with_user_key(history, &user_encryption_key)? } else { String::new() }, + politics: if let Some(ref politics) = query_row.politics { decrypt_data_with_user_key(politics, &user_encryption_key)? } else { String::new() }, + economy: if let Some(ref economy) = query_row.economy { decrypt_data_with_user_key(economy, &user_encryption_key)? } else { String::new() }, + religion: if let Some(ref religion) = query_row.religion { decrypt_data_with_user_key(religion, &user_encryption_key)? } else { String::new() }, + languages: if let Some(ref languages) = query_row.languages { decrypt_data_with_user_key(languages, &user_encryption_key)? } else { String::new() }, + laws: Vec::new(), + biomes: Vec::new(), + issues: Vec::new(), + customs: Vec::new(), + kingdoms: Vec::new(), + climate: Vec::new(), + resources: Vec::new(), + wildlife: Vec::new(), + arts: Vec::new(), + ethnic_groups: Vec::new(), + social_classes: Vec::new(), + important_characters: Vec::new(), + series_world_id: query_row.series_world_id.clone(), + }; + + if let Some(element_type) = query_row.element_type { + let world_element: WorldElement = WorldElement { + id: query_row.element_id.clone().unwrap_or_default(), + name: if let Some(ref element_name) = query_row.element_name { decrypt_data_with_user_key(element_name, &user_encryption_key)? } else { String::new() }, + description: if let Some(ref element_description) = query_row.element_description { decrypt_data_with_user_key(element_description, &user_encryption_key)? } else { String::new() }, + element_type: None, + }; + push_element_to_world(&mut new_world, element_type, world_element); + } + + worlds.push(new_world); + } + } + + Ok(WorldListResponse { worlds, enabled }) +} + +/// Updates a world's properties and all its elements. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `world` - The WorldProps object containing updated world data +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the update was successful, false otherwise. +pub fn update_world(conn: &Connection, user_id: &str, world: &WorldProps, lang: Lang) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let encrypted_name: String = if world.name.is_empty() { String::new() } else { encrypt_data_with_user_key(&world.name, &user_encryption_key)? }; + let encrypted_history: String = if world.history.is_empty() { String::new() } else { encrypt_data_with_user_key(&world.history, &user_encryption_key)? }; + let encrypted_politics: String = if world.politics.is_empty() { String::new() } else { encrypt_data_with_user_key(&world.politics, &user_encryption_key)? }; + let encrypted_economy: String = if world.economy.is_empty() { String::new() } else { encrypt_data_with_user_key(&world.economy, &user_encryption_key)? }; + let encrypted_religion: String = if world.religion.is_empty() { String::new() } else { encrypt_data_with_user_key(&world.religion, &user_encryption_key)? }; + let encrypted_languages: String = if world.languages.is_empty() { String::new() } else { encrypt_data_with_user_key(&world.languages, &user_encryption_key)? }; + + let element_categories: Vec<(&str, &Vec)> = vec![ + ("laws", &world.laws), + ("biomes", &world.biomes), + ("issues", &world.issues), + ("customs", &world.customs), + ("kingdoms", &world.kingdoms), + ("climate", &world.climate), + ("resources", &world.resources), + ("wildlife", &world.wildlife), + ("arts", &world.arts), + ("ethnicGroups", &world.ethnic_groups), + ("socialClasses", &world.social_classes), + ("importantCharacters", &world.important_characters), + ]; + + let mut elements_to_update: Vec = Vec::new(); + for (key, elements) in &element_categories { + for world_element in *elements { + let encrypted_element_name: String = encrypt_data_with_user_key(&world_element.name, &user_encryption_key)?; + let encrypted_description: String = if world_element.description.is_empty() { String::new() } else { encrypt_data_with_user_key(&world_element.description, &user_encryption_key)? }; + let element_type_id: i64 = get_element_types(key); + elements_to_update.push(repo::WorldElementValue { + id: world_element.id.clone(), + name: encrypted_element_name, + description: encrypted_description, + element_type: element_type_id, + }); + } + } + + let hashed_name: String = hash_element(&world.name); + let last_update: i64 = timestamp_in_seconds(); + repo::update_world(conn, user_id, &world.id, &encrypted_name, &hashed_name, &encrypted_history, &encrypted_politics, &encrypted_economy, &encrypted_religion, &encrypted_languages, last_update, lang, world.series_world_id.as_deref())?; + repo::update_world_elements(conn, user_id, &elements_to_update, last_update, lang) +} + +/// Adds a new element to an existing world. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `world_id` - The unique identifier of the world to add the element to +/// * `element_name` - The name of the new element +/// * `element_type` - The type of element (e.g., "laws", "biomes", "customs") +/// * `lang` - The language for error messages ("fr" or "en") +/// * `existing_element_id` - Optional existing element ID for syncing purposes +/// Returns the unique identifier of the newly created element. +/// Errors if an element with the same name already exists in this world. +pub fn add_new_element_to_world( + conn: &Connection, user_id: &str, world_id: &str, element_name: &str, element_type: &str, + lang: Lang, existing_element_id: Option<&str>, +) -> AppResult { + let user_encryption_key: String = get_user_encryption_key(user_id)?; + let hashed_element_name: String = hash_element(element_name); + if existing_element_id.is_none() && repo::check_element_exist(conn, world_id, &hashed_element_name, lang)? { + return Err(AppError::Internal(if lang == Lang::Fr { format!("Vous avez d\u{00e9}j\u{00e0} un \u{00e9}l\u{00e9}ment avec ce nom {}.", element_name) } else { format!("You already have an element named {}.", element_name) })); + } + let element_type_id: i64 = get_element_types(element_type); + let encrypted_element_name: String = encrypt_data_with_user_key(element_name, &user_encryption_key)?; + let element_id: String = create_unique_id(existing_element_id); + let last_update: i64 = timestamp_in_seconds(); + repo::insert_new_element(conn, user_id, &element_id, element_type_id, world_id, &encrypted_element_name, &hashed_element_name, last_update, lang) +} + +/// Removes an element from a world. +/// * `conn` - Database connection +/// * `user_id` - The unique identifier of the user +/// * `book_id` - The unique identifier of the book +/// * `element_id` - The unique identifier of the element to remove +/// * `deleted_at` - The timestamp of deletion +/// * `lang` - The language for error messages ("fr" or "en") +/// Returns true if the deletion was successful, false otherwise. +pub fn remove_element_from_world(conn: &Connection, user_id: &str, book_id: &str, element_id: &str, deleted_at: i64, lang: Lang) -> AppResult { + let deleted: bool = repo::delete_element(conn, user_id, element_id, lang)?; + if deleted { + tombstone_repo::insert(conn, element_id, "book_world_elements", element_id, Some(book_id), user_id, deleted_at, lang)?; + } + Ok(deleted) +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..7e35d8b --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,62 @@ +use serde::Serialize; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("Database error: {0}")] + Database(#[from] rusqlite::Error), + + #[error("Encryption error: {0}")] + Encryption(String), + + #[error("Keyring error: {0}")] + Keyring(String), + + #[error("Authentication error: {0}")] + Auth(String), + + #[error("Not found: {0}")] + NotFound(String), + + #[error("Validation error: {0}")] + Validation(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Internal error: {0}")] + Internal(String), +} + +impl Serialize for AppError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("AppError", 2)?; + state.serialize_field("kind", &self.error_kind())?; + state.serialize_field("message", &self.to_string())?; + state.end() + } +} + +impl AppError { + fn error_kind(&self) -> &str { + match self { + AppError::Database(_) => "DATABASE", + AppError::Encryption(_) => "ENCRYPTION", + AppError::Keyring(_) => "KEYRING", + AppError::Auth(_) => "AUTH", + AppError::NotFound(_) => "NOT_FOUND", + AppError::Validation(_) => "VALIDATION", + AppError::Io(_) => "IO", + AppError::Json(_) => "JSON", + AppError::Internal(_) => "INTERNAL", + } + } +} + +pub type AppResult = Result; diff --git a/src-tauri/src/helpers.rs b/src-tauri/src/helpers.rs new file mode 100644 index 0000000..4fcab44 --- /dev/null +++ b/src-tauri/src/helpers.rs @@ -0,0 +1,59 @@ +use chrono::Utc; +use regex::Regex; + + +/// Returns the current UNIX timestamp in seconds. +/// Equivalent to TS `System.timeStampInSeconds()`. +pub fn timestamp_in_seconds() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0) +} + +/// Creates a new UUID v4 string, or reuses an existing ID if provided. +/// Equivalent to TS `System.createUniqueId()`. +pub fn create_unique_id(existing_id: Option<&str>) -> String { + match existing_id { + Some(id) => id.to_string(), + None => uuid::Uuid::new_v4().to_string(), + } +} + +/// Returns the current date as an ISO 8601 string. +/// Equivalent to TS `System.getCurrentDate()`. +pub fn get_current_date() -> String { + Utc::now().to_rfc3339() +} + +/// Converts an ISO date string to a MySQL-compatible date (YYYY-MM-DD). +/// Equivalent to TS `System.dateToMySqlDate()`. +pub fn date_to_mysql_date(iso_date_string: &str) -> String { + match chrono::DateTime::parse_from_rfc3339(iso_date_string) { + Ok(date_object) => date_object.format("%Y-%m-%d").to_string(), + Err(_) => iso_date_string.to_string(), + } +} + +/// Converts HTML content to plain text by stripping tags and decoding entities. +/// Equivalent to TS `System.htmlToText()`. +pub fn html_to_text(html_node: &str) -> String { + let mut text: String = html_node.to_string(); + let p_regex: Regex = Regex::new(r"(?i)]*>").unwrap(); + let br_regex: Regex = Regex::new(r"(?i)").unwrap(); + let span_heading_regex: Regex = Regex::new(r"(?i)]*>").unwrap(); + text = p_regex.replace_all(&text, "\n").to_string(); + text = br_regex.replace_all(&text, "\n").to_string(); + text = span_heading_regex.replace_all(&text, "").to_string(); + text = text.replace("'", "'"); + text = text.replace(""", "\""); + text = text.replace("&", "&"); + text = text.replace("<", "<"); + text = text.replace(">", ">"); + text = text.replace("'", "'"); + let double_newline_regex: Regex = Regex::new(r"\r?\n\s*\n").unwrap(); + text = double_newline_regex.replace_all(&text, "\n").to_string(); + let spaces_regex: Regex = Regex::new(r"[ \t]+").unwrap(); + text = spaces_regex.replace_all(&text, " ").to_string(); + text.trim().to_string() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..f6ccc35 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,172 @@ +mod crypto; +mod db; +mod domains; +mod error; +mod helpers; +mod shared; + +use db::connection::create_db_manager; +use shared::session::create_session; +use std::path::PathBuf; + +pub fn run() { + let app_data_dir = dirs_next::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("com.eritors.scribe.desktop"); + + let db_manager = create_db_manager(app_data_dir); + let session = create_session(); + + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .manage(db_manager) + .manage(session) + .invoke_handler(tauri::generate_handler![ + // ─── User ────────────────────────────────────── + domains::user::commands::init_user, + domains::user::commands::db_initialize, + domains::user::commands::get_token, + domains::user::commands::set_token, + domains::user::commands::remove_token, + domains::user::commands::get_user_encryption_key, + domains::user::commands::get_platform, + domains::user::commands::get_user_info, + domains::user::commands::sync_user, + // ─── Offline ─────────────────────────────────── + domains::offline::commands::offline_pin_set, + domains::offline::commands::offline_pin_verify, + domains::offline::commands::offline_mode_get, + domains::offline::commands::offline_mode_set, + domains::offline::commands::offline_sync_check, + // ─── Book (includes story, guideline, export, sync) ─ + domains::book::commands::get_books, + domains::book::commands::get_book, + domains::book::commands::get_book_basic_information, + domains::book::commands::create_book, + domains::book::commands::update_book_basic_info, + domains::book::commands::delete_book, + domains::book::commands::update_book_tool_setting, + domains::book::commands::get_book_story, + domains::book::commands::update_book_story, + domains::book::commands::add_incident, + domains::book::commands::remove_incident, + domains::book::commands::add_plot_point, + domains::book::commands::remove_plot_point, + domains::book::commands::add_issue, + domains::book::commands::remove_issue, + domains::book::commands::get_worlds, + domains::book::commands::add_world, + domains::book::commands::add_world_element, + domains::book::commands::remove_world_element, + domains::book::commands::update_world, + domains::book::commands::get_guideline, + domains::book::commands::update_guideline, + domains::book::commands::get_ai_guideline, + domains::book::commands::update_ai_guideline, + domains::book::commands::get_book_export_info, + domains::book::commands::export_book, + domains::book::commands::get_synced_books, + domains::book::commands::upload_book_to_server, + domains::book::commands::sync_save_book, + domains::book::commands::sync_book_to_client, + domains::book::commands::sync_book_to_server, + // ─── Chapter ─────────────────────────────────── + domains::chapter::commands::get_chapters, + domains::chapter::commands::get_whole_chapter, + domains::chapter::commands::get_chapter_story, + domains::chapter::commands::get_companion_content, + domains::chapter::commands::get_chapter_content, + domains::chapter::commands::save_chapter_content, + domains::chapter::commands::get_last_chapter, + domains::chapter::commands::add_chapter, + domains::chapter::commands::remove_chapter, + domains::chapter::commands::update_chapter, + domains::chapter::commands::add_chapter_information, + domains::chapter::commands::remove_chapter_information, + domains::chapter::commands::get_book_tags, + // ─── Character ───────────────────────────────── + domains::character::commands::get_character_list, + domains::character::commands::get_character_attributes, + domains::character::commands::create_character, + domains::character::commands::add_character_attribute, + domains::character::commands::delete_character_attribute, + domains::character::commands::update_character, + domains::character::commands::delete_character, + // ─── Location ────────────────────────────────── + domains::location::commands::get_all_locations, + domains::location::commands::add_location_section, + domains::location::commands::add_location_element, + domains::location::commands::add_location_sub_element, + domains::location::commands::update_locations, + domains::location::commands::update_location_section_with_series_link, + domains::location::commands::delete_location_section, + domains::location::commands::delete_location_element, + domains::location::commands::delete_location_sub_element, + // ─── Spell ───────────────────────────────────── + domains::spell::commands::get_spell_list, + domains::spell::commands::get_spell_tags, + domains::spell::commands::get_spell_detail, + domains::spell::commands::create_spell, + domains::spell::commands::update_spell, + domains::spell::commands::delete_spell, + domains::spell::commands::create_spell_tag, + domains::spell::commands::update_spell_tag, + domains::spell::commands::delete_spell_tag, + // ─── Series ──────────────────────────────────── + domains::series::commands::get_series_list, + domains::series::commands::get_series_detail, + domains::series::commands::create_series, + domains::series::commands::update_series, + domains::series::commands::delete_series, + domains::series::commands::get_series_books, + domains::series::commands::add_book_to_series, + domains::series::commands::remove_book_from_series, + domains::series::commands::reorder_series_books, + domains::series::commands::get_series_for_book, + // ─── Series Character ────────────────────────── + domains::series_character::commands::get_series_character_list, + domains::series_character::commands::get_series_character_attributes, + domains::series_character::commands::add_series_character, + domains::series_character::commands::update_series_character, + domains::series_character::commands::delete_series_character, + domains::series_character::commands::add_series_character_attribute, + domains::series_character::commands::delete_series_character_attribute, + // ─── Series Location ─────────────────────────── + domains::series_location::commands::get_series_location_list, + domains::series_location::commands::add_series_location_section, + domains::series_location::commands::add_series_location_element, + domains::series_location::commands::add_series_location_sub_element, + domains::series_location::commands::delete_series_location, + domains::series_location::commands::delete_series_location_element, + domains::series_location::commands::delete_series_location_sub_element, + // ─── Series World ────────────────────────────── + domains::series_world::commands::get_series_world_list, + domains::series_world::commands::add_series_world, + domains::series_world::commands::update_series_world, + domains::series_world::commands::add_series_world_element, + domains::series_world::commands::delete_series_world_element, + // ─── Series Spell ────────────────────────────── + domains::series_spell::commands::get_series_spell_list, + domains::series_spell::commands::get_series_spell_detail, + domains::series_spell::commands::add_series_spell, + domains::series_spell::commands::update_series_spell, + domains::series_spell::commands::delete_series_spell, + domains::series_spell::commands::add_series_spell_tag, + domains::series_spell::commands::update_series_spell_tag, + domains::series_spell::commands::delete_series_spell_tag, + // ─── Series Sync ─────────────────────────────── + domains::series_sync::commands::upload_series_to_server, + domains::series_sync::commands::sync_save_series, + domains::series_sync::commands::sync_series_to_client, + domains::series_sync::commands::sync_series_to_server, + domains::series_sync::commands::series_sync_upload, + // ─── Sync ────────────────────────────────────── + domains::sync::commands::get_synced_series, + // ─── Tombstone ───────────────────────────────── + domains::tombstone::commands::get_tombstones_since, + domains::tombstone::commands::apply_book_tombstones, + domains::tombstone::commands::apply_series_tombstones, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..802975d --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + eritorsscribe_lib::run(); +} diff --git a/src-tauri/src/shared/mod.rs b/src-tauri/src/shared/mod.rs new file mode 100644 index 0000000..425f8bf --- /dev/null +++ b/src-tauri/src/shared/mod.rs @@ -0,0 +1,3 @@ +pub mod ai_models; +pub mod session; +pub mod types; diff --git a/src-tauri/src/shared/types.rs b/src-tauri/src/shared/types.rs new file mode 100644 index 0000000..eef67ef --- /dev/null +++ b/src-tauri/src/shared/types.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Lang { + Fr, + En, +}