Add database schema, encryption utilities, and local database service
- Implement `schema.ts` for SQLite schema creation, indexing, and sync metadata initialization. - Develop `encryption.ts` with AES-256-GCM encryption utilities for securing database data. - Add `database.service.ts` to manage CRUD operations with encryption support, user-specific databases, and schema initialization. - Integrate book, chapter, and character operations with encrypted content handling and sync preparation.
This commit is contained in:
@@ -27,6 +27,7 @@ import GuideTour, {GuideStep} from "@/components/GuideTour";
|
||||
import {UserProps} from "@/lib/models/User";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {getOfflineDataService} from "@/lib/services/offline-data.service";
|
||||
|
||||
interface MinMax {
|
||||
min: number;
|
||||
@@ -122,7 +123,23 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
}
|
||||
setIsAddingBook(true);
|
||||
try {
|
||||
const bookId: string = await System.authPostToServer<string>('book/add', {
|
||||
const offlineDataService = getOfflineDataService();
|
||||
const bookData = {
|
||||
title,
|
||||
subTitle: subtitle,
|
||||
type: selectedBookType,
|
||||
summary,
|
||||
serie: 0,
|
||||
publicationDate,
|
||||
desiredWordCount: wordCount
|
||||
};
|
||||
|
||||
const bookId: string = await offlineDataService.createBook(
|
||||
bookData,
|
||||
session.user?.id || '',
|
||||
async () => {
|
||||
// Only called if online
|
||||
const id = await System.authPostToServer<string>('book/add', {
|
||||
title: title,
|
||||
subTitle: subtitle,
|
||||
type: selectedBookType,
|
||||
@@ -130,21 +147,19 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
||||
serie: 0,
|
||||
publicationDate: publicationDate,
|
||||
desiredWordCount: wordCount,
|
||||
}, token, lang)
|
||||
if (!bookId) {
|
||||
errorMessage(t('addNewBookForm.error.addingBook'));
|
||||
setIsAddingBook(false);
|
||||
return;
|
||||
}, token, lang);
|
||||
if (!id) {
|
||||
throw new Error(t('addNewBookForm.error.addingBook'));
|
||||
}
|
||||
return id;
|
||||
}
|
||||
);
|
||||
|
||||
const book: BookProps = {
|
||||
bookId: bookId,
|
||||
title,
|
||||
subTitle: subtitle,
|
||||
type: selectedBookType,
|
||||
summary, serie: 0,
|
||||
publicationDate,
|
||||
desiredWordCount: wordCount
|
||||
...bookData
|
||||
};
|
||||
|
||||
setSession({
|
||||
...session,
|
||||
user: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import GuideTour, {GuideStep} from "@/components/GuideTour";
|
||||
import User from "@/lib/models/User";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import {getOfflineDataService} from "@/lib/services/offline-data.service";
|
||||
|
||||
export default function BookList() {
|
||||
const {session, setSession} = useContext(SessionContext);
|
||||
@@ -113,7 +114,10 @@ export default function BookList() {
|
||||
async function getBooks(): Promise<void> {
|
||||
setIsLoadingBooks(true);
|
||||
try {
|
||||
const bookResponse: BookListProps[] = await System.authGetQueryToServer<BookListProps[]>('books', accessToken, lang);
|
||||
const offlineDataService = getOfflineDataService();
|
||||
const bookResponse: BookListProps[] = await offlineDataService.getBooks(
|
||||
() => System.authGetQueryToServer<BookListProps[]>('books', accessToken, lang)
|
||||
);
|
||||
if (bookResponse) {
|
||||
const booksByType: Record<string, BookProps[]> = bookResponse.reduce((groups: Record<string, BookProps[]>, book: BookListProps): Record<string, BookProps[]> => {
|
||||
const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : '';
|
||||
|
||||
457
electron/database/database.service.ts
Normal file
457
electron/database/database.service.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import sqlite3 from 'node-sqlite3-wasm';
|
||||
import path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { initializeSchema } from './schema.js';
|
||||
import { encrypt, decrypt, encryptObject, decryptObject, hash } from './encryption.js';
|
||||
|
||||
// Type alias for compatibility
|
||||
type Database = sqlite3.Database;
|
||||
|
||||
// Mappers
|
||||
import * as BookMapper from './mappers/book.mapper.js';
|
||||
import * as ChapterMapper from './mappers/chapter.mapper.js';
|
||||
import * as CharacterMapper from './mappers/character.mapper.js';
|
||||
import * as AIMapper from './mappers/ai.mapper.js';
|
||||
import * as UserMapper from './mappers/user.mapper.js';
|
||||
import * as WorldMapper from './mappers/world.mapper.js';
|
||||
|
||||
// Types from mappers (which contain all necessary interfaces)
|
||||
import type { BookProps, BookListProps } from './mappers/book.mapper.js';
|
||||
import type { ChapterProps } from './mappers/chapter.mapper.js';
|
||||
import type { CharacterProps } from './mappers/character.mapper.js';
|
||||
import type { Conversation, Message } from './mappers/ai.mapper.js';
|
||||
import type { UserProps } from './mappers/user.mapper.js';
|
||||
import type { WorldProps } from './mappers/world.mapper.js';
|
||||
|
||||
/**
|
||||
* DatabaseService - Handles all local database operations
|
||||
* Provides CRUD operations with automatic encryption/decryption
|
||||
* Maps between DB snake_case and TypeScript camelCase interfaces
|
||||
*/
|
||||
export class DatabaseService {
|
||||
private db: Database | null = null;
|
||||
private userEncryptionKey: string | null = null;
|
||||
private userId: string | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Initialize the database for a specific user
|
||||
* @param userId - User ID for encryption key
|
||||
* @param encryptionKey - User's encryption key (generated at first login)
|
||||
*/
|
||||
initialize(userId: string, encryptionKey: string): void {
|
||||
if (this.db) {
|
||||
this.close();
|
||||
}
|
||||
|
||||
// Get user data directory
|
||||
const userDataPath = app.getPath('userData');
|
||||
const dbPath = path.join(userDataPath, `eritors-local-${userId}.db`);
|
||||
|
||||
this.db = new sqlite3.Database(dbPath);
|
||||
this.userEncryptionKey = encryptionKey;
|
||||
this.userId = userId;
|
||||
|
||||
// Initialize schema
|
||||
initializeSchema(this.db);
|
||||
|
||||
console.log(`Database initialized for user ${userId} at ${dbPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
close(): void {
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
this.userEncryptionKey = null;
|
||||
this.userId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database is initialized
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.db !== null && this.userEncryptionKey !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive field
|
||||
*/
|
||||
private encryptField(data: string): string {
|
||||
if (!this.userEncryptionKey) throw new Error('Encryption key not set');
|
||||
const encrypted = encrypt(data, this.userEncryptionKey);
|
||||
return JSON.stringify(encrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive field
|
||||
*/
|
||||
private decryptField(encryptedData: string): string {
|
||||
if (!this.userEncryptionKey) throw new Error('Encryption key not set');
|
||||
try {
|
||||
const parsed = JSON.parse(encryptedData);
|
||||
return decrypt(parsed, this.userEncryptionKey);
|
||||
} catch {
|
||||
// If not encrypted (for migration), return as-is
|
||||
return encryptedData;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== BOOK OPERATIONS ==========
|
||||
|
||||
/**
|
||||
* Get all books for the current user
|
||||
*/
|
||||
getBooks(): BookListProps[] {
|
||||
if (!this.db || !this.userId) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.db.all(`
|
||||
SELECT * FROM erit_books
|
||||
WHERE author_id = ?
|
||||
ORDER BY book_id DESC
|
||||
`, [this.userId]) as unknown as BookMapper.DBBook[];
|
||||
|
||||
return rows.map(row => BookMapper.dbToBookList(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single book by ID with all related data
|
||||
*/
|
||||
getBook(bookId: string): BookProps | null {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.db.all('SELECT * FROM erit_books WHERE book_id = ?', [bookId]) as unknown as BookMapper.DBBook[];
|
||||
const row = rows[0];
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const book = BookMapper.dbToBook(row);
|
||||
|
||||
// Load chapters
|
||||
const chapterRows = this.db.all(`
|
||||
SELECT * FROM book_chapters
|
||||
WHERE book_id = ?
|
||||
ORDER BY chapter_order ASC
|
||||
`, [bookId]) as unknown as ChapterMapper.DBChapter[];
|
||||
|
||||
book.chapters = chapterRows.map(chapterRow => {
|
||||
// Load chapter content
|
||||
const contentRows = this.db!.all(`
|
||||
SELECT * FROM book_chapter_content
|
||||
WHERE chapter_id = ?
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
`, [chapterRow.chapter_id]) as unknown as ChapterMapper.DBChapterContent[];
|
||||
const contentRow = contentRows[0];
|
||||
|
||||
// Decrypt content if encrypted
|
||||
if (contentRow && contentRow.content) {
|
||||
try {
|
||||
contentRow.content = this.decryptField(contentRow.content);
|
||||
} catch (error) {
|
||||
console.warn('Failed to decrypt chapter content:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return ChapterMapper.dbToChapter(chapterRow, contentRow);
|
||||
});
|
||||
|
||||
return book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update a book
|
||||
*/
|
||||
saveBook(book: BookProps | BookListProps, authorId?: string): void {
|
||||
if (!this.db || !this.userId) throw new Error('Database not initialized');
|
||||
|
||||
const dbBook = 'bookId' in book
|
||||
? BookMapper.bookToDb(book, authorId || this.userId, 0)
|
||||
: BookMapper.bookListToDb(book, 0);
|
||||
|
||||
// Hash the title
|
||||
dbBook.hashed_title = hash(dbBook.title);
|
||||
if (dbBook.sub_title) {
|
||||
dbBook.hashed_sub_title = hash(dbBook.sub_title);
|
||||
}
|
||||
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE 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, words_count,
|
||||
cover_image, book_meta, synced
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
dbBook.book_id, dbBook.type, dbBook.author_id, dbBook.title, dbBook.hashed_title,
|
||||
dbBook.sub_title ?? null, dbBook.hashed_sub_title, dbBook.summary, dbBook.serie_id ?? null,
|
||||
dbBook.desired_release_date ?? null, dbBook.desired_word_count ?? null, dbBook.words_count ?? null,
|
||||
dbBook.cover_image ?? null, dbBook.book_meta ?? null, 0
|
||||
]);
|
||||
|
||||
// Add to pending changes for sync
|
||||
this.addPendingChange('erit_books', 'INSERT', dbBook.book_id, dbBook);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a book
|
||||
*/
|
||||
deleteBook(bookId: string): void {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
this.db.run('DELETE FROM erit_books WHERE book_id = ?', [bookId]);
|
||||
this.addPendingChange('erit_books', 'DELETE', bookId);
|
||||
}
|
||||
|
||||
// ========== CHAPTER OPERATIONS ==========
|
||||
|
||||
/**
|
||||
* Save or update a chapter
|
||||
*/
|
||||
saveChapter(chapter: ChapterProps, bookId: string, contentId?: string): void {
|
||||
if (!this.db || !this.userId) throw new Error('Database not initialized');
|
||||
|
||||
const dbChapter = ChapterMapper.chapterToDb(chapter, bookId, this.userId, 0);
|
||||
dbChapter.hashed_title = hash(dbChapter.title);
|
||||
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE INTO book_chapters (
|
||||
chapter_id, book_id, author_id, title, hashed_title, words_count,
|
||||
chapter_order, meta_chapter, synced
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
dbChapter.chapter_id, dbChapter.book_id, dbChapter.author_id, dbChapter.title,
|
||||
dbChapter.hashed_title, dbChapter.words_count ?? null, dbChapter.chapter_order ?? null,
|
||||
dbChapter.meta_chapter, 0
|
||||
]);
|
||||
|
||||
// Save encrypted content
|
||||
const dbContent = ChapterMapper.chapterContentToDb(
|
||||
chapter.chapterContent,
|
||||
contentId || crypto.randomUUID(),
|
||||
chapter.chapterId,
|
||||
this.userId,
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
// Encrypt the content
|
||||
dbContent.content = this.encryptField(dbContent.content);
|
||||
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE INTO book_chapter_content (
|
||||
content_id, chapter_id, author_id, version, content, words_count,
|
||||
meta_chapter_content, time_on_it, synced
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
dbContent.content_id, dbContent.chapter_id, dbContent.author_id, dbContent.version,
|
||||
dbContent.content, dbContent.words_count, dbContent.meta_chapter_content,
|
||||
dbContent.time_on_it, 0
|
||||
]);
|
||||
|
||||
this.addPendingChange('book_chapters', 'INSERT', chapter.chapterId, dbChapter);
|
||||
}
|
||||
|
||||
// ========== CHARACTER OPERATIONS ==========
|
||||
|
||||
/**
|
||||
* Get all characters for a book
|
||||
*/
|
||||
getCharacters(bookId: string): CharacterProps[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const characterRows = this.db.all('SELECT * FROM book_characters WHERE book_id = ?', [bookId]) as unknown as CharacterMapper.DBCharacter[];
|
||||
|
||||
return characterRows.map(charRow => {
|
||||
const attrRows = this.db!.all('SELECT * FROM book_characters_attributes WHERE character_id = ?', [charRow.character_id]) as unknown as CharacterMapper.DBCharacterAttribute[];
|
||||
return CharacterMapper.dbToCharacter(charRow, attrRows);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update a character
|
||||
*/
|
||||
saveCharacter(character: CharacterProps, bookId: string): void {
|
||||
if (!this.db || !this.userId) throw new Error('Database not initialized');
|
||||
|
||||
const dbCharacter = CharacterMapper.characterToDb(character, bookId, this.userId, 0);
|
||||
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE INTO book_characters (
|
||||
character_id, book_id, user_id, first_name, last_name, category, title,
|
||||
image, role, biography, history, char_meta, synced
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
dbCharacter.character_id, dbCharacter.book_id, dbCharacter.user_id,
|
||||
dbCharacter.first_name, dbCharacter.last_name ?? null, dbCharacter.category,
|
||||
dbCharacter.title ?? null, dbCharacter.image ?? null, dbCharacter.role ?? null, dbCharacter.biography ?? null,
|
||||
dbCharacter.history ?? null, dbCharacter.char_meta, 0
|
||||
]);
|
||||
|
||||
// Delete old attributes and insert new ones
|
||||
this.db.run('DELETE FROM book_characters_attributes WHERE character_id = ?', [dbCharacter.character_id]);
|
||||
|
||||
const attributes = CharacterMapper.characterAttributesToDb(character, this.userId, 0);
|
||||
|
||||
for (const attr of attributes) {
|
||||
this.db.run(`
|
||||
INSERT INTO book_characters_attributes (
|
||||
attr_id, character_id, user_id, attribute_name, attribute_value, attr_meta, synced
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
attr.attr_id, attr.character_id, attr.user_id, attr.attribute_name,
|
||||
attr.attribute_value, attr.attr_meta, 0
|
||||
]);
|
||||
}
|
||||
|
||||
this.addPendingChange('book_characters', 'INSERT', dbCharacter.character_id, dbCharacter);
|
||||
}
|
||||
|
||||
// ========== AI CONVERSATION OPERATIONS ==========
|
||||
|
||||
/**
|
||||
* Get all conversations for a book
|
||||
*/
|
||||
getConversations(bookId: string): Conversation[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const convoRows = this.db.all('SELECT * FROM ai_conversations WHERE book_id = ? ORDER BY start_date DESC', [bookId]) as unknown as AIMapper.DBConversation[];
|
||||
|
||||
return convoRows.map(convoRow => {
|
||||
const messageRows = this.db!.all('SELECT * FROM ai_messages_history WHERE conversation_id = ? ORDER BY message_date ASC', [convoRow.conversation_id]) as unknown as AIMapper.DBMessage[];
|
||||
|
||||
// Decrypt messages
|
||||
messageRows.forEach(msg => {
|
||||
try {
|
||||
msg.message = this.decryptField(msg.message);
|
||||
} catch (error) {
|
||||
console.warn('Failed to decrypt AI message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return AIMapper.dbToConversation(convoRow, messageRows);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a conversation with messages
|
||||
*/
|
||||
saveConversation(conversation: Conversation, bookId: string): void {
|
||||
if (!this.db || !this.userId) throw new Error('Database not initialized');
|
||||
|
||||
const dbConvo = AIMapper.conversationToDb(conversation, bookId, this.userId, 0);
|
||||
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE INTO ai_conversations (
|
||||
conversation_id, book_id, mode, title, start_date, status, user_id, summary, convo_meta, synced
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
dbConvo.conversation_id, dbConvo.book_id, dbConvo.mode, dbConvo.title,
|
||||
dbConvo.start_date, dbConvo.status, dbConvo.user_id, dbConvo.summary ?? null,
|
||||
dbConvo.convo_meta, 0
|
||||
]);
|
||||
|
||||
// Save encrypted messages
|
||||
for (const message of conversation.messages) {
|
||||
const dbMessage = AIMapper.messageToDb(message, conversation.id, 0);
|
||||
// Encrypt the message content
|
||||
dbMessage.message = this.encryptField(dbMessage.message);
|
||||
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE INTO ai_messages_history (
|
||||
message_id, conversation_id, role, message, message_date, meta_message, synced
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
dbMessage.message_id, dbMessage.conversation_id, dbMessage.role,
|
||||
dbMessage.message, dbMessage.message_date, dbMessage.meta_message, 0
|
||||
]);
|
||||
}
|
||||
|
||||
this.addPendingChange('ai_conversations', 'INSERT', dbConvo.conversation_id, dbConvo);
|
||||
}
|
||||
|
||||
// ========== SYNC OPERATIONS ==========
|
||||
|
||||
/**
|
||||
* Add a pending change for sync
|
||||
*/
|
||||
private addPendingChange(tableName: string, operation: string, recordId: string, data?: any): void {
|
||||
if (!this.db) return;
|
||||
|
||||
this.db.run(`
|
||||
INSERT INTO _pending_changes (table_name, operation, record_id, data, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [tableName, operation, recordId, data ? JSON.stringify(data) : null, Date.now()]);
|
||||
|
||||
// Update sync metadata
|
||||
this.db.run(`
|
||||
UPDATE _sync_metadata
|
||||
SET pending_changes = pending_changes + 1
|
||||
WHERE table_name = ?
|
||||
`, [tableName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending changes for sync
|
||||
*/
|
||||
getPendingChanges(limit: number = 100): any[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return this.db.all(`
|
||||
SELECT * FROM _pending_changes
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?
|
||||
`, [limit]) as any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark changes as synced
|
||||
*/
|
||||
markChangesSynced(changeIds: number[]): void {
|
||||
if (!this.db || changeIds.length === 0) return;
|
||||
|
||||
const placeholders = changeIds.map(() => '?').join(',');
|
||||
this.db.run(`DELETE FROM _pending_changes WHERE id IN (${placeholders})`, changeIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last sync time for a table
|
||||
*/
|
||||
updateLastSync(tableName: string): void {
|
||||
if (!this.db) return;
|
||||
|
||||
this.db.run(`
|
||||
UPDATE _sync_metadata
|
||||
SET last_sync_at = ?, pending_changes = 0
|
||||
WHERE table_name = ?
|
||||
`, [Date.now(), tableName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status
|
||||
*/
|
||||
getSyncStatus(): { table: string; lastSync: number; pending: number }[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.db.all('SELECT * FROM _sync_metadata', []) as unknown as any[];
|
||||
|
||||
return rows.map(row => ({
|
||||
table: row.table_name as string,
|
||||
lastSync: row.last_sync_at as number,
|
||||
pending: row.pending_changes as number
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let dbServiceInstance: DatabaseService | null = null;
|
||||
|
||||
export function getDatabaseService(): DatabaseService {
|
||||
if (!dbServiceInstance) {
|
||||
dbServiceInstance = new DatabaseService();
|
||||
}
|
||||
return dbServiceInstance;
|
||||
}
|
||||
137
electron/database/encryption.ts
Normal file
137
electron/database/encryption.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Encryption utilities using AES-256-GCM for local database encryption
|
||||
* Each user has a unique encryption key derived from their userId and a master secret
|
||||
*/
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_LENGTH = 32; // 256 bits
|
||||
const IV_LENGTH = 16; // 128 bits
|
||||
const SALT_LENGTH = 64;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
export interface EncryptedData {
|
||||
encryptedData: string;
|
||||
iv: string;
|
||||
authTag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique encryption key for a user
|
||||
* This key is generated once at first login and stored securely in electron-store
|
||||
* @param userId - The user's unique identifier
|
||||
* @returns Base64 encoded encryption key
|
||||
*/
|
||||
export function generateUserEncryptionKey(userId: string): string {
|
||||
// Generate a random salt for this user
|
||||
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||
|
||||
// Create a deterministic key based on userId and random salt
|
||||
// This ensures each user has a unique, strong key
|
||||
const key = crypto.pbkdf2Sync(
|
||||
userId,
|
||||
salt,
|
||||
100000, // iterations
|
||||
KEY_LENGTH,
|
||||
'sha512'
|
||||
);
|
||||
|
||||
// Combine salt and key for storage
|
||||
const combined = Buffer.concat([salt, key]);
|
||||
return combined.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the actual encryption key from the stored combined salt+key
|
||||
* @param storedKey - Base64 encoded salt+key combination
|
||||
* @returns Encryption key buffer
|
||||
*/
|
||||
function extractKeyFromStored(storedKey: string): Buffer {
|
||||
const combined = Buffer.from(storedKey, 'base64');
|
||||
// Extract key (last KEY_LENGTH bytes)
|
||||
return combined.subarray(SALT_LENGTH, SALT_LENGTH + KEY_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive data using AES-256-GCM
|
||||
* @param data - Plain text data to encrypt
|
||||
* @param userKey - User's encryption key (base64)
|
||||
* @returns Encrypted data with IV and auth tag
|
||||
*/
|
||||
export function encrypt(data: string, userKey: string): EncryptedData {
|
||||
try {
|
||||
const key = extractKeyFromStored(userKey);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
encryptedData: encrypted,
|
||||
iv: iv.toString('hex'),
|
||||
authTag: authTag.toString('hex')
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data encrypted with AES-256-GCM
|
||||
* @param encryptedData - Encrypted data object
|
||||
* @param userKey - User's encryption key (base64)
|
||||
* @returns Decrypted plain text
|
||||
*/
|
||||
export function decrypt(encryptedData: EncryptedData, userKey: string): string {
|
||||
try {
|
||||
const key = extractKeyFromStored(userKey);
|
||||
const iv = Buffer.from(encryptedData.iv, 'hex');
|
||||
const authTag = Buffer.from(encryptedData.authTag, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encryptedData.encryptedData, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an object by converting it to JSON first
|
||||
* @param obj - Object to encrypt
|
||||
* @param userKey - User's encryption key
|
||||
* @returns Encrypted data
|
||||
*/
|
||||
export function encryptObject<T>(obj: T, userKey: string): EncryptedData {
|
||||
const jsonString = JSON.stringify(obj);
|
||||
return encrypt(jsonString, userKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and parse an encrypted object
|
||||
* @param encryptedData - Encrypted data object
|
||||
* @param userKey - User's encryption key
|
||||
* @returns Decrypted and parsed object
|
||||
*/
|
||||
export function decryptObject<T>(encryptedData: EncryptedData, userKey: string): T {
|
||||
const decrypted = decrypt(encryptedData, userKey);
|
||||
return JSON.parse(decrypted) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash data using SHA-256 (for non-reversible hashing like titles)
|
||||
* @param data - Data to hash
|
||||
* @returns Hex encoded hash
|
||||
*/
|
||||
export function hash(data: string): string {
|
||||
return crypto.createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
120
electron/database/mappers/ai.mapper.ts
Normal file
120
electron/database/mappers/ai.mapper.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* TypeScript interfaces (copied from lib/models for type safety)
|
||||
*/
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
type: string;
|
||||
message: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title?: string;
|
||||
date?: string;
|
||||
type?: string;
|
||||
status: number;
|
||||
totalPrice: number;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface ConversationProps {
|
||||
id: string;
|
||||
mode: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database row types (snake_case from SQLite)
|
||||
*/
|
||||
export interface DBConversation {
|
||||
conversation_id: string;
|
||||
book_id: string;
|
||||
mode: string;
|
||||
title: string;
|
||||
start_date: number; // Unix timestamp
|
||||
status: number;
|
||||
user_id: string;
|
||||
summary?: string;
|
||||
convo_meta: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
export interface DBMessage {
|
||||
message_id: string;
|
||||
conversation_id: string;
|
||||
role: string; // 'user' or 'model'
|
||||
message: string;
|
||||
message_date: number; // Unix timestamp
|
||||
meta_message: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: DB → TypeScript Interfaces
|
||||
*/
|
||||
|
||||
export function dbToConversation(dbConvo: DBConversation, messages: DBMessage[] = []): Conversation {
|
||||
return {
|
||||
id: dbConvo.conversation_id,
|
||||
title: dbConvo.title,
|
||||
date: new Date(dbConvo.start_date).toISOString(),
|
||||
type: dbConvo.mode as any,
|
||||
status: dbConvo.status,
|
||||
totalPrice: 0, // Computed from messages if needed
|
||||
messages: messages.map(dbToMessage)
|
||||
};
|
||||
}
|
||||
|
||||
export function dbToConversationProps(dbConvo: DBConversation): ConversationProps {
|
||||
return {
|
||||
id: dbConvo.conversation_id,
|
||||
mode: dbConvo.mode,
|
||||
title: dbConvo.title,
|
||||
startDate: new Date(dbConvo.start_date).toISOString(),
|
||||
status: dbConvo.status
|
||||
};
|
||||
}
|
||||
|
||||
export function dbToMessage(dbMessage: DBMessage): Message {
|
||||
return {
|
||||
id: parseInt(dbMessage.message_id, 10) || 0,
|
||||
type: dbMessage.role as any,
|
||||
message: dbMessage.message,
|
||||
date: new Date(dbMessage.message_date).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: TypeScript Interfaces → DB
|
||||
*/
|
||||
|
||||
export function conversationToDb(conversation: Conversation, bookId: string, userId: string, synced: number = 0): DBConversation {
|
||||
return {
|
||||
conversation_id: conversation.id,
|
||||
book_id: bookId,
|
||||
mode: conversation.type || 'chatbot',
|
||||
title: conversation.title || 'Untitled Conversation',
|
||||
start_date: conversation.date ? new Date(conversation.date).getTime() : Date.now(),
|
||||
status: conversation.status,
|
||||
user_id: userId,
|
||||
summary: '',
|
||||
convo_meta: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
|
||||
export function messageToDb(message: Message, conversationId: string, synced: number = 0): DBMessage {
|
||||
return {
|
||||
message_id: message.id.toString(),
|
||||
conversation_id: conversationId,
|
||||
role: message.type,
|
||||
message: message.message,
|
||||
message_date: message.date ? new Date(message.date).getTime() : Date.now(),
|
||||
meta_message: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
406
electron/database/mappers/book.mapper.ts
Normal file
406
electron/database/mappers/book.mapper.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* TypeScript interfaces (copied from lib/models for type safety)
|
||||
*/
|
||||
|
||||
export interface Author {
|
||||
id: string;
|
||||
name: string;
|
||||
lastName: string;
|
||||
authorName?: string;
|
||||
}
|
||||
|
||||
export interface ActChapter {
|
||||
chapterInfoId: string;
|
||||
chapterId: string;
|
||||
title: string;
|
||||
chapterOrder: number;
|
||||
actId: number;
|
||||
incidentId?: string;
|
||||
plotPointId?: string;
|
||||
summary: string;
|
||||
goal: string;
|
||||
}
|
||||
|
||||
export interface ChapterProps {
|
||||
chapterId: string;
|
||||
chapterOrder: number;
|
||||
title: string;
|
||||
chapterContent: ChapterContent;
|
||||
}
|
||||
|
||||
export interface ChapterContent {
|
||||
version: number;
|
||||
content: string;
|
||||
wordsCount: number;
|
||||
}
|
||||
|
||||
export interface BookProps {
|
||||
bookId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
author?: Author;
|
||||
serie?: number;
|
||||
subTitle?: string;
|
||||
summary?: string;
|
||||
publicationDate?: string;
|
||||
desiredWordCount?: number;
|
||||
totalWordCount?: number;
|
||||
coverImage?: string;
|
||||
chapters?: ChapterProps[];
|
||||
}
|
||||
|
||||
export interface BookListProps {
|
||||
id: string;
|
||||
type: string;
|
||||
authorId: string;
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
summary?: string;
|
||||
serieId?: number;
|
||||
desiredReleaseDate?: string;
|
||||
desiredWordCount?: number;
|
||||
wordCount?: number;
|
||||
coverImage?: string;
|
||||
bookMeta?: string;
|
||||
}
|
||||
|
||||
export interface GuideLine {
|
||||
tone: string;
|
||||
atmosphere: string;
|
||||
writingStyle: string;
|
||||
themes: string;
|
||||
symbolism: string;
|
||||
motifs: string;
|
||||
narrativeVoice: string;
|
||||
pacing: string;
|
||||
intendedAudience: string;
|
||||
keyMessages: string;
|
||||
}
|
||||
|
||||
export interface GuideLineAI {
|
||||
narrativeType: number;
|
||||
dialogueType: number;
|
||||
globalResume: string;
|
||||
atmosphere: string;
|
||||
verbeTense: number;
|
||||
langue: number;
|
||||
themes: string;
|
||||
}
|
||||
|
||||
export interface PlotPoint {
|
||||
plotPointId: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
linkedIncidentId: string;
|
||||
chapters?: ActChapter[];
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
incidentId: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
chapters?: ActChapter[];
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database row types (snake_case from SQLite)
|
||||
*/
|
||||
export interface DBBook {
|
||||
book_id: string;
|
||||
type: string;
|
||||
author_id: string;
|
||||
title: string;
|
||||
hashed_title: string;
|
||||
sub_title?: string;
|
||||
hashed_sub_title: string;
|
||||
summary: string;
|
||||
serie_id?: number;
|
||||
desired_release_date?: string;
|
||||
desired_word_count?: number;
|
||||
words_count?: number;
|
||||
cover_image?: string;
|
||||
book_meta?: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
export interface DBGuideLine {
|
||||
user_id: string;
|
||||
book_id: string;
|
||||
tone: string;
|
||||
atmosphere: string;
|
||||
writing_style: string;
|
||||
themes: string;
|
||||
symbolism: string;
|
||||
motifs: string;
|
||||
narrative_voice: string;
|
||||
pacing: string;
|
||||
intended_audience: string;
|
||||
key_messages: string;
|
||||
meta_guide_line: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
export interface DBGuideLineAI {
|
||||
user_id: string;
|
||||
book_id: string;
|
||||
global_resume: string;
|
||||
themes: string;
|
||||
verbe_tense: number;
|
||||
narrative_type: number;
|
||||
langue: number;
|
||||
dialogue_type: number;
|
||||
tone: string;
|
||||
atmosphere: string;
|
||||
current_resume: string;
|
||||
meta: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
export interface DBPlotPoint {
|
||||
plot_point_id: string;
|
||||
title: string;
|
||||
hashed_title: string;
|
||||
summary?: string;
|
||||
linked_incident_id?: string;
|
||||
author_id: string;
|
||||
book_id: string;
|
||||
meta_plot: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
export interface DBIncident {
|
||||
incident_id: string;
|
||||
author_id: string;
|
||||
book_id: string;
|
||||
title: string;
|
||||
hashed_title: string;
|
||||
summary?: string;
|
||||
meta_incident: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
export interface DBIssue {
|
||||
issue_id: string;
|
||||
author_id: string;
|
||||
book_id: string;
|
||||
name: string;
|
||||
hashed_issue_name: string;
|
||||
meta_issue: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: DB → TypeScript Interfaces
|
||||
*/
|
||||
|
||||
export function dbToBookList(dbBook: DBBook): BookListProps {
|
||||
return {
|
||||
id: dbBook.book_id,
|
||||
type: dbBook.type,
|
||||
authorId: dbBook.author_id,
|
||||
title: dbBook.title,
|
||||
subTitle: dbBook.sub_title,
|
||||
summary: dbBook.summary,
|
||||
serieId: dbBook.serie_id,
|
||||
desiredReleaseDate: dbBook.desired_release_date,
|
||||
desiredWordCount: dbBook.desired_word_count,
|
||||
wordCount: dbBook.words_count,
|
||||
coverImage: dbBook.cover_image,
|
||||
bookMeta: dbBook.book_meta
|
||||
};
|
||||
}
|
||||
|
||||
export function dbToBook(dbBook: DBBook, author?: Author): BookProps {
|
||||
return {
|
||||
bookId: dbBook.book_id,
|
||||
type: dbBook.type,
|
||||
title: dbBook.title,
|
||||
author,
|
||||
serie: dbBook.serie_id,
|
||||
subTitle: dbBook.sub_title,
|
||||
summary: dbBook.summary,
|
||||
publicationDate: dbBook.desired_release_date,
|
||||
desiredWordCount: dbBook.desired_word_count,
|
||||
totalWordCount: dbBook.words_count,
|
||||
coverImage: dbBook.cover_image,
|
||||
chapters: [] // Populated separately
|
||||
};
|
||||
}
|
||||
|
||||
export function dbToGuideLine(dbGuideLine: DBGuideLine): GuideLine {
|
||||
return {
|
||||
tone: dbGuideLine.tone,
|
||||
atmosphere: dbGuideLine.atmosphere,
|
||||
writingStyle: dbGuideLine.writing_style,
|
||||
themes: dbGuideLine.themes,
|
||||
symbolism: dbGuideLine.symbolism,
|
||||
motifs: dbGuideLine.motifs,
|
||||
narrativeVoice: dbGuideLine.narrative_voice,
|
||||
pacing: dbGuideLine.pacing,
|
||||
intendedAudience: dbGuideLine.intended_audience,
|
||||
keyMessages: dbGuideLine.key_messages
|
||||
};
|
||||
}
|
||||
|
||||
export function dbToGuideLineAI(dbGuideLineAI: DBGuideLineAI): GuideLineAI {
|
||||
return {
|
||||
narrativeType: dbGuideLineAI.narrative_type,
|
||||
dialogueType: dbGuideLineAI.dialogue_type,
|
||||
globalResume: dbGuideLineAI.global_resume,
|
||||
atmosphere: dbGuideLineAI.atmosphere,
|
||||
verbeTense: dbGuideLineAI.verbe_tense,
|
||||
langue: dbGuideLineAI.langue,
|
||||
themes: dbGuideLineAI.themes
|
||||
};
|
||||
}
|
||||
|
||||
export function dbToPlotPoint(dbPlotPoint: DBPlotPoint): PlotPoint {
|
||||
return {
|
||||
plotPointId: dbPlotPoint.plot_point_id,
|
||||
title: dbPlotPoint.title,
|
||||
summary: dbPlotPoint.summary || '',
|
||||
linkedIncidentId: dbPlotPoint.linked_incident_id || '',
|
||||
chapters: [] // Populated separately
|
||||
};
|
||||
}
|
||||
|
||||
export function dbToIncident(dbIncident: DBIncident): Incident {
|
||||
return {
|
||||
incidentId: dbIncident.incident_id,
|
||||
title: dbIncident.title,
|
||||
summary: dbIncident.summary || '',
|
||||
chapters: [] // Populated separately
|
||||
};
|
||||
}
|
||||
|
||||
export function dbToIssue(dbIssue: DBIssue): Issue {
|
||||
return {
|
||||
id: dbIssue.issue_id,
|
||||
name: dbIssue.name
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: TypeScript Interfaces → DB
|
||||
*/
|
||||
|
||||
export function bookListToDb(book: BookListProps, synced: number = 0): DBBook {
|
||||
return {
|
||||
book_id: book.id,
|
||||
type: book.type,
|
||||
author_id: book.authorId,
|
||||
title: book.title,
|
||||
hashed_title: '', // Will be computed with hash function
|
||||
sub_title: book.subTitle,
|
||||
hashed_sub_title: '',
|
||||
summary: book.summary || '',
|
||||
serie_id: book.serieId,
|
||||
desired_release_date: book.desiredReleaseDate,
|
||||
desired_word_count: book.desiredWordCount,
|
||||
words_count: book.wordCount,
|
||||
cover_image: book.coverImage,
|
||||
book_meta: book.bookMeta || '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
|
||||
export function bookToDb(book: BookProps, authorId: string, synced: number = 0): DBBook {
|
||||
return {
|
||||
book_id: book.bookId,
|
||||
type: book.type,
|
||||
author_id: authorId,
|
||||
title: book.title,
|
||||
hashed_title: '',
|
||||
sub_title: book.subTitle,
|
||||
hashed_sub_title: '',
|
||||
summary: book.summary || '',
|
||||
serie_id: book.serie,
|
||||
desired_release_date: book.publicationDate,
|
||||
desired_word_count: book.desiredWordCount,
|
||||
words_count: book.totalWordCount,
|
||||
cover_image: book.coverImage,
|
||||
book_meta: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
|
||||
export function guideLineToDb(guideLine: GuideLine, userId: string, bookId: string, synced: number = 0): DBGuideLine {
|
||||
return {
|
||||
user_id: userId,
|
||||
book_id: bookId,
|
||||
tone: guideLine.tone,
|
||||
atmosphere: guideLine.atmosphere,
|
||||
writing_style: guideLine.writingStyle,
|
||||
themes: guideLine.themes,
|
||||
symbolism: guideLine.symbolism,
|
||||
motifs: guideLine.motifs,
|
||||
narrative_voice: guideLine.narrativeVoice,
|
||||
pacing: guideLine.pacing,
|
||||
intended_audience: guideLine.intendedAudience,
|
||||
key_messages: guideLine.keyMessages,
|
||||
meta_guide_line: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
|
||||
export function guideLineAIToDb(guideLineAI: GuideLineAI, userId: string, bookId: string, synced: number = 0): DBGuideLineAI {
|
||||
return {
|
||||
user_id: userId,
|
||||
book_id: bookId,
|
||||
global_resume: guideLineAI.globalResume,
|
||||
themes: guideLineAI.themes,
|
||||
verbe_tense: guideLineAI.verbeTense,
|
||||
narrative_type: guideLineAI.narrativeType,
|
||||
langue: guideLineAI.langue,
|
||||
dialogue_type: guideLineAI.dialogueType,
|
||||
tone: '',
|
||||
atmosphere: guideLineAI.atmosphere,
|
||||
current_resume: '',
|
||||
meta: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
|
||||
export function plotPointToDb(plotPoint: PlotPoint, authorId: string, bookId: string, synced: number = 0): DBPlotPoint {
|
||||
return {
|
||||
plot_point_id: plotPoint.plotPointId,
|
||||
title: plotPoint.title,
|
||||
hashed_title: '',
|
||||
summary: plotPoint.summary,
|
||||
linked_incident_id: plotPoint.linkedIncidentId,
|
||||
author_id: authorId,
|
||||
book_id: bookId,
|
||||
meta_plot: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
|
||||
export function incidentToDb(incident: Incident, authorId: string, bookId: string, synced: number = 0): DBIncident {
|
||||
return {
|
||||
incident_id: incident.incidentId,
|
||||
author_id: authorId,
|
||||
book_id: bookId,
|
||||
title: incident.title,
|
||||
hashed_title: '',
|
||||
summary: incident.summary,
|
||||
meta_incident: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
|
||||
export function issueToDb(issue: Issue, authorId: string, bookId: string, synced: number = 0): DBIssue {
|
||||
return {
|
||||
issue_id: issue.id,
|
||||
author_id: authorId,
|
||||
book_id: bookId,
|
||||
name: issue.name,
|
||||
hashed_issue_name: '',
|
||||
meta_issue: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
174
electron/database/mappers/chapter.mapper.ts
Normal file
174
electron/database/mappers/chapter.mapper.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* TypeScript interfaces (copied from lib/models for type safety)
|
||||
*/
|
||||
|
||||
export interface ChapterContent {
|
||||
version: number;
|
||||
content: string;
|
||||
wordsCount: number;
|
||||
}
|
||||
|
||||
export interface ChapterProps {
|
||||
chapterId: string;
|
||||
chapterOrder: number;
|
||||
title: string;
|
||||
chapterContent: ChapterContent;
|
||||
}
|
||||
|
||||
export interface ActChapter {
|
||||
chapterInfoId: string;
|
||||
chapterId: string;
|
||||
title: string;
|
||||
chapterOrder: number;
|
||||
actId: number;
|
||||
incidentId?: string;
|
||||
plotPointId?: string;
|
||||
summary: string;
|
||||
goal: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database row types (snake_case from SQLite)
|
||||
*/
|
||||
export interface DBChapter {
|
||||
chapter_id: string;
|
||||
book_id: string;
|
||||
author_id: string;
|
||||
title: string;
|
||||
hashed_title?: string;
|
||||
words_count?: number;
|
||||
chapter_order?: number;
|
||||
meta_chapter: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
export interface DBChapterContent {
|
||||
content_id: string;
|
||||
chapter_id: string;
|
||||
author_id: string;
|
||||
version: number;
|
||||
content: string;
|
||||
words_count: number;
|
||||
meta_chapter_content: string;
|
||||
time_on_it: number;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
export interface DBChapterInfo {
|
||||
chapter_info_id: string;
|
||||
chapter_id?: string;
|
||||
act_id?: number;
|
||||
incident_id?: string;
|
||||
plot_point_id?: string;
|
||||
book_id?: string;
|
||||
author_id?: string;
|
||||
summary: string;
|
||||
goal: string;
|
||||
meta_chapter_info: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: DB → TypeScript Interfaces
|
||||
*/
|
||||
|
||||
export function dbToChapter(dbChapter: DBChapter, dbContent?: DBChapterContent): ChapterProps {
|
||||
const chapterContent: ChapterContent = dbContent ? {
|
||||
version: dbContent.version,
|
||||
content: dbContent.content,
|
||||
wordsCount: dbContent.words_count
|
||||
} : {
|
||||
version: 2,
|
||||
content: '',
|
||||
wordsCount: 0
|
||||
};
|
||||
|
||||
return {
|
||||
chapterId: dbChapter.chapter_id,
|
||||
chapterOrder: dbChapter.chapter_order || 0,
|
||||
title: dbChapter.title,
|
||||
chapterContent
|
||||
};
|
||||
}
|
||||
|
||||
export function dbToChapterContent(dbContent: DBChapterContent): ChapterContent {
|
||||
return {
|
||||
version: dbContent.version,
|
||||
content: dbContent.content,
|
||||
wordsCount: dbContent.words_count
|
||||
};
|
||||
}
|
||||
|
||||
export function dbToActChapter(dbChapter: DBChapter, dbInfo: DBChapterInfo): ActChapter {
|
||||
return {
|
||||
chapterInfoId: dbInfo.chapter_info_id,
|
||||
chapterId: dbChapter.chapter_id,
|
||||
title: dbChapter.title,
|
||||
chapterOrder: dbChapter.chapter_order || 0,
|
||||
actId: dbInfo.act_id || 0,
|
||||
incidentId: dbInfo.incident_id,
|
||||
plotPointId: dbInfo.plot_point_id,
|
||||
summary: dbInfo.summary,
|
||||
goal: dbInfo.goal
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: TypeScript Interfaces → DB
|
||||
*/
|
||||
|
||||
export function chapterToDb(chapter: ChapterProps, bookId: string, authorId: string, synced: number = 0): DBChapter {
|
||||
return {
|
||||
chapter_id: chapter.chapterId,
|
||||
book_id: bookId,
|
||||
author_id: authorId,
|
||||
title: chapter.title,
|
||||
hashed_title: '',
|
||||
words_count: chapter.chapterContent.wordsCount,
|
||||
chapter_order: chapter.chapterOrder,
|
||||
meta_chapter: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
|
||||
export function chapterContentToDb(
|
||||
content: ChapterContent,
|
||||
contentId: string,
|
||||
chapterId: string,
|
||||
authorId: string,
|
||||
timeOnIt: number = 0,
|
||||
synced: number = 0
|
||||
): DBChapterContent {
|
||||
return {
|
||||
content_id: contentId,
|
||||
chapter_id: chapterId,
|
||||
author_id: authorId,
|
||||
version: content.version,
|
||||
content: content.content,
|
||||
words_count: content.wordsCount,
|
||||
meta_chapter_content: '',
|
||||
time_on_it: timeOnIt,
|
||||
synced
|
||||
};
|
||||
}
|
||||
|
||||
export function actChapterToDbInfo(
|
||||
actChapter: ActChapter,
|
||||
bookId: string,
|
||||
authorId: string,
|
||||
synced: number = 0
|
||||
): DBChapterInfo {
|
||||
return {
|
||||
chapter_info_id: actChapter.chapterInfoId,
|
||||
chapter_id: actChapter.chapterId,
|
||||
act_id: actChapter.actId,
|
||||
incident_id: actChapter.incidentId,
|
||||
plot_point_id: actChapter.plotPointId,
|
||||
book_id: bookId,
|
||||
author_id: authorId,
|
||||
summary: actChapter.summary,
|
||||
goal: actChapter.goal,
|
||||
meta_chapter_info: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
185
electron/database/mappers/character.mapper.ts
Normal file
185
electron/database/mappers/character.mapper.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* TypeScript interfaces (copied from lib/models for type safety)
|
||||
*/
|
||||
|
||||
export interface Attribute {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CharacterProps {
|
||||
id: string | null;
|
||||
name: string;
|
||||
lastName: string;
|
||||
category: string;
|
||||
title: string;
|
||||
image: string;
|
||||
physical?: Attribute[];
|
||||
psychological?: Attribute[];
|
||||
relations?: Attribute[];
|
||||
skills?: Attribute[];
|
||||
weaknesses?: Attribute[];
|
||||
strengths?: Attribute[];
|
||||
goals?: Attribute[];
|
||||
motivations?: Attribute[];
|
||||
role: string;
|
||||
biography?: string;
|
||||
history?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database row types (snake_case from SQLite)
|
||||
*/
|
||||
export interface DBCharacter {
|
||||
character_id: string;
|
||||
book_id: string;
|
||||
user_id: string;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
category: string;
|
||||
title?: string;
|
||||
image?: string;
|
||||
role?: string;
|
||||
biography?: string;
|
||||
history?: string;
|
||||
char_meta: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
export interface DBCharacterAttribute {
|
||||
attr_id: string;
|
||||
character_id: string;
|
||||
user_id: string;
|
||||
attribute_name: string; // Format: "section:attributeName" (e.g., "physical:Height")
|
||||
attribute_value: string; // JSON stringified Attribute
|
||||
attr_meta: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: DB → TypeScript Interfaces
|
||||
*/
|
||||
|
||||
export function dbToCharacter(dbChar: DBCharacter, attributes: DBCharacterAttribute[] = []): CharacterProps {
|
||||
// Group attributes by section
|
||||
const physical: Attribute[] = [];
|
||||
const psychological: Attribute[] = [];
|
||||
const relations: Attribute[] = [];
|
||||
const skills: Attribute[] = [];
|
||||
const weaknesses: Attribute[] = [];
|
||||
const strengths: Attribute[] = [];
|
||||
const goals: Attribute[] = [];
|
||||
const motivations: Attribute[] = [];
|
||||
|
||||
for (const attr of attributes) {
|
||||
try {
|
||||
const parsedValue: Attribute = JSON.parse(attr.attribute_value);
|
||||
const section = attr.attribute_name.split(':')[0];
|
||||
|
||||
switch (section) {
|
||||
case 'physical':
|
||||
physical.push(parsedValue);
|
||||
break;
|
||||
case 'psychological':
|
||||
psychological.push(parsedValue);
|
||||
break;
|
||||
case 'relations':
|
||||
relations.push(parsedValue);
|
||||
break;
|
||||
case 'skills':
|
||||
skills.push(parsedValue);
|
||||
break;
|
||||
case 'weaknesses':
|
||||
weaknesses.push(parsedValue);
|
||||
break;
|
||||
case 'strengths':
|
||||
strengths.push(parsedValue);
|
||||
break;
|
||||
case 'goals':
|
||||
goals.push(parsedValue);
|
||||
break;
|
||||
case 'motivations':
|
||||
motivations.push(parsedValue);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse character attribute:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: dbChar.character_id,
|
||||
name: dbChar.first_name,
|
||||
lastName: dbChar.last_name || '',
|
||||
category: dbChar.category as any,
|
||||
title: dbChar.title || '',
|
||||
image: dbChar.image || '',
|
||||
physical,
|
||||
psychological,
|
||||
relations,
|
||||
skills,
|
||||
weaknesses,
|
||||
strengths,
|
||||
goals,
|
||||
motivations,
|
||||
role: dbChar.role || '',
|
||||
biography: dbChar.biography,
|
||||
history: dbChar.history
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: TypeScript Interfaces → DB
|
||||
*/
|
||||
|
||||
export function characterToDb(character: CharacterProps, bookId: string, userId: string, synced: number = 0): DBCharacter {
|
||||
return {
|
||||
character_id: character.id || crypto.randomUUID(),
|
||||
book_id: bookId,
|
||||
user_id: userId,
|
||||
first_name: character.name,
|
||||
last_name: character.lastName,
|
||||
category: character.category,
|
||||
title: character.title,
|
||||
image: character.image,
|
||||
role: character.role,
|
||||
biography: character.biography,
|
||||
history: character.history,
|
||||
char_meta: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
|
||||
export function characterAttributesToDb(
|
||||
character: CharacterProps,
|
||||
userId: string,
|
||||
synced: number = 0
|
||||
): DBCharacterAttribute[] {
|
||||
const attributes: DBCharacterAttribute[] = [];
|
||||
|
||||
const addAttributes = (section: string, attrs: Attribute[]) => {
|
||||
for (const attr of attrs) {
|
||||
attributes.push({
|
||||
attr_id: attr.id || crypto.randomUUID(),
|
||||
character_id: character.id || '',
|
||||
user_id: userId,
|
||||
attribute_name: `${section}:${attr.name}`,
|
||||
attribute_value: JSON.stringify(attr),
|
||||
attr_meta: '',
|
||||
synced
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
addAttributes('physical', character.physical || []);
|
||||
addAttributes('psychological', character.psychological || []);
|
||||
addAttributes('relations', character.relations || []);
|
||||
addAttributes('skills', character.skills || []);
|
||||
addAttributes('weaknesses', character.weaknesses || []);
|
||||
addAttributes('strengths', character.strengths || []);
|
||||
addAttributes('goals', character.goals || []);
|
||||
addAttributes('motivations', character.motivations || []);
|
||||
|
||||
return attributes;
|
||||
}
|
||||
153
electron/database/mappers/user.mapper.ts
Normal file
153
electron/database/mappers/user.mapper.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* TypeScript interfaces (copied from lib/models for type safety)
|
||||
*/
|
||||
|
||||
export interface Subscription {
|
||||
subType: string;
|
||||
subTier: number;
|
||||
status: boolean;
|
||||
}
|
||||
|
||||
export interface UserProps {
|
||||
id: string;
|
||||
name: string;
|
||||
lastName: string;
|
||||
username: string;
|
||||
authorName?: string;
|
||||
email?: string;
|
||||
accountVerified: boolean;
|
||||
termsAccepted: boolean;
|
||||
aiUsage: number;
|
||||
apiKeys: {
|
||||
gemini: boolean;
|
||||
openai: boolean;
|
||||
anthropic: boolean;
|
||||
};
|
||||
books?: any[];
|
||||
guideTour?: { [key: string]: boolean }[];
|
||||
subscription?: Subscription[];
|
||||
writingLang: number;
|
||||
writingLevel: number;
|
||||
ritePoints: number;
|
||||
creditsBalance: number;
|
||||
groupId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database row types (snake_case from SQLite)
|
||||
*/
|
||||
export interface DBUser {
|
||||
user_id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
origin_email: string;
|
||||
origin_username: string;
|
||||
author_name?: string;
|
||||
origin_author_name?: string;
|
||||
plateform: string;
|
||||
social_id?: string;
|
||||
user_group: number;
|
||||
password?: string;
|
||||
term_accepted: number;
|
||||
verify_code?: string;
|
||||
reg_date: number;
|
||||
account_verified: number;
|
||||
user_meta: string; // JSON containing apiKeys, guideTour, writingLang, writingLevel, aiUsage
|
||||
erite_points: number;
|
||||
stripe_customer_id?: string;
|
||||
credits_balance: number;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
interface UserMeta {
|
||||
apiKeys?: {
|
||||
gemini: boolean;
|
||||
openai: boolean;
|
||||
anthropic: boolean;
|
||||
};
|
||||
guideTour?: { [key: string]: boolean }[];
|
||||
subscription?: Subscription[];
|
||||
writingLang?: number;
|
||||
writingLevel?: number;
|
||||
aiUsage?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: DB → TypeScript Interfaces
|
||||
*/
|
||||
|
||||
export function dbToUser(dbUser: DBUser): UserProps {
|
||||
let meta: UserMeta = {};
|
||||
try {
|
||||
meta = JSON.parse(dbUser.user_meta || '{}');
|
||||
} catch (error) {
|
||||
console.error('Failed to parse user_meta:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
id: dbUser.user_id,
|
||||
name: dbUser.first_name,
|
||||
lastName: dbUser.last_name,
|
||||
username: dbUser.username,
|
||||
authorName: dbUser.author_name,
|
||||
email: dbUser.email,
|
||||
accountVerified: dbUser.account_verified === 1,
|
||||
termsAccepted: dbUser.term_accepted === 1,
|
||||
aiUsage: meta.aiUsage || 0,
|
||||
apiKeys: meta.apiKeys || {
|
||||
gemini: false,
|
||||
openai: false,
|
||||
anthropic: false
|
||||
},
|
||||
books: [], // Populated separately
|
||||
guideTour: meta.guideTour || [],
|
||||
subscription: meta.subscription || [],
|
||||
writingLang: meta.writingLang || 1,
|
||||
writingLevel: meta.writingLevel || 1,
|
||||
ritePoints: dbUser.erite_points,
|
||||
creditsBalance: dbUser.credits_balance,
|
||||
groupId: dbUser.user_group
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: TypeScript Interfaces → DB
|
||||
*/
|
||||
|
||||
export function userToDb(user: UserProps, synced: number = 0): DBUser {
|
||||
const meta: UserMeta = {
|
||||
apiKeys: user.apiKeys,
|
||||
guideTour: user.guideTour,
|
||||
subscription: user.subscription,
|
||||
writingLang: user.writingLang,
|
||||
writingLevel: user.writingLevel,
|
||||
aiUsage: user.aiUsage
|
||||
};
|
||||
|
||||
return {
|
||||
user_id: user.id,
|
||||
first_name: user.name,
|
||||
last_name: user.lastName,
|
||||
username: user.username,
|
||||
email: user.email || '',
|
||||
origin_email: user.email || '',
|
||||
origin_username: user.username,
|
||||
author_name: user.authorName,
|
||||
origin_author_name: user.authorName,
|
||||
plateform: 'electron',
|
||||
social_id: undefined,
|
||||
user_group: user.groupId,
|
||||
password: undefined,
|
||||
term_accepted: user.termsAccepted ? 1 : 0,
|
||||
verify_code: undefined,
|
||||
reg_date: Date.now(),
|
||||
account_verified: user.accountVerified ? 1 : 0,
|
||||
user_meta: JSON.stringify(meta),
|
||||
erite_points: user.ritePoints,
|
||||
stripe_customer_id: undefined,
|
||||
credits_balance: user.creditsBalance,
|
||||
synced
|
||||
};
|
||||
}
|
||||
222
electron/database/mappers/world.mapper.ts
Normal file
222
electron/database/mappers/world.mapper.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* TypeScript interfaces (copied from lib/models for type safety)
|
||||
*/
|
||||
|
||||
export interface WorldElement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface WorldProps {
|
||||
id: string;
|
||||
name: string;
|
||||
history: string;
|
||||
politics: string;
|
||||
economy: string;
|
||||
religion: string;
|
||||
languages: string;
|
||||
laws?: WorldElement[];
|
||||
biomes?: WorldElement[];
|
||||
issues?: WorldElement[];
|
||||
customs?: WorldElement[];
|
||||
kingdoms?: WorldElement[];
|
||||
climate?: WorldElement[];
|
||||
resources?: WorldElement[];
|
||||
wildlife?: WorldElement[];
|
||||
arts?: WorldElement[];
|
||||
ethnicGroups?: WorldElement[];
|
||||
socialClasses?: WorldElement[];
|
||||
importantCharacters?: WorldElement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Database row types (snake_case from SQLite)
|
||||
*/
|
||||
export interface DBWorld {
|
||||
world_id: string;
|
||||
name: string;
|
||||
hashed_name: string;
|
||||
author_id: string;
|
||||
book_id: string;
|
||||
history?: string;
|
||||
politics?: string;
|
||||
economy?: string;
|
||||
religion?: string;
|
||||
languages?: string;
|
||||
meta_world: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
export interface DBWorldElement {
|
||||
element_id: string;
|
||||
world_id: string;
|
||||
user_id: string;
|
||||
element_type: number; // Type identifier for different element categories
|
||||
name: string;
|
||||
original_name: string;
|
||||
description?: string;
|
||||
meta_element: string;
|
||||
synced?: number;
|
||||
}
|
||||
|
||||
// Element type constants
|
||||
export enum WorldElementType {
|
||||
LAW = 1,
|
||||
BIOME = 2,
|
||||
ISSUE = 3,
|
||||
CUSTOM = 4,
|
||||
KINGDOM = 5,
|
||||
CLIMATE = 6,
|
||||
RESOURCE = 7,
|
||||
WILDLIFE = 8,
|
||||
ART = 9,
|
||||
ETHNIC_GROUP = 10,
|
||||
SOCIAL_CLASS = 11,
|
||||
IMPORTANT_CHARACTER = 12
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: DB → TypeScript Interfaces
|
||||
*/
|
||||
|
||||
export function dbToWorld(dbWorld: DBWorld, elements: DBWorldElement[] = []): WorldProps {
|
||||
// Group elements by type
|
||||
const laws: WorldElement[] = [];
|
||||
const biomes: WorldElement[] = [];
|
||||
const issues: WorldElement[] = [];
|
||||
const customs: WorldElement[] = [];
|
||||
const kingdoms: WorldElement[] = [];
|
||||
const climate: WorldElement[] = [];
|
||||
const resources: WorldElement[] = [];
|
||||
const wildlife: WorldElement[] = [];
|
||||
const arts: WorldElement[] = [];
|
||||
const ethnicGroups: WorldElement[] = [];
|
||||
const socialClasses: WorldElement[] = [];
|
||||
const importantCharacters: WorldElement[] = [];
|
||||
|
||||
for (const elem of elements) {
|
||||
const worldElement: WorldElement = {
|
||||
id: elem.element_id,
|
||||
name: elem.name,
|
||||
description: elem.description || ''
|
||||
};
|
||||
|
||||
switch (elem.element_type) {
|
||||
case WorldElementType.LAW:
|
||||
laws.push(worldElement);
|
||||
break;
|
||||
case WorldElementType.BIOME:
|
||||
biomes.push(worldElement);
|
||||
break;
|
||||
case WorldElementType.ISSUE:
|
||||
issues.push(worldElement);
|
||||
break;
|
||||
case WorldElementType.CUSTOM:
|
||||
customs.push(worldElement);
|
||||
break;
|
||||
case WorldElementType.KINGDOM:
|
||||
kingdoms.push(worldElement);
|
||||
break;
|
||||
case WorldElementType.CLIMATE:
|
||||
climate.push(worldElement);
|
||||
break;
|
||||
case WorldElementType.RESOURCE:
|
||||
resources.push(worldElement);
|
||||
break;
|
||||
case WorldElementType.WILDLIFE:
|
||||
wildlife.push(worldElement);
|
||||
break;
|
||||
case WorldElementType.ART:
|
||||
arts.push(worldElement);
|
||||
break;
|
||||
case WorldElementType.ETHNIC_GROUP:
|
||||
ethnicGroups.push(worldElement);
|
||||
break;
|
||||
case WorldElementType.SOCIAL_CLASS:
|
||||
socialClasses.push(worldElement);
|
||||
break;
|
||||
case WorldElementType.IMPORTANT_CHARACTER:
|
||||
importantCharacters.push(worldElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: dbWorld.world_id,
|
||||
name: dbWorld.name,
|
||||
history: dbWorld.history || '',
|
||||
politics: dbWorld.politics || '',
|
||||
economy: dbWorld.economy || '',
|
||||
religion: dbWorld.religion || '',
|
||||
languages: dbWorld.languages || '',
|
||||
laws,
|
||||
biomes,
|
||||
issues,
|
||||
customs,
|
||||
kingdoms,
|
||||
climate,
|
||||
resources,
|
||||
wildlife,
|
||||
arts,
|
||||
ethnicGroups,
|
||||
socialClasses,
|
||||
importantCharacters
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MAPPERS: TypeScript Interfaces → DB
|
||||
*/
|
||||
|
||||
export function worldToDb(world: WorldProps, authorId: string, bookId: string, synced: number = 0): DBWorld {
|
||||
return {
|
||||
world_id: world.id,
|
||||
name: world.name,
|
||||
hashed_name: '',
|
||||
author_id: authorId,
|
||||
book_id: bookId,
|
||||
history: world.history,
|
||||
politics: world.politics,
|
||||
economy: world.economy,
|
||||
religion: world.religion,
|
||||
languages: world.languages,
|
||||
meta_world: '',
|
||||
synced
|
||||
};
|
||||
}
|
||||
|
||||
export function worldElementsToDb(world: WorldProps, userId: string, synced: number = 0): DBWorldElement[] {
|
||||
const elements: DBWorldElement[] = [];
|
||||
|
||||
const addElements = (type: WorldElementType, elems: WorldElement[]) => {
|
||||
for (const elem of elems) {
|
||||
elements.push({
|
||||
element_id: elem.id,
|
||||
world_id: world.id,
|
||||
user_id: userId,
|
||||
element_type: type,
|
||||
name: elem.name,
|
||||
original_name: elem.name,
|
||||
description: elem.description,
|
||||
meta_element: '',
|
||||
synced
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
addElements(WorldElementType.LAW, world.laws || []);
|
||||
addElements(WorldElementType.BIOME, world.biomes || []);
|
||||
addElements(WorldElementType.ISSUE, world.issues || []);
|
||||
addElements(WorldElementType.CUSTOM, world.customs || []);
|
||||
addElements(WorldElementType.KINGDOM, world.kingdoms || []);
|
||||
addElements(WorldElementType.CLIMATE, world.climate || []);
|
||||
addElements(WorldElementType.RESOURCE, world.resources || []);
|
||||
addElements(WorldElementType.WILDLIFE, world.wildlife || []);
|
||||
addElements(WorldElementType.ART, world.arts || []);
|
||||
addElements(WorldElementType.ETHNIC_GROUP, world.ethnicGroups || []);
|
||||
addElements(WorldElementType.SOCIAL_CLASS, world.socialClasses || []);
|
||||
addElements(WorldElementType.IMPORTANT_CHARACTER, world.importantCharacters || []);
|
||||
|
||||
return elements;
|
||||
}
|
||||
525
electron/database/schema.ts
Normal file
525
electron/database/schema.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
import sqlite3 from 'node-sqlite3-wasm';
|
||||
|
||||
type Database = sqlite3.Database;
|
||||
|
||||
/**
|
||||
* SQLite schema based on the MySQL erit_main_db schema
|
||||
* All tables use snake_case naming to match the server database
|
||||
* Data is encrypted before storage and decrypted on retrieval
|
||||
*/
|
||||
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Initialize the local SQLite database with all required tables
|
||||
* @param db - SQLite database instance
|
||||
*/
|
||||
export function initializeSchema(db: Database): void {
|
||||
// Enable foreign keys
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Create sync metadata table (tracks last sync times)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _sync_metadata (
|
||||
table_name TEXT PRIMARY KEY,
|
||||
last_sync_at INTEGER NOT NULL,
|
||||
last_push_at INTEGER,
|
||||
pending_changes INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Create pending changes queue (for offline operations)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _pending_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_name TEXT NOT NULL,
|
||||
operation TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
|
||||
record_id TEXT NOT NULL,
|
||||
data TEXT, -- JSON data for INSERT/UPDATE
|
||||
created_at INTEGER NOT NULL,
|
||||
retry_count INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// AI Conversations
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_conversations (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
book_id TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
start_date INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
convo_meta TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// AI Messages History
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_messages_history (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
message_date INTEGER NOT NULL,
|
||||
meta_message TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (conversation_id) REFERENCES ai_conversations(conversation_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Acts
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_acts (
|
||||
act_id INTEGER PRIMARY KEY,
|
||||
title TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Act Summaries
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_act_summaries (
|
||||
act_sum_id TEXT PRIMARY KEY,
|
||||
book_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
act_index INTEGER NOT NULL,
|
||||
summary TEXT,
|
||||
meta_acts TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book AI Guide Line
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_ai_guide_line (
|
||||
user_id TEXT NOT NULL,
|
||||
book_id TEXT NOT NULL,
|
||||
global_resume TEXT,
|
||||
themes TEXT,
|
||||
verbe_tense INTEGER,
|
||||
narrative_type INTEGER,
|
||||
langue INTEGER,
|
||||
dialogue_type INTEGER,
|
||||
tone TEXT,
|
||||
atmosphere TEXT,
|
||||
current_resume TEXT,
|
||||
meta TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (user_id, book_id),
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Chapters
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_chapters (
|
||||
chapter_id TEXT PRIMARY KEY,
|
||||
book_id TEXT NOT NULL,
|
||||
author_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
hashed_title TEXT,
|
||||
words_count INTEGER,
|
||||
chapter_order INTEGER,
|
||||
meta_chapter TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Chapter Content
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_chapter_content (
|
||||
content_id TEXT PRIMARY KEY,
|
||||
chapter_id TEXT NOT NULL,
|
||||
author_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 2,
|
||||
content TEXT NOT NULL,
|
||||
words_count INTEGER NOT NULL,
|
||||
meta_chapter_content TEXT NOT NULL,
|
||||
time_on_it INTEGER NOT NULL DEFAULT 0,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (chapter_id) REFERENCES book_chapters(chapter_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Chapter Infos
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_chapter_infos (
|
||||
chapter_info_id TEXT PRIMARY KEY,
|
||||
chapter_id TEXT,
|
||||
act_id INTEGER,
|
||||
incident_id TEXT,
|
||||
plot_point_id TEXT,
|
||||
book_id TEXT,
|
||||
author_id TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
goal TEXT NOT NULL,
|
||||
meta_chapter_info TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (chapter_id) REFERENCES book_chapters(chapter_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (incident_id) REFERENCES book_incidents(incident_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (plot_point_id) REFERENCES book_plot_points(plot_point_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Characters
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_characters (
|
||||
character_id TEXT PRIMARY KEY,
|
||||
book_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT,
|
||||
category TEXT NOT NULL,
|
||||
title TEXT,
|
||||
image TEXT,
|
||||
role TEXT,
|
||||
biography TEXT,
|
||||
history TEXT,
|
||||
char_meta TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Character Attributes
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_characters_attributes (
|
||||
attr_id TEXT PRIMARY KEY,
|
||||
character_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
attribute_name TEXT NOT NULL,
|
||||
attribute_value TEXT NOT NULL,
|
||||
attr_meta TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (character_id) REFERENCES book_characters(character_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Character Relations
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_characters_relations (
|
||||
rel_id INTEGER PRIMARY KEY,
|
||||
character_id INTEGER NOT NULL,
|
||||
char_name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
history TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Guide Line
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_guide_line (
|
||||
user_id TEXT NOT NULL,
|
||||
book_id TEXT NOT NULL,
|
||||
tone TEXT NOT NULL,
|
||||
atmosphere TEXT NOT NULL,
|
||||
writing_style TEXT NOT NULL,
|
||||
themes TEXT NOT NULL,
|
||||
symbolism TEXT NOT NULL,
|
||||
motifs TEXT NOT NULL,
|
||||
narrative_voice TEXT NOT NULL,
|
||||
pacing TEXT NOT NULL,
|
||||
intended_audience TEXT NOT NULL,
|
||||
key_messages TEXT NOT NULL,
|
||||
meta_guide_line TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (user_id, book_id),
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Incidents
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_incidents (
|
||||
incident_id TEXT PRIMARY KEY,
|
||||
author_id TEXT NOT NULL,
|
||||
book_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
hashed_title TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
meta_incident TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Issues
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_issues (
|
||||
issue_id TEXT PRIMARY KEY,
|
||||
author_id TEXT NOT NULL,
|
||||
book_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
hashed_issue_name TEXT NOT NULL,
|
||||
meta_issue TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Location
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_location (
|
||||
loc_id TEXT PRIMARY KEY,
|
||||
book_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
loc_name TEXT NOT NULL,
|
||||
loc_original_name TEXT NOT NULL,
|
||||
loc_meta TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book Plot Points
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_plot_points (
|
||||
plot_point_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
hashed_title TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
linked_incident_id TEXT,
|
||||
author_id TEXT NOT NULL,
|
||||
book_id TEXT NOT NULL,
|
||||
meta_plot TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book World
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_world (
|
||||
world_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
hashed_name TEXT NOT NULL,
|
||||
author_id TEXT NOT NULL,
|
||||
book_id TEXT NOT NULL,
|
||||
history TEXT,
|
||||
politics TEXT,
|
||||
economy TEXT,
|
||||
religion TEXT,
|
||||
languages TEXT,
|
||||
meta_world TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Book World Elements
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS book_world_elements (
|
||||
element_id TEXT PRIMARY KEY,
|
||||
world_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
element_type INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
meta_element TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (world_id) REFERENCES book_world(world_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Erit Books
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS erit_books (
|
||||
book_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
author_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
hashed_title TEXT NOT NULL,
|
||||
sub_title TEXT,
|
||||
hashed_sub_title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
serie_id INTEGER,
|
||||
desired_release_date TEXT,
|
||||
desired_word_count INTEGER,
|
||||
words_count INTEGER,
|
||||
cover_image TEXT,
|
||||
book_meta TEXT,
|
||||
synced INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Erit Book Series
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS erit_book_series (
|
||||
serie_id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
author_id INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Erit Editor Settings
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS erit_editor (
|
||||
user_id TEXT,
|
||||
type TEXT NOT NULL,
|
||||
text_size INTEGER NOT NULL,
|
||||
text_intent INTEGER NOT NULL,
|
||||
interline TEXT NOT NULL,
|
||||
paper_width INTEGER NOT NULL,
|
||||
theme TEXT NOT NULL,
|
||||
focus INTEGER NOT NULL,
|
||||
synced INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Erit Users
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS erit_users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
origin_email TEXT NOT NULL,
|
||||
origin_username TEXT NOT NULL,
|
||||
author_name TEXT,
|
||||
origin_author_name TEXT,
|
||||
plateform TEXT NOT NULL,
|
||||
social_id TEXT,
|
||||
user_group INTEGER NOT NULL DEFAULT 4,
|
||||
password TEXT,
|
||||
term_accepted INTEGER NOT NULL DEFAULT 0,
|
||||
verify_code TEXT,
|
||||
reg_date INTEGER NOT NULL,
|
||||
account_verified INTEGER NOT NULL DEFAULT 0,
|
||||
user_meta TEXT NOT NULL,
|
||||
erite_points INTEGER NOT NULL DEFAULT 100,
|
||||
stripe_customer_id TEXT,
|
||||
credits_balance REAL DEFAULT 0,
|
||||
synced INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Location Element
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS location_element (
|
||||
element_id TEXT PRIMARY KEY,
|
||||
location TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
element_name TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
element_description TEXT,
|
||||
element_meta TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (location) REFERENCES book_location(loc_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Location Sub Element
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS location_sub_element (
|
||||
sub_element_id TEXT PRIMARY KEY,
|
||||
element_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
sub_elem_name TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
sub_elem_description TEXT,
|
||||
sub_elem_meta TEXT NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (element_id) REFERENCES location_element(element_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// User Keys
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_keys (
|
||||
user_id TEXT NOT NULL,
|
||||
brand TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
actif INTEGER NOT NULL DEFAULT 1,
|
||||
synced INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES erit_users(user_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// User Last Chapter
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_last_chapter (
|
||||
user_id TEXT NOT NULL,
|
||||
book_id TEXT NOT NULL,
|
||||
chapter_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
synced INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (user_id, book_id),
|
||||
FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (chapter_id) REFERENCES book_chapters(chapter_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes for better performance
|
||||
createIndexes(db);
|
||||
|
||||
// Initialize sync metadata for all tables
|
||||
initializeSyncMetadata(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create indexes for frequently queried columns
|
||||
*/
|
||||
function createIndexes(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conversations_book ON ai_conversations(book_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user ON ai_conversations(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation ON ai_messages_history(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chapters_book ON book_chapters(book_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chapter_content_chapter ON book_chapter_content(chapter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_characters_book ON book_characters(book_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_character_attrs_character ON book_characters_attributes(character_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_world_book ON book_world(book_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_world_elements_world ON book_world_elements(world_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_changes_table ON _pending_changes(table_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_changes_created ON _pending_changes(created_at);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize sync metadata for all tables
|
||||
*/
|
||||
function initializeSyncMetadata(db: Database): void {
|
||||
const tables = [
|
||||
'ai_conversations', 'ai_messages_history', 'book_acts', 'book_act_summaries',
|
||||
'book_ai_guide_line', 'book_chapters', 'book_chapter_content', 'book_chapter_infos',
|
||||
'book_characters', 'book_characters_attributes', 'book_guide_line', 'book_incidents',
|
||||
'book_issues', 'book_location', 'book_plot_points', 'book_world', 'book_world_elements',
|
||||
'erit_books', 'erit_editor', 'erit_users', 'location_element', 'location_sub_element',
|
||||
'user_keys', 'user_last_chapter'
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO _sync_metadata (table_name, last_sync_at, pending_changes)
|
||||
VALUES (?, 0, 0)
|
||||
`, [table]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all tables (for testing/reset)
|
||||
*/
|
||||
export function dropAllTables(db: Database): void {
|
||||
const tables = db.all(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
`, []) as unknown as { name: string }[];
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
|
||||
for (const { name } of tables) {
|
||||
db.exec(`DROP TABLE IF EXISTS ${name}`);
|
||||
}
|
||||
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
@@ -2,33 +2,51 @@ import { BookProps, BookListProps } from '@/lib/models/Book';
|
||||
import { CharacterProps } from '@/lib/models/Character';
|
||||
import { Conversation } from '@/lib/models/QuillSense';
|
||||
import { WorldProps } from '@/lib/models/World';
|
||||
import { ChapterProps } from '@/lib/models/Chapter';
|
||||
|
||||
/**
|
||||
* Service pour gérer les données avec cache local
|
||||
* Sauvegarde automatiquement dans la DB locale (Electron) quand disponible
|
||||
* OfflineDataService - Manages data retrieval with offline/online mode
|
||||
* Automatically caches to local DB when online, serves from local DB when offline
|
||||
*/
|
||||
export class OfflineDataService {
|
||||
private isOffline: boolean = false;
|
||||
|
||||
/**
|
||||
* Set offline status (called by OfflineProvider)
|
||||
*/
|
||||
setOfflineStatus(offline: boolean): void {
|
||||
this.isOffline = offline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current offline status
|
||||
*/
|
||||
getOfflineStatus(): boolean {
|
||||
return this.isOffline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all books - from local DB if offline, from server otherwise
|
||||
*/
|
||||
export async function getBooks(fetchFromServer: () => Promise<BookListProps[]>): Promise<BookListProps[]> {
|
||||
async getBooks(fetchFromServer: () => Promise<BookListProps[]>): Promise<BookListProps[]> {
|
||||
if (!window.electron) {
|
||||
return await fetchFromServer();
|
||||
}
|
||||
|
||||
// TODO: Check if offline mode is enabled
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
if (isOffline) {
|
||||
if (this.isOffline) {
|
||||
// Fetch from local DB
|
||||
const result = await window.electron.dbGetBooks();
|
||||
if (result.success) {
|
||||
return result.data || [];
|
||||
if (result.success && result.data) {
|
||||
const titles = result.data.map(b => b.title).join(', ');
|
||||
console.log(`📚 Books from LOCAL DB: ${titles}`);
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to get books from local DB');
|
||||
} else {
|
||||
// Fetch from server and save to local DB
|
||||
const books = await fetchFromServer();
|
||||
const titles = books.map(b => b.title).join(', ');
|
||||
console.log(`📚 Books from SERVER: ${titles}`);
|
||||
|
||||
// Save to local DB in background
|
||||
for (const book of books) {
|
||||
@@ -46,7 +64,7 @@ export async function getBooks(fetchFromServer: () => Promise<BookListProps[]>):
|
||||
/**
|
||||
* Get single book - from local DB if offline, from server otherwise
|
||||
*/
|
||||
export async function getBook(
|
||||
async getBook(
|
||||
bookId: string,
|
||||
fetchFromServer: () => Promise<BookProps>
|
||||
): Promise<BookProps> {
|
||||
@@ -54,16 +72,16 @@ export async function getBook(
|
||||
return await fetchFromServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
if (isOffline) {
|
||||
if (this.isOffline) {
|
||||
const result = await window.electron.dbGetBook(bookId);
|
||||
if (result.success && result.data) {
|
||||
console.log(`📖 "${result.data.title}" from LOCAL DB`);
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Book not found in local DB');
|
||||
} else {
|
||||
const book = await fetchFromServer();
|
||||
console.log(`📖 "${book.title}" from SERVER`);
|
||||
|
||||
// Save to local DB
|
||||
try {
|
||||
@@ -76,10 +94,38 @@ export async function getBook(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new book - creates on server if online, local UUID if offline
|
||||
*/
|
||||
async createBook(
|
||||
bookData: Omit<BookProps, 'bookId'>,
|
||||
authorId: string,
|
||||
createOnServer: () => Promise<string>
|
||||
): Promise<string> {
|
||||
if (!window.electron) {
|
||||
return await createOnServer();
|
||||
}
|
||||
|
||||
if (this.isOffline) {
|
||||
// Generate local UUID and save to local DB
|
||||
const localBookId = crypto.randomUUID();
|
||||
const book: BookProps = { ...bookData, bookId: localBookId };
|
||||
await window.electron.dbSaveBook(book, authorId);
|
||||
console.log(`💾 Book "${book.title}" created locally (offline mode)`);
|
||||
return localBookId;
|
||||
} else {
|
||||
// Create on server and save to local DB
|
||||
const serverBookId = await createOnServer();
|
||||
const book: BookProps = { ...bookData, bookId: serverBookId };
|
||||
await window.electron.dbSaveBook(book, authorId);
|
||||
return serverBookId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save book - save to local DB and sync to server later if offline
|
||||
*/
|
||||
export async function saveBook(
|
||||
async saveBook(
|
||||
book: BookProps,
|
||||
authorId: string | undefined,
|
||||
saveToServer: () => Promise<void>
|
||||
@@ -88,12 +134,10 @@ export async function saveBook(
|
||||
return await saveToServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
// Always save to local DB first
|
||||
await window.electron.dbSaveBook(book, authorId);
|
||||
|
||||
if (!isOffline) {
|
||||
if (!this.isOffline) {
|
||||
// Also save to server
|
||||
try {
|
||||
await saveToServer();
|
||||
@@ -101,13 +145,15 @@ export async function saveBook(
|
||||
console.error('Failed to save to server, will sync later:', error);
|
||||
// Data is already in local DB, will be synced later
|
||||
}
|
||||
} else {
|
||||
console.log(`💾 Book queued for sync (offline mode)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get characters for a book
|
||||
*/
|
||||
export async function getCharacters(
|
||||
async getCharacters(
|
||||
bookId: string,
|
||||
fetchFromServer: () => Promise<CharacterProps[]>
|
||||
): Promise<CharacterProps[]> {
|
||||
@@ -115,16 +161,18 @@ export async function getCharacters(
|
||||
return await fetchFromServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
if (isOffline) {
|
||||
if (this.isOffline) {
|
||||
const result = await window.electron.dbGetCharacters(bookId);
|
||||
if (result.success) {
|
||||
return result.data || [];
|
||||
if (result.success && result.data) {
|
||||
const names = result.data.map(c => c.name).join(', ');
|
||||
console.log(`👤 Characters from LOCAL DB: ${names}`);
|
||||
return result.data;
|
||||
}
|
||||
throw new Error(result.error || 'Failed to get characters from local DB');
|
||||
} else {
|
||||
const characters = await fetchFromServer();
|
||||
const names = characters.map(c => c.name).join(', ');
|
||||
console.log(`👤 Characters from SERVER: ${names}`);
|
||||
|
||||
// Save to local DB
|
||||
for (const character of characters) {
|
||||
@@ -139,10 +187,36 @@ export async function getCharacters(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create character
|
||||
*/
|
||||
async createCharacter(
|
||||
characterData: Omit<CharacterProps, 'id'>,
|
||||
bookId: string,
|
||||
createOnServer: () => Promise<string>
|
||||
): Promise<string> {
|
||||
if (!window.electron) {
|
||||
return await createOnServer();
|
||||
}
|
||||
|
||||
if (this.isOffline) {
|
||||
const localId = crypto.randomUUID();
|
||||
const character = { ...characterData, id: localId };
|
||||
await window.electron.dbSaveCharacter(character, bookId);
|
||||
console.log(`💾 Character "${character.name}" created locally (offline mode)`);
|
||||
return localId;
|
||||
} else {
|
||||
const serverId = await createOnServer();
|
||||
const character = { ...characterData, id: serverId };
|
||||
await window.electron.dbSaveCharacter(character, bookId);
|
||||
return serverId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save character
|
||||
*/
|
||||
export async function saveCharacter(
|
||||
async saveCharacter(
|
||||
character: CharacterProps,
|
||||
bookId: string,
|
||||
saveToServer: () => Promise<void>
|
||||
@@ -151,24 +225,24 @@ export async function saveCharacter(
|
||||
return await saveToServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
// Always save to local DB first
|
||||
await window.electron.dbSaveCharacter(character, bookId);
|
||||
|
||||
if (!isOffline) {
|
||||
if (!this.isOffline) {
|
||||
try {
|
||||
await saveToServer();
|
||||
} catch (error) {
|
||||
console.error('Failed to save to server, will sync later:', error);
|
||||
}
|
||||
} else {
|
||||
console.log(`💾 Character queued for sync (offline mode)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversations for a book
|
||||
*/
|
||||
export async function getConversations(
|
||||
async getConversations(
|
||||
bookId: string,
|
||||
fetchFromServer: () => Promise<Conversation[]>
|
||||
): Promise<Conversation[]> {
|
||||
@@ -176,16 +250,18 @@ export async function getConversations(
|
||||
return await fetchFromServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
if (isOffline) {
|
||||
if (this.isOffline) {
|
||||
const result = await window.electron.dbGetConversations(bookId);
|
||||
if (result.success) {
|
||||
const titles = result.data?.map((c: any) => c.title).join(', ') || 'none';
|
||||
console.log(`💬 Conversations from LOCAL DB: ${titles}`);
|
||||
return result.data || [];
|
||||
}
|
||||
throw new Error(result.error || 'Failed to get conversations from local DB');
|
||||
} else {
|
||||
const conversations = await fetchFromServer();
|
||||
const titles = conversations.map(c => c.title).join(', ');
|
||||
console.log(`💬 Conversations from SERVER: ${titles}`);
|
||||
|
||||
// Save to local DB
|
||||
for (const conversation of conversations) {
|
||||
@@ -200,10 +276,36 @@ export async function getConversations(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create conversation
|
||||
*/
|
||||
async createConversation(
|
||||
conversationData: Omit<Conversation, 'id'>,
|
||||
bookId: string,
|
||||
createOnServer: () => Promise<string>
|
||||
): Promise<string> {
|
||||
if (!window.electron) {
|
||||
return await createOnServer();
|
||||
}
|
||||
|
||||
if (this.isOffline) {
|
||||
const localId = crypto.randomUUID();
|
||||
const conversation = { ...conversationData, id: localId };
|
||||
await window.electron.dbSaveConversation(conversation, bookId);
|
||||
console.log(`💾 Conversation "${conversation.title}" created locally (offline mode)`);
|
||||
return localId;
|
||||
} else {
|
||||
const serverId = await createOnServer();
|
||||
const conversation = { ...conversationData, id: serverId };
|
||||
await window.electron.dbSaveConversation(conversation, bookId);
|
||||
return serverId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save conversation
|
||||
*/
|
||||
export async function saveConversation(
|
||||
async saveConversation(
|
||||
conversation: Conversation,
|
||||
bookId: string,
|
||||
saveToServer: () => Promise<void>
|
||||
@@ -212,16 +314,82 @@ export async function saveConversation(
|
||||
return await saveToServer();
|
||||
}
|
||||
|
||||
const isOffline = false; // Replace with actual offline check
|
||||
|
||||
// Always save to local DB first
|
||||
await window.electron.dbSaveConversation(conversation, bookId);
|
||||
|
||||
if (!isOffline) {
|
||||
if (!this.isOffline) {
|
||||
try {
|
||||
await saveToServer();
|
||||
} catch (error) {
|
||||
console.error('Failed to save to server, will sync later:', error);
|
||||
}
|
||||
} else {
|
||||
console.log(`💾 Conversation queued for sync (offline mode)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create chapter
|
||||
*/
|
||||
async createChapter(
|
||||
chapterData: Omit<ChapterProps, 'chapterId'>,
|
||||
bookId: string,
|
||||
createOnServer: () => Promise<string>
|
||||
): Promise<string> {
|
||||
if (!window.electron) {
|
||||
return await createOnServer();
|
||||
}
|
||||
|
||||
if (this.isOffline) {
|
||||
const localId = crypto.randomUUID();
|
||||
const chapter = { ...chapterData, chapterId: localId };
|
||||
await window.electron.dbSaveChapter(chapter, bookId);
|
||||
console.log(`💾 Chapter "${chapter.title}" created locally (offline mode)`);
|
||||
return localId;
|
||||
} else {
|
||||
const serverId = await createOnServer();
|
||||
const chapter = { ...chapterData, chapterId: serverId };
|
||||
await window.electron.dbSaveChapter(chapter, bookId);
|
||||
return serverId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save chapter content
|
||||
*/
|
||||
async saveChapter(
|
||||
chapter: ChapterProps,
|
||||
bookId: string,
|
||||
saveToServer: () => Promise<void>
|
||||
): Promise<void> {
|
||||
if (!window.electron) {
|
||||
return await saveToServer();
|
||||
}
|
||||
|
||||
// Always save to local DB first
|
||||
await window.electron.dbSaveChapter(chapter, bookId);
|
||||
|
||||
if (!this.isOffline) {
|
||||
try {
|
||||
await saveToServer();
|
||||
} catch (error) {
|
||||
console.error('Failed to save to server, will sync later:', error);
|
||||
}
|
||||
} else {
|
||||
console.log(`💾 Chapter "${chapter.title}" queued for sync (offline mode)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let offlineDataServiceInstance: OfflineDataService | null = null;
|
||||
|
||||
/**
|
||||
* Get OfflineDataService singleton instance
|
||||
*/
|
||||
export function getOfflineDataService(): OfflineDataService {
|
||||
if (!offlineDataServiceInstance) {
|
||||
offlineDataServiceInstance = new OfflineDataService();
|
||||
}
|
||||
return offlineDataServiceInstance;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user