fix: strip read_file line numbers accidentally injected into JS files
All checks were successful
CI / lint (push) Successful in 13s
CI / security (push) Successful in 8s
CI / test (push) Successful in 16s
CI / build (push) Successful in 3s

This commit is contained in:
Bruno Charest 2026-05-28 16:40:14 -04:00
parent 7866f93778
commit 643a73e0f5
12 changed files with 8947 additions and 10017 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,462 +1,461 @@
1|// dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870)
1|// dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870)
import { state } from './state.js';
3|
4|// ---------------------------------------------------------------------------
5|// Recent files
6|// ---------------------------------------------------------------------------
7|let _recentRefreshTimer = null;
8|let _recentTimestampTimer = null;
9|let _recentFilesCache = [];
10|
11|// ---------------------------------------------------------------------------
12|// Dashboard Recent Files Widget
13|// ---------------------------------------------------------------------------
14|// ── Dashboard Stats Widget ──
15|const DashboardStatsWidget = {
16| async load() {
17| const grid = document.getElementById("dashboard-stats-grid");
18| if (!grid) return;
19| grid.innerHTML = '<div class="dashboard-stats-loading">Chargement...</div>';
20| try {
21| const data = await api("/api/dashboard");
22| this.render(data);
23| } catch (err) {
24| grid.innerHTML = `<div class="dashboard-recent-empty">Erreur: ${escapeHtml(err.message)}</div>`;
25| }
26| },
27| render(data) {
28| const grid = document.getElementById("dashboard-stats-grid");
29| if (!grid) return;
30| const fmtSize = (bytes) => bytes < 1024 ? `${bytes} o` : bytes < 1048576 ? `${(bytes/1024).toFixed(1)} Ko` : bytes < 1073741824 ? `${(bytes/1048576).toFixed(1)} Mo` : `${(bytes/1073741824).toFixed(1)} Go`;
31| const items = [
32| { icon: "files", label: "Fichiers", value: data.total_files.toLocaleString() },
33| { icon: "tags", label: "Tags uniques", value: data.total_tags.toLocaleString() },
34| { icon: "hard-drive", label: "Taille totale", value: fmtSize(data.total_size_bytes) },
35| { icon: "folder-open", label: "Vaults", value: data.vaults.length.toString() },
36| ];
37| grid.innerHTML = items.map(i => `
38| <div class="stat-card">
39| <i data-lucide="${i.icon}" class="stat-icon"></i>
40| <span class="stat-value">${i.value}</span>
41| <span class="stat-label">${i.label}</span>
42| </div>
43| `).join("");
44| safeCreateIcons();
45| }
46|};
47|
48|// ── Dashboard Shared Widget ──
49|const DashboardSharedWidget = {
50| async load() {
51| const grid = document.getElementById("dashboard-shared-grid");
52| const empty = document.getElementById("dashboard-shared-empty");
53| if (!grid) return;
54| try {
55| const shares = await api("/api/shares");
56| if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; }
57| if (empty) empty.style.display = "none";
58| grid.innerHTML = shares.map(s => `
59| <div class="shared-card" data-vault="${escapeHtml(s.vault)}" data-path="${escapeHtml(s.path)}">
60| <div class="shared-card-header">
61| <i data-lucide="file-text" style="width:14px;height:14px"></i>
62| <span class="shared-card-title">${escapeHtml(s.path.split("/").pop().replace(/\.md$/i, ""))}</span>
63| <span class="shared-card-vault">${escapeHtml(s.vault)}</span>
64| </div>
65| <div class="shared-card-meta">
66| <span>${s.access_count || 0} vue(s)</span>
67| ${s.expires_at ? `<span>Expire le ${new Date(s.expires_at).toLocaleDateString("fr-FR")}</span>` : ""}
68| </div>
69| <div class="shared-card-actions">
70| <button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">📋 Copier</button>
71| <button class="shared-open-btn">📂 Ouvrir</button>
72| <button class="shared-revoke-btn" data-id="${s.id}">🗑</button>
73| </div>
74| </div>
75| `).join("");
76| lucide.createIcons();
77| grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => {
78| e.stopPropagation();
79| const url = b.dataset.url;
80| try { await navigator.clipboard.writeText(url); } catch { const ta = document.createElement("textarea"); ta.value=url; ta.style.position="fixed"; ta.style.left="-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); }
81| showToast("Lien copié !", "success");
82| }));
83| grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => {
84| e.stopPropagation();
85| const card = b.closest(".shared-card");
86| if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path);
87| }));
88| grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => {
89| e.stopPropagation();
90| await api(`/api/share/${b.dataset.id}`, { method: "DELETE" });
91| showToast("Partage révoqué", "success");
92| this.load();
93| }));
94| grid.querySelectorAll(".shared-card").forEach(card => card.addEventListener("click", () => {
95| TabManager.openPreview(card.dataset.vault, card.dataset.path);
96| }));
97| } catch (err) { if (empty) empty.style.display = ""; }
98| }
99|};
100|
101|// ── Dashboard Conflicts Widget ──
102|const DashboardConflictsWidget = {
103| async load() {
104| const container = document.getElementById("dashboard-conflicts-container");
105| if (!container) return;
106| try {
107| const data = await api("/api/conflicts");
108| if (data.total === 0) { container.innerHTML = ""; return; }
109| this.render(data.conflicts, container);
110| } catch (err) { container.innerHTML = ""; }
111| },
112| render(conflicts, container) {
113| container.innerHTML = `
114| <div class="dashboard-section">
115| <div class="dashboard-header">
116| <div class="dashboard-title-row">
117| <i data-lucide="alert-triangle" class="dashboard-icon" style="color:var(--accent-orange)"></i>
118| <h2>Conflits de synchronisation</h2>
119| <span class="dashboard-badge" style="background:var(--accent-orange)">${conflicts.length}</span>
120| </div>
121| </div>
122| <div class="dashboard-conflicts-grid">
123| ${conflicts.map(c => `
124| <div class="conflict-card">
125| <div class="conflict-info">
126| <span class="conflict-vault">${escapeHtml(c.vault)}</span>
127| <span class="conflict-name">${escapeHtml(c.conflict_path.split("/").pop())}</span>
128| <span class="conflict-date">Conflit du ${c.conflict_date.replace(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/, "$3/$2/$1 $4:$5")}</span>
129| </div>
130| <div class="conflict-actions">
131| <button class="conflict-btn keep-local" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">Garder l'original</button>
132| <button class="conflict-btn keep-conflict" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">Garder le conflit</button>
133| </div>
134| </div>
135| `).join("")}
136| </div>
137| </div>`;
138| lucide.createIcons();
139| container.querySelectorAll(".keep-local").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_local")));
140| container.querySelectorAll(".keep-conflict").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_conflict")));
141| },
142| async _resolve(d, action) {
143| try {
144| await api("/api/conflicts/resolve", { method: "POST", body: JSON.stringify({ vault: d.vault, conflict_path: d.conflict, original_path: d.original, action }) });
145| showToast("Conflit résolu", "success");
146| this.load();
147| } catch (err) { showToast("Erreur: " + err.message, "error"); }
148| }
149|};
150|
151|const DashboardRecentWidget = {
152| _cache: [],
153| _currentFilter: "",
154|
155| async load(vaultFilter = "") {
156| const v = vaultFilter || selectedContextVault || "all";
157| this._currentFilter = v;
158| this.showLoading();
159|
160| let url = "/api/recent?mode=opened";
161| if (v !== "all") url += `&vault=${encodeURIComponent(v)}`;
162|
163| try {
164| const data = await api(url);
165| this._cache = data.files || [];
166| this.render();
167| } catch (err) {
168| console.error("Dashboard: Failed to load recent files:", err);
169| this.showError();
170| }
171| },
172|
173| async toggleBookmark(vault, path, title, card) {
174| try {
175| const data = await api("/api/bookmarks/toggle", {
176| method: "POST",
177| headers: { "Content-Type": "application/json" },
178| body: JSON.stringify({ vault, path, title }),
179| });
180|
181| // Refresh both widgets to keep sync
182| DashboardBookmarkWidget.load();
183|
184| // Update current card icon if it exists
185| if (card) {
186| const btn = card.querySelector(".dashboard-card-bookmark-btn");
187| if (btn) {
188| btn.classList.toggle("active", data.bookmarked);
189| const icon = btn.querySelector("i");
190| if (icon) icon.setAttribute("data-lucide", data.bookmarked ? "bookmark" : "bookmark-plus");
191| safeCreateIcons();
192| }
193| }
194|
195| // Check if we need to refresh the current list to reflect bookmark status across all cards
196| // To avoid flickering, just update the cache and re-render if needed or do a silent refresh
197| this._cache.forEach(f => {
198| if (f.vault === vault && f.path === path) f.bookmarked = data.bookmarked;
199| });
200| } catch (err) {
201| console.error("Failed to toggle bookmark:", err);
202| showToast("Erreur lors de l'épinglage", "error");
203| }
204| },
205|
206| showLoading() {
207| const grid = document.getElementById("dashboard-recent-grid");
208| const loading = document.getElementById("dashboard-loading");
209| const empty = document.getElementById("dashboard-recent-empty");
210| const count = document.getElementById("dashboard-count");
211|
212| if (grid) grid.innerHTML = "";
213| if (loading) loading.classList.add("active");
214| if (empty) empty.classList.add("hidden");
215| if (count) count.textContent = "";
216| },
217|
218| render() {
219| const grid = document.getElementById("dashboard-recent-grid");
220| const loading = document.getElementById("dashboard-loading");
221| const empty = document.getElementById("dashboard-recent-empty");
222| const count = document.getElementById("dashboard-count");
223|
224| if (loading) loading.classList.remove("active");
225|
226| if (!this._cache || this._cache.length === 0) {
227| this.showEmpty();
228| return;
229| }
230|
231| if (empty) empty.classList.add("hidden");
232| if (count) count.textContent = `${this._cache.length} fichier${this._cache.length > 1 ? "s" : ""}`;
233|
234| if (!grid) return;
235| grid.innerHTML = "";
236|
237| this._cache.forEach((f, index) => {
238| const card = this._createCard(f, index);
239| grid.appendChild(card);
240| });
241|
242| safeCreateIcons();
243| },
244|
245| _createCard(file, index) {
246| const card = document.createElement("div");
247| card.className = "dashboard-card";
248| card.setAttribute("data-vault", file.vault);
249| card.setAttribute("data-path", file.path);
250| card.style.animationDelay = `${Math.min(index * 50, 400)}ms`;
251|
252| // Header with icon and vault badge
253| const header = document.createElement("div");
254| header.className = "dashboard-card-header";
255|
256| const iconContainer = document.createElement("div");
257| iconContainer.className = "dashboard-card-icon";
258| const fileIconName = getFileIcon(file.path);
259| try {
260| iconContainer.appendChild(icon(fileIconName, 24));
261| } catch (e) {
262| console.error("Error creating icon:", fileIconName, e);
263| // Fallback to default file icon
264| iconContainer.appendChild(icon("file", 24));
265| }
266|
267| const badge = document.createElement("span");
268| badge.className = "dashboard-vault-badge";
269| badge.textContent = file.vault;
270|
271| const bookmarkBtn = document.createElement("button");
272| bookmarkBtn.className = `dashboard-card-bookmark-btn ${file.bookmarked ? "active" : ""}`;
273| bookmarkBtn.title = file.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks";
274| bookmarkBtn.innerHTML = `<i data-lucide="${file.bookmarked ? "bookmark" : "bookmark-plus"}" style="width:14px;height:14px"></i>`;
275|
276| bookmarkBtn.addEventListener("click", (e) => {
277| e.stopPropagation();
278| this.toggleBookmark(file.vault, file.path, file.title, card);
279| });
280|
281| header.appendChild(iconContainer);
282| header.appendChild(badge);
283| header.appendChild(bookmarkBtn);
284| card.appendChild(header);
285|
286| // Title
287| const title = document.createElement("h3");
288| title.className = "dashboard-card-title";
289| title.textContent = file.title || file.path.split("/").pop();
290| title.title = file.title || file.path;
291| card.appendChild(title);
292|
293| // Path (compact)
294| const pathParts = file.path.split("/");
295| if (pathParts.length > 1) {
296| const path = document.createElement("div");
297| path.className = "dashboard-card-path";
298| path.textContent = pathParts.slice(0, -1).join(" / ");
299| path.title = file.path;
300| card.appendChild(path);
301| }
302|
303| // Footer with time and tags
304| const footer = document.createElement("div");
305| footer.className = "dashboard-card-footer";
306|
307| const time = document.createElement("span");
308| time.className = "dashboard-card-time";
309| time.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> ${file.mtime_human || this._humanizeDelta(file.mtime)}`;
310|
311| footer.appendChild(time);
312|
313| // Tags
314| if (file.tags && file.tags.length > 0) {
315| const tags = document.createElement("div");
316| tags.className = "dashboard-card-tags";
317| file.tags.slice(0, 3).forEach((tag) => {
318| const tagEl = document.createElement("span");
319| tagEl.className = "tag-pill";
320| tagEl.textContent = tag;
321| tags.appendChild(tagEl);
322| });
323| footer.appendChild(tags);
324| }
325|
326| card.appendChild(footer);
327|
328| // Click handler
329| card.addEventListener("click", () => {
330| openFile(file.vault, file.path);
331| });
332|
333| return card;
334| },
335|
336| showEmpty() {
337| const grid = document.getElementById("dashboard-recent-grid");
338| const loading = document.getElementById("dashboard-loading");
339| const empty = document.getElementById("dashboard-recent-empty");
340| const count = document.getElementById("dashboard-count");
341|
342| if (grid) grid.innerHTML = "";
343| if (loading) loading.classList.remove("active");
344| if (empty) empty.classList.remove("hidden");
345| if (count) count.textContent = "0 fichiers";
346| safeCreateIcons();
347| },
348|
349| showError() {
350| this.showEmpty();
351| const empty = document.getElementById("dashboard-recent-empty");
352| if (empty) {
353| const msg = empty.querySelector("span");
354| if (msg) msg.textContent = "Erreur de chargement";
355| }
356| },
357|
358| _humanizeDelta(mtime) {
359| const delta = Date.now() / 1000 - mtime;
360| if (delta < 60) return "à l'instant";
361| if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`;
362| if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`;
363| if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`;
364| return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
365| },
366|
367| populateVaultFilter() {
368| const select = document.getElementById("dashboard-vault-filter");
369| if (!select) return;
370|
371| // Keep first option "Tous les vaults"
372| while (select.options.length > 1) select.remove(1);
373|
374| if (typeof allVaults !== "undefined" && Array.isArray(state.allVaults)) {
375| state.allVaults.forEach((v) => {
376| const opt = document.createElement("option");
377| opt.value = v.name;
378| opt.textContent = v.name;
379| select.appendChild(opt);
380| });
381| }
382| syncVaultSelectors();
383| },
384|
385| init() {
386| const select = document.getElementById("dashboard-vault-filter");
387| if (select) {
388| select.addEventListener("change", async () => {
389| await setSelectedVaultContext(select.value, { focusVault: select.value !== "all" });
390| });
391| }
392|
393| this.populateVaultFilter();
394| },
395|};
396|
397|// ── Dashboard Bookmarks Widget ──
398|// (moved from app.js 3810-3870; logically belongs with dashboard widgets)
399|const DashboardBookmarkWidget = {
400| _cache: [],
401| _currentFilter: "",
402|
403| async load(vaultFilter = "") {
404| const v = vaultFilter || selectedContextVault || "all";
405| this._currentFilter = v;
406| this.showLoading();
407|
408| let url = "/api/bookmarks";
409| if (v !== "all") url += `?vault=${encodeURIComponent(v)}`;
410|
411| try {
412| const data = await api(url);
413| this._cache = data.files || [];
414| this.render();
415| } catch (err) {
416| console.error("Dashboard: Failed to load bookmarks:", err);
417| this.showEmpty();
418| }
419| },
420|
421| showLoading() {
422| const grid = document.getElementById("dashboard-bookmarks-grid");
423| const empty = document.getElementById("dashboard-bookmarks-empty");
424| const section = document.getElementById("dashboard-bookmarks-section");
425|
426| if (grid) grid.innerHTML = "";
427| if (empty) empty.classList.add("hidden");
428| },
429|
430| render() {
431| const grid = document.getElementById("dashboard-bookmarks-grid");
432| const empty = document.getElementById("dashboard-bookmarks-empty");
433| const section = document.getElementById("dashboard-bookmarks-section");
434|
435| if (!this._cache || this._cache.length === 0) {
436| if (grid) grid.innerHTML = "";
437| if (empty) empty.classList.remove("hidden");
438| return;
439| }
440|
441| if (empty) empty.classList.add("hidden");
442| if (!grid) return;
443| grid.innerHTML = "";
444|
445| this._cache.forEach((f, idx) => {
446| const card = DashboardRecentWidget._createCard(f, idx);
447| grid.appendChild(card);
448| });
449|
450| safeCreateIcons();
451| },
452|
453| showEmpty() {
454| const grid = document.getElementById("dashboard-bookmarks-grid");
455| const empty = document.getElementById("dashboard-bookmarks-empty");
456| if (grid) grid.innerHTML = "";
457| if (empty) empty.classList.remove("hidden");
458| }
459|};
460|
461|export { DashboardRecentWidget, DashboardStatsWidget, DashboardBookmarkWidget, DashboardSharedWidget };
462|
// ---------------------------------------------------------------------------
// Recent files
// ---------------------------------------------------------------------------
let _recentRefreshTimer = null;
let _recentTimestampTimer = null;
let _recentFilesCache = [];
// ---------------------------------------------------------------------------
// Dashboard Recent Files Widget
// ---------------------------------------------------------------------------
// ── Dashboard Stats Widget ──
const DashboardStatsWidget = {
async load() {
const grid = document.getElementById("dashboard-stats-grid");
if (!grid) return;
grid.innerHTML = '<div class="dashboard-stats-loading">Chargement...</div>';
try {
const data = await api("/api/dashboard");
this.render(data);
} catch (err) {
grid.innerHTML = `<div class="dashboard-recent-empty">Erreur: ${escapeHtml(err.message)}</div>`;
}
},
render(data) {
const grid = document.getElementById("dashboard-stats-grid");
if (!grid) return;
const fmtSize = (bytes) => bytes < 1024 ? `${bytes} o` : bytes < 1048576 ? `${(bytes/1024).toFixed(1)} Ko` : bytes < 1073741824 ? `${(bytes/1048576).toFixed(1)} Mo` : `${(bytes/1073741824).toFixed(1)} Go`;
const items = [
{ icon: "files", label: "Fichiers", value: data.total_files.toLocaleString() },
{ icon: "tags", label: "Tags uniques", value: data.total_tags.toLocaleString() },
{ icon: "hard-drive", label: "Taille totale", value: fmtSize(data.total_size_bytes) },
{ icon: "folder-open", label: "Vaults", value: data.vaults.length.toString() },
];
grid.innerHTML = items.map(i => `
<div class="stat-card">
<i data-lucide="${i.icon}" class="stat-icon"></i>
<span class="stat-value">${i.value}</span>
<span class="stat-label">${i.label}</span>
</div>
`).join("");
safeCreateIcons();
}
};
// ── Dashboard Shared Widget ──
const DashboardSharedWidget = {
async load() {
const grid = document.getElementById("dashboard-shared-grid");
const empty = document.getElementById("dashboard-shared-empty");
if (!grid) return;
try {
const shares = await api("/api/shares");
if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; }
if (empty) empty.style.display = "none";
grid.innerHTML = shares.map(s => `
<div class="shared-card" data-vault="${escapeHtml(s.vault)}" data-path="${escapeHtml(s.path)}">
<div class="shared-card-header">
<i data-lucide="file-text" style="width:14px;height:14px"></i>
<span class="shared-card-title">${escapeHtml(s.path.split("/").pop().replace(/\.md$/i, ""))}</span>
<span class="shared-card-vault">${escapeHtml(s.vault)}</span>
</div>
<div class="shared-card-meta">
<span>${s.access_count || 0} vue(s)</span>
${s.expires_at ? `<span>Expire le ${new Date(s.expires_at).toLocaleDateString("fr-FR")}</span>` : ""}
</div>
<div class="shared-card-actions">
<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">📋 Copier</button>
<button class="shared-open-btn">📂 Ouvrir</button>
<button class="shared-revoke-btn" data-id="${s.id}">🗑</button>
</div>
</div>
`).join("");
lucide.createIcons();
grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => {
e.stopPropagation();
const url = b.dataset.url;
try { await navigator.clipboard.writeText(url); } catch { const ta = document.createElement("textarea"); ta.value=url; ta.style.position="fixed"; ta.style.left="-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); }
showToast("Lien copié !", "success");
}));
grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => {
e.stopPropagation();
const card = b.closest(".shared-card");
if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path);
}));
grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => {
e.stopPropagation();
await api(`/api/share/${b.dataset.id}`, { method: "DELETE" });
showToast("Partage révoqué", "success");
this.load();
}));
grid.querySelectorAll(".shared-card").forEach(card => card.addEventListener("click", () => {
TabManager.openPreview(card.dataset.vault, card.dataset.path);
}));
} catch (err) { if (empty) empty.style.display = ""; }
}
};
// ── Dashboard Conflicts Widget ──
const DashboardConflictsWidget = {
async load() {
const container = document.getElementById("dashboard-conflicts-container");
if (!container) return;
try {
const data = await api("/api/conflicts");
if (data.total === 0) { container.innerHTML = ""; return; }
this.render(data.conflicts, container);
} catch (err) { container.innerHTML = ""; }
},
render(conflicts, container) {
container.innerHTML = `
<div class="dashboard-section">
<div class="dashboard-header">
<div class="dashboard-title-row">
<i data-lucide="alert-triangle" class="dashboard-icon" style="color:var(--accent-orange)"></i>
<h2>Conflits de synchronisation</h2>
<span class="dashboard-badge" style="background:var(--accent-orange)">${conflicts.length}</span>
</div>
</div>
<div class="dashboard-conflicts-grid">
${conflicts.map(c => `
<div class="conflict-card">
<div class="conflict-info">
<span class="conflict-vault">${escapeHtml(c.vault)}</span>
<span class="conflict-name">${escapeHtml(c.conflict_path.split("/").pop())}</span>
<span class="conflict-date">Conflit du ${c.conflict_date.replace(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/, "$3/$2/$1 $4:$5")}</span>
</div>
<div class="conflict-actions">
<button class="conflict-btn keep-local" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">Garder l'original</button>
<button class="conflict-btn keep-conflict" data-vault="${escapeHtml(c.vault)}" data-conflict="${escapeHtml(c.conflict_path)}" data-original="${escapeHtml(c.original_path)}">Garder le conflit</button>
</div>
</div>
`).join("")}
</div>
</div>`;
lucide.createIcons();
container.querySelectorAll(".keep-local").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_local")));
container.querySelectorAll(".keep-conflict").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_conflict")));
},
async _resolve(d, action) {
try {
await api("/api/conflicts/resolve", { method: "POST", body: JSON.stringify({ vault: d.vault, conflict_path: d.conflict, original_path: d.original, action }) });
showToast("Conflit résolu", "success");
this.load();
} catch (err) { showToast("Erreur: " + err.message, "error"); }
}
};
const DashboardRecentWidget = {
_cache: [],
_currentFilter: "",
async load(vaultFilter = "") {
const v = vaultFilter || selectedContextVault || "all";
this._currentFilter = v;
this.showLoading();
let url = "/api/recent?mode=opened";
if (v !== "all") url += `&vault=${encodeURIComponent(v)}`;
try {
const data = await api(url);
this._cache = data.files || [];
this.render();
} catch (err) {
console.error("Dashboard: Failed to load recent files:", err);
this.showError();
}
},
async toggleBookmark(vault, path, title, card) {
try {
const data = await api("/api/bookmarks/toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vault, path, title }),
});
// Refresh both widgets to keep sync
DashboardBookmarkWidget.load();
// Update current card icon if it exists
if (card) {
const btn = card.querySelector(".dashboard-card-bookmark-btn");
if (btn) {
btn.classList.toggle("active", data.bookmarked);
const icon = btn.querySelector("i");
if (icon) icon.setAttribute("data-lucide", data.bookmarked ? "bookmark" : "bookmark-plus");
safeCreateIcons();
}
}
// Check if we need to refresh the current list to reflect bookmark status across all cards
// To avoid flickering, just update the cache and re-render if needed or do a silent refresh
this._cache.forEach(f => {
if (f.vault === vault && f.path === path) f.bookmarked = data.bookmarked;
});
} catch (err) {
console.error("Failed to toggle bookmark:", err);
showToast("Erreur lors de l'épinglage", "error");
}
},
showLoading() {
const grid = document.getElementById("dashboard-recent-grid");
const loading = document.getElementById("dashboard-loading");
const empty = document.getElementById("dashboard-recent-empty");
const count = document.getElementById("dashboard-count");
if (grid) grid.innerHTML = "";
if (loading) loading.classList.add("active");
if (empty) empty.classList.add("hidden");
if (count) count.textContent = "";
},
render() {
const grid = document.getElementById("dashboard-recent-grid");
const loading = document.getElementById("dashboard-loading");
const empty = document.getElementById("dashboard-recent-empty");
const count = document.getElementById("dashboard-count");
if (loading) loading.classList.remove("active");
if (!this._cache || this._cache.length === 0) {
this.showEmpty();
return;
}
if (empty) empty.classList.add("hidden");
if (count) count.textContent = `${this._cache.length} fichier${this._cache.length > 1 ? "s" : ""}`;
if (!grid) return;
grid.innerHTML = "";
this._cache.forEach((f, index) => {
const card = this._createCard(f, index);
grid.appendChild(card);
});
safeCreateIcons();
},
_createCard(file, index) {
const card = document.createElement("div");
card.className = "dashboard-card";
card.setAttribute("data-vault", file.vault);
card.setAttribute("data-path", file.path);
card.style.animationDelay = `${Math.min(index * 50, 400)}ms`;
// Header with icon and vault badge
const header = document.createElement("div");
header.className = "dashboard-card-header";
const iconContainer = document.createElement("div");
iconContainer.className = "dashboard-card-icon";
const fileIconName = getFileIcon(file.path);
try {
iconContainer.appendChild(icon(fileIconName, 24));
} catch (e) {
console.error("Error creating icon:", fileIconName, e);
// Fallback to default file icon
iconContainer.appendChild(icon("file", 24));
}
const badge = document.createElement("span");
badge.className = "dashboard-vault-badge";
badge.textContent = file.vault;
const bookmarkBtn = document.createElement("button");
bookmarkBtn.className = `dashboard-card-bookmark-btn ${file.bookmarked ? "active" : ""}`;
bookmarkBtn.title = file.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks";
bookmarkBtn.innerHTML = `<i data-lucide="${file.bookmarked ? "bookmark" : "bookmark-plus"}" style="width:14px;height:14px"></i>`;
bookmarkBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.toggleBookmark(file.vault, file.path, file.title, card);
});
header.appendChild(iconContainer);
header.appendChild(badge);
header.appendChild(bookmarkBtn);
card.appendChild(header);
// Title
const title = document.createElement("h3");
title.className = "dashboard-card-title";
title.textContent = file.title || file.path.split("/").pop();
title.title = file.title || file.path;
card.appendChild(title);
// Path (compact)
const pathParts = file.path.split("/");
if (pathParts.length > 1) {
const path = document.createElement("div");
path.className = "dashboard-card-path";
path.textContent = pathParts.slice(0, -1).join(" / ");
path.title = file.path;
card.appendChild(path);
}
// Footer with time and tags
const footer = document.createElement("div");
footer.className = "dashboard-card-footer";
const time = document.createElement("span");
time.className = "dashboard-card-time";
time.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> ${file.mtime_human || this._humanizeDelta(file.mtime)}`;
footer.appendChild(time);
// Tags
if (file.tags && file.tags.length > 0) {
const tags = document.createElement("div");
tags.className = "dashboard-card-tags";
file.tags.slice(0, 3).forEach((tag) => {
const tagEl = document.createElement("span");
tagEl.className = "tag-pill";
tagEl.textContent = tag;
tags.appendChild(tagEl);
});
footer.appendChild(tags);
}
card.appendChild(footer);
// Click handler
card.addEventListener("click", () => {
openFile(file.vault, file.path);
});
return card;
},
showEmpty() {
const grid = document.getElementById("dashboard-recent-grid");
const loading = document.getElementById("dashboard-loading");
const empty = document.getElementById("dashboard-recent-empty");
const count = document.getElementById("dashboard-count");
if (grid) grid.innerHTML = "";
if (loading) loading.classList.remove("active");
if (empty) empty.classList.remove("hidden");
if (count) count.textContent = "0 fichiers";
safeCreateIcons();
},
showError() {
this.showEmpty();
const empty = document.getElementById("dashboard-recent-empty");
if (empty) {
const msg = empty.querySelector("span");
if (msg) msg.textContent = "Erreur de chargement";
}
},
_humanizeDelta(mtime) {
const delta = Date.now() / 1000 - mtime;
if (delta < 60) return "à l'instant";
if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`;
if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`;
if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`;
return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
},
populateVaultFilter() {
const select = document.getElementById("dashboard-vault-filter");
if (!select) return;
// Keep first option "Tous les vaults"
while (select.options.length > 1) select.remove(1);
if (typeof allVaults !== "undefined" && Array.isArray(state.allVaults)) {
state.allVaults.forEach((v) => {
const opt = document.createElement("option");
opt.value = v.name;
opt.textContent = v.name;
select.appendChild(opt);
});
}
syncVaultSelectors();
},
init() {
const select = document.getElementById("dashboard-vault-filter");
if (select) {
select.addEventListener("change", async () => {
await setSelectedVaultContext(select.value, { focusVault: select.value !== "all" });
});
}
this.populateVaultFilter();
},
};
// ── Dashboard Bookmarks Widget ──
// (moved from app.js 3810-3870; logically belongs with dashboard widgets)
const DashboardBookmarkWidget = {
_cache: [],
_currentFilter: "",
async load(vaultFilter = "") {
const v = vaultFilter || selectedContextVault || "all";
this._currentFilter = v;
this.showLoading();
let url = "/api/bookmarks";
if (v !== "all") url += `?vault=${encodeURIComponent(v)}`;
try {
const data = await api(url);
this._cache = data.files || [];
this.render();
} catch (err) {
console.error("Dashboard: Failed to load bookmarks:", err);
this.showEmpty();
}
},
showLoading() {
const grid = document.getElementById("dashboard-bookmarks-grid");
const empty = document.getElementById("dashboard-bookmarks-empty");
const section = document.getElementById("dashboard-bookmarks-section");
if (grid) grid.innerHTML = "";
if (empty) empty.classList.add("hidden");
},
render() {
const grid = document.getElementById("dashboard-bookmarks-grid");
const empty = document.getElementById("dashboard-bookmarks-empty");
const section = document.getElementById("dashboard-bookmarks-section");
if (!this._cache || this._cache.length === 0) {
if (grid) grid.innerHTML = "";
if (empty) empty.classList.remove("hidden");
return;
}
if (empty) empty.classList.add("hidden");
if (!grid) return;
grid.innerHTML = "";
this._cache.forEach((f, idx) => {
const card = DashboardRecentWidget._createCard(f, idx);
grid.appendChild(card);
});
safeCreateIcons();
},
showEmpty() {
const grid = document.getElementById("dashboard-bookmarks-grid");
const empty = document.getElementById("dashboard-bookmarks-empty");
if (grid) grid.innerHTML = "";
if (empty) empty.classList.remove("hidden");
}
};
export { DashboardRecentWidget, DashboardStatsWidget, DashboardBookmarkWidget, DashboardSharedWidget };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -56,4 +56,4 @@ export const state = {
activeSidebarTab: "vaults",
filterDebounce: null,
vaultSettings: {},
};
};

View File

@ -1,438 +1,437 @@
1|/* ObsiGate — Sync: SSE client + PWA registration */
2|import {
3| state.currentVault,
4| state.currentPath,
5| state.activeSidebarTab,
6| selectedContextVault
7|} from './state.js';
8|import { showToast } from './ui.js';
9|import { loadVaults, loadTags, refreshTagsForContext } from './sidebar.js';
10|
11|// ---------------------------------------------------------------------------
12|// SSE Client — IndexUpdateManager
13|// ---------------------------------------------------------------------------
14|export const IndexUpdateManager = (() => {
15| let eventSource = null;
16| let reconnectTimer = null;
17| let reconnectDelay = 1000;
18| const MAX_RECONNECT_DELAY = 30000;
19| let recentEvents = [];
20| const MAX_RECENT_EVENTS = 20;
21| let connectionState = "disconnected"; // disconnected | connecting | connected
22|
23| function connect() {
24| if (eventSource) {
25| eventSource.close();
26| }
27| connectionState = "connecting";
28| _updateBadge();
29|
30| eventSource = new EventSource("/api/events");
31|
32| eventSource.addEventListener("connected", (e) => {
33| connectionState = "connected";
34| reconnectDelay = 1000;
35| _updateBadge();
36| });
37|
38| eventSource.addEventListener("index_updated", (e) => {
39| try {
40| const data = JSON.parse(e.data);
41| _addEvent("index_updated", data);
42| _onIndexUpdated(data);
43| } catch (err) {
44| console.error("SSE parse error:", err);
45| }
46| });
47|
48| eventSource.addEventListener("index_reloaded", (e) => {
49| try {
50| const data = JSON.parse(e.data);
51| _addEvent("index_reloaded", data);
52| _onIndexReloaded(data);
53| } catch (err) {
54| console.error("SSE parse error:", err);
55| }
56| });
57|
58| eventSource.addEventListener("vault_added", (e) => {
59| try {
60| const data = JSON.parse(e.data);
61| _addEvent("vault_added", data);
62| showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info");
63| loadVaults();
64| loadTags();
65| } catch (err) {
66| console.error("SSE parse error:", err);
67| }
68| });
69|
70| eventSource.addEventListener("vault_removed", (e) => {
71| try {
72| const data = JSON.parse(e.data);
73| _addEvent("vault_removed", data);
74| showToast(`Vault "${data.vault}" supprimé`, "info");
75| loadVaults();
76| loadTags();
77| } catch (err) {
78| console.error("SSE parse error:", err);
79| }
80| });
81|
82| eventSource.addEventListener("index_start", (e) => {
83| try {
84| const data = JSON.parse(e.data);
85| _addEvent("index_start", data);
86| connectionState = "syncing";
87| _updateBadge();
88| showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info");
89| } catch (err) {
90| console.error("SSE parse error:", err);
91| }
92| });
93|
94| eventSource.addEventListener("index_progress", (e) => {
95| try {
96| const data = JSON.parse(e.data);
97| _addEvent("index_progress", data);
98| connectionState = "syncing";
99| _updateBadge();
100| loadVaults();
101| loadTags();
102| } catch (err) {
103| console.error("SSE parse error:", err);
104| }
105| });
106|
107| eventSource.addEventListener("index_complete", (e) => {
108| try {
109| const data = JSON.parse(e.data);
110| _addEvent("index_complete", data);
111| connectionState = "connected";
112| _updateBadge();
113| showToast(`Indexation terminée (${data.total_files} fichiers)`, "success");
114| loadVaults();
115| loadTags();
116| } catch (err) {
117| console.error("SSE parse error:", err);
118| }
119| });
120|
121| eventSource.onerror = () => {
122| connectionState = "disconnected";
123| _updateBadge();
124| eventSource.close();
125| eventSource = null;
126| _scheduleReconnect();
127| };
128| }
129|
130| function _scheduleReconnect() {
131| if (reconnectTimer) clearTimeout(reconnectTimer);
132| reconnectTimer = setTimeout(() => {
133| reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
134| connect();
135| }, reconnectDelay);
136| }
137|
138| function _addEvent(type, data) {
139| recentEvents.unshift({
140| type,
141| data,
142| timestamp: new Date().toISOString(),
143| });
144| if (recentEvents.length > MAX_RECENT_EVENTS) {
145| recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
146| }
147| }
148|
149| async function _onIndexUpdated(data) {
150| // Brief syncing state
151| connectionState = "syncing";
152| _updateBadge();
153|
154| const n = data.total_changes || 0;
155| const vaults = (data.vaults || []).join(", ");
156| // Toast removed: silent auto-indexing — no notification needed
157|
158| // Refresh sidebar and tags if affected vault matches current context
159| const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault);
160| if (affectsCurrentVault) {
161| try {
162| await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
163| // Refresh current file if it was updated
164| if (currentVault && state.currentPath) {
165| const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === state.currentPath);
166| if (changed) {
167| openFile(state.currentVault, state.currentPath);
168| }
169| }
170| } catch (err) {
171| console.error("Error refreshing after index update:", err);
172| }
173| }
174|
175| // Refresh recent tab if it is active
176| if (state.activeSidebarTab === "recent") {
177| const vaultFilter = document.getElementById("recent-vault-filter");
178| loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
179| }
180|
181| setTimeout(() => {
182| connectionState = "connected";
183| _updateBadge();
184| }, 1500);
185| }
186|
187| async function _onIndexReloaded(data) {
188| connectionState = "syncing";
189| _updateBadge();
190| showToast("Index complet rechargé", "info");
191| try {
192| await Promise.all([loadVaults(), loadTags()]);
193| } catch (err) {
194| console.error("Error refreshing after full reload:", err);
195| }
196| setTimeout(() => {
197| connectionState = "connected";
198| _updateBadge();
199| }, 1500);
200| }
201|
202| function _updateBadge() {
203| const badge = document.getElementById("sync-badge");
204| if (!badge) return;
205| badge.className = "sync-badge sync-badge--" + connectionState;
206| const labels = {
207| disconnected: "Déconnecté",
208| connecting: "Connexion...",
209| connected: "Synchronisé",
210| syncing: "Mise à jour...",
211| };
212| badge.title = labels[connectionState] || connectionState;
213| }
214|
215| function disconnect() {
216| if (eventSource) {
217| eventSource.close();
218| eventSource = null;
219| }
220| if (reconnectTimer) {
221| clearTimeout(reconnectTimer);
222| reconnectTimer = null;
223| }
224| connectionState = "disconnected";
225| _updateBadge();
226| }
227|
228| function getState() {
229| return connectionState;
230| }
231|
232| function getRecentEvents() {
233| return recentEvents;
234| }
235|
236| return { connect, disconnect, getState, getRecentEvents };
237|})();
238|
239|// ---------------------------------------------------------------------------
240|// Sync status badge and panel
241|// ---------------------------------------------------------------------------
242|
243|function initSyncStatus() {
244| const badge = document.getElementById("sync-badge");
245| if (!badge) return;
246|
247| badge.addEventListener("click", (e) => {
248| e.stopPropagation();
249| toggleSyncPanel();
250| });
251|
252| IndexUpdateManager.connect();
253|}
254|
255|function toggleSyncPanel() {
256| let panel = document.getElementById("sync-panel");
257| if (panel) {
258| panel.remove();
259| return;
260| }
261| // Auto reconnect if disconnected when user opens the panel
262| if (IndexUpdateManager.getState() === "disconnected") {
263| IndexUpdateManager.connect();
264| }
265| panel = document.createElement("div");
266| panel.id = "sync-panel";
267| panel.className = "sync-panel";
268| _renderSyncPanel(panel);
269| document.body.appendChild(panel);
270|
271| // Close on outside click
272| setTimeout(() => {
273| document.addEventListener("click", _closeSyncPanelOutside, { once: true });
274| }, 0);
275|}
276|
277|function _closeSyncPanelOutside(e) {
278| const panel = document.getElementById("sync-panel");
279| if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
280| panel.remove();
281| }
282|}
283|
284|function _renderSyncPanel(panel) {
285| const state = IndexUpdateManager.getState();
286| const events = IndexUpdateManager.getRecentEvents();
287|
288| const stateLabels = {
289| disconnected: "Déconnecté",
290| connecting: "Connexion...",
291| connected: "Connecté",
292| syncing: "Synchronisation...",
293| };
294|
295| let html = `<div class="sync-panel__header">
296| <span class="sync-panel__title">Synchronisation</span>
297| <span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
298| </div>`;
299|
300| if (events.length === 0) {
301| html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
302| } else {
303| html += `<div class="sync-panel__events">`;
304| events.slice(0, 10).forEach((ev) => {
305| const time = new Date(ev.timestamp).toLocaleTimeString();
306| const typeLabels = {
307| index_updated: "Mise à jour",
308| index_reloaded: "Rechargement",
309| vault_added: "Vault ajouté",
310| vault_removed: "Vault supprimé",
311| index_start: "Démarrage index.",
312| index_progress: "Vault indexé",
313| index_complete: "Indexation tech.",
314| };
315| const label = typeLabels[ev.type] || ev.type;
316| let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
317| if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
318| if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`;
319| if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
320| html += `<div class="sync-panel__event">
321| <span class="sync-panel__event-type">${label}</span>
322| <span class="sync-panel__event-detail">${detail}</span>
323| <span class="sync-panel__event-time">${time}</span>
324| </div>`;
325| });
326| html += `</div>`;
327| }
328|
329| panel.innerHTML = html;
330|}
331|
332|// ---------------------------------------------------------------------------
333|// PWA Service Worker Registration
334|// ---------------------------------------------------------------------------
335|function registerServiceWorker() {
336| if ("serviceWorker" in navigator) {
337| window.addEventListener("load", () => {
338| navigator.serviceWorker
339| .register("/sw.js")
340| .then((registration) => {
341| console.log("[PWA] Service Worker registered successfully:", registration.scope);
342|
343| // Check for updates periodically
344| setInterval(() => {
345| registration.update();
346| }, 60000); // Check every minute
347|
348| // Handle service worker updates
349| registration.addEventListener("updatefound", () => {
350| const newWorker = registration.installing;
351| newWorker.addEventListener("statechange", () => {
352| if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
353| // New service worker available
354| showUpdateNotification();
355| }
356| });
357| });
358| })
359| .catch((error) => {
360| console.log("[PWA] Service Worker registration failed:", error);
361| });
362| });
363| }
364|}
365|
366|function showUpdateNotification() {
367| const message = document.createElement("div");
368| message.className = "pwa-update-notification";
369| message.innerHTML = `
370| <div class="pwa-update-content">
371| <span>Une nouvelle version d'ObsiGate est disponible !</span>
372| <button class="pwa-update-btn" onclick="window.location.reload()">Mettre à jour</button>
373| <button class="pwa-update-dismiss" onclick="this.parentElement.parentElement.remove()">×</button>
374| </div>
375| `;
376| document.body.appendChild(message);
377|
378| // Auto-dismiss after 30 seconds
379| setTimeout(() => {
380| if (message.parentElement) {
381| message.remove();
382| }
383| }, 30000);
384|}
385|
386|// Handle install prompt
387|let deferredPrompt;
388|window.addEventListener("beforeinstallprompt", (e) => {
389| e.preventDefault();
390| deferredPrompt = e;
391|
392| // Show install button if desired
393| const installBtn = document.getElementById("pwa-install-btn");
394| if (installBtn) {
395| installBtn.style.display = "block";
396| installBtn.addEventListener("click", async () => {
397| if (deferredPrompt) {
398| deferredPrompt.prompt();
399| const { outcome } = await deferredPrompt.userChoice;
400| console.log(`[PWA] User response to install prompt: ${outcome}`);
401| deferredPrompt = null;
402| installBtn.style.display = "none";
403| }
404| });
405| }
406|});
407|
408|// Log when app is installed
409|window.addEventListener("appinstalled", () => {
410| console.log("[PWA] ObsiGate has been installed");
411| showToast("ObsiGate installé avec succès !", "success");
412|});
413|
414|// ── Dashboard tab switching (runs on page load and after rebuild) ──
415|function initDashboardTabs() {
416| document.querySelectorAll(".dashboard-tab").forEach(tab => {
417| // Remove existing listeners by cloning
418| const newTab = tab.cloneNode(true);
419| tab.parentNode.replaceChild(newTab, tab);
420| newTab.addEventListener("click", function() {
421| document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
422| document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
423| this.classList.add("active");
424| const panel = document.getElementById("dashboard-panel-" + this.dataset.tab);
425| if (panel) panel.classList.add("active");
426| });
427| });
428|}
429|
430|// ---------------------------------------------------------------------------
431|// Init — called by app.js orchestrator
432|// ---------------------------------------------------------------------------
433|export { initSyncStatus };
434|export function init() {
435| registerServiceWorker();
436| initDashboardTabs();
437|}
438|
1|/* ObsiGate — Sync: SSE client + PWA registration */
import {
state.currentVault,
state.currentPath,
state.activeSidebarTab,
selectedContextVault
} from './state.js';
import { showToast } from './ui.js';
import { loadVaults, loadTags, refreshTagsForContext } from './sidebar.js';
// ---------------------------------------------------------------------------
// SSE Client — IndexUpdateManager
// ---------------------------------------------------------------------------
export const IndexUpdateManager = (() => {
let eventSource = null;
let reconnectTimer = null;
let reconnectDelay = 1000;
const MAX_RECONNECT_DELAY = 30000;
let recentEvents = [];
const MAX_RECENT_EVENTS = 20;
let connectionState = "disconnected"; // disconnected | connecting | connected
function connect() {
if (eventSource) {
eventSource.close();
}
connectionState = "connecting";
_updateBadge();
eventSource = new EventSource("/api/events");
eventSource.addEventListener("connected", (e) => {
connectionState = "connected";
reconnectDelay = 1000;
_updateBadge();
});
eventSource.addEventListener("index_updated", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_updated", data);
_onIndexUpdated(data);
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_reloaded", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_reloaded", data);
_onIndexReloaded(data);
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("vault_added", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("vault_added", data);
showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("vault_removed", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("vault_removed", data);
showToast(`Vault "${data.vault}" supprimé`, "info");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_start", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_start", data);
connectionState = "syncing";
_updateBadge();
showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info");
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_progress", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_progress", data);
connectionState = "syncing";
_updateBadge();
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_complete", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_complete", data);
connectionState = "connected";
_updateBadge();
showToast(`Indexation terminée (${data.total_files} fichiers)`, "success");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.onerror = () => {
connectionState = "disconnected";
_updateBadge();
eventSource.close();
eventSource = null;
_scheduleReconnect();
};
}
function _scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
connect();
}, reconnectDelay);
}
function _addEvent(type, data) {
recentEvents.unshift({
type,
data,
timestamp: new Date().toISOString(),
});
if (recentEvents.length > MAX_RECENT_EVENTS) {
recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
}
}
async function _onIndexUpdated(data) {
// Brief syncing state
connectionState = "syncing";
_updateBadge();
const n = data.total_changes || 0;
const vaults = (data.vaults || []).join(", ");
// Toast removed: silent auto-indexing — no notification needed
// Refresh sidebar and tags if affected vault matches current context
const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault);
if (affectsCurrentVault) {
try {
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
// Refresh current file if it was updated
if (currentVault && state.currentPath) {
const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === state.currentPath);
if (changed) {
openFile(state.currentVault, state.currentPath);
}
}
} catch (err) {
console.error("Error refreshing after index update:", err);
}
}
// Refresh recent tab if it is active
if (state.activeSidebarTab === "recent") {
const vaultFilter = document.getElementById("recent-vault-filter");
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
}
setTimeout(() => {
connectionState = "connected";
_updateBadge();
}, 1500);
}
async function _onIndexReloaded(data) {
connectionState = "syncing";
_updateBadge();
showToast("Index complet rechargé", "info");
try {
await Promise.all([loadVaults(), loadTags()]);
} catch (err) {
console.error("Error refreshing after full reload:", err);
}
setTimeout(() => {
connectionState = "connected";
_updateBadge();
}, 1500);
}
function _updateBadge() {
const badge = document.getElementById("sync-badge");
if (!badge) return;
badge.className = "sync-badge sync-badge--" + connectionState;
const labels = {
disconnected: "Déconnecté",
connecting: "Connexion...",
connected: "Synchronisé",
syncing: "Mise à jour...",
};
badge.title = labels[connectionState] || connectionState;
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
connectionState = "disconnected";
_updateBadge();
}
function getState() {
return connectionState;
}
function getRecentEvents() {
return recentEvents;
}
return { connect, disconnect, getState, getRecentEvents };
})();
// ---------------------------------------------------------------------------
// Sync status badge and panel
// ---------------------------------------------------------------------------
function initSyncStatus() {
const badge = document.getElementById("sync-badge");
if (!badge) return;
badge.addEventListener("click", (e) => {
e.stopPropagation();
toggleSyncPanel();
});
IndexUpdateManager.connect();
}
function toggleSyncPanel() {
let panel = document.getElementById("sync-panel");
if (panel) {
panel.remove();
return;
}
// Auto reconnect if disconnected when user opens the panel
if (IndexUpdateManager.getState() === "disconnected") {
IndexUpdateManager.connect();
}
panel = document.createElement("div");
panel.id = "sync-panel";
panel.className = "sync-panel";
_renderSyncPanel(panel);
document.body.appendChild(panel);
// Close on outside click
setTimeout(() => {
document.addEventListener("click", _closeSyncPanelOutside, { once: true });
}, 0);
}
function _closeSyncPanelOutside(e) {
const panel = document.getElementById("sync-panel");
if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
panel.remove();
}
}
function _renderSyncPanel(panel) {
const state = IndexUpdateManager.getState();
const events = IndexUpdateManager.getRecentEvents();
const stateLabels = {
disconnected: "Déconnecté",
connecting: "Connexion...",
connected: "Connecté",
syncing: "Synchronisation...",
};
let html = `<div class="sync-panel__header">
<span class="sync-panel__title">Synchronisation</span>
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
</div>`;
if (events.length === 0) {
html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
} else {
html += `<div class="sync-panel__events">`;
events.slice(0, 10).forEach((ev) => {
const time = new Date(ev.timestamp).toLocaleTimeString();
const typeLabels = {
index_updated: "Mise à jour",
index_reloaded: "Rechargement",
vault_added: "Vault ajouté",
vault_removed: "Vault supprimé",
index_start: "Démarrage index.",
index_progress: "Vault indexé",
index_complete: "Indexation tech.",
};
const label = typeLabels[ev.type] || ev.type;
let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`;
if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
html += `<div class="sync-panel__event">
<span class="sync-panel__event-type">${label}</span>
<span class="sync-panel__event-detail">${detail}</span>
<span class="sync-panel__event-time">${time}</span>
</div>`;
});
html += `</div>`;
}
panel.innerHTML = html;
}
// ---------------------------------------------------------------------------
// PWA Service Worker Registration
// ---------------------------------------------------------------------------
function registerServiceWorker() {
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("[PWA] Service Worker registered successfully:", registration.scope);
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60000); // Check every minute
// Handle service worker updates
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
// New service worker available
showUpdateNotification();
}
});
});
})
.catch((error) => {
console.log("[PWA] Service Worker registration failed:", error);
});
});
}
}
function showUpdateNotification() {
const message = document.createElement("div");
message.className = "pwa-update-notification";
message.innerHTML = `
<div class="pwa-update-content">
<span>Une nouvelle version d'ObsiGate est disponible !</span>
<button class="pwa-update-btn" onclick="window.location.reload()">Mettre à jour</button>
<button class="pwa-update-dismiss" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
`;
document.body.appendChild(message);
// Auto-dismiss after 30 seconds
setTimeout(() => {
if (message.parentElement) {
message.remove();
}
}, 30000);
}
// Handle install prompt
let deferredPrompt;
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
deferredPrompt = e;
// Show install button if desired
const installBtn = document.getElementById("pwa-install-btn");
if (installBtn) {
installBtn.style.display = "block";
installBtn.addEventListener("click", async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`[PWA] User response to install prompt: ${outcome}`);
deferredPrompt = null;
installBtn.style.display = "none";
}
});
}
});
// Log when app is installed
window.addEventListener("appinstalled", () => {
console.log("[PWA] ObsiGate has been installed");
showToast("ObsiGate installé avec succès !", "success");
});
// ── Dashboard tab switching (runs on page load and after rebuild) ──
function initDashboardTabs() {
document.querySelectorAll(".dashboard-tab").forEach(tab => {
// Remove existing listeners by cloning
const newTab = tab.cloneNode(true);
tab.parentNode.replaceChild(newTab, tab);
newTab.addEventListener("click", function() {
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
this.classList.add("active");
const panel = document.getElementById("dashboard-panel-" + this.dataset.tab);
if (panel) panel.classList.add("active");
});
});
}
// ---------------------------------------------------------------------------
// Init — called by app.js orchestrator
// ---------------------------------------------------------------------------
export { initSyncStatus };
export function init() {
registerServiceWorker();
initDashboardTabs();
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff