/* ObsiGate — Offline IndexedDB Storage * Stores: file index, file contents, pending changes queue * Schema v1: * files store: { path, vault, title, tags[], snippet, modified, tokens[], weight } * content store: { key: "vault::path", content: string, modified } * pending store: { id, vault, path, content, action, timestamp } */ const DB_NAME = 'obsigate-offline'; const DB_VERSION = 1; let _db = null; let _dbPromise = null; export const OfflineDB = { async open() { if (_db) return _db; if (_dbPromise) return _dbPromise; _dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = (e) => { const db = e.target.result; console.log('[OfflineDB] Upgrading to v' + DB_VERSION); // File index store — keyed by composite "vault::path" if (!db.objectStoreNames.contains('files')) { const store = db.createObjectStore('files', { keyPath: 'key' }); store.createIndex('vault', 'vault', { unique: false }); store.createIndex('modified', 'modified', { unique: false }); store.createIndex('title', 'title', { unique: false }); } // File contents store — keyed by "vault::path" if (!db.objectStoreNames.contains('content')) { db.createObjectStore('content', { keyPath: 'key' }); } // Pending changes queue — auto-increment key if (!db.objectStoreNames.contains('pending')) { const store = db.createObjectStore('pending', { keyPath: 'id', autoIncrement: true }); store.createIndex('timestamp', 'timestamp', { unique: false }); } }; req.onsuccess = (e) => { _db = e.target.result; console.log('[OfflineDB] Database opened'); resolve(_db); }; req.onerror = () => { _dbPromise = null; reject(req.error); }; }); return _dbPromise; }, /* ── File Index ── */ async putFileIndex(file) { const db = await this.open(); return new Promise((resolve, reject) => { const tx = db.transaction('files', 'readwrite'); const store = tx.objectStore('files'); const key = file.vault + '::' + file.path; const doc = { key: key, vault: file.vault, path: file.path, title: file.title, tags: file.tags || [], snippet: file.snippet || '', modified: file.modified || '', tokens: file.tokens || [], weight: file.score || 0 }; store.put(doc); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); }, async putFileIndexBatch(files) { const db = await this.open(); return new Promise((resolve, reject) => { const tx = db.transaction('files', 'readwrite'); const store = tx.objectStore('files'); for (const file of files) { const key = file.vault + '::' + file.path; store.put({ key: key, vault: file.vault, path: file.path, title: file.title, tags: file.tags || [], snippet: file.snippet || '', modified: file.modified || '', tokens: file.tokens || [], score: file.score || 0 }); } tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); }, async getFileIndex(vault, path) { const db = await this.open(); const key = vault + '::' + path; return new Promise((resolve) => { const tx = db.transaction('files', 'readonly'); const store = tx.objectStore('files'); const req = store.get(key); req.onsuccess = () => resolve(req.result || null); req.onerror = () => resolve(null); }); }, /* Search in file index — title matching and prefix */ async searchFileIndex(query, vaultFilter) { const db = await this.open(); const lowerQ = query.toLowerCase(); const words = lowerQ.split(/\s+/).filter(Boolean); return new Promise((resolve) => { const tx = db.transaction('files', 'readonly'); const store = tx.objectStore('files'); const results = []; store.openCursor().onsuccess = (e) => { const cursor = e.target.result; if (!cursor) { // Sort by relevance: title match > path match > snippet match results.sort((a, b) => b._score - a._score); resolve(results.slice(0, 50)); return; } const doc = cursor.value; // Vault filter if (vaultFilter && vaultFilter !== 'all' && doc.vault !== vaultFilter) { cursor.continue(); return; } // Score based on match quality let score = 0; const titleLower = doc.title.toLowerCase(); const pathLower = doc.path.toLowerCase(); const snippetLower = (doc.snippet || '').toLowerCase(); for (const word of words) { if (titleLower.includes(word)) score += 10; else if (pathLower.includes(word)) score += 5; else if (snippetLower.includes(word)) score += 2; } // Full query match bonus if (titleLower.includes(lowerQ)) score += 5; if (pathLower === lowerQ) score += 8; if (score > 0) { results.push({ vault: doc.vault, path: doc.path, title: doc.title, tags: doc.tags, snippet: doc.snippet, score: score, modified: doc.modified, _score: score }); } cursor.continue(); }; }); }, async getAllFileIndex(vaultFilter) { const db = await this.open(); return new Promise((resolve) => { const tx = db.transaction('files', 'readonly'); const store = tx.objectStore('files'); const results = []; store.openCursor().onsuccess = (e) => { const cursor = e.target.result; if (!cursor) { resolve(results); return; } if (!vaultFilter || vaultFilter === 'all' || cursor.value.vault === vaultFilter) { results.push(cursor.value); } cursor.continue(); }; }); }, async clearFileIndex(vaultFilter) { const db = await this.open(); return new Promise((resolve) => { const tx = db.transaction('files', 'readwrite'); const store = tx.objectStore('files'); if (!vaultFilter || vaultFilter === 'all') { store.clear().onsuccess = () => resolve(); } else { store.openCursor().onsuccess = (e) => { const cursor = e.target.result; if (!cursor) { resolve(); return; } if (cursor.value.vault === vaultFilter) cursor.delete(); cursor.continue(); }; } }); }, /* ── File Contents ── */ async putFileContent(vault, path, content, modified) { const db = await this.open(); return new Promise((resolve, reject) => { const tx = db.transaction('content', 'readwrite'); const store = tx.objectStore('content'); const key = vault + '::' + path; store.put({ key: key, content: content, modified: modified || '' }); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); }, async getFileContent(vault, path) { const db = await this.open(); const key = vault + '::' + path; return new Promise((resolve) => { const tx = db.transaction('content', 'readonly'); const store = tx.objectStore('content'); const req = store.get(key); req.onsuccess = () => resolve(req.result || null); req.onerror = () => resolve(null); }); }, async removeFileContent(vault, path) { const db = await this.open(); return new Promise((resolve) => { const tx = db.transaction('content', 'readwrite'); const store = tx.objectStore('content'); const key = vault + '::' + path; store.delete(key); tx.oncomplete = () => resolve(); }); }, /* ── Pending Changes Queue ── */ async addPending(entry) { const db = await this.open(); return new Promise((resolve, reject) => { const tx = db.transaction('pending', 'readwrite'); const store = tx.objectStore('pending'); const doc = { vault: entry.vault, path: entry.path, content: entry.content || '', action: entry.action || 'save', timestamp: Date.now() }; const req = store.add(doc); req.onsuccess = () => resolve(req.result); tx.onerror = () => reject(tx.error); }); }, async getPendingCount() { const db = await this.open(); return new Promise((resolve) => { const tx = db.transaction('pending', 'readonly'); const store = tx.objectStore('pending'); store.count().onsuccess = (e) => resolve(e.target.result); }); }, async getAllPending() { const db = await this.open(); return new Promise((resolve) => { const tx = db.transaction('pending', 'readonly'); const store = tx.objectStore('pending'); const results = []; const index = store.index('timestamp'); index.openCursor().onsuccess = (e) => { const cursor = e.target.result; if (!cursor) { resolve(results); return; } results.push(cursor.value); cursor.continue(); }; }); }, async clearPending() { const db = await this.open(); return new Promise((resolve) => { const tx = db.transaction('pending', 'readwrite'); tx.objectStore('pending').clear().onsuccess = () => resolve(); }); }, async removePendingById(id) { const db = await this.open(); return new Promise((resolve) => { const tx = db.transaction('pending', 'readwrite'); tx.objectStore('pending').delete(id); tx.oncomplete = () => resolve(); }); }, /* ── Stats ── */ async getStats() { const db = await this.open(); const fileCount = await new Promise(r => { db.transaction('files','readonly').objectStore('files').count().onsuccess = e => r(e.target.result); }); const contentCount = await new Promise(r => { db.transaction('content','readonly').objectStore('content').count().onsuccess = e => r(e.target.result); }); const pendingCount = await this.getPendingCount(); return { fileCount, contentCount, pendingCount }; }, /* ── Full Sync ── */ async syncFileIndex(serverFiles) { // serverFiles: array of {vault,path,title,tags,snippet,modified,tokens} await this.clearFileIndex(null); await this.putFileIndexBatch(serverFiles); console.log('[OfflineDB] File index synced:', serverFiles.length, 'files'); }, async pruneContentCache(maxEntries) { maxEntries = maxEntries || 50; const db = await this.open(); return new Promise((resolve) => { const tx = db.transaction('content', 'readwrite'); const store = tx.objectStore('content'); const keys = []; store.openCursor().onsuccess = (e) => { const cursor = e.target.result; if (!cursor) { // Delete oldest if over maxEntries if (keys.length > maxEntries) { const toDelete = keys.slice(0, keys.length - maxEntries); for (const k of toDelete) store.delete(k); } resolve(); return; } keys.push(cursor.value.key); cursor.continue(); }; }); } }; /* ── Network Status ── */ let _online = navigator.onLine; let _listeners = []; export const NetworkStatus = { get isOnline() { return _online; }, init() { window.addEventListener('online', () => { _online = true; _listeners.forEach(fn => fn(true)); console.log('[Network] Online'); }); window.addEventListener('offline', () => { _online = false; _listeners.forEach(fn => fn(false)); console.log('[Network] Offline'); }); }, onChange(fn) { _listeners.push(fn); return () => { _listeners = _listeners.filter(f => f !== fn); }; } };