- Forge: Tab indent, Ctrl+K, auto-focus, AI status, messages erreur FR - Forge: slash commands clean, cache-bust iframe, preview serveur - Forge: iframe 82vh, hauteur auto, table inline, sélecteur langage - PWA hors-ligne: IndexedDB (offline-db.js + offline.js) - Mermaid: thème dark complet, highlight.js preview - Backend: màj CSP, rename chain, bookmarks/history/shares - Roadmap: consolidation dans docs/ROADMAP.md (75 items) - Infra: docker-compose.podman.yml, suppression test-vault - .gitignore: exclusion odysseus/
378 lines
12 KiB
JavaScript
378 lines
12 KiB
JavaScript
/* 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); };
|
|
}
|
|
};
|