// IndexedDB wrapper for offline Bible storage export interface BibleVersion { id: string name: string abbreviation: string language: string isDefault?: boolean downloadedAt?: string size?: number books?: BibleBook[] } export interface BibleBook { id: string name: string abbreviation: string orderNum: number testament: string chaptersCount: number chapters?: BibleChapter[] } export interface BibleChapter { id: string bookId: string chapterNum: number verseCount: number verses: BibleVerse[] } export interface BibleVerse { id: string chapterId: string verseNum: number text: string } export interface DownloadProgress { versionId: string status: 'pending' | 'downloading' | 'completed' | 'failed' progress: number totalBooks: number downloadedBooks: number totalChapters: number downloadedChapters: number startedAt: string completedAt?: string error?: string } class OfflineStorage { private db: IDBDatabase | null = null private readonly dbName = 'BibleStorage' private readonly dbVersion = 1 async init(): Promise { if (this.db) return return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.dbVersion) request.onerror = () => reject(request.error) request.onsuccess = () => { this.db = request.result resolve() } request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result // Bible versions store if (!db.objectStoreNames.contains('versions')) { const versionsStore = db.createObjectStore('versions', { keyPath: 'id' }) versionsStore.createIndex('language', 'language', { unique: false }) versionsStore.createIndex('abbreviation', 'abbreviation', { unique: false }) } // Bible books store if (!db.objectStoreNames.contains('books')) { const booksStore = db.createObjectStore('books', { keyPath: 'id' }) booksStore.createIndex('versionId', 'versionId', { unique: false }) booksStore.createIndex('orderNum', 'orderNum', { unique: false }) } // Bible chapters store if (!db.objectStoreNames.contains('chapters')) { const chaptersStore = db.createObjectStore('chapters', { keyPath: 'id' }) chaptersStore.createIndex('bookId', 'bookId', { unique: false }) chaptersStore.createIndex('versionBookChapter', ['versionId', 'bookId', 'chapterNum'], { unique: true }) } // Download progress store if (!db.objectStoreNames.contains('downloads')) { const downloadsStore = db.createObjectStore('downloads', { keyPath: 'versionId' }) downloadsStore.createIndex('status', 'status', { unique: false }) } // Settings store if (!db.objectStoreNames.contains('settings')) { const settingsStore = db.createObjectStore('settings', { keyPath: 'key' }) } } }) } // Version management async saveVersion(version: BibleVersion): Promise { await this.init() return this.performTransaction('versions', 'readwrite', (store) => { const versionsStore = store as IDBObjectStore versionsStore.put({ ...version, downloadedAt: new Date().toISOString() }) }) } async getVersion(versionId: string): Promise { await this.init() return this.performTransaction('versions', 'readonly', (store) => { const versionsStore = store as IDBObjectStore return versionsStore.get(versionId) }) } async getAllVersions(): Promise { await this.init() const versions = await this.performTransaction('versions', 'readonly', (store) => { const versionsStore = store as IDBObjectStore return versionsStore.getAll() }) console.log('[OfflineStorage] getAllVersions result:', versions) return versions } async deleteVersion(versionId: string): Promise { await this.init() return this.performTransaction(['versions', 'books', 'chapters', 'downloads'], 'readwrite', (stores) => { const storeArray = stores as IDBObjectStore[] const [versionsStore, booksStore, chaptersStore, downloadsStore] = storeArray // Delete version versionsStore.delete(versionId) // Delete all books for this version const booksIndex = booksStore.index('versionId') const booksRequest = booksIndex.getAllKeys(versionId) booksRequest.onsuccess = () => { booksRequest.result.forEach((bookId: any) => { booksStore.delete(bookId) // Delete all chapters for each book const chaptersIndex = chaptersStore.index('bookId') const chaptersRequest = chaptersIndex.getAllKeys(bookId) chaptersRequest.onsuccess = () => { chaptersRequest.result.forEach((chapterId: any) => { chaptersStore.delete(chapterId) }) } }) } // Delete download progress downloadsStore.delete(versionId) }) } // Chapter management async saveChapter(versionId: string, bookId: string, chapter: BibleChapter): Promise { await this.init() return this.performTransaction('chapters', 'readwrite', (store) => { const chaptersStore = store as IDBObjectStore chaptersStore.put({ ...chapter, versionId, bookId }) }) } async getChapter(versionId: string, bookId: string, chapterNum: number): Promise { await this.init() return this.performTransaction('chapters', 'readonly', (store) => { const chaptersStore = store as IDBObjectStore const index = chaptersStore.index('versionBookChapter') return index.get([versionId, bookId, chapterNum]) }) } async getChaptersForBook(bookId: string): Promise { await this.init() return this.performTransaction('chapters', 'readonly', (store) => { const chaptersStore = store as IDBObjectStore const index = chaptersStore.index('bookId') return index.getAll(bookId) }) } // Book management async saveBook(versionId: string, book: BibleBook): Promise { await this.init() return this.performTransaction('books', 'readwrite', (store) => { const booksStore = store as IDBObjectStore booksStore.put({ ...book, versionId }) }) } async getBooksForVersion(versionId: string): Promise { await this.init() const books = await this.performTransaction('books', 'readonly', (store) => { const booksStore = store as IDBObjectStore const index = booksStore.index('versionId') return index.getAll(versionId) }) // Sort books by orderNum to maintain biblical order return books.sort((a, b) => (a.orderNum || 0) - (b.orderNum || 0)) } // Download progress management async saveDownloadProgress(progress: DownloadProgress): Promise { await this.init() return this.performTransaction('downloads', 'readwrite', (store) => { const downloadsStore = store as IDBObjectStore downloadsStore.put(progress) }) } async getDownloadProgress(versionId: string): Promise { await this.init() return this.performTransaction('downloads', 'readonly', (store) => { const downloadsStore = store as IDBObjectStore return downloadsStore.get(versionId) }) } async getAllDownloads(): Promise { await this.init() return this.performTransaction('downloads', 'readonly', (store) => { const downloadsStore = store as IDBObjectStore return downloadsStore.getAll() }) } async deleteDownloadProgress(versionId: string): Promise { await this.init() return this.performTransaction('downloads', 'readwrite', (store) => { const downloadsStore = store as IDBObjectStore downloadsStore.delete(versionId) }) } // Settings management async saveSetting(key: string, value: any): Promise { await this.init() return this.performTransaction('settings', 'readwrite', (store) => { const settingsStore = store as IDBObjectStore settingsStore.put({ key, value, updatedAt: new Date().toISOString() }) }) } async getSetting(key: string): Promise { await this.init() const result = await this.performTransaction('settings', 'readonly', (store) => { const settingsStore = store as IDBObjectStore return settingsStore.get(key) }) return result?.value } // Storage info async getStorageInfo(): Promise<{ used: number; quota: number; percentage: number }> { if ('storage' in navigator && 'estimate' in navigator.storage) { const estimate = await navigator.storage.estimate() const used = estimate.usage || 0 const quota = estimate.quota || 0 const percentage = quota > 0 ? (used / quota) * 100 : 0 return { used, quota, percentage } } return { used: 0, quota: 0, percentage: 0 } } // Check if version is available offline async isVersionAvailableOffline(versionId: string): Promise { const version = await this.getVersion(versionId) return !!version } // Get offline reading statistics async getOfflineStats(): Promise<{ totalVersions: number totalBooks: number totalChapters: number storageUsed: number lastSyncDate?: string }> { const versions = await this.getAllVersions() let totalBooks = 0 let totalChapters = 0 for (const version of versions) { const books = await this.getBooksForVersion(version.id) totalBooks += books.length for (const book of books) { const chapters = await this.getChaptersForBook(book.id) totalChapters += chapters.length } } const storageInfo = await this.getStorageInfo() const lastSyncDate = await this.getSetting('lastSyncDate') return { totalVersions: versions.length, totalBooks, totalChapters, storageUsed: storageInfo.used, lastSyncDate } } // Helper method for transactions private performTransaction( storeNames: string | string[], mode: IDBTransactionMode, operation: (store: IDBObjectStore | IDBObjectStore[]) => IDBRequest | Promise | T ): Promise { return new Promise((resolve, reject) => { if (!this.db) { reject(new Error('Database not initialized')) return } const transaction = this.db.transaction(storeNames, mode) transaction.onerror = () => reject(transaction.error) let stores: IDBObjectStore | IDBObjectStore[] if (typeof storeNames === 'string') { stores = transaction.objectStore(storeNames) } else { stores = storeNames.map(name => transaction.objectStore(name)) } try { const result = operation(stores) if (result instanceof Promise) { result.then(resolve).catch(reject) } else if (result && typeof result === 'object' && 'onsuccess' in result) { const request = result as IDBRequest request.onsuccess = () => resolve(request.result) request.onerror = () => reject(request.error) } else { resolve(result as T) } } catch (error) { reject(error) } }) } // Clear all data async clearAll(): Promise { await this.init() return this.performTransaction(['versions', 'books', 'chapters', 'downloads', 'settings'], 'readwrite', (stores) => { const storeArray = stores as IDBObjectStore[] storeArray.forEach((store: IDBObjectStore) => store.clear()) }) } } // Export singleton instance export const offlineStorage = new OfflineStorage() // Bible download manager export class BibleDownloadManager { private downloadQueue: Set = new Set() async downloadVersion(versionId: string, onProgress?: (progress: DownloadProgress) => void): Promise { if (this.downloadQueue.has(versionId)) { throw new Error('Version is already being downloaded') } this.downloadQueue.add(versionId) try { // Initialize progress const progress: DownloadProgress = { versionId, status: 'downloading', progress: 0, totalBooks: 0, downloadedBooks: 0, totalChapters: 0, downloadedChapters: 0, startedAt: new Date().toISOString() } await offlineStorage.saveDownloadProgress(progress) onProgress?.(progress) // Get version info and books console.log(`[Download] Starting download for version: ${versionId}`) const versionResponse = await fetch(`/api/bible/books?version=${versionId}`) if (!versionResponse.ok) { throw new Error('Failed to fetch version books') } const versionData = await versionResponse.json() if (!versionData.success) { throw new Error(versionData.error || 'Failed to fetch version books') } const { version, books } = versionData progress.totalBooks = books.length await offlineStorage.saveDownloadProgress(progress) onProgress?.(progress) // Save version info console.log('[Download] Saving version info:', version) await offlineStorage.saveVersion(version) // Calculate total chapters from the chapters array in each book progress.totalChapters = books.reduce((sum: number, book: any) => sum + (book.chapters?.length || 0), 0) await offlineStorage.saveDownloadProgress(progress) onProgress?.(progress) // Download each book and its chapters for (const book of books) { console.log(`[Download] Downloading book: ${book.name}`) // Map the book data to match our interface const bookData = { id: book.id, name: book.name, abbreviation: book.bookKey, orderNum: book.orderNum, testament: book.testament, chaptersCount: book.chapters?.length || 0 } await offlineStorage.saveBook(versionId, bookData) progress.downloadedBooks++ // Download all chapters for this book if (book.chapters) { for (const chapterInfo of book.chapters) { const chapterResponse = await fetch(`/api/bible/chapter?book=${book.id}&chapter=${chapterInfo.chapterNum}&version=${versionId}`) if (chapterResponse.ok) { const chapterData = await chapterResponse.json() if (chapterData.chapter) { // Map the chapter data to match our interface const mappedChapter = { id: chapterData.chapter.id, bookId: book.id, chapterNum: chapterData.chapter.chapterNum, verseCount: chapterData.chapter.verses.length, verses: chapterData.chapter.verses } await offlineStorage.saveChapter(versionId, book.id, mappedChapter) } } progress.downloadedChapters++ progress.progress = Math.round((progress.downloadedChapters / progress.totalChapters) * 100) await offlineStorage.saveDownloadProgress(progress) onProgress?.(progress) // Small delay to prevent overwhelming the API await new Promise(resolve => setTimeout(resolve, 50)) } } } // Mark as completed progress.status = 'completed' progress.completedAt = new Date().toISOString() progress.progress = 100 await offlineStorage.saveDownloadProgress(progress) onProgress?.(progress) console.log(`[Download] Version ${versionId} downloaded successfully`) } catch (error) { console.error(`[Download] Failed to download version ${versionId}:`, error) const progress = await offlineStorage.getDownloadProgress(versionId) if (progress) { progress.status = 'failed' progress.error = error instanceof Error ? error.message : 'Unknown error' await offlineStorage.saveDownloadProgress(progress) onProgress?.(progress) } throw error } finally { this.downloadQueue.delete(versionId) } } async deleteVersion(versionId: string): Promise { await offlineStorage.deleteVersion(versionId) console.log(`[Download] Version ${versionId} deleted from offline storage`) } isDownloading(versionId: string): boolean { return this.downloadQueue.has(versionId) } async getDownloadedVersions(): Promise { return offlineStorage.getAllVersions() } async getStorageInfo(): Promise<{ used: number; quota: number; percentage: number }> { return offlineStorage.getStorageInfo() } } // Export singleton instance export const bibleDownloadManager = new BibleDownloadManager()