feat: finalisation éditeur Forge + mode hors-ligne PWA + consolidation roadmap
- 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/
This commit is contained in:
@@ -609,6 +609,30 @@ function initConfigModal() {
|
||||
const saveHiddenBtn = document.getElementById("cfg-save-hidden-files");
|
||||
if (saveHiddenBtn) saveHiddenBtn.addEventListener("click", saveHiddenFilesSettings);
|
||||
|
||||
// AI Keys
|
||||
const saveAIKeysBtn = document.getElementById("cfg-save-ai-keys");
|
||||
if (saveAIKeysBtn) saveAIKeysBtn.addEventListener("click", saveAIKeys);
|
||||
const testAIKeysBtn = document.getElementById("cfg-test-ai-keys");
|
||||
if (testAIKeysBtn) testAIKeysBtn.addEventListener("click", testAIKeys);
|
||||
// Load existing keys on modal open
|
||||
document.getElementById("config-open-btn").addEventListener("click", loadAIKeys);
|
||||
|
||||
// Config nav search
|
||||
var cfgSearch = document.getElementById("config-nav-search");
|
||||
if (cfgSearch) {
|
||||
cfgSearch.addEventListener("input", function() {
|
||||
var q = cfgSearch.value.toLowerCase();
|
||||
document.querySelectorAll("#config-scroll .config-section").forEach(function(s) {
|
||||
var text = s.textContent.toLowerCase();
|
||||
s.style.display = (q && !text.includes(q)) ? "none" : "";
|
||||
});
|
||||
document.querySelectorAll("#config-nav .help-nav-link").forEach(function(a) {
|
||||
var li = a.closest("li");
|
||||
if (li) li.style.display = (q && !a.textContent.toLowerCase().includes(q)) ? "none" : "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.classList.contains("active")) {
|
||||
closeConfigModal();
|
||||
@@ -1290,6 +1314,64 @@ function updateRegexPreview() {
|
||||
}
|
||||
|
||||
|
||||
// ── AI Keys management ──
|
||||
async function loadAIKeys() {
|
||||
try {
|
||||
const data = await api("/api/config/ai-keys");
|
||||
["DEEPSEEK_API_KEY","OPENROUTER_API_KEY","GEMINI_API_KEY"].forEach(k => {
|
||||
const id = "cfg-" + k.toLowerCase().replace(/_api_key/g, "") + "-key";
|
||||
const input = document.getElementById(id);
|
||||
if (input && data[k]) input.placeholder = data[k];
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function saveAIKeys() {
|
||||
const keys = {};
|
||||
const map = { "cfg-deepseek-key": "DEEPSEEK_API_KEY", "cfg-openrouter-key": "OPENROUTER_API_KEY", "cfg-gemini-key": "GEMINI_API_KEY" };
|
||||
for (const [id, name] of Object.entries(map)) {
|
||||
const input = document.getElementById(id);
|
||||
if (input && input.value.trim()) keys[name] = input.value.trim();
|
||||
}
|
||||
if (!Object.keys(keys).length) { showToast("Aucune cle a sauvegarder", "warning"); return; }
|
||||
try {
|
||||
await api("/api/config/ai-keys", { method: "POST", body: JSON.stringify(keys) });
|
||||
showToast("Cles API sauvegardees", "success");
|
||||
// Clear inputs
|
||||
Object.keys(map).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
|
||||
loadAIKeys();
|
||||
} catch(e) { showToast("Erreur: " + e.message, "error"); }
|
||||
}
|
||||
|
||||
async function testAIKeys() {
|
||||
const status = document.getElementById("cfg-ai-status");
|
||||
if (status) status.textContent = "Test en cours...";
|
||||
try {
|
||||
// Test connectivity
|
||||
const results = await api("/api/config/ai-keys/test", { method: "POST" });
|
||||
var msg = Object.entries(results).map(([k,v]) => k + ": " + v).join(" | ");
|
||||
if (status) status.textContent = msg;
|
||||
// Fetch models for configured providers
|
||||
for (const p of ["deepseek","openrouter","gemini"]) {
|
||||
if (results[p] === "ok") {
|
||||
try {
|
||||
const m = await api("/api/config/ai-models?provider=" + p);
|
||||
var sel = document.getElementById("cfg-" + p + "-model");
|
||||
if (sel && m.models && m.models.length) {
|
||||
sel.innerHTML = '<option value="">-- Modele (' + m.models.length + ') --</option>';
|
||||
m.models.forEach(function(md) {
|
||||
sel.innerHTML += '<option value="' + md + '">' + md + '</option>';
|
||||
});
|
||||
}
|
||||
} catch(e) { console.warn("Failed to fetch models for", p, e); }
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
if (status) status.textContent = "Erreur: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
initSidebarTabs,
|
||||
initConfigModal,
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
/* 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); };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
/* ObsiGate — Offline Integration Module
|
||||
* Ties together: IndexedDB, offline search, sync queue, UI indicator
|
||||
*/
|
||||
import { OfflineDB, NetworkStatus } from './offline-db.js';
|
||||
import { state } from './state.js';
|
||||
import { showToast } from './ui.js';
|
||||
|
||||
/* ── Init ── */
|
||||
export async function initOffline() {
|
||||
NetworkStatus.init();
|
||||
|
||||
// Open DB early
|
||||
try {
|
||||
await OfflineDB.open();
|
||||
console.log('[Offline] IndexedDB ready');
|
||||
} catch (e) {
|
||||
console.warn('[Offline] IndexedDB not available:', e);
|
||||
}
|
||||
|
||||
NetworkStatus.onChange((online) => {
|
||||
updateOfflineIndicator();
|
||||
if (online) {
|
||||
syncPendingChanges();
|
||||
showToast('Connexion retablie — synchronisation en cours...', 'info');
|
||||
} else {
|
||||
showToast('Mode hors-ligne — vos modifications seront synchronisees au retour', 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
updateOfflineIndicator();
|
||||
}
|
||||
|
||||
/* ── UI Indicator ── */
|
||||
function updateOfflineIndicator() {
|
||||
const badge = document.getElementById('offline-badge');
|
||||
if (!badge) return;
|
||||
|
||||
if (!NetworkStatus.isOnline) {
|
||||
// Build or show indicator
|
||||
badge.innerHTML = replaceLucideIcon(badge, 'wifi-off',
|
||||
'<i data-lucide="wifi-off" style="width:14px;height:14px"></i>');
|
||||
badge.title = 'Mode hors-ligne';
|
||||
badge.style.display = 'flex';
|
||||
badge.className = 'header-icon offline-indicator offline';
|
||||
|
||||
// Add pending count if any
|
||||
OfflineDB.getPendingCount().then(count => {
|
||||
if (count > 0) {
|
||||
const span = badge.querySelector('.offline-pending') || document.createElement('span');
|
||||
span.className = 'offline-pending';
|
||||
span.textContent = String(count);
|
||||
if (!badge.contains(span)) badge.appendChild(span);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
badge.className = 'header-icon offline-indicator';
|
||||
}
|
||||
}
|
||||
|
||||
function replaceLucideIcon(container, iconName, html) {
|
||||
const existing = container.querySelector('i[data-lucide]');
|
||||
if (existing && existing.getAttribute('data-lucide') === iconName) return container.innerHTML;
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Sync File Index from Server ── */
|
||||
export async function syncFileIndexFromServer() {
|
||||
if (!NetworkStatus.isOnline) {
|
||||
console.log('[Offline] Skipping index sync — offline');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Use a wildcard query to get recent files and populate offline index
|
||||
const resp = await fetch('/api/search/advanced?q=*&vault=all&limit=200');
|
||||
if (!resp.ok) {
|
||||
console.warn('[Offline] Index sync failed — API returned', resp.status);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const files = (data.results || []).map(r => ({
|
||||
vault: r.vault,
|
||||
path: r.path,
|
||||
title: r.title,
|
||||
tags: r.tags || [],
|
||||
snippet: r.snippet || '',
|
||||
modified: r.modified || '',
|
||||
tokens: []
|
||||
}));
|
||||
if (files.length > 0) {
|
||||
await OfflineDB.syncFileIndex(files);
|
||||
console.log('[Offline] Index synced:', files.length, 'files');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Offline] Index sync failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cache File When Viewed ── */
|
||||
export async function cacheViewedFile(vault, path, content, modified) {
|
||||
if (!vault || !path) return;
|
||||
try {
|
||||
await OfflineDB.putFileContent(vault, path, content, modified);
|
||||
await OfflineDB.pruneContentCache(50);
|
||||
} catch (e) {
|
||||
console.warn('[Offline] Cache file failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Offline Search ── */
|
||||
export async function searchOffline(query, vaultFilter) {
|
||||
try {
|
||||
return await OfflineDB.searchFileIndex(query, vaultFilter || state.currentVault);
|
||||
} catch (e) {
|
||||
console.error('[Offline] Search failed:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Get Cached File ── */
|
||||
export async function getCachedFile(vault, path) {
|
||||
try {
|
||||
return await OfflineDB.getFileContent(vault, path);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Pending Changes Queue ── */
|
||||
let _syncing = false;
|
||||
|
||||
export async function queueOfflineSave(vault, path, content) {
|
||||
try {
|
||||
const id = await OfflineDB.addPending({ vault, path, content, action: 'save' });
|
||||
updateOfflineIndicator();
|
||||
console.log('[Offline] Queued save:', vault, path, 'id=', id);
|
||||
return id;
|
||||
} catch (e) {
|
||||
console.error('[Offline] Queue save failed:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncPendingChanges() {
|
||||
if (!NetworkStatus.isOnline || _syncing) return;
|
||||
_syncing = true;
|
||||
|
||||
try {
|
||||
const pending = await OfflineDB.getAllPending();
|
||||
if (pending.length === 0) {
|
||||
_syncing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Offline] Syncing', pending.length, 'pending changes...');
|
||||
|
||||
for (const entry of pending) {
|
||||
try {
|
||||
const resp = await fetch('/api/file/' + encodeURIComponent(entry.vault) + '/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: entry.path, content: entry.content })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
await OfflineDB.removePendingById(entry.id);
|
||||
console.log('[Offline] Synced:', entry.vault, entry.path);
|
||||
} else if (resp.status === 409) {
|
||||
// Conflict — keep in queue for manual resolution
|
||||
console.warn('[Offline] Conflict on:', entry.vault, entry.path);
|
||||
} else {
|
||||
console.error('[Offline] Sync failed for:', entry.vault, entry.path, resp.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Offline] Sync error for:', entry.vault, entry.path, e);
|
||||
break; // Stop on network error
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = await OfflineDB.getPendingCount();
|
||||
console.log('[Offline] Sync complete. Remaining:', remaining);
|
||||
updateOfflineIndicator();
|
||||
|
||||
if (remaining === 0) {
|
||||
showToast('Toutes les modifications ont ete synchronisees', 'success');
|
||||
} else {
|
||||
showToast(remaining + ' modification(s) en attente de resolution', 'warning');
|
||||
}
|
||||
} finally {
|
||||
_syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Conflict Detection ── */
|
||||
export async function checkConflict(vault, path, localModified) {
|
||||
if (!NetworkStatus.isOnline) return null;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/file/' + encodeURIComponent(vault) + '/info?path=' + encodeURIComponent(path));
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
if (data.modified && data.modified !== localModified) {
|
||||
return { serverModified: data.modified, localModified };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { api } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { showLoading, el, hideProgressBar, showWelcome, highlightSearchText, loadSavedSearches } from './viewer.js';
|
||||
import { searchOffline } from './offline.js';
|
||||
import { _getEffective } from './config.js';
|
||||
import { TabManager, showToast } from './ui.js';
|
||||
import { addTagFilter, buildSearchResultsHeader, shouldDisplayPath, removeTagFilter, TagFilterService } from './sidebar.js';
|
||||
@@ -804,6 +805,27 @@ export async function performAdvancedSearch(query, vaultFilter, tagFilter, offse
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === "AbortError") return;
|
||||
if (searchId !== state.currentSearchId) return;
|
||||
|
||||
// Try offline search fallback
|
||||
console.log('[Search] Network failed, trying offline search');
|
||||
const offlineResults = await searchOffline(query, vaultFilter);
|
||||
if (offlineResults && offlineResults.length > 0) {
|
||||
const data = {
|
||||
query: query,
|
||||
vault_filter: vaultFilter,
|
||||
count: offlineResults.length,
|
||||
total: offlineResults.length,
|
||||
offset: 0,
|
||||
limit: effectiveLimit,
|
||||
results: offlineResults,
|
||||
offline: true
|
||||
};
|
||||
state.advancedSearchTotal = offlineResults.length;
|
||||
state.advancedSearchOffset = 0;
|
||||
renderAdvancedSearchResults(data, query, tagFilter);
|
||||
return;
|
||||
}
|
||||
|
||||
showWelcome();
|
||||
} finally {
|
||||
hideProgressBar();
|
||||
|
||||
+39
-1
@@ -1,10 +1,11 @@
|
||||
/* ObsiGate — Sync: SSE client + PWA registration */
|
||||
/* ObsiGate — Sync: SSE client + PWA registration + Offline mode */
|
||||
import { state } from './state.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { api } from './auth.js';
|
||||
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { loadRecentFiles } from './config.js';
|
||||
import { openFile } from './viewer.js';
|
||||
import { initOffline, syncFileIndexFromServer } from './offline.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE Client — IndexUpdateManager
|
||||
@@ -550,5 +551,42 @@ function initDashboardTabs() {
|
||||
export { initSyncStatus, initDashboardTabs };
|
||||
export function init() {
|
||||
registerServiceWorker();
|
||||
initOffline();
|
||||
// Sync file index after a short delay (let vaults load first)
|
||||
setTimeout(() => syncFileIndexFromServer(), 3000);
|
||||
initDashboardTabs();
|
||||
|
||||
// Listen for Forge editor rename events
|
||||
window.addEventListener('message', function(e) {
|
||||
if (!e.data || !e.data.type) return;
|
||||
if (e.data.type === 'forge-renamed') {
|
||||
var vault = e.data.vault;
|
||||
var oldPath = e.data.oldPath;
|
||||
var newPath = e.data.newPath;
|
||||
console.log('[Forge] File renamed:', vault, oldPath, '->', newPath);
|
||||
var titleEl = document.getElementById('editor-title');
|
||||
if (titleEl && titleEl.textContent.indexOf('Forge:') === 0) {
|
||||
titleEl.textContent = 'Forge: ' + newPath.split('/').pop();
|
||||
}
|
||||
refreshSidebarTreePreservingState(vault);
|
||||
if (state.currentVault === vault && state.currentPath === oldPath) {
|
||||
import('./viewer.js').then(function(m) { m.openFile(vault, newPath); });
|
||||
}
|
||||
}
|
||||
if (e.data.type === 'forge-close') {
|
||||
var modal = document.getElementById('editor-modal');
|
||||
var forgeFrame = document.getElementById('forge-iframe');
|
||||
if (forgeFrame) forgeFrame.remove();
|
||||
if (modal) modal.classList.remove('active');
|
||||
// Restore header/footer
|
||||
var headerEl = modal.querySelector('.editor-header');
|
||||
var footerEl = modal.querySelector('.editor-footer');
|
||||
if (headerEl) headerEl.style.display = '';
|
||||
if (footerEl) footerEl.style.display = '';
|
||||
// Refresh current file content
|
||||
if (state.currentVault && state.currentPath) {
|
||||
import('./viewer.js').then(function(m) { m.openFile(state.currentVault, state.currentPath); });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+10
-3
@@ -517,6 +517,9 @@ export function initTagResize() {
|
||||
// Frontmatter Accent Card Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Persist toggle state across document openings
|
||||
let _frontmatterOpen = false;
|
||||
|
||||
export function buildFrontmatterCard(frontmatter) {
|
||||
// Helper: format date
|
||||
function formatDate(iso) {
|
||||
@@ -530,11 +533,11 @@ export function buildFrontmatterCard(frontmatter) {
|
||||
// Extract boolean flags
|
||||
const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] }));
|
||||
|
||||
// Toggle state
|
||||
let isOpen = true;
|
||||
// Toggle state (persisted across documents)
|
||||
let isOpen = _frontmatterOpen;
|
||||
|
||||
// Build header with chevron
|
||||
const chevron = el("span", { class: "fm-chevron open" });
|
||||
const chevron = el("span", { class: "fm-chevron " + (isOpen ? "open" : "closed") });
|
||||
chevron.innerHTML = '<i data-lucide="chevron-down" style="width:14px;height:14px"></i>';
|
||||
|
||||
const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]);
|
||||
@@ -612,9 +615,13 @@ export function buildFrontmatterCard(frontmatter) {
|
||||
// Assemble the card
|
||||
const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]);
|
||||
|
||||
// Set initial display based on persisted state
|
||||
if (!isOpen) acCard.style.display = "none";
|
||||
|
||||
// Toggle functionality
|
||||
fmHeader.addEventListener("click", () => {
|
||||
isOpen = !isOpen;
|
||||
_frontmatterOpen = isOpen;
|
||||
if (isOpen) {
|
||||
acCard.style.display = "block";
|
||||
chevron.classList.remove("closed");
|
||||
|
||||
+49
-29
@@ -275,10 +275,22 @@ async function openEditor(vaultName, filePath) {
|
||||
state.editorPath = filePath;
|
||||
|
||||
const modal = document.getElementById("editor-modal");
|
||||
const titleEl = document.getElementById("editor-title");
|
||||
const titleInput = document.getElementById("editor-title-input");
|
||||
const filenameInput = document.getElementById("editor-filename-input");
|
||||
const statFile = document.getElementById("editor-stat-file");
|
||||
const statWords = document.getElementById("editor-stat-words");
|
||||
const bodyEl = document.getElementById("editor-body");
|
||||
|
||||
titleEl.textContent = `Édition: ${filePath.split("/").pop()}`;
|
||||
// Restore header/footer (may be hidden by Forge)
|
||||
var headerEl = modal.querySelector(".editor-header");
|
||||
var footerEl = modal.querySelector(".editor-footer");
|
||||
var container = modal.querySelector(".editor-container");
|
||||
if (headerEl) headerEl.style.display = "";
|
||||
if (footerEl) footerEl.style.display = "";
|
||||
if (container) container.style.maxHeight = "";
|
||||
|
||||
filenameInput.value = filePath.split("/").pop();
|
||||
statFile.textContent = vaultName + "/" + filePath;
|
||||
|
||||
// Fetch raw content
|
||||
const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`;
|
||||
@@ -322,6 +334,11 @@ async function openEditor(vaultName, filePath) {
|
||||
if (update.docChanged) {
|
||||
clearTimeout(window._obsigateAutoSaveTimer);
|
||||
window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000);
|
||||
// Mark dirty
|
||||
var sd = document.getElementById("editor-save-dot");
|
||||
var sl = document.getElementById("editor-save-label");
|
||||
if (sd) sd.className = "editor-save-dot dirty";
|
||||
if (sl) sl.textContent = "Unsaved";
|
||||
}
|
||||
}),
|
||||
];
|
||||
@@ -478,9 +495,13 @@ async function saveFile(silent = false) {
|
||||
|
||||
const content = state.editorView ? state.editorView.state.doc.toString() : state.fallbackEditorEl.value;
|
||||
const saveBtn = document.getElementById("editor-save");
|
||||
const originalHTML = saveBtn ? saveBtn.innerHTML : '';
|
||||
const saveDot = document.getElementById("editor-save-dot");
|
||||
const saveLabel = document.getElementById("editor-save-label");
|
||||
|
||||
try {
|
||||
// Update save dot to saving state
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot saving'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Saving...';
|
||||
if (saveBtn && !silent) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
|
||||
@@ -499,35 +520,41 @@ async function saveFile(silent = false) {
|
||||
throw new Error(error.detail || "Erreur de sauvegarde");
|
||||
}
|
||||
|
||||
// Update save dot to saved state
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot ok'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Saved';
|
||||
|
||||
if (silent) {
|
||||
// Auto-save: brief green flash on save button
|
||||
if (saveBtn) {
|
||||
saveBtn.style.background = '#22c55e';
|
||||
saveBtn.style.color = '#fff';
|
||||
saveBtn.style.borderColor = '#22c55e';
|
||||
saveBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px"></i>';
|
||||
safeCreateIcons();
|
||||
setTimeout(() => {
|
||||
if (saveBtn) {
|
||||
saveBtn.style.background = '';
|
||||
saveBtn.style.color = '';
|
||||
saveBtn.style.borderColor = '';
|
||||
saveBtn.innerHTML = originalHTML;
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (saveBtn) {
|
||||
saveBtn.style.background = '';
|
||||
saveBtn.style.color = '';
|
||||
saveBtn.style.borderColor = '';
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
saveBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px"></i>';
|
||||
safeCreateIcons();
|
||||
|
||||
setTimeout(() => closeEditor(), 800);
|
||||
closeEditor();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Save error:", err);
|
||||
alert(`Erreur: ${err.message}`);
|
||||
saveBtn.innerHTML = originalHTML;
|
||||
saveBtn.disabled = false;
|
||||
safeCreateIcons();
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot err'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Erreur';
|
||||
// If offline, queue the save for later sync
|
||||
if (!navigator.onLine) {
|
||||
try {
|
||||
const { queueOfflineSave } = await import('./offline.js');
|
||||
await queueOfflineSave(state.editorVault, state.editorPath, content);
|
||||
alert('Vous etes hors-ligne. La modification a ete enregistree localement.');
|
||||
} catch (e) { alert('Erreur: ' + err.message); }
|
||||
} else {
|
||||
alert('Erreur: ' + err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,14 +600,7 @@ function initEditor() {
|
||||
|
||||
cancelBtn.addEventListener("click", closeEditor);
|
||||
deleteBtn.addEventListener("click", deleteFile);
|
||||
saveBtn.addEventListener("click", saveFile);
|
||||
|
||||
// Close on overlay click
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) {
|
||||
closeEditor();
|
||||
}
|
||||
});
|
||||
saveBtn.addEventListener("click", () => saveFile());
|
||||
|
||||
// ESC to close
|
||||
document.addEventListener("keydown", (e) => {
|
||||
|
||||
@@ -524,9 +524,15 @@ export function renderFile(data) {
|
||||
const forgeBtn = el("button", { class: "btn-action btn-forge", title: "Forge (nouvel editeur)" }, [icon("hammer", 14), document.createTextNode("Forge")]);
|
||||
forgeBtn.addEventListener("click", () => {
|
||||
var modal = document.getElementById("editor-modal");
|
||||
var titleEl = document.getElementById("editor-title");
|
||||
var container = modal.querySelector(".editor-container");
|
||||
var headerEl = modal.querySelector(".editor-header");
|
||||
var footerEl = modal.querySelector(".editor-footer");
|
||||
var bodyEl = document.getElementById("editor-body");
|
||||
titleEl.textContent = "Forge: " + data.path.split("/").pop();
|
||||
// Hide header/footer since Forge has its own
|
||||
if (headerEl) headerEl.style.display = "none";
|
||||
if (footerEl) footerEl.style.display = "none";
|
||||
// Make container fill the modal
|
||||
if (container) container.style.maxHeight = "100vh";
|
||||
bodyEl.innerHTML = "";
|
||||
bodyEl.style.overflow = "hidden";
|
||||
var iframe = document.createElement("iframe");
|
||||
|
||||
Reference in New Issue
Block a user