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:
natreex
2025-11-17 09:34:54 -05:00
parent 09768aafcf
commit d5eb1691d9
12 changed files with 2763 additions and 197 deletions

View File

@@ -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: {

View File

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

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

View 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');
}

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

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

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

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

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

View 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
View 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');
}

View File

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