Remove all database mappers and README file
This commit is contained in:
@@ -2,31 +2,14 @@ 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';
|
||||
export type Database = sqlite3.Database;
|
||||
|
||||
/**
|
||||
* DatabaseService - Handles all local database operations
|
||||
* Provides CRUD operations with automatic encryption/decryption
|
||||
* Maps between DB snake_case and TypeScript camelCase interfaces
|
||||
* DatabaseService - Manages SQLite database connection ONLY
|
||||
* No business logic, no CRUD operations
|
||||
* Just connection management and encryption key storage
|
||||
*/
|
||||
export class DatabaseService {
|
||||
private db: Database | null = null;
|
||||
@@ -79,370 +62,25 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive field
|
||||
* Get database connection
|
||||
* Use this in repositories and model classes
|
||||
*/
|
||||
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);
|
||||
getDb(): Database | null {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive field
|
||||
* Get user encryption key
|
||||
*/
|
||||
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));
|
||||
getEncryptionKey(): string | null {
|
||||
return this.userEncryptionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single book by ID with all related data
|
||||
* Get current user ID
|
||||
*/
|
||||
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
|
||||
}));
|
||||
getUserId(): string | null {
|
||||
return this.userId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user