Migrate from window.electron to tauri IPC functions across components

- Replaced `window.electron.invoke` calls with equivalent `tauri` function calls for all IPC interactions.
- Removed `electron.d.ts` TypeScript definitions as they are no longer needed.
- Updated related logic for offline/online state synchronization.
- Added `types.rs` and `shared/mod.rs` modules to support Tauri IPC integration with Rust enums and shared logic.
- Refactored IPC request queues to use updated handler names for consistency with Tauri.
This commit is contained in:
natreex
2026-03-21 09:34:13 -04:00
parent 1a15692e40
commit ee4438834c
144 changed files with 21258 additions and 876 deletions

View File

@@ -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]);

View File

@@ -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<SessionContextProps>(SessionContext);
const t = useTranslations();
const {lang} = useContext<LangContextProps>(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'));

View File

@@ -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<SessionContextProps>(SessionContext)
const t = useTranslations();
const {lang} = useContext<LangContextProps>(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<void> {
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<void> {
@@ -102,28 +86,10 @@ export default function SocialForm() {
}
async function handleOAuthClick(provider: 'google' | 'facebook' | 'apple'): Promise<void> {
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'));
}
}

View File

@@ -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);
}
};

View File

@@ -5,46 +5,34 @@ 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<void> {
// Initialize database with user's encryption key
if (window.electron) {
try {
// Get encryption key
const encryptionKey = await window.electron.getUserEncryptionKey(userId);
const encryptionKey = await tauri.getUserEncryptionKey(userId);
if (encryptionKey) {
// Initialize database
await window.electron.dbInitialize(userId, encryptionKey);
// Navigate to main page
window.location.href = '/';
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();
}
tauri.logout();
}
useEffect((): void => {
// Check if we have offline capability
async function checkOfflineCapability() {
if (window.electron) {
const offlineStatus = await window.electron.offlineModeGet();
const offlineStatus = await tauri.offlineModeGet();
if (!offlineStatus.hasPin) {
window.location.href = '/login/login';
}
}
}
checkOfflineCapability().then();
}, []);

View File

@@ -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<SyncedBook[]>('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<RemovedItemRecord[]>('db:tombstones:since', lastOnlineTimestamp);
const localTombstones: RemovedItemRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as RemovedItemRecord[];
const serverResponse: SyncedBooksResponse = await System.authPostToServer<SyncedBooksResponse>('books/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale);
serverBooksResponse = serverResponse.books;
await window.electron.invoke<void>('db:tombstones:apply:books', serverResponse.tombstones);
await tauri.applyBookTombstones(serverResponse.tombstones);
} else {
const serverResponse: SyncedBooksResponse = await System.authPostToServer<SyncedBooksResponse>('books/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale);
serverBooksResponse = serverResponse.books;
}
} else {
if (offlineMode.isDatabaseInitialized) {
localBooksResponse = await window.electron.invoke<SyncedBook[]>('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<SyncedSeries[]>('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<RemovedItemRecord[]>('db:tombstones:since', lastOnlineTimestamp);
const localTombstones: RemovedItemRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as RemovedItemRecord[];
const serverResponse: SyncedSeriesResponse = await System.authPostToServer<SyncedSeriesResponse>('series/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale);
serverSeriesResponse = serverResponse.series;
await window.electron.invoke<void>('db:tombstones:apply:series', serverResponse.tombstones);
await tauri.applySeriesTombstones(serverResponse.tombstones);
} else {
const serverResponse: SyncedSeriesResponse = await System.authPostToServer<SyncedSeriesResponse>('series/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale);
serverSeriesResponse = serverResponse.series;
}
} else {
if (offlineMode.isDatabaseInitialized) {
localSeriesResponse = await window.electron.invoke<SyncedSeries[]>('db:series:synced');
localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[];
}
}
@@ -437,42 +451,16 @@ 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<void> {
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);
await tauri.dbInitialize(userId, encryptionKey);
setOfflineMode(prev => ({...prev, isDatabaseInitialized: true}));
const localUser:UserProps = await window.electron.invoke('db:user:info');
const localUser: UserProps = await tauri.getUserInfo();
if (localUser && localUser.id) {
setSession({
isConnected: true,
@@ -488,7 +476,6 @@ function ScribeContent() {
} else {
errorMessage(t("homePage.errors.encryptionKeyError"));
}
}
} catch (error) {
console.error('[OfflinePin] Error initializing offline mode:', error);
errorMessage(t("homePage.errors.offlineModeError"));
@@ -530,12 +517,10 @@ function ScribeContent() {
async function checkAuthentification(): Promise<void> {
let token: string | null = null;
if (typeof window !== 'undefined' && window.electron) {
try {
token = await window.electron.getToken();
token = await tauri.getToken();
} catch (e) {
console.error('Error getting token from electron:', e);
}
console.error('Error getting token:', e);
}
if (token) {
@@ -543,22 +528,20 @@ function ScribeContent() {
const user: UserProps = await System.authGetQueryToServer<UserProps>('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);
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,13 +581,11 @@ 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();
const offlineStatus = await tauri.offlineModeGet();
if (offlineStatus.hasPin && offlineStatus.lastUserId) {
setOfflineMode((prev:OfflineMode):OfflineMode => ({...prev, isOffline: true, isNetworkOnline: false}));
@@ -613,15 +593,12 @@ function ScribeContent() {
setIsLoading(false);
return;
} else {
if (window.electron) {
await window.electron.removeToken();
window.electron.logout();
}
await tauri.removeToken();
tauri.logout();
}
} catch (offlineError) {
errorMessage(t("homePage.errors.offlineError"));
}
}
if (e instanceof Error) {
errorMessage(e.message);
@@ -630,9 +607,8 @@ function ScribeContent() {
}
}
} else {
if (window.electron) {
try {
const offlineStatus = await window.electron.offlineModeGet();
const offlineStatus = await tauri.offlineModeGet();
if (offlineStatus.hasPin && offlineStatus.lastUserId) {
setOfflineMode(prev => ({...prev, isOffline: true, isNetworkOnline: false}));
@@ -643,8 +619,7 @@ function ScribeContent() {
} catch (error) {
errorMessage(t("homePage.errors.authenticationError"));
}
window.electron.logout();
}
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<ChapterProps | null>(`chapter/last-chapter`, session.accessToken, locale, {bookid: currentBook?.bookId});
}
@@ -768,7 +743,7 @@ function ScribeContent() {
!isTermsAccepted && !isCurrentlyOffline() && <TermsOfUse onAccept={handleTermsAcceptance}/>
}
{
showPinSetup && window.electron && (
showPinSetup && (
<OfflinePinSetup
showOnFirstLogin={true}
onClose={():void => setShowPinSetup(false)}
@@ -779,12 +754,10 @@ function ScribeContent() {
)
}
{
showPinVerify && window.electron && (
showPinVerify && (
<OfflinePinVerify
onSuccess={handlePinVerifySuccess}
onCancel={():void => {
//window.electron.logout();
}}
onCancel={():void => {}}
/>
)
}

View File

@@ -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<void> {
System.removeCookie("token");
await window.electron.removeToken();
window.electron.logout();
await tauri.removeToken();
tauri.logout();
}
return (

View File

@@ -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<string>('db:book:create', bookData);
bookId = await tauri.createBook(bookData);
} else {
bookId = await System.authPostToServer<string>('book/add', bookData, token, lang);
}

View File

@@ -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<BookProps[]>('books', accessToken, lang),
offlineMode.isDatabaseInitialized
? window.electron.invoke<BookProps[]>('db:book:books')
? tauri.getBooks()
: Promise.resolve([]),
System.authGetQueryToServer<SeriesListItemProps[]>('series/list', accessToken, lang),
offlineMode.isDatabaseInitialized
? window.electron.invoke<SeriesListItemProps[]>('db:series:list')
? tauri.getSeriesList() as Promise<SeriesListItemProps[]>
: Promise.resolve([])
]);
@@ -221,8 +222,8 @@ export default function BookList() {
return;
}
const [localBooks, localSeries] = await Promise.all([
window.electron.invoke<BookProps[]>('db:book:books'),
window.electron.invoke<SeriesListItemProps[]>('db:series:list')
tauri.getBooks(),
tauri.getSeriesList() as Promise<SeriesListItemProps[]>
]);
booksResponse = localBooks.map(b => ({...b, itIsLocal: true}));
seriesResponse = localSeries;
@@ -396,25 +397,20 @@ 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<BookProps>(
'book/basic-information', accessToken, lang, {id: bookId}
);
}
}
if (!bookResponse) {
errorMessage(t("bookList.errorBookDetails"));

View File

@@ -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<boolean>('db:book:updateBasicInformation', basicInfoData);
response = await tauri.updateBookBasicInfo(basicInfoData);
} else {
response = await System.authPostToServer<boolean>('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) {

View File

@@ -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<boolean>('db:book:delete', deleteData);
response = await tauri.deleteBook(deleteData.id, deleteData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>(
`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<boolean>('db:book:delete', deleteData);
await tauri.deleteBook(deleteData.id, deleteData.deletedAt);
}
}
if (response) {

View File

@@ -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<ChapterExportInfo[]>(
'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<boolean>('db:book:export', {
const result: boolean = await tauri.exportBook({
bookId: book.bookId,
format,
selections: selectedChapters.length === chapters.length ? null : selectedChapters

View File

@@ -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<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',

View File

@@ -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<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',

View File

@@ -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<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',

View File

@@ -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<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getCharacterAttributes(character?.id!) as AttributeResponse;
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',

View File

@@ -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,15 +84,11 @@ function GuideLineSetting(props: any, ref: any) {
async function getAIGuideLine(): Promise<void> {
try {
let response: GuideLineAI;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<GuideLineAI>('db:book:guideline:ai:get', {id: bookId});
} else {
if (book?.localBook) {
response = await window.electron.invoke<GuideLineAI>('db:book:guideline:ai:get', {id: bookId});
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getAIGuideLine(bookId);
} else {
response = await System.authGetQueryToServer<GuideLineAI>(`book/ai/guideline`, userToken, lang, {id: bookId});
}
}
if (response) {
setPlotSummary(response.globalResume || '');
setVerbTense(response.verbeTense?.toString() || '');
@@ -113,11 +110,8 @@ function GuideLineSetting(props: any, ref: any) {
async function getGuideLine(): Promise<void> {
try {
let response: GuideLine;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<GuideLine>('db:book:guideline:get', {id: bookId});
} else {
if (book?.localBook) {
response = await window.electron.invoke<GuideLine>('db:book:guideline:get', {id: bookId});
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getGuideLine(bookId);
} else {
response = await System.authGetQueryToServer<GuideLine>(
`book/guide-line`,
@@ -126,7 +120,6 @@ function GuideLineSetting(props: any, ref: any) {
{id: bookId},
);
}
}
if (response) {
setTone(response.tone);
setAtmosphere(response.atmosphere);
@@ -165,7 +158,7 @@ function GuideLineSetting(props: any, ref: any) {
keyMessages: keyMessages,
};
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:guideline:update', guidelineData);
response = await tauri.updateGuideLine(guidelineData);
} else {
response = await System.authPostToServer<boolean>(
'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<boolean>('db:book:guideline:ai:update', aiGuidelineData);
response = await tauri.updateAIGuideLine(aiGuidelineData);
} else {
response = await System.authPostToServer<boolean>(
'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) {

View File

@@ -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<boolean>('db:book:tool:update', {
bookId: currentEntityId,
toolName: 'locations',
enabled: enabled
});
response = await tauri.updateBookToolSetting(currentEntityId, 'locations', enabled);
} else {
response = await System.authPatchToServer<boolean>('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<SeriesLocationItem[]>('db:series:location:list', {seriesId: currentEntityId});
response = await tauri.getSeriesLocationList(currentEntityId) as SeriesLocationItem[];
} else {
response = await System.authGetQueryToServer<SeriesLocationItem[]>(
'series/location/list',
@@ -190,17 +187,13 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
}
} else {
let response: LocationListResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: currentEntityId});
} else {
if (book?.localBook) {
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: currentEntityId});
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getAllLocations(currentEntityId, true) as LocationListResponse;
} else {
response = await System.authGetQueryToServer<LocationListResponse>(`location/all`, token, lang, {
bookid: currentEntityId,
});
}
}
if (response) {
setSections(response.locations);
setToolEnabled(response.enabled);
@@ -238,7 +231,7 @@ export function LocationComponent(props: LocationComponentProps, ref: React.Ref<
name: newSectionName,
};
if (isCurrentlyOffline() || localSeries) {
sectionId = await window.electron.invoke<string>('db:series:location:section:add', addData);
sectionId = await tauri.addSeriesLocationSection(addData);
} else {
sectionId = await System.authPostToServer<string>(
'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<string>('db:location:section:add', {
bookId: currentEntityId,
locationName: newSectionName,
});
sectionId = await tauri.addLocationSection(newSectionName, currentEntityId);
} else {
sectionId = await System.authPostToServer<string>(`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<string>('db:series:location:element:add', addData);
elementId = await tauri.addSeriesLocationElement(addData);
} else {
elementId = await System.authPostToServer<string>(
'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<string>('db:location:element:add', {
bookId: currentEntityId,
locationId: sectionId,
elementName: newElementNames[sectionId],
});
elementId = await tauri.addLocationElement(sectionId, newElementNames[sectionId]);
} else {
elementId = await System.authPostToServer<string>(`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<string>('db:series:location:subelement:add', addData);
subElementId = await tauri.addSeriesLocationSubElement(addData);
} else {
subElementId = await System.authPostToServer<string>(
'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<string>('db:location:subelement:add', {
elementId: elementId,
subElementName: newSubElementNames[elementIndex],
});
subElementId = await tauri.addLocationSubElement(elementId, newSubElementNames[elementIndex]);
} else {
subElementId = await System.authPostToServer<string>(`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<boolean>('db:series:location:element:delete', deleteData);
response = await tauri.deleteSeriesLocationElement(deleteData.elementId!, deleteData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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<boolean>('db:location:element:delete', {
elementId: elementId, bookId: currentEntityId, deletedAt,
});
response = await tauri.deleteLocationElement(elementId!, currentEntityId, deletedAt);
} else {
response = await System.authDeleteToServer<boolean>(`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<boolean>('db:series:location:subelement:delete', deleteData);
response = await tauri.deleteSeriesLocationSubElement(deleteData.subElementId!, deleteData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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<boolean>('db:location:subelement:delete', {
subElementId: subElementId, bookId: currentEntityId, deletedAt,
});
response = await tauri.deleteLocationSubElement(subElementId!, currentEntityId, deletedAt);
} else {
response = await System.authDeleteToServer<boolean>(`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<boolean>('db:series:location:delete', deleteData);
response = await tauri.deleteSeriesLocation(deleteData.locationId, deleteData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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<boolean>('db:location:delete', {
locationId: sectionId, bookId: currentEntityId, deletedAt,
});
response = await tauri.deleteLocationSection(sectionId, currentEntityId, deletedAt);
} else {
response = await System.authDeleteToServer<boolean>(`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<boolean>('db:location:update', {
locations: sections,
});
response = await tauri.updateLocations(sections) as boolean;
} else {
response = await System.authPostToServer<boolean>(`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) {

View File

@@ -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<string>('db:book:incident:add', {
bookId,
name: newIncidentTitle,
});
incidentId = await tauri.addIncident(bookId!, newIncidentTitle);
} else {
incidentId = await System.authPostToServer<string>('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<boolean>('db:book:incident:remove', deleteData);
response = await tauri.removeIncident(deleteData.bookId!, deleteData.incidentId, deleteData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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<string>('db:book:plot:add', plotData);
plotId = await tauri.addPlotPoint(plotData.bookId!, plotData.name, plotData.incidentId);
} else {
plotId = await System.authPostToServer<string>('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<boolean>('db:book:plot:remove', deleteData);
response = await tauri.removePlotPoint(deleteData.plotId, deleteData.bookId!, deleteData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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<string>('db:chapter:information:add', linkData);
linkId = await tauri.addChapterInformation(linkData as any);
} else {
linkId = await System.authPostToServer<string>('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<boolean>('db:chapter:information:remove', unlinkData);
response = await tauri.removeChapterInformation(unlinkData.chapterInfoId, unlinkData.bookId!, unlinkData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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) {

View File

@@ -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<string>('db:book:issue:add', {
bookId,
name: newIssueName,
});
issueId = await tauri.addIssue(bookId!, newIssueName);
} else {
issueId = await System.authPostToServer<string>('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<boolean>('db:book:issue:remove', {
bookId,
issueId,
deletedAt,
});
response = await tauri.removeIssue(bookId!, issueId, deletedAt);
} else {
response = await System.authDeleteToServer<boolean>(
'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) {

View File

@@ -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<boolean>('db:chapter:remove', deleteData);
response = await tauri.removeChapter(deleteData.chapterId, deleteData.bookId!, deleteData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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<string>('db:chapter:add', chapterData);
responseId = await tauri.addChapter(chapterData);
} else {
responseId = await System.authPostToServer<string>('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) {

View File

@@ -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,17 +77,13 @@ export function Story(props: any, ref: any) {
async function getStoryData(): Promise<void> {
try {
let response: StoryFetchData;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<StoryFetchData>('db:book:story:get', {bookid: bookId});
} else {
if (book?.localBook) {
response = await window.electron.invoke<StoryFetchData>('db:book:story:get', {bookid: bookId});
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getBookStory(bookId) as StoryFetchData;
} else {
response = await System.authGetQueryToServer<StoryFetchData>(`book/story`, userToken, lang, {
bookid: bookId,
});
}
}
if (response) {
setActs(response.acts);
setMainChapters(response.mainChapter);
@@ -143,12 +140,12 @@ export function Story(props: any, ref: any) {
issues,
};
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:story:update', storyData);
response = await tauri.updateBookStory(storyData);
} else {
response = await System.authPostToServer<boolean>('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) {

View File

@@ -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<boolean>('db:series:world:element:delete', deleteData);
response = await tauri.deleteSeriesWorldElement(elementId, deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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<boolean>('db:book:world:element:remove', {
elementId, bookId: book?.bookId, deletedAt,
});
response = await tauri.removeWorldElement(elementId, book?.bookId || '', deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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<string>('db:series:world:element:add', addData);
elementId = await tauri.addSeriesWorldElement(addData);
} else {
elementId = await System.authPostToServer<string>(
'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<string>('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) {

View File

@@ -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<boolean>('db:book:tool:update', {
bookId: currentEntityId,
toolName: 'worlds',
enabled: enabled
});
response = await tauri.updateBookToolSetting(currentEntityId, 'worlds', enabled);
} else {
response = await System.authPatchToServer<boolean>('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<WorldListResponse>('db:book:worlds:get', {bookid: currentEntityId});
response = await tauri.getWorlds(currentEntityId, true);
} else {
response = await System.authGetQueryToServer<WorldListResponse>('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<string>('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<boolean>('db:book:world:update', {
world: currentWorld,
bookId: currentEntityId,
});
response = await tauri.updateWorld(currentWorld);
} else {
// Book mode: online
response = await System.authPatchToServer<boolean>('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 = {

View File

@@ -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,19 +114,11 @@ export default function DraftCompanion() {
async function getDraftContent(): Promise<void> {
try {
let response: CompanionContent | null;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<CompanionContent>('db:chapter:content:companion', {
bookid: book?.bookId,
chapterid: chapter?.chapterId,
version: chapter?.chapterContent.version,
});
} else {
if (book?.localBook) {
response = await window.electron.invoke<CompanionContent>('db:chapter:content:companion', {
bookid: book?.bookId,
chapterid: chapter?.chapterId,
version: chapter?.chapterContent.version,
});
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getCompanionContent(
chapter?.chapterId ?? '',
chapter?.chapterContent.version ?? 0,
) as CompanionContent | null;
} else {
response = await System.authGetQueryToServer<CompanionContent>(`chapter/content/companion`, session.accessToken, lang, {
bookid: book?.bookId,
@@ -133,7 +126,6 @@ export default function DraftCompanion() {
version: chapter?.chapterContent.version,
});
}
}
if (response && mainEditor) {
mainEditor.commands.setContent(JSON.parse(response.content));
setDraftVersion(response.version);
@@ -169,17 +161,13 @@ export default function DraftCompanion() {
async function fetchTags(): Promise<void> {
try {
let responseTags: BookTags | null;
if (isCurrentlyOffline()) {
responseTags = await window.electron.invoke<BookTags>('db:book:tags', book?.bookId);
} else {
if (book?.localBook) {
responseTags = await window.electron.invoke<BookTags>('db:book:tags', book?.bookId);
if (isCurrentlyOffline() || book?.localBook) {
responseTags = await tauri.getBookTags(book?.bookId ?? '') as BookTags | null;
} else {
responseTags = await System.authGetQueryToServer<BookTags>(`book/tags`, session.accessToken, lang, {
bookId: book?.bookId
});
}
}
if (responseTags) {
setCharacters(responseTags.characters);
setLocations(responseTags.locations);

View File

@@ -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<boolean>('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<boolean>(`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) {

View File

@@ -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<SeriesSyncUploadResponse>('db:series:sync:upload', requestData);
response = await tauri.seriesSyncUpload(requestData) as SeriesSyncUploadResponse;
} else {
// Online + livre serveur → Server
response = await System.authPostToServer<SeriesSyncUploadResponse>(
'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});
}
}

View File

@@ -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,15 +80,11 @@ export default function ScribeChapterComponent() {
async function getChapterList(): Promise<void> {
try {
let response: ChapterListProps[]|null;
if (isCurrentlyOffline()){
response = await window.electron.invoke<ChapterListProps[]>('db:book:chapters', book?.bookId)
} else {
if (book?.localBook){
response = await window.electron.invoke<ChapterListProps[]>('db:book:chapters', book?.bookId)
if (isCurrentlyOffline() || book?.localBook){
response = await tauri.getChapters(book?.bookId ?? '') as ChapterListProps[];
} else {
response = await System.authGetQueryToServer<ChapterListProps[]>(`book/chapters?id=${book?.bookId}`, userToken, lang);
}
}
if (response) {
setChapters(response);
}
@@ -104,19 +101,8 @@ 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<ChapterProps>('db:chapter:whole', {
bookid: book?.bookId,
id: chapterId,
version: version,
})
} else {
if (book?.localBook){
response = await window.electron.invoke<ChapterProps>('db:chapter:whole', {
bookid: book?.bookId,
id: chapterId,
version: version,
})
if (isCurrentlyOffline() || book?.localBook) {
response = await tauri.getWholeChapter(chapterId, version, book?.bookId ?? '');
} else {
response = await System.authGetQueryToServer<ChapterProps>(`chapter/whole`, userToken, lang, {
bookid: book?.bookId,
@@ -124,7 +110,6 @@ export default function ScribeChapterComponent() {
version: version,
});
}
}
if (!response) {
errorMessage(t("scribeChapterComponent.errorFetchChapter"));
return;
@@ -148,12 +133,12 @@ export default function ScribeChapterComponent() {
title: title,
};
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:chapter:update', updateData);
response = await tauri.updateChapter(updateData.chapterId, updateData.title, updateData.chapterOrder);
} else {
response = await System.authPostToServer<boolean>('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<boolean>('db:chapter:remove', {
chapterId: removeChapterId,
bookId: book?.bookId,
deletedAt,
});
response = await tauri.removeChapter(removeChapterId, book?.bookId ?? '', deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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<string>('db:chapter:add', addData);
chapterId = await tauri.addChapter({
bookId: addData.bookId ?? '',
title: addData.title,
chapterOrder: addData.chapterOrder,
});
} else {
chapterId = await System.authPostToServer<string>('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) {

View File

@@ -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,16 +54,14 @@ 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
await tauri.offlineModeSet(true, 30);
onSuccess?.();
} else {
setError(result.error || t('offline.pin.errors.setupFailed'));
}
}
} catch (error) {
console.error('[OfflinePin] Error setting PIN:', error);
setError(t('offline.pin.errors.setupFailed'));

View File

@@ -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,8 +30,7 @@ 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);
@@ -44,7 +44,6 @@ export default function OfflinePinVerify({ onSuccess, onCancel }: OfflinePinVeri
setError(result.error || t('offline.pin.verify.incorrect'));
}
}
}
} catch (error) {
setError(t('offline.pin.verify.error'));
} finally {
@@ -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 (

View File

@@ -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;
}

View File

@@ -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<void> {
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<void> {
return window.electron.openExternal('https://discord.gg/CHXRPvmaXm');
return tauri.openExternal('https://discord.gg/CHXRPvmaXm');
}
}
];

View File

@@ -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<SetStateAction<boolean>>;
@@ -88,7 +89,7 @@ export default function AddNewSeriesForm({setCloseForm, onSeriesCreated}: AddNew
let response: string;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<string>('db:series:create', createData);
response = await tauri.createSeries(createData);
} else {
response = await System.authPostToServer<string>(
'series/add',

View File

@@ -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<boolean>('db:series:delete', deleteData);
success = await tauri.deleteSeries(deleteData.seriesId, deleteData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>(
'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});
}
}

View File

@@ -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<void> }>) {
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<SeriesDetailResponse>('db:series:detail', {seriesId});
response = await tauri.getSeriesDetail(seriesId);
} else {
response = await System.authGetQueryToServer<SeriesDetailResponse>(
'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<boolean>('db:series:update', updateData);
success = await tauri.updateSeries(updateData);
} else {
const response: SeriesUpdateResponse = await System.authPutToServer<SeriesUpdateResponse>(
'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});
}
}

View File

@@ -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<void> }>) {
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<SeriesBookProps[]>('db:series:books', {seriesId});
response = await tauri.getSeriesBooks(seriesId);
} else {
response = await System.authGetQueryToServer<SeriesBookProps[]>(
'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<boolean>('db:series:book:add', addData);
response = await tauri.addBookToSeries(addData.seriesId, addData.bookId);
} else {
response = await System.authPostToServer<boolean>(
'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<boolean>('db:series:book:remove', removeData);
response = await tauri.removeBookFromSeries(removeData.seriesId, removeData.bookId, removeData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>(
'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<boolean>('db:series:book:reorder', reorderData);
response = await tauri.reorderSeriesBooks(reorderData.seriesId, reorderData.booksOrder);
} else {
response = await System.authPutToServer<boolean>(
'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});
}
}

View File

@@ -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,30 +13,17 @@ export default function OfflineProvider({ children }: OfflineProviderProps) {
const initializeDatabase = useCallback(async (userId: string, encryptionKey?: string): Promise<boolean> => {
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,
@@ -45,7 +33,7 @@ export default function OfflineProvider({ children }: OfflineProviderProps) {
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,

65
electron.d.ts vendored
View File

@@ -1,65 +0,0 @@
/**
* TypeScript declarations for window.electron API
* Must match exactly with electron/preload.ts
*
* Usage:
* - Use invoke<T>(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: <T>(channel: string, ...args: any[]) => Promise<T>;
// Token management (shortcuts for convenience)
getToken: () => Promise<string | null>;
setToken: (token: string) => Promise<void>;
removeToken: () => Promise<void>;
// Language management (shortcuts for convenience)
getLang: () => Promise<'fr' | 'en'>;
setLang: (lang: 'fr' | 'en') => Promise<void>;
// 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<string>;
getUserEncryptionKey: (userId: string) => Promise<string | null>;
setUserEncryptionKey: (userId: string, encryptionKey: string) => Promise<void>;
// Database initialization (shortcut for convenience)
dbInitialize: (userId: string, encryptionKey: string) => Promise<boolean>;
// Open external links (browser/native app)
openExternal: (url: string) => Promise<void>;
// 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 {};

View File

@@ -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<SeriesCharacterProps[]>(
'db:series:character:list',
{seriesId: bookSeriesId}
);
response = await tauri.getSeriesCharacterList(bookSeriesId);
} else {
response = await System.authGetQueryToServer<SeriesCharacterProps[]>(
'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<SeriesCharacterProps[]>(
'db:series:character:list',
{seriesId: entityId}
);
response = await tauri.getSeriesCharacterList(entityId);
} else {
response = await System.authGetQueryToServer<SeriesCharacterProps[]>(
'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<CharacterListResponse>('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<CharacterListResponse>('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<CharacterListResponse>(
@@ -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<boolean>('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<boolean>('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<string>('db:series:character:add', seriesCharacterData);
characterId = await tauri.addSeriesCharacter(seriesCharacterData);
} else {
characterId = await System.authPostToServer<string>(
'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<string>('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<string>('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<boolean>('db:series:character:update', updateData);
response = await tauri.updateSeriesCharacter(updateData);
} else {
response = await System.authPatchToServer<boolean>('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<boolean>('db:character:update', requestData);
// Offline OU livre local → Tauri
response = await tauri.updateCharacter(requestData.character);
} else {
// Online + livre serveur → Server
response = await System.authPostToServer<boolean>('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<boolean>('db:series:character:delete', requestData);
response = await tauri.deleteSeriesCharacter(requestData.characterId, requestData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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<boolean>('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<boolean>('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<string>('db:series:character:attribute:add', requestData);
attributeId = await tauri.addSeriesCharacterAttribute(requestData);
} else {
attributeId = await System.authPostToServer<string>('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<string>('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<string>('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<boolean>('db:series:character:attribute:delete', requestData);
response = await tauri.deleteSeriesCharacterAttribute(requestData.attributeId, requestData.deletedAt);
} else {
response = await System.authDeleteToServer<boolean>('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<boolean>('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<boolean>('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<string>('db:series:character:add', seriesCharacterData);
// Mode offline ou livre local → Tauri
seriesCharacterId = await tauri.addSeriesCharacter(seriesCharacterData);
} else {
// Mode online → Serveur
seriesCharacterId = await System.authPostToServer<string>(
@@ -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<boolean>('db:character:update', updateData);
// Mode offline ou livre local → Tauri
updateResponse = await tauri.updateCharacter(updateData.character);
} else {
// Mode online → Serveur
updateResponse = await System.authPostToServer<boolean>('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<string>('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<string>('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}});
}
}

View File

@@ -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<SeriesLocationItem[]>(
'db:series:location:list',
{seriesId: bookSeriesId}
);
response = await tauri.getSeriesLocationList(bookSeriesId);
} else {
response = await System.authGetQueryToServer<SeriesLocationItem[]>(
'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<SeriesLocationItem[]>(
'db:series:location:list',
{seriesId: entityId}
);
response = await tauri.getSeriesLocationList(entityId);
} else {
response = await System.authGetQueryToServer<SeriesLocationItem[]>(
'series/location/list',
@@ -209,9 +204,9 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn {
} else {
let response: LocationListResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: entityId});
response = await tauri.getAllLocations(entityId, true) as unknown as LocationListResponse;
} else if (book?.localBook) {
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: entityId});
response = await tauri.getAllLocations(entityId, true) as unknown as LocationListResponse;
} else {
response = await System.authGetQueryToServer<LocationListResponse>(
'location/all',
@@ -257,12 +252,12 @@ export function useLocations(config: UseLocationsConfig): UseLocationsReturn {
};
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:tool:update', requestData);
response = await tauri.updateBookToolSetting(requestData.bookId!, requestData.toolName, requestData.enabled);
} else {
response = await System.authPatchToServer<boolean>('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<string>('db:series:location:section:add', addData);
sectionId = await tauri.addSeriesLocationSection(addData);
} else {
sectionId = await System.authPostToServer<string>(
'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<string>('db:location:section:add', requestData);
sectionId = await tauri.addLocationSection(requestData.locationName, requestData.bookId, undefined, undefined);
} else {
sectionId = await System.authPostToServer<string>('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<string>('db:series:location:element:add', addData);
elementId = await tauri.addSeriesLocationElement(addData);
} else {
elementId = await System.authPostToServer<string>(
'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<string>('db:location:element:add', requestData);
elementId = await tauri.addLocationElement(requestData.locationId, requestData.elementName, undefined);
} else {
elementId = await System.authPostToServer<string>('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<string>('db:series:location:subelement:add', addData);
subElementId = await tauri.addSeriesLocationSubElement(addData);
} else {
subElementId = await System.authPostToServer<string>(
'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<string>('db:location:subelement:add', requestData);
subElementId = await tauri.addLocationSubElement(requestData.elementId, requestData.subElementName, undefined);
} else {
subElementId = await System.authPostToServer<string>('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<boolean>('db:series:location:delete', deleteData);
success = await tauri.deleteSeriesLocation(deleteData.locationId, deleteData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>('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<boolean>('db:location:delete', requestData);
success = await tauri.deleteLocationSection(requestData.locationId, requestData.bookId, requestData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>('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<boolean>('db:series:location:element:delete', deleteData);
success = await tauri.deleteSeriesLocationElement(deleteData.elementId!, deleteData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>('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<boolean>('db:location:element:delete', requestData);
success = await tauri.deleteLocationElement(requestData.elementId!, requestData.bookId, requestData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>('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<boolean>('db:series:location:subelement:delete', deleteData);
success = await tauri.deleteSeriesLocationSubElement(deleteData.subElementId!, deleteData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>('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<boolean>('db:location:subelement:delete', requestData);
success = await tauri.deleteLocationSubElement(requestData.subElementId!, requestData.bookId, requestData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>('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<boolean>('db:location:update', requestData);
response = await tauri.updateLocations(requestData.locations);
} else {
response = await System.authPostToServer<boolean>('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<string>('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<string>('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<boolean>('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<boolean>('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<string>('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<string>('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<string>('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<string>('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<string>('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<string>('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}});
}
}

View File

@@ -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<SeriesSpellListResponse>(
'db:series:spell:list',
{seriesId: bookSeriesId}
);
response = await tauri.getSeriesSpellList(bookSeriesId) as SeriesSpellListResponse;
} else {
response = await System.authGetQueryToServer<SeriesSpellListResponse>(
'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<SeriesSpellListResponse>(
'db:series:spell:list',
{seriesId: entityId}
);
response = await tauri.getSeriesSpellList(entityId) as SeriesSpellListResponse;
} else {
response = await System.authGetQueryToServer<SeriesSpellListResponse>(
'series/spell/list',
@@ -174,9 +169,9 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn {
} else {
let response: SpellListResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<SpellListResponse>('db:spell:list', {bookid: entityId});
response = await tauri.getSpellList(entityId, true) as SpellListResponse;
} else if (book?.localBook) {
response = await window.electron.invoke<SpellListResponse>('db:spell:list', {bookid: entityId});
response = await tauri.getSpellList(entityId, true) as SpellListResponse;
} else {
response = await System.authGetQueryToServer<SpellListResponse>(
'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<SeriesSpellDetailResponse>(
'db:series:spell:detail',
{spellId: spell.id}
);
response = await tauri.getSeriesSpellDetail(spell.id) as SeriesSpellDetailResponse;
} else {
response = await System.authGetQueryToServer<SeriesSpellDetailResponse>(
'series/spell/detail',
@@ -270,9 +262,9 @@ export function useSpells(config: UseSpellsConfig): UseSpellsReturn {
} else {
let response: SpellProps;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<SpellProps>('db:spell:detail', {spellid: spell.id});
response = await tauri.getSpellDetail(spell.id) as SpellProps;
} else if (book?.localBook) {
response = await window.electron.invoke<SpellProps>('db:spell:detail', {spellid: spell.id});
response = await tauri.getSpellDetail(spell.id) as SpellProps;
} else {
response = await System.authGetQueryToServer<SpellProps>(
'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<SeriesSpellDetailResponse>(
'db:series:spell:detail',
{spellId: response.seriesSpellId}
);
seriesSpellResponse = await tauri.getSeriesSpellDetail(response.seriesSpellId) as SeriesSpellDetailResponse;
} else {
seriesSpellResponse = await System.authGetQueryToServer<SeriesSpellDetailResponse>(
'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<boolean>('db:book:tool:update', requestData);
response = await tauri.updateBookToolSetting(requestData.bookId, requestData.toolName, requestData.enabled);
} else {
response = await System.authPatchToServer<boolean>('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<string>('db:series:spell:add', data);
newSpellId = await tauri.addSeriesSpell(data);
} else {
newSpellId = await System.authPostToServer<string>('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<string>('db:spell:create', data);
newSpellId = await tauri.createSpell(data.bookId, data.spell);
} else {
newSpellId = await System.authPostToServer<string>('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<boolean>('db:series:spell:update', data);
success = await tauri.updateSeriesSpell(data);
} else {
success = await System.authPutToServer<boolean>('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<boolean>('db:spell:update', data);
success = await tauri.updateSpell(data.id, data);
} else {
success = await System.authPutToServer<boolean>('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<boolean>('db:series:spell:delete', requestData);
success = await tauri.deleteSeriesSpell(requestData.spellId, requestData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>('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<boolean>('db:spell:delete', requestData);
success = await tauri.deleteSpell(requestData.spellId, requestData.bookId, requestData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>('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<string>('db:series:spell:add', seriesSpellData);
seriesSpellId = await tauri.addSeriesSpell(seriesSpellData);
} else {
// Mode online → Serveur
seriesSpellId = await System.authPostToServer<string>(
@@ -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<boolean>('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<boolean>('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<SeriesSpellDetailResponse>(
'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<SeriesSpellDetailResponse>(
@@ -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<string>('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<string>('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<string>('db:series:spell:tag:add', addData);
tagId = await tauri.addSeriesSpellTag(addData);
} else {
tagId = await System.authPostToServer<string>(
'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<SpellTagProps>('db:spell:tag:create', requestData);
newTag = await tauri.createSpellTag(requestData.bookId, requestData.name, requestData.color) as SpellTagProps;
} else {
newTag = await System.authPostToServer<SpellTagProps>('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<boolean>('db:series:spell:tag:update', requestData);
success = await tauri.updateSeriesSpellTag(requestData);
} else {
success = await System.authPutToServer<boolean>('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<boolean>('db:spell:tag:update', requestData);
success = await tauri.updateSpellTag(requestData.tagId, requestData.name, requestData.color);
} else {
success = await System.authPutToServer<boolean>('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<boolean>('db:series:spell:tag:delete', deleteData);
success = await tauri.deleteSeriesSpellTag(deleteData.tagId, deleteData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>('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<boolean>('db:spell:tag:delete', requestData);
success = await tauri.deleteSpellTag(requestData.tagId, requestData.bookId, requestData.deletedAt);
} else {
success = await System.authDeleteToServer<boolean>('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<SeriesSpellDetailResponse>(
'db:series:spell:detail',
{spellId: selectedSpell.seriesSpellId}
);
seriesSpellResponse = await tauri.getSeriesSpellDetail(selectedSpell.seriesSpellId) as SeriesSpellDetailResponse;
} else {
seriesSpellResponse = await System.authGetQueryToServer<SeriesSpellDetailResponse>(
'series/spell/detail',

View File

@@ -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<SeriesWorldProps[]>(
'db:series:world:list',
{seriesId: bookSeriesId}
);
response = await tauri.getSeriesWorldList(bookSeriesId);
} else {
response = await System.authGetQueryToServer<SeriesWorldProps[]>(
'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<SeriesWorldProps[]>(
'db:series:world:list',
{seriesId: entityId}
);
response = await tauri.getSeriesWorldList(entityId);
} else {
response = await System.authGetQueryToServer<SeriesWorldProps[]>(
'series/world/list',
@@ -212,9 +207,9 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn {
} else {
let response: WorldListResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<WorldListResponse>('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<WorldListResponse>('db:book:worlds:get', {bookid: entityId});
response = await tauri.getWorlds(entityId, true) as unknown as WorldListResponse;
} else {
response = await System.authGetQueryToServer<WorldListResponse>(
'book/worlds',
@@ -293,11 +288,11 @@ export function useWorlds(config: UseWorldsConfig): UseWorldsReturn {
};
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:tool:update', requestData);
response = await tauri.updateBookToolSetting(requestData.bookId, requestData.toolName, requestData.enabled);
} else {
response = await System.authPatchToServer<boolean>('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<string>('db:series:world:add', addData);
newWorldId = await tauri.addSeriesWorld(addData);
} else {
newWorldId = await System.authPostToServer<string>(
'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<string>('db:book:world:add', requestData);
newWorldId = await tauri.addWorld(requestData.bookId || entityId, requestData.worldName, requestData.id, requestData.seriesWorldId);
} else {
newWorldId = await System.authPostToServer<string>('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<boolean>('db:series:world:update', updateData);
response = await tauri.updateSeriesWorld(updateData);
} else {
response = await System.authPatchToServer<boolean>('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<boolean>('db:book:world:update', requestData);
response = await tauri.updateWorld(requestData.world || requestData);
} else {
response = await System.authPatchToServer<boolean>('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<string>('db:series:world:add', seriesWorldData);
// Mode offline ou livre local → Tauri
seriesWorldId = await tauri.addSeriesWorld(seriesWorldData);
} else {
// Mode online → Serveur
seriesWorldId = await System.authPostToServer<string>('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<boolean>('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<boolean>('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<string>('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<string>('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}});
}
}

View File

@@ -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<CompleteBook>('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<boolean>('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<boolean>('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<CompleteBook>('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<SyncedBook[]>('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<RemovedItemRecord[]>(
'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<SyncedBooksResponse>(
'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<void>('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<SyncedBooksResponse>(
@@ -235,7 +229,7 @@ export default function useSyncBooks() {
}
} else {
if (offlineMode.isDatabaseInitialized) {
localBooksResponse = await window.electron.invoke<SyncedBook[]>('db:books:synced');
localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[];
}
}

View File

@@ -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<CompleteSeries>('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<boolean>('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<boolean>('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<CompleteSeries>(
'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<SyncedSeries[]>('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<RemovedItemRecord[]>(
'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<SyncedSeriesResponse>(
'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<void>('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<SyncedSeriesResponse>(
@@ -324,7 +315,7 @@ export default function useSyncSeries() {
}
} else {
if (offlineMode.isDatabaseInitialized) {
localSeriesResponse = await window.electron.invoke<SyncedSeries[]>('db:series:synced');
localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[];
}
}

View File

@@ -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/',

View File

@@ -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<T> = 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
})

View File

@@ -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<T>(
* const { data, error, loading, execute } = useDbOperation();
*
* const loadBooks = async () => {
* await execute(() => window.electron.invoke('db:book:getAll'));
* await execute(() => tauri.getBooks());
* };
*/
export function useDbOperation<T>() {

243
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,2 @@
pub mod repo;
pub mod service;

View File

@@ -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<String>,
}
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<Vec<ActQuery>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<String> {
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<Vec<BookActSummariesTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedActSummaryResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<Vec<BookActSummariesTable>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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())
}

View File

@@ -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<String>,
pub incidents: Option<Vec<IncidentProps>>,
pub plot_points: Option<Vec<PlotPointProps>>,
pub chapters: Option<Vec<ActChapter>>,
}
pub struct ActStory {
pub act_id: i64,
pub summary: String,
pub chapter_summary: String,
pub chapter_goal: String,
pub incidents: Vec<IncidentStory>,
pub plot_points: Vec<PlotPointStory>,
}
#[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<String>,
pub plot_point_id: Option<String>,
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<Vec<ActChapter>>,
}
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<String>,
pub chapters: Option<Vec<ActChapter>>,
}
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<Vec<ActChapter>> {
let act_chapter_query_results: Vec<chapter_repo::ActChapterQuery> = 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<ActChapter> = 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<Vec<IncidentProps>> {
let incident_query_results: Vec<incident_repo::IncidentQuery> = 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<IncidentProps> = Vec::new();
if incident_query_results.is_empty() {
return Ok(incidents);
}
for incident_record in &incident_query_results {
let mut associated_chapters: Vec<ActChapter> = 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<Vec<PlotPointProps>> {
let plot_point_query_results: Vec<plotpoint_repo::PlotPointQuery> = 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<PlotPointProps> = 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<ActChapter> = 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<bool> {
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<Vec<ActProps>> {
let user_encryption_key: String = get_user_encryption_key(user_id)?;
let act_chapters: Vec<ActChapter> = get_all_chapter_from_acts(conn, user_id, book_id, lang)?;
let act_queries: Vec<repo::ActQuery> = repo::fetch_all_acts(conn, user_id, book_id, lang)?;
let book_incidents: Vec<IncidentProps> = get_incidents(conn, user_id, book_id, &act_chapters, lang)?;
let book_plot_points: Vec<PlotPointProps> = get_plot_points(conn, user_id, book_id, &act_chapters, lang)?;
let mut acts: Vec<ActProps> = 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<bool> {
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<bool> {
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)
}

View File

@@ -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<SessionState>) -> 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<DbManager>, _user_id: &str) -> Result<std::sync::MutexGuard<'a, crate::db::connection::DatabaseManager>, 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<String>,
pub summary: Option<String>,
pub book_type: String,
pub serie_id: Option<i64>,
pub desired_release_date: Option<String>,
pub desired_word_count: Option<i64>,
}
#[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<DbManager>, session: State<SessionState>) -> Result<Vec<service::BookProps>, 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<DbManager>, session: State<SessionState>) -> Result<service::BookProps, 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_book(conn, &user_id, &book_id, lang)
}
#[tauri::command]
pub fn get_book_basic_information(book_id: String, db: State<DbManager>, session: State<SessionState>) -> Result<service::BookProps, 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_book(conn, &user_id, &book_id, lang)
}
#[tauri::command]
pub fn create_book(data: CreateBookData, db: State<DbManager>, session: State<SessionState>) -> Result<String, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::remove_book(conn, &user_id, &data.id, data.deleted_at, lang)
}
#[tauri::command]
pub fn update_book_tool_setting(data: UpdateBookToolData, db: State<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<serde_json::Value, 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 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<act_service::ActProps>,
pub main_chapters: Vec<act_service::ChapterProps>,
}
#[tauri::command]
pub fn update_book_story(data: UpdateStoryData, db: State<DbManager>, session: State<SessionState>) -> Result<bool, AppError> {
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<String>,
}
#[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<DbManager>, session: State<SessionState>) -> Result<String, AppError> {
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<DbManager>, session: State<SessionState>) -> Result<bool, AppError> {
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<String>,
}
#[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<DbManager>, session: State<SessionState>) -> Result<String, AppError> {
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<DbManager>, session: State<SessionState>) -> Result<bool, AppError> {
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<String>,
}
#[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<DbManager>, session: State<SessionState>) -> Result<String, AppError> {
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<DbManager>, session: State<SessionState>) -> Result<bool, AppError> {
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<String>,
pub series_world_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddWorldElementData {
pub world_id: String,
pub element_name: String,
pub element_type: String,
pub id: Option<String>,
}
#[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<DbManager>, session: State<SessionState>) -> Result<world_service::WorldListResponse, AppError> {
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<DbManager>, session: State<SessionState>) -> Result<String, AppError> {
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<DbManager>, session: State<SessionState>) -> Result<String, AppError> {
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<DbManager>, session: State<SessionState>) -> Result<bool, AppError> {
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<DbManager>, session: State<SessionState>) -> Result<bool, AppError> {
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<String>,
pub atmosphere: Option<String>,
pub writing_style: Option<String>,
pub themes: Option<String>,
pub symbolism: Option<String>,
pub motifs: Option<String>,
pub narrative_voice: Option<String>,
pub pacing: Option<String>,
pub intended_audience: Option<String>,
pub key_messages: Option<String>,
}
#[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<DbManager>, session: State<SessionState>) -> Result<Option<guideline_service::GuideLineProps>, 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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<guideline_service::GuideLineAI, 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_ai(conn, &user_id, &data.id, lang)
}
#[tauri::command]
pub fn update_ai_guideline(data: UpdateAIGuidelineData, db: State<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<Vec<crate::domains::chapter::repo::ChapterSelectionParam>>,
}
#[tauri::command]
pub fn get_book_export_info(data: ExportInfoData, db: State<DbManager>, session: State<SessionState>) -> Result<Vec<chapter_service::ChapterExportInfo>, 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<DbManager>, session: State<SessionState>) -> Result<Vec<u8>, 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<DbManager>, session: State<SessionState>) -> Result<Vec<sync_service::SyncedBookFull>, 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<DbManager>, session: State<SessionState>) -> Result<service::CompleteBook, AppError> {
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<DbManager>, session: State<SessionState>) -> Result<bool, AppError> {
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<DbManager>, session: State<SessionState>) -> Result<bool, 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::sync_book_from_server_to_client(conn, &user_id, &data, lang)
}
#[tauri::command]
pub fn sync_book_to_server(data: service::BookSyncCompare, db: State<DbManager>, session: State<SessionState>) -> Result<service::CompleteBook, 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_complete_sync_book(conn, &user_id, &data, lang)
}

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod repo;
pub mod service;

View File

@@ -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<String>,
pub hashed_sub_title: Option<String>,
pub summary: Option<String>,
pub serie_id: Option<i64>,
pub desired_release_date: Option<String>,
pub desired_word_count: Option<i64>,
pub words_count: Option<i64>,
pub cover_image: Option<String>,
}
#[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<String>,
pub hashed_sub_title: Option<String>,
pub summary: Option<String>,
pub serie_id: Option<i64>,
pub desired_release_date: Option<String>,
pub desired_word_count: Option<i64>,
pub words_count: Option<i64>,
pub last_update: i64,
pub cover_image: Option<String>,
}
pub struct SyncedBookResult {
pub book_id: String,
pub book_type: String,
pub title: String,
pub sub_title: Option<String>,
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<Vec<BookQuery>> {
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::<Result<Vec<_>, _>>()
.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<BookQuery> {
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<bool> {
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<String> {
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<bool> {
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<bool> {
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<Vec<EritBooksTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedBookResult>> {
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::<Result<Vec<_>, _>>()
.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<i64>, desired_release_date: Option<&str>,
desired_word_count: Option<i64>, words_count: Option<i64>, cover_image: Option<&str>,
last_update: i64, lang: Lang,
) -> AppResult<bool> {
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<Vec<EritBooksTable>> {
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::<Result<Vec<_>, _>>()
.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<Option<BookToolsTable>> {
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<bool> {
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<Option<SyncedBookToolsResult>> {
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<String> {
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
}
}
}

View File

@@ -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<String>,
pub desired_release_date: String,
pub desired_word_count: i64,
pub word_count: i64,
pub cover_image: String,
pub book_meta: Option<String>,
pub tools: Option<BookToolsSettings>,
}
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<CompleteChapterContent>,
}
#[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<String>,
pub last_update: i64,
pub chapters: Vec<SyncedChapter>,
pub characters: Vec<SyncedCharacter>,
pub locations: Vec<SyncedLocation>,
pub worlds: Vec<SyncedWorld>,
pub incidents: Vec<SyncedIncident>,
pub plot_points: Vec<SyncedPlotPoint>,
pub issues: Vec<SyncedIssue>,
pub act_summaries: Vec<SyncedActSummary>,
pub guide_line: Option<SyncedGuideLine>,
pub ai_guide_line: Option<SyncedAIGuideLine>,
pub book_tools: Option<SyncedBookTools>,
pub spells: Vec<SyncedSpell>,
pub spell_tags: Vec<SyncedSpellTag>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BookSyncCompare {
pub id: String,
pub chapters: Vec<String>,
pub chapter_contents: Vec<String>,
pub chapter_infos: Vec<String>,
pub characters: Vec<String>,
pub character_attributes: Vec<String>,
pub locations: Vec<String>,
pub location_elements: Vec<String>,
pub location_sub_elements: Vec<String>,
pub worlds: Vec<String>,
pub world_elements: Vec<String>,
pub incidents: Vec<String>,
pub plot_points: Vec<String>,
pub issues: Vec<String>,
pub act_summaries: Vec<String>,
pub guide_line: bool,
pub ai_guide_line: bool,
pub book_tools: bool,
pub spells: Vec<String>,
pub spell_tags: Vec<String>,
}
#[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<repo::EritBooksTable>,
pub act_summaries: Vec<BookActSummariesTable>,
pub ai_guide_line: Vec<BookAIGuideLineTable>,
pub chapters: Vec<BookChaptersTable>,
pub chapter_contents: Vec<BookChapterContentTable>,
pub chapter_infos: Vec<BookChapterInfosTable>,
pub characters: Vec<BookCharactersTable>,
pub character_attributes: Vec<BookCharactersAttributesTable>,
pub guide_line: Vec<BookGuideLineTable>,
pub incidents: Vec<BookIncidentsTable>,
pub issues: Vec<BookIssuesTable>,
pub locations: Vec<BookLocationTable>,
pub plot_points: Vec<BookPlotPointsTable>,
pub worlds: Vec<BookWorldTable>,
pub world_elements: Vec<BookWorldElementsTable>,
pub location_elements: Vec<LocationElementTable>,
pub location_sub_elements: Vec<LocationSubElementTable>,
pub book_tools: Vec<repo::BookToolsTable>,
pub spells: Vec<BookSpellsTable>,
pub spell_tags: Vec<BookSpellTagsTable>,
}
#[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<String>,
pub themes: Option<String>,
pub verbe_tense: Option<i64>,
pub narrative_type: Option<i64>,
pub langue: Option<i64>,
pub dialogue_type: Option<i64>,
pub tone: Option<String>,
pub atmosphere: Option<String>,
pub current_resume: Option<String>,
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<String>,
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<String>,
pub notes: Option<String>,
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<String>,
pub nickname: Option<String>,
pub age: Option<i64>,
pub gender: Option<String>,
pub species: Option<String>,
pub nationality: Option<String>,
pub status: Option<String>,
pub title: Option<String>,
pub category: String,
pub image: Option<String>,
pub role: Option<String>,
pub biography: Option<String>,
pub history: Option<String>,
pub speech_pattern: Option<String>,
pub catchphrase: Option<String>,
pub residence: Option<String>,
pub notes: Option<String>,
pub color: Option<String>,
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<String>,
pub atmosphere: Option<String>,
pub writing_style: Option<String>,
pub themes: Option<String>,
pub symbolism: Option<String>,
pub motifs: Option<String>,
pub narrative_voice: Option<String>,
pub pacing: Option<String>,
pub intended_audience: Option<String>,
pub key_messages: Option<String>,
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<String>,
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<String>,
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<String>,
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<String>,
pub politics: Option<String>,
pub economy: Option<String>,
pub religion: Option<String>,
pub languages: Option<String>,
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<String>,
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<String>,
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<String>,
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<String>,
pub components: Option<String>,
pub limitations: Option<String>,
pub notes: Option<String>,
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<String>,
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<String>,
pub cover_image: Option<String>,
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<String>,
pub nickname: Option<String>,
pub age: Option<i64>,
pub gender: Option<String>,
pub species: Option<String>,
pub nationality: Option<String>,
pub status: Option<String>,
pub title: Option<String>,
pub category: String,
pub image: Option<String>,
pub role: Option<String>,
pub biography: Option<String>,
pub history: Option<String>,
pub speech_pattern: Option<String>,
pub catchphrase: Option<String>,
pub residence: Option<String>,
pub notes: Option<String>,
pub color: Option<String>,
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<String>,
pub politics: Option<String>,
pub economy: Option<String>,
pub religion: Option<String>,
pub languages: Option<String>,
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<String>,
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<String>,
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<String>,
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<String>,
pub components: Option<String>,
pub limitations: Option<String>,
pub notes: Option<String>,
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<String>,
pub last_update: i64,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompleteSeries {
pub series: Vec<SeriesTable>,
pub series_books: Vec<SeriesBooksTable>,
pub series_characters: Vec<SeriesCharactersTable>,
pub series_character_attributes: Vec<SeriesCharacterAttributesTable>,
pub series_worlds: Vec<SeriesWorldsTable>,
pub series_world_elements: Vec<SeriesWorldElementsTable>,
pub series_locations: Vec<SeriesLocationsTable>,
pub series_location_elements: Vec<SeriesLocationElementsTable>,
pub series_location_sub_elements: Vec<SeriesLocationSubElementsTable>,
pub series_spells: Vec<SeriesSpellsTable>,
pub series_spell_tags: Vec<SeriesSpellTagsTable>,
}
// ===== 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<SyncedSeriesCharacterAttribute>,
}
#[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<SyncedSeriesWorldElement>,
}
#[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<SyncedSeriesLocationSubElement>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncedSeriesLocation {
pub id: String,
pub name: String,
pub last_update: i64,
pub elements: Vec<SyncedSeriesLocationElement>,
}
#[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<String>,
pub last_update: i64,
pub books: Vec<SyncedSeriesBook>,
pub characters: Vec<SyncedSeriesCharacter>,
pub worlds: Vec<SyncedSeriesWorld>,
pub locations: Vec<SyncedSeriesLocation>,
pub spells: Vec<SyncedSeriesSpell>,
pub spell_tags: Vec<SyncedSeriesSpellTag>,
}
// ===== 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<Vec<BookProps>> {
let user_key: String = get_user_encryption_key(user_id)?;
let books: Vec<repo::BookQuery> = repo::fetch_books(conn, user_id, lang)?;
if books.is_empty() {
return Ok(vec![]);
}
let mut book_props_list: Vec<BookProps> = 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<String> {
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<BookProps> {
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::BookToolsTable> = repo::fetch_book_tools(conn, user_id, book_id, lang)?;
let series_id: Option<String> = 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<bool> {
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<bool> {
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<bool> {
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<CompleteBookData> {
let book_data: repo::BookQuery = repo::fetch_book(conn, book_id, user_id, lang)?;
let chapters: Vec<chapter_repo::ChapterBookResult> = 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<CompleteChapterContent> = 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,
})
}

View File

@@ -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<SessionState>) -> 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<DbManager>, session: State<SessionState>) -> Result<Vec<service::ChapterProps>, 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<DbManager>, session: State<SessionState>) -> Result<service::ChapterProps, 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_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<DbManager>, session: State<SessionState>) -> Result<Vec<service::ActStory>, 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<DbManager>, session: State<SessionState>) -> Result<service::CompanionContent, 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_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<DbManager>, session: State<SessionState>) -> Result<String, 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_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<DbManager>, session: State<SessionState>) -> Result<bool, 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 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<DbManager>, session: State<SessionState>) -> Result<Option<service::ChapterProps>, 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<String>,
}
#[tauri::command]
pub fn add_chapter(data: AddChapterData, db: State<DbManager>, session: State<SessionState>) -> Result<String, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<String>,
pub incident_id: Option<String>,
pub chapter_info_id: Option<String>,
}
#[tauri::command]
pub fn add_chapter_information(data: AddChapterInfoData, db: State<DbManager>, session: State<SessionState>) -> Result<String, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<Tag>,
pub locations: Vec<Tag>,
pub objects: Vec<Tag>,
pub world_elements: Vec<Tag>,
}
#[tauri::command]
pub fn get_book_tags(book_id: String, db: State<DbManager>, session: State<SessionState>) -> Result<BookTags, 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 character_response = crate::domains::character::service::get_character_list(conn, &user_id, &book_id, lang)?;
let character_tags: Vec<Tag> = 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<Tag> = 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<Tag> = 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<Tag> = 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 })
}

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod repo;
pub mod service;

View File

@@ -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<String>,
pub plot_point_id: Option<String>,
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<i64>,
pub incident_title: Option<String>,
pub incident_summary: Option<String>,
pub plot_point_id: Option<i64>,
pub plot_title: Option<String>,
pub plot_summary: Option<String>,
}
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<i64>,
pub chapter_order: i64,
pub last_update: i64,
}
pub struct BookChapterInfosTable {
pub chapter_info_id: String,
pub chapter_id: String,
pub act_id: Option<i64>,
pub incident_id: Option<String>,
pub plot_point_id: Option<String>,
pub book_id: String,
pub author_id: String,
pub summary: Option<String>,
pub goal: Option<String>,
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<String>,
pub book_id: String,
pub last_update: i64,
}
pub struct ChapterBookResult {
pub title: String,
pub chapter_order: i64,
pub content: Option<String>,
}
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<bool> {
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<String> {
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<Vec<ActChapterQuery>> {
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::<Result<Vec<_>, _>>()
.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<Vec<ChapterQueryResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<String> {
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<bool> {
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<bool> {
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<Box<dyn rusqlite::types::ToSql>> = 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<Option<LastChapterResult>> {
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<bool> {
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<Vec<ChapterStoryQueryResult>> {
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<String>>(2)?.unwrap_or_default(),
chapter_summary: query_row.get::<_, Option<String>>(3)?.unwrap_or_default(),
chapter_goal: query_row.get::<_, Option<String>>(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::<Result<Vec<_>, _>>()
.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<bool> {
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<Vec<ChapterBookResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<BookChaptersTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<BookChapterInfosTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedChapterResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedChapterInfoResult>> {
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::<Result<Vec<_>, _>>()
.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<i64>, chapter_order: Option<i64>, last_update: i64, lang: Lang) -> AppResult<bool> {
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<i64>,
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<bool> {
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<Vec<BookChaptersTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<BookChapterInfosTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<ChapterExportInfoResult>> {
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<String>>(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::<Result<Vec<_>, _>>()
.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<Vec<SelectedChapterContentResult>> {
let conditions: Vec<String> = 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<Box<dyn rusqlite::types::ToSql>> = 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::<Result<Vec<_>, _>>()
.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)
}

View File

@@ -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<ChapterContent>,
}
#[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<SyncedChapterContent>,
pub info: Option<SyncedChapterInfo>,
}
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<i64>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChapterExportInfo {
pub chapter_id: String,
pub title: String,
pub chapter_order: i64,
pub available_versions: Vec<i64>,
}
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<IncidentStory>,
pub plot_points: Vec<PlotPointStory>,
}
struct TipTapMark {
mark_type: String,
attrs: Option<serde_json::Map<String, Value>>,
}
struct TipTapNode {
node_type: Option<String>,
text: Option<String>,
content: Option<Vec<TipTapNode>>,
attrs: Option<serde_json::Map<String, Value>>,
marks: Option<Vec<TipTapMark>>,
}
/// 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<Vec<ChapterProps>> {
let chapter_query_results: Vec<repo::ChapterQueryResult> = repo::fetch_all_chapter_from_a_book(conn, user_id, book_id, lang)?;
let mut decrypted_chapters: Vec<ChapterProps> = 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<ChapterProps> {
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<bool> {
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<Option<ChapterProps>> {
let last_chapter_record: Option<repo::LastChapterResult> = 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::ChapterContentQueryResult> = 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<String> {
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<bool> {
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<String> {
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<bool> {
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<CompanionContent> {
let companion_version: i64 = version - 1;
let companion_content_results: Vec<chapter_content_repo::CompanionContentQueryResult> = 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<Vec<ActStory>> {
let chapter_story_results: Vec<repo::ChapterStoryQueryResult> = repo::fetch_chapter_story(conn, user_id, chapter_id, lang)?;
let mut act_stories_map: HashMap<i64, ActStory> = 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<String> {
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<bool> {
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#039;")
}
fn parse_marks(marks_value: &Value) -> Option<Vec<TipTapMark>> {
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<Vec<TipTapNode>> = 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<Vec<TipTapMark>>) -> 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!("<strong>{}</strong>", rendered_text),
"italic" => rendered_text = format!("<em>{}</em>", rendered_text),
"underline" => rendered_text = format!("<u>{}</u>", rendered_text),
"strike" => rendered_text = format!("<s>{}</s>", rendered_text),
"code" => rendered_text = format!("<code>{}</code>", 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!("<a href=\"{}\">{}</a>", 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::<Vec<String>>().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!("<p{}>{}</p>", 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!("<h{}{}>{}</h{}>", heading_level, text_align_style, children_html, heading_level)
}
"bulletList" => format!("<ul>{}</ul>", children_html),
"orderedList" => format!("<ol>{}</ol>", children_html),
"listItem" => format!("<li>{}</li>", children_html),
"blockquote" => format!("<blockquote>{}</blockquote>", children_html),
"codeBlock" => format!("<pre><code>{}</code></pre>", children_html),
"hardBreak" => "<br />".to_string(),
"horizontalRule" => "<hr />".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<ChapterContentData> {
let mut processed_chapters: Vec<ChapterContentData> = 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<Vec<ChapterExportInfo>> {
let results: Vec<repo::ChapterExportInfoResult> = 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<ChapterExportInfo> = Vec::new();
for result in results {
if result.available_versions.is_empty() { continue; }
let mut versions: Vec<i64> = result.available_versions
.split(',')
.filter_map(|version_string| version_string.trim().parse::<i64>().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<book_service::CompleteBookData> {
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::SelectedChapterContentResult> = 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<book_service::CompleteChapterContent> = 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,
})
}

View File

@@ -0,0 +1,2 @@
pub mod repo;
pub mod service;

View File

@@ -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<String>,
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<Vec<ChapterContentQueryResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<Vec<CompanionContentQueryResult>> {
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::<Result<Vec<_>, _>>()
.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<ContentQueryResult> {
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<bool> {
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<Vec<BookChapterContentTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedChapterContentResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<Vec<BookChapterContentTable>> {
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::<Result<Vec<_>, _>>()
.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<ChapterContentQueryResult> {
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<String>>(4)?.unwrap_or_default(),
version: query_row.get::<_, Option<i64>>(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)
}

View File

@@ -0,0 +1 @@

View File

@@ -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<SessionState>) -> 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<DbManager>, session: State<SessionState>) -> Result<service::CharacterListResponse, 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.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<DbManager>, session: State<SessionState>) -> Result<Vec<service::CharacterAttribute>, 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<String>,
}
#[tauri::command]
pub fn create_character(data: CreateCharacterData, db: State<DbManager>, session: State<SessionState>) -> Result<String, 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::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<String>,
}
#[tauri::command]
pub fn add_character_attribute(data: AddCharacterAttributeData, db: State<DbManager>, session: State<SessionState>) -> Result<String, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::delete_character(conn, &user_id, &data.book_id, &data.character_id, data.deleted_at, lang)
}

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod repo;
pub mod service;

View File

@@ -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<String>,
pub nickname: Option<String>,
pub age: Option<String>,
pub gender: Option<String>,
pub species: Option<String>,
pub nationality: Option<String>,
pub status: Option<String>,
pub category: String,
pub title: Option<String>,
pub image: Option<String>,
pub role: Option<String>,
pub biography: Option<String>,
pub history: Option<String>,
pub speech_pattern: Option<String>,
pub catchphrase: Option<String>,
pub residence: Option<String>,
pub notes: Option<String>,
pub color: Option<String>,
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<String>,
pub age: Option<String>,
pub gender: Option<String>,
pub species: Option<String>,
pub nationality: Option<String>,
pub status: Option<String>,
pub title: String,
pub category: String,
pub image: String,
pub role: String,
pub biography: String,
pub history: String,
pub speech_pattern: Option<String>,
pub catchphrase: Option<String>,
pub residence: Option<String>,
pub notes: Option<String>,
pub color: Option<String>,
pub series_character_id: Option<String>,
}
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<String>,
pub age: Option<String>,
pub gender: Option<String>,
pub species: Option<String>,
pub nationality: Option<String>,
pub status: Option<String>,
pub category: String,
pub title: String,
pub role: String,
pub biography: String,
pub history: String,
pub speech_pattern: Option<String>,
pub catchphrase: Option<String>,
pub residence: Option<String>,
pub notes: Option<String>,
pub color: Option<String>,
pub attribute_name: String,
pub attribute_value: String,
}
pub struct CharacterData {
pub first_name: String,
pub last_name: Option<String>,
pub nickname: Option<String>,
pub age: Option<String>,
pub gender: Option<String>,
pub species: Option<String>,
pub nationality: Option<String>,
pub status: Option<String>,
pub title: Option<String>,
pub category: Option<String>,
pub image: Option<String>,
pub role: Option<String>,
pub biography: Option<String>,
pub history: Option<String>,
pub speech_pattern: Option<String>,
pub catchphrase: Option<String>,
pub residence: Option<String>,
pub notes: Option<String>,
pub color: Option<String>,
}
pub struct SyncCharacterData {
pub first_name: String,
pub last_name: Option<String>,
pub nickname: Option<String>,
pub age: Option<String>,
pub gender: Option<String>,
pub species: Option<String>,
pub nationality: Option<String>,
pub status: Option<String>,
pub category: String,
pub title: Option<String>,
pub image: Option<String>,
pub role: Option<String>,
pub biography: Option<String>,
pub history: Option<String>,
pub speech_pattern: Option<String>,
pub catchphrase: Option<String>,
pub residence: Option<String>,
pub notes: Option<String>,
pub color: Option<String>,
}
/// 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<Vec<CharacterResult>> {
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::<Result<Vec<_>, _>>()
.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<String> {
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<String> {
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<bool> {
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<bool> {
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<bool> {
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<Vec<AttributeResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<CompleteCharacterResult>> {
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<Box<dyn rusqlite::types::ToSql>> = 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::<Vec<_>>().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::<Result<Vec<_>, _>>()
.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<bool> {
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<bool> {
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<bool> {
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<Vec<BookCharactersTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<BookCharactersAttributesTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedCharacterResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedCharacterAttributeResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<bool> {
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<Vec<BookCharactersTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<BookCharactersAttributesTable>> {
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::<Result<Vec<_>, _>>()
.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)
}

View File

@@ -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<String>,
pub name: String,
pub last_name: String,
pub nickname: String,
pub age: Option<i64>,
pub gender: String,
pub species: String,
pub nationality: String,
pub status: String,
pub category: String,
pub title: String,
pub image: String,
pub physical: Vec<AttributeName>,
pub psychological: Vec<AttributeName>,
pub relations: Vec<AttributeName>,
pub skills: Vec<AttributeName>,
pub weaknesses: Vec<AttributeName>,
pub strengths: Vec<AttributeName>,
pub goals: Vec<AttributeName>,
pub motivations: Vec<AttributeName>,
pub arc: Vec<AttributeName>,
pub secrets: Vec<AttributeName>,
pub fears: Vec<AttributeName>,
pub flaws: Vec<AttributeName>,
pub beliefs: Vec<AttributeName>,
pub conflicts: Vec<AttributeName>,
pub quotes: Vec<AttributeName>,
pub distinguishing_marks: Vec<AttributeName>,
pub items: Vec<AttributeName>,
pub affiliations: Vec<AttributeName>,
pub role: String,
pub biography: Option<String>,
pub history: Option<String>,
pub speech_pattern: Option<String>,
pub catchphrase: Option<String>,
pub residence: Option<String>,
pub notes: Option<String>,
pub color: Option<String>,
pub series_character_id: Option<String>,
}
#[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<i64>,
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<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CharacterListResponse {
pub characters: Vec<CharacterProps>,
pub enabled: bool,
}
pub struct CompleteCharacterProps {
pub id: Option<String>,
pub name: String,
pub last_name: String,
pub nickname: String,
pub age: Option<i64>,
pub gender: String,
pub species: String,
pub nationality: String,
pub status: String,
pub title: String,
pub category: String,
pub image: Option<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 physical: Vec<Attribute>,
pub psychological: Vec<Attribute>,
pub relations: Vec<Attribute>,
pub skills: Vec<Attribute>,
pub weaknesses: Vec<Attribute>,
pub strengths: Vec<Attribute>,
pub goals: Vec<Attribute>,
pub motivations: Vec<Attribute>,
pub arc: Vec<Attribute>,
pub secrets: Vec<Attribute>,
pub fears: Vec<Attribute>,
pub flaws: Vec<Attribute>,
pub beliefs: Vec<Attribute>,
pub conflicts: Vec<Attribute>,
pub quotes: Vec<Attribute>,
pub distinguishing_marks: Vec<Attribute>,
pub items: Vec<Attribute>,
pub affiliations: Vec<Attribute>,
}
#[derive(Serialize)]
pub struct Attribute {
pub id: String,
pub name: String,
}
#[derive(Serialize)]
pub struct CharacterAttribute {
pub r#type: String,
pub values: Vec<Attribute>,
}
pub struct SyncedCharacter {
pub id: String,
pub name: String,
pub last_update: i64,
pub attributes: Vec<SyncedCharacterAttribute>,
}
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<CharacterListResponse> {
let book_tools: Option<book_repo::BookToolsTable> = 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::CharacterResult> = 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<CharacterProps> = 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::<i64>().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<String> {
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<AttributeName>)> = 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<bool> {
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<String> {
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<bool> {
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<bool> {
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<Vec<CharacterAttribute>> {
let user_key: String = get_user_encryption_key(user_id)?;
let encrypted_attributes: Vec<repo::AttributeResult> = repo::fetch_attributes(conn, character_id, user_id, lang)?;
if encrypted_attributes.is_empty() {
return Ok(vec![]);
}
let mut attributes_by_type: HashMap<String, Vec<Attribute>> = 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<CharacterAttribute> = 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<Vec<CompleteCharacterProps>> {
let encrypted_character_list: Vec<repo::CompleteCharacterResult> = 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<String, CompleteCharacterProps> = 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::<i64>().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<String, CompleteCharacterProps> = 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<String> = Vec::new();
let full_name: String = [&character.name, &character.last_name].iter().filter(|name| !name.is_empty()).map(|name| name.as_str()).collect::<Vec<&str>>().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<Attribute>)> = 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::<Vec<&str>>().join(", ");
character_description_lines.push(format!("{} : {}", capitalized_property_key, formatted_attribute_values));
}
}
character_description_lines.join("\n")
}).collect::<Vec<String>>().join("\n\n");
formatted_characters_description
}

View File

@@ -0,0 +1 @@
pub mod service;

View File

@@ -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<String> {
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<bool> {
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<String> {
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<u8> = fs::read(user_directory)
.map_err(|error| crate::error::AppError::Internal(error.to_string()))?;
Ok(BASE64.encode(&file_data))
}

View File

@@ -0,0 +1 @@
pub mod service;

View File

@@ -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<bool> {
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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = if let Some(ref history) = world.history { Some(encrypt_data_with_user_key(history, &user_encryption_key)?) } else { None };
let encrypted_world_politics: Option<String> = if let Some(ref politics) = world.politics { Some(encrypt_data_with_user_key(politics, &user_encryption_key)?) } else { None };
let encrypted_world_economy: Option<String> = if let Some(ref economy) = world.economy { Some(encrypt_data_with_user_key(economy, &user_encryption_key)?) } else { None };
let encrypted_world_religion: Option<String> = if let Some(ref religion) = world.religion { Some(encrypt_data_with_user_key(religion, &user_encryption_key)?) } else { None };
let encrypted_world_languages: Option<String> = 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<String> = 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<String> = 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<String> = if let Some(ref themes) = ai_guideline.themes { Some(encrypt_data_with_user_key(themes, &user_encryption_key)?) } else { None };
let encrypted_tone: Option<String> = if let Some(ref tone) = ai_guideline.tone { Some(encrypt_data_with_user_key(tone, &user_encryption_key)?) } else { None };
let encrypted_atmosphere: Option<String> = 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<String> = 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<String> = if let Some(ref tone) = guideline.tone { Some(encrypt_data_with_user_key(tone, &user_encryption_key)?) } else { None };
let encrypted_atmosphere: Option<String> = if let Some(ref atmosphere) = guideline.atmosphere { Some(encrypt_data_with_user_key(atmosphere, &user_encryption_key)?) } else { None };
let encrypted_writing_style: Option<String> = 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<String> = if let Some(ref themes) = guideline.themes { Some(encrypt_data_with_user_key(themes, &user_encryption_key)?) } else { None };
let encrypted_symbolism: Option<String> = if let Some(ref symbolism) = guideline.symbolism { Some(encrypt_data_with_user_key(symbolism, &user_encryption_key)?) } else { None };
let encrypted_motifs: Option<String> = if let Some(ref motifs) = guideline.motifs { Some(encrypt_data_with_user_key(motifs, &user_encryption_key)?) } else { None };
let encrypted_narrative_voice: Option<String> = 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<String> = if let Some(ref pacing) = guideline.pacing { Some(encrypt_data_with_user_key(pacing, &user_encryption_key)?) } else { None };
let encrypted_intended_audience: Option<String> = 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<String> = 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<String> = 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<String> = if let Some(ref components) = spell.components { Some(encrypt_data_with_user_key(components, &user_encryption_key)?) } else { None };
let encrypted_limitations: Option<String> = if let Some(ref limitations) = spell.limitations { Some(encrypt_data_with_user_key(limitations, &user_encryption_key)?) } else { None };
let encrypted_notes: Option<String> = 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)
}

View File

@@ -0,0 +1 @@
pub mod service;

View File

@@ -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<u8>,
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<ExportResult> {
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<ChapterContentData> = 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<u8> = 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<ExportResult> {
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<ChapterContentData> = 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<u8> = 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<ExportResult> {
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<ZipLibrary> = 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#"<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>{}</title>
<link rel="stylesheet" type="text/css" href="styles.css"/>
</head>
<body>
{}
</body>
</html>"#,
&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<u8> = 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<u8> = 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) })
}

View File

@@ -0,0 +1,2 @@
pub mod repo;
pub mod service;

View File

@@ -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<String>,
pub themes: Option<String>,
pub verbe_tense: Option<i64>,
pub narrative_type: Option<i64>,
pub langue: Option<i64>,
pub dialogue_type: Option<i64>,
pub tone: Option<String>,
pub atmosphere: Option<String>,
pub current_resume: Option<String>,
pub last_update: i64,
}
pub struct BookGuideLineTable {
pub user_id: String,
pub book_id: String,
pub tone: Option<String>,
pub atmosphere: Option<String>,
pub writing_style: Option<String>,
pub themes: Option<String>,
pub symbolism: Option<String>,
pub motifs: Option<String>,
pub narrative_voice: Option<String>,
pub pacing: Option<String>,
pub intended_audience: Option<String>,
pub key_messages: Option<String>,
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<String>,
pub themes: Option<String>,
pub verbe_tense: Option<i64>,
pub narrative_type: Option<i64>,
pub langue: Option<i64>,
pub dialogue_type: Option<i64>,
pub tone: Option<String>,
pub atmosphere: Option<String>,
pub current_resume: Option<String>,
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<Vec<GuideLineQuery>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<i64>,
dialogue_type: Option<i64>, encrypted_plot_summary: Option<&str>,
encrypted_tone_atmosphere: Option<&str>, verb_tense: Option<i64>,
language: Option<i64>, encrypted_themes: Option<&str>, last_update: i64, lang: Lang,
) -> AppResult<bool> {
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<GuideLineAIQuery> {
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<Vec<BookAIGuideLineTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<BookGuideLineTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedGuideLineResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedAIGuideLineResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<bool> {
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<i64>, narrative_type: Option<i64>,
langue: Option<i64>, dialogue_type: Option<i64>, tone: Option<&str>,
atmosphere: Option<&str>, current_resume: Option<&str>, last_update: i64, lang: Lang,
) -> AppResult<bool> {
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<bool> {
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)
}

View File

@@ -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<i64>,
pub dialogue_type: Option<i64>,
pub global_resume: Option<String>,
pub atmosphere: Option<String>,
pub verbe_tense: Option<i64>,
pub langue: Option<i64>,
pub current_resume: Option<String>,
pub themes: Option<String>,
}
/// 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<Option<GuideLineProps>> {
let guide_line_results: Vec<repo::GuideLineQuery> = 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<bool> {
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<GuideLineAI> {
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<bool> {
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,
)
}

View File

@@ -0,0 +1,2 @@
pub mod repo;
pub mod service;

View File

@@ -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<String>,
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<Vec<IncidentQuery>> {
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::<Result<Vec<_>, _>>()
.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<String> {
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<bool> {
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<bool> {
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<Vec<BookIncidentsTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedIncidentResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<Vec<BookIncidentsTable>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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())
}

View File

@@ -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<ActChapterQuery>,
}
/// 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<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 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<Vec<IncidentProps>> {
let incident_query_results: Vec<repo::IncidentQuery> = 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<IncidentProps> = Vec::new();
if !incident_query_results.is_empty() {
for incident_record in &incident_query_results {
let mut associated_chapters: Vec<ActChapterQuery> = 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<bool> {
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)
}

View File

@@ -0,0 +1,2 @@
pub mod repo;
pub mod service;

View File

@@ -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<Vec<IssueQuery>> {
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::<Result<Vec<_>, _>>()
.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<String> {
let existing_issue: Option<String> = 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<bool> {
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<Vec<BookIssuesTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedIssueResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<Vec<BookIssuesTable>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<bool> {
let existing_issue: Option<i32> = 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())
}

View File

@@ -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<Vec<IssueProps>> {
let issue_query_results: Vec<repo::IssueQuery> = 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<IssueProps> = 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<String> {
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<bool> {
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)
}

View File

@@ -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<SessionState>) -> 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<DbManager>, session: State<SessionState>) -> Result<service::LocationListResponse, 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_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<String>,
pub series_location_id: Option<String>,
}
#[tauri::command]
pub fn add_location_section(data: AddLocationSectionData, db: State<DbManager>, session: State<SessionState>) -> Result<String, 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::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<String>,
}
#[tauri::command]
pub fn add_location_element(data: AddLocationElementData, db: State<DbManager>, session: State<SessionState>) -> Result<String, 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::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<String>,
}
#[tauri::command]
pub fn add_location_sub_element(data: AddLocationSubElementData, db: State<DbManager>, session: State<SessionState>) -> Result<String, 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::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<service::LocationProps>,
}
#[tauri::command]
pub fn update_locations(data: UpdateLocationsData, db: State<DbManager>, session: State<SessionState>) -> Result<serde_json::Value, 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 (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<String>,
pub series_location_id: Option<String>,
}
#[tauri::command]
pub fn update_location_section_with_series_link(data: UpdateLocationSectionWithSeriesLinkData, db: State<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::delete_location_sub_element(conn, &user_id, &data.book_id, &data.sub_element_id, data.deleted_at, lang)
}

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod repo;
pub mod service;

View File

@@ -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<String>,
pub element_name: Option<String>,
pub element_description: Option<String>,
pub sub_element_id: Option<String>,
pub sub_elem_name: Option<String>,
pub sub_elem_description: Option<String>,
pub series_location_id: Option<String>,
}
pub struct LocationElementQueryResult {
pub sub_element_id: Option<String>,
pub sub_elem_name: Option<String>,
pub sub_elem_description: Option<String>,
pub element_id: String,
pub element_name: String,
pub element_description: Option<String>,
}
pub struct LocationByTagResult {
pub element_name: String,
pub element_description: Option<String>,
pub sub_elem_name: Option<String>,
pub sub_elem_description: Option<String>,
}
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<String>,
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<String>,
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<Vec<LocationQueryResult>> {
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::<Result<Vec<_>, _>>()
.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<String> {
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<String> {
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<String> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<Vec<LocationElementQueryResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<LocationByTagResult>> {
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::<Vec<_>>().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<Box<dyn rusqlite::types::ToSql>> = 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::<Result<Vec<_>, _>>()
.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<bool> {
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<bool> {
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<bool> {
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<Vec<BookLocationTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<LocationElementTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<LocationSubElementTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedLocationResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedLocationElementResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedLocationSubElementResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<bool> {
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<bool> {
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<Vec<BookLocationTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<LocationElementTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<LocationSubElementTable>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
let mut set_clauses: Vec<String> = vec![format!("last_update={}", last_update)];
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = 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)
}

View File

@@ -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<SubElement>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocationProps {
pub id: String,
pub name: String,
pub elements: Vec<Element>,
pub series_location_id: Option<String>,
}
#[derive(Serialize)]
pub struct LocationListResponse {
pub locations: Vec<LocationProps>,
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<SyncedLocationSubElement>,
}
/// Synced location (lightweight, for comparison).
pub struct SyncedLocation {
pub id: String,
pub name: String,
pub last_update: i64,
pub elements: Vec<SyncedLocationElement>,
}
/// 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<LocationListResponse> {
let book_tools: Option<book_repo::BookToolsTable> = 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::LocationQueryResult> = 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<LocationProps> = Vec::new();
for record in &location_records {
let location_index: Option<usize> = 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<usize> = 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<String> {
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<String> {
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<String> {
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<bool> {
let encrypted_name: Option<String> = 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<String> = 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<bool> {
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<bool> {
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<bool> {
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<Vec<SubElement>> {
let tag_records: Vec<repo::LocationElementQueryResult> = 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<String, usize> = 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<SubElement> = Vec::new();
let mut processed_ids: std::collections::HashSet<String> = 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<Vec<Element>> {
let location_tag_records: Vec<repo::LocationByTagResult> = 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<Element> = Vec::new();
for record in &location_tag_records {
let element_index: Option<usize> = 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<String> = 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::<Vec<String>>()
.join("\n\n")
}

View File

@@ -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;

View File

@@ -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<String>,
pub user_id: Option<String>,
}
#[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<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OfflineModeData {
pub enabled: bool,
pub sync_interval_days: Option<i64>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncCheckResult {
pub should_sync: bool,
pub days_since_sync: Option<i64>,
pub sync_interval: Option<i64>,
}
#[tauri::command]
pub fn offline_pin_set(data: PinData, session: State<SessionState>) -> Result<OfflineResult, 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();
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<DbManager>, session: State<SessionState>) -> Result<OfflineResult, AppError> {
let last_user_id: Option<String> = 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<String> = 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<OfflineModeStatus, AppError> {
let last_user_id: Option<String> = 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<bool, AppError> {
// 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<SyncCheckResult, AppError> {
Ok(SyncCheckResult { should_sync: false, days_since_sync: None, sync_interval: None })
}

View File

@@ -0,0 +1 @@
pub mod commands;

View File

@@ -0,0 +1,2 @@
pub mod repo;
pub mod service;

View File

@@ -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<String>,
pub linked_incident_id: Option<String>,
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<String>,
}
/// 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<Vec<PlotPointQuery>> {
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<String>>(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::<Result<Vec<_>, _>>()
.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<String> {
let existing_plot_point: Option<String> = 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<bool> {
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<bool> {
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<Vec<BookPlotPointsTable>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedPlotPointResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<Vec<BookPlotPointsTable>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
let existing_plot_point: Option<i32> = 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())
}

View File

@@ -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<String>,
pub chapters: Vec<ActChapterQuery>,
}
/// 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<Vec<PlotPointProps>> {
let plot_point_query_results: Vec<repo::PlotPointQuery> = 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<PlotPointProps> = Vec::new();
if !plot_point_query_results.is_empty() {
for plot_point_row in &plot_point_query_results {
let mut associated_chapters: Vec<ActChapterQuery> = 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<String> {
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<bool> {
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)
}

View File

@@ -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<SessionState>) -> 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<DbManager>, session: State<SessionState>) -> Result<Vec<service::SeriesListItemProps>, 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<DbManager>, session: State<SessionState>) -> Result<service::SeriesDetailProps, 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_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<Vec<String>>,
}
#[tauri::command]
pub fn create_series(data: CreateSeriesData, db: State<DbManager>, session: State<SessionState>) -> Result<String, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<Vec<service::SeriesBookProps>, 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<DbManager>, session: State<SessionState>) -> Result<bool, 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 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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<String>,
}
#[tauri::command]
pub fn reorder_series_books(data: ReorderSeriesBooksData, db: State<DbManager>, session: State<SessionState>) -> Result<bool, 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 books_order: Vec<service::BooksOrderPost> = 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<DbManager>, session: State<SessionState>) -> Result<Option<String>, 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)
}

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod repo;
pub mod service;

View File

@@ -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<String>,
pub cover_image: Option<String>,
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<String>,
}
pub struct SeriesListItem {
pub series_id: String,
pub name: String,
pub description: Option<String>,
pub cover_image: Option<String>,
pub book_count: i64,
pub book_ids: Option<String>,
}
pub struct SeriesTableResult {
pub series_id: String,
pub user_id: String,
pub name: String,
pub hashed_name: String,
pub description: Option<String>,
pub cover_image: Option<String>,
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<String>,
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<Vec<SeriesListItem>> {
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::<Result<Vec<_>, _>>()
.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<Option<SeriesResult>> {
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<String> {
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<bool> {
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<bool> {
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<Vec<SeriesBookResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<Option<String>> {
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<Vec<SeriesTableResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SeriesBooksTableResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedSeriesResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedSeriesBookResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SeriesTableResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
is_series_exist(conn, user_id, series_id, lang)
}

View File

@@ -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<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SeriesDetailProps {
pub id: String,
pub name: String,
pub description: String,
pub cover_image: Option<String>,
pub books: Vec<SeriesBookProps>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SeriesBookProps {
pub book_id: String,
pub title: String,
pub order: i64,
pub cover_image: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SeriesListItemProps {
pub id: String,
pub name: String,
pub description: String,
pub cover_image: Option<String>,
pub book_count: i64,
pub book_ids: Vec<String>,
}
#[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<Vec<SeriesListItemProps>> {
let user_key: String = get_user_encryption_key(user_id)?;
let series_results: Vec<repo::SeriesListItem> = repo::fetch_user_series(conn, user_id, lang)?;
let mut series_list: Vec<SeriesListItemProps> = 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<String> = 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<SeriesDetailProps> {
let user_key: String = get_user_encryption_key(user_id)?;
let series_result: Option<repo::SeriesResult> = 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::SeriesBookResult> = repo::fetch_series_books(conn, user_id, series_id, lang)?;
let mut books: Vec<SeriesBookProps> = 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<String> {
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<String> = 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<bool> {
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<String> = 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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<repo::BookOrderItem> = 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<Option<String>> {
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<Vec<SeriesBookProps>> {
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::SeriesBookResult> = repo::fetch_series_books(conn, user_id, series_id, lang)?;
let mut books: Vec<SeriesBookProps> = 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)
}

View File

@@ -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<SessionState>) -> 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<DbManager>, session: State<SessionState>) -> Result<Vec<service::SeriesCharacterListProps>, 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<DbManager>, session: State<SessionState>) -> Result<service::CharacterAttributesResponse, 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_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<DbManager>, session: State<SessionState>) -> Result<String, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::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<DbManager>, session: State<SessionState>) -> Result<String, 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::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<DbManager>, session: State<SessionState>) -> Result<bool, 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::delete_attribute(conn, &user_id, &data.attribute_id, data.deleted_at, lang)
}

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod repo;
pub mod service;

View File

@@ -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<String>,
pub age: Option<String>,
pub gender: Option<String>,
pub species: Option<String>,
pub nationality: Option<String>,
pub status: Option<String>,
pub title: String,
pub category: String,
pub image: String,
pub role: String,
pub biography: String,
pub history: String,
pub speech_pattern: Option<String>,
pub catchphrase: Option<String>,
pub residence: Option<String>,
pub notes: Option<String>,
pub color: Option<String>,
}
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<String>,
pub nickname: Option<String>,
pub age: Option<String>,
pub gender: Option<String>,
pub species: Option<String>,
pub nationality: Option<String>,
pub status: Option<String>,
pub title: Option<String>,
pub category: String,
pub image: Option<String>,
pub role: Option<String>,
pub biography: Option<String>,
pub history: Option<String>,
pub speech_pattern: Option<String>,
pub catchphrase: Option<String>,
pub residence: Option<String>,
pub notes: Option<String>,
pub color: Option<String>,
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<Vec<SeriesCharacterResult>> {
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::<Result<Vec<_>, _>>()
.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<String> {
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<String> {
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<bool> {
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<bool> {
// 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<bool> {
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<Vec<SeriesCharacterAttributeResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<Vec<SeriesCharactersTableResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SeriesCharacterAttributesTableResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedSeriesCharacterResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SyncedSeriesCharacterAttributeResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SeriesCharactersTableResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SeriesCharacterAttributesTableResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<Vec<SeriesCharactersTableResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SeriesCharacterAttributesTableResult>> {
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::<Result<Vec<_>, _>>()
.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<Vec<SeriesCharactersTableResult>> {
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<Vec<SeriesCharacterAttributesTableResult>> {
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::<Result<Vec<_>, _>>()
.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<bool> {
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<bool> {
is_attribute_exist(conn, user_id, attr_id, lang)
}

View File

@@ -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<String>,
pub name: String,
pub last_name: String,
pub nickname: String,
pub age: Option<i64>,
pub gender: String,
pub species: String,
pub nationality: String,
pub status: String,
pub category: String,
pub title: String,
pub image: String,
pub physical: Vec<NameItem>,
pub psychological: Vec<NameItem>,
pub relations: Vec<NameItem>,
pub skills: Vec<NameItem>,
pub weaknesses: Vec<NameItem>,
pub strengths: Vec<NameItem>,
pub goals: Vec<NameItem>,
pub motivations: Vec<NameItem>,
pub arc: Vec<NameItem>,
pub secrets: Vec<NameItem>,
pub fears: Vec<NameItem>,
pub flaws: Vec<NameItem>,
pub beliefs: Vec<NameItem>,
pub conflicts: Vec<NameItem>,
pub quotes: Vec<NameItem>,
pub distinguishing_marks: Vec<NameItem>,
pub items: Vec<NameItem>,
pub affiliations: Vec<NameItem>,
pub role: String,
pub biography: Option<String>,
pub history: Option<String>,
pub speech_pattern: Option<String>,
pub catchphrase: Option<String>,
pub residence: Option<String>,
pub notes: Option<String>,
pub color: Option<String>,
}
#[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<i64>,
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<SeriesAttribute>,
}
/// 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<Vec<SeriesCharacterListProps>> {
let characters: Vec<repo::SeriesCharacterResult> = 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<SeriesCharacterListProps> = 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<i64> = if let Some(ref age) = character.age { Some(decrypt_data_with_user_key(age, &user_key)?.parse::<i64>().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<String> {
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<String> = if !character.last_name.is_empty() { Some(encrypt_data_with_user_key(&character.last_name, &user_key)?) } else { None };
let encrypted_nickname: Option<String> = if !character.nickname.is_empty() { Some(encrypt_data_with_user_key(&character.nickname, &user_key)?) } else { None };
let encrypted_age: Option<String> = if let Some(age) = character.age { Some(encrypt_data_with_user_key(&age.to_string(), &user_key)?) } else { None };
let encrypted_gender: Option<String> = if !character.gender.is_empty() { Some(encrypt_data_with_user_key(&character.gender, &user_key)?) } else { None };
let encrypted_species: Option<String> = if !character.species.is_empty() { Some(encrypt_data_with_user_key(&character.species, &user_key)?) } else { None };
let encrypted_nationality: Option<String> = if !character.nationality.is_empty() { Some(encrypt_data_with_user_key(&character.nationality, &user_key)?) } else { None };
let encrypted_status: Option<String> = if !character.status.is_empty() { Some(encrypt_data_with_user_key(&character.status, &user_key)?) } else { None };
let encrypted_title: Option<String> = if !character.title.is_empty() { Some(encrypt_data_with_user_key(&character.title, &user_key)?) } else { None };
let encrypted_category: Option<String> = if !character.category.is_empty() { Some(encrypt_data_with_user_key(&character.category, &user_key)?) } else { None };
let encrypted_image: Option<String> = if !character.image.is_empty() { Some(encrypt_data_with_user_key(&character.image, &user_key)?) } else { None };
let encrypted_role: Option<String> = if !character.role.is_empty() { Some(encrypt_data_with_user_key(&character.role, &user_key)?) } else { None };
let encrypted_biography: Option<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<bool> {
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<String> = if !character.last_name.is_empty() { Some(encrypt_data_with_user_key(&character.last_name, &user_key)?) } else { None };
let encrypted_nickname: Option<String> = if !character.nickname.is_empty() { Some(encrypt_data_with_user_key(&character.nickname, &user_key)?) } else { None };
let encrypted_age: Option<String> = if let Some(age) = character.age { Some(encrypt_data_with_user_key(&age.to_string(), &user_key)?) } else { None };
let encrypted_gender: Option<String> = if !character.gender.is_empty() { Some(encrypt_data_with_user_key(&character.gender, &user_key)?) } else { None };
let encrypted_species: Option<String> = if !character.species.is_empty() { Some(encrypt_data_with_user_key(&character.species, &user_key)?) } else { None };
let encrypted_nationality: Option<String> = if !character.nationality.is_empty() { Some(encrypt_data_with_user_key(&character.nationality, &user_key)?) } else { None };
let encrypted_status: Option<String> = if !character.status.is_empty() { Some(encrypt_data_with_user_key(&character.status, &user_key)?) } else { None };
let encrypted_title: Option<String> = if !character.title.is_empty() { Some(encrypt_data_with_user_key(&character.title, &user_key)?) } else { None };
let encrypted_category: Option<String> = if !character.category.is_empty() { Some(encrypt_data_with_user_key(&character.category, &user_key)?) } else { None };
let encrypted_image: Option<String> = if !character.image.is_empty() { Some(encrypt_data_with_user_key(&character.image, &user_key)?) } else { None };
let encrypted_role: Option<String> = if !character.role.is_empty() { Some(encrypt_data_with_user_key(&character.role, &user_key)?) } else { None };
let encrypted_biography: Option<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<bool> {
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<String> {
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<bool> {
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<CharacterAttributesResponse> {
let user_key: String = get_user_encryption_key(user_id)?;
let attributes_result: Vec<repo::SeriesCharacterAttributeResult> = repo::fetch_attributes(conn, character_id, user_id, lang)?;
let mut attributes: Vec<SeriesAttribute> = 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 })
}

Some files were not shown because too many files have changed in this diff Show More