ObsiGate/frontend/js/viewer.js
Bruno Charest 7866f93778
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 6s
refactor: state.js → mutable object to fix 'assignment to constant' errors
ES module imports are read-only live bindings — can't reassign
imported let/const variables. Replace individual 'export let' with
single 'export const state = {...}' mutable object.

All modules updated: import { state } from './state.js'
All state access changed to state.xxx pattern.

Fixes cascade of 'Assignment to constant variable' errors.
2026-05-28 16:34:39 -04:00

1555 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

1|/* ObsiGate — Viewer module: Outline, ScrollSpy, ReadingProgress, file viewer, frontmatter card, editor init */
import { state } from './state.js';
3|import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon } from "./utils.js";
4|
5|// initEditor is defined in utils.js — re-exported below.
6|
7|// ---------------------------------------------------------------------------
8|// Outline/TOC Manager
9|// ---------------------------------------------------------------------------
10|
11|const OutlineManager = {
12| /**
13| * Slugify text to create valid IDs
14| */
15| slugify(text) {
16| return (
17| text
18| .toLowerCase()
19| .normalize("NFD")
20| .replace(/[\u0300-\u036f]/g, "")
21| .replace(/[^\p{L}\p{N}\s-]/gu, "")
22| .replace(/\s+/g, "-")
23| .replace(/-+/g, "-")
24| .trim() || "heading"
25| );
26| },
27|
28| /**
29| * Parse headings from markdown content
30| */
31| parseHeadings() {
32| const contentArea = document.querySelector(".md-content");
33| if (!contentArea) return [];
34|
35| const headings = [];
36| const h2s = contentArea.querySelectorAll("h2");
37| const h3s = contentArea.querySelectorAll("h3");
38| const allHeadings = [...h2s, ...h3s].sort((a, b) => {
39| return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
40| });
41|
42| const usedIds = new Map();
43|
44| allHeadings.forEach((heading) => {
45| const text = heading.textContent.trim();
46| if (!text) return;
47|
48| const level = parseInt(heading.tagName[1]);
49| let id = this.slugify(text);
50|
51| // Handle duplicate IDs
52| if (usedIds.has(id)) {
53| const count = usedIds.get(id) + 1;
54| usedIds.set(id, count);
55| id = `${id}-${count}`;
56| } else {
57| usedIds.set(id, 1);
58| }
59|
60| // Inject ID into heading if not present
61| if (!heading.id) {
62| heading.id = id;
63| } else {
64| id = heading.id;
65| }
66|
67| headings.push({
68| id,
69| level,
70| text,
71| element: heading,
72| });
73| });
74|
75| return headings;
76| },
77|
78| /**
79| * Render outline list
80| */
81| renderOutline(headings) {
82| const outlineList = document.getElementById("outline-list");
83| const outlineEmpty = document.getElementById("outline-empty");
84|
85| if (!outlineList) return;
86|
87| outlineList.innerHTML = "";
88|
89| if (!headings || headings.length === 0) {
90| outlineList.hidden = true;
91| if (outlineEmpty) {
92| outlineEmpty.hidden = false;
93| safeCreateIcons();
94| }
95| return;
96| }
97|
98| outlineList.hidden = false;
99| if (outlineEmpty) outlineEmpty.hidden = true;
100|
101| headings.forEach((heading) => {
102| const item = el(
103| "a",
104| {
105| class: `outline-item level-${heading.level}`,
106| href: `#${heading.id}`,
107| "data-heading-id": heading.id,
108| role: "link",
109| },
110| [document.createTextNode(heading.text)],
111| );
112|
113| item.addEventListener("click", (e) => {
114| e.preventDefault();
115| this.scrollToHeading(heading.id);
116| });
117|
118| outlineList.appendChild(item);
119| });
120|
121| state.headingsCache = headings;
122| },
123|
124| /**
125| * Scroll to heading with smooth behavior
126| */
127| scrollToHeading(headingId) {
128| const heading = document.getElementById(headingId);
129| if (!heading) return;
130|
131| const contentArea = document.getElementById("content-area");
132| if (!contentArea) return;
133|
134| // Calculate offset for fixed header (if any)
135| const headerHeight = 80;
136| const headingTop = heading.offsetTop;
137|
138| contentArea.scrollTo({
139| top: headingTop - headerHeight,
140| behavior: "smooth",
141| });
142|
143| // Update active state immediately
144| this.setActiveHeading(headingId);
145| },
146|
147| /**
148| * Set active heading in outline
149| */
150| setActiveHeading(headingId) {
151| if (state.activeHeadingId === headingId) return;
152|
153| state.activeHeadingId = headingId;
154|
155| const items = document.querySelectorAll(".outline-item");
156| items.forEach((item) => {
157| if (item.getAttribute("data-heading-id") === headingId) {
158| item.classList.add("active");
159| item.setAttribute("aria-current", "location");
160| // Scroll outline item into view
161| item.scrollIntoView({ block: "nearest", behavior: "smooth" });
162| } else {
163| item.classList.remove("active");
164| item.removeAttribute("aria-current");
165| }
166| });
167| },
168|
169| /**
170| * Initialize outline for current document
171| */
172| init() {
173| const headings = this.parseHeadings();
174| this.renderOutline(headings);
175| ScrollSpyManager.init(headings);
176| ReadingProgressManager.init();
177| },
178|
179| /**
180| * Cleanup
181| */
182| destroy() {
183| ScrollSpyManager.destroy();
184| ReadingProgressManager.destroy();
185| state.headingsCache = [];
186| state.activeHeadingId = null;
187| },
188|};
189|
190|
191|// ---------------------------------------------------------------------------
192|// Scroll Spy Manager
193|// ---------------------------------------------------------------------------
194|
195|const ScrollSpyManager = {
196| observer: null,
197| headings: [],
198|
199| init(headings) {
200| this.destroy();
201| this.headings = headings;
202|
203| if (!headings || headings.length === 0) return;
204|
205| const contentArea = document.getElementById("content-area");
206| if (!contentArea) return;
207|
208| const options = {
209| root: contentArea,
210| rootMargin: "-20% 0px -70% 0px",
211| threshold: [0, 0.3, 0.5, 1.0],
212| };
213|
214| this.observer = new IntersectionObserver((entries) => {
215| // Find the most visible heading
216| let mostVisible = null;
217| let maxRatio = 0;
218|
219| entries.forEach((entry) => {
220| if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
221| maxRatio = entry.intersectionRatio;
222| mostVisible = entry.target;
223| }
224| });
225|
226| if (mostVisible && mostVisible.id) {
227| OutlineManager.setActiveHeading(mostVisible.id);
228| }
229| }, options);
230|
231| // Observe all headings
232| headings.forEach((heading) => {
233| if (heading.element) {
234| this.observer.observe(heading.element);
235| }
236| });
237| },
238|
239| destroy() {
240| if (this.observer) {
241| this.observer.disconnect();
242| this.observer = null;
243| }
244| this.headings = [];
245| },
246|};
247|
248|
249|// ---------------------------------------------------------------------------
250|// Reading Progress Manager
251|// ---------------------------------------------------------------------------
252|
253|const ReadingProgressManager = {
254| scrollHandler: null,
255|
256| init() {
257| this.destroy();
258|
259| const contentArea = document.getElementById("content-area");
260| if (!contentArea) return;
261|
262| this.scrollHandler = this.throttle(() => {
263| this.updateProgress();
264| }, 100);
265|
266| contentArea.addEventListener("scroll", this.scrollHandler);
267| this.updateProgress();
268| },
269|
270| updateProgress() {
271| const contentArea = document.getElementById("content-area");
272| const progressFill = document.getElementById("reading-progress-fill");
273| const progressText = document.getElementById("reading-progress-text");
274|
275| if (!contentArea || !progressFill || !progressText) return;
276|
277| const scrollTop = contentArea.scrollTop;
278| const scrollHeight = contentArea.scrollHeight;
279| const clientHeight = contentArea.clientHeight;
280|
281| const maxScroll = scrollHeight - clientHeight;
282| const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0;
283|
284| progressFill.style.width = `${percentage}%`;
285| progressText.textContent = `${percentage}%`;
286| },
287|
288| throttle(func, delay) {
289| let lastCall = 0;
290| return function (...args) {
291| const now = Date.now();
292| if (now - lastCall >= delay) {
293| lastCall = now;
294| func.apply(this, args);
295| }
296| };
297| },
298|
299| destroy() {
300| const contentArea = document.getElementById("content-area");
301| if (contentArea && this.scrollHandler) {
302| contentArea.removeEventListener("scroll", this.scrollHandler);
303| }
304| this.scrollHandler = null;
305|
306| // Reset progress
307| const progressFill = document.getElementById("reading-progress-fill");
308| const progressText = document.getElementById("reading-progress-text");
309| if (progressFill) progressFill.style.width = "0%";
310| if (progressText) progressText.textContent = "0%";
311| },
312|};
313|
314|
315|// ---------------------------------------------------------------------------
316|// File viewer
317|// ---------------------------------------------------------------------------
318|async function openFile(vaultName, filePath) {
319| state.currentVault = vaultName;
320| state.currentPath = filePath;
321| state.showingSource = false;
322| state.cachedRawSource = null;
323|
324| // Highlight active
325| syncActiveFileTreeItem(vaultName, filePath);
326|
327| // Show loading state while fetching
328| const area = document.getElementById("content-area");
329| area.innerHTML = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
330|
331| try {
332| const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
333| const data = await api(url);
334| renderFile(data);
335| } catch (err) {
336| area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier.</p></div>';
337| }
338|}
339|
340|async function renderBacklinksPanel(vault, path, container) {
341| try {
342| const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`);
343| if (!data.backlinks || data.backlinks.length === 0) return;
344|
345| const panel = el("div", { class: "backlinks-panel" });
346| const header = el("div", { class: "backlinks-header" }, [
347| icon("link", 14),
348| document.createTextNode(` ${data.total} lien(s) entrant(s)`),
349| ]);
350| panel.appendChild(header);
351|
352| const list = el("div", { class: "backlinks-list" });
353| data.backlinks.forEach((bl) => {
354| const item = el("div", { class: "backlink-item" });
355| const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]);
356| const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]);
357| item.appendChild(icon(getFileIcon(bl.path), 12));
358| item.appendChild(vaultBadge);
359| item.appendChild(titleEl);
360| item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path));
361| item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); });
362| list.appendChild(item);
363| });
364| panel.appendChild(list);
365| container.appendChild(panel);
366| } catch (err) {
367| // Silently ignore — backlinks are optional
368| console.debug("Backlinks fetch failed:", err);
369| }
370|}
371|
372|function renderFile(data) {
373| const area = document.getElementById("content-area");
374|
375| // Handle unsupported (binary) files
376| if (data.unsupported) {
377| const sizeStr = data.size_bytes
378| ? data.size_bytes < 1024 ? `${data.size_bytes} o`
379| : data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko`
380| : `${(data.size_bytes / 1048576).toFixed(1)} Mo`
381| : "";
382| area.innerHTML = `
383| <div class="unsupported-file">
384| <i data-lucide="file" style="width:48px;height:48px"></i>
385| <div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
386| <div>Ce fichier est binaire et ne peut pas être affiché.</div>
387| ${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
388| <button class="btn-action" id="unsupported-download-btn">
389| <i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
390| </button>
391| </div>`;
392| lucide.createIcons();
393| document.getElementById("unsupported-download-btn").addEventListener("click", () => {
394| const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
395| window.open(dlUrl, "_blank");
396| });
397| return;
398| }
399|
400| // Breadcrumb
401| const parts = data.path.split("/");
402| const breadcrumbEls = [];
403| breadcrumbEls.push(
404| makeBreadcrumbSpan(data.vault, () => {
405| focusPathInSidebar(data.vault, "", { alignToTop: "center" });
406| }),
407| );
408| let accumulated = "";
409| parts.forEach((part, i) => {
410| breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")]));
411| accumulated += (accumulated ? "/" : "") + part;
412| const p = accumulated;
413| if (i < parts.length - 1) {
414| breadcrumbEls.push(
415| makeBreadcrumbSpan(part, () => {
416| focusPathInSidebar(data.vault, p, { alignToTop: "center" });
417| }),
418| );
419| } else {
420| breadcrumbEls.push(
421| makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => {
422| focusPathInSidebar(data.vault, data.path, { alignToTop: "center" });
423| }),
424| );
425| }
426| });
427|
428| const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls);
429|
430| // Tags
431| const tagsDiv = el("div", { class: "file-tags" });
432| (data.tags || []).forEach((tag) => {
433| if (!TagFilterService.isTagFiltered(tag)) {
434| const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
435| t.addEventListener("click", () => searchByTag(tag));
436| tagsDiv.appendChild(t);
437| }
438| });
439|
440| // Action buttons
441| const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]);
442| copyBtn.addEventListener("click", async () => {
443| try {
444| // Fetch raw content if not already cached
445| if (!state.cachedRawSource) {
446| const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
447| const rawData = await api(rawUrl);
448| state.cachedRawSource = rawData.raw;
449| }
450| await navigator.clipboard.writeText(state.cachedRawSource);
451| copyBtn.lastChild.textContent = "Copié !";
452| setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500);
453| } catch (err) {
454| console.error("Copy error:", err);
455| showToast("Erreur lors de la copie", "error");
456| }
457| });
458|
459| const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]);
460|
461| // MD download button
462| const mdBtn = el("button", { class: "btn-action", title: "Télécharger en .md" }, [icon("file-text", 14), document.createTextNode(".md")]);
463| mdBtn.addEventListener("click", () => {
464| const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
465| const a = document.createElement("a");
466| a.href = dlUrl;
467| a.download = data.path.split("/").pop();
468| document.body.appendChild(a);
469| a.click();
470| document.body.removeChild(a);
471| });
472|
473| // PDF download button
474| const pdfBtn = el("button", { class: "btn-action", title: "Télécharger en PDF" }, [icon("file", 14), document.createTextNode("PDF")]);
475| pdfBtn.addEventListener("click", () => {
476| const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`;
477| window.open(pdfUrl, "_blank");
478| });
479|
480| const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]);
481| editBtn.addEventListener("click", () => {
482| openEditor(data.vault, data.path);
483| });
484|
485| const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenêtre" }, [icon("external-link", 14), document.createTextNode("pop-out")]);
486| openNewWindowBtn.addEventListener("click", () => {
487| const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}`;
488| window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no");
489| });
490|
491| const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]);
492| tocBtn.addEventListener("click", () => {
493| RightSidebarManager.toggle();
494| });
495|
496| // Share button — check if already shared
497| const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]);
498| // Check if already shared and color the button
499| (async () => {
500| try {
501| const shares = await api("/api/shares");
502| if (shares.some(s => s.vault === data.vault && s.path === data.path)) {
503| shareBtn.classList.add("shared");
504| shareBtn.title = "Document partagé — cliquer pour gérer";
505| }
506| } catch (e) { /* ignore */ }
507| })();
508| shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path));
509|
510| // Bookmark button — check if already bookmarked
511| const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: "Ajouter/Retirer des bookmarks" }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]);
512| // Check bookmark status and color the button
513| (async () => {
514| try {
515| const bms = await api("/api/bookmarks");
516| if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) {
517| bookmarkBtn.classList.add("active");
518| bookmarkBtn.title = "Retirer des bookmarks";
519| }
520| } catch (e) { /* ignore */ }
521| })();
522| bookmarkBtn.addEventListener("click", async () => {
523| try {
524| const res = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: data.vault, path: data.path, title: data.title }) });
525| bookmarkBtn.classList.toggle("active", res.bookmarked);
526| bookmarkBtn.title = res.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks";
527| showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success");
528| if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load();
529| } catch (err) { showToast("Erreur: " + err.message, "error"); }
530| });
531|
532| // Frontmatter — Accent Card
533| let fmSection = null;
534| if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
535| fmSection = buildFrontmatterCard(data.frontmatter);
536| }
537|
538| // Content container (rendered HTML)
539| const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" });
540| mdDiv.innerHTML = data.html;
541|
542| // Raw source container (hidden initially)
543| const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" });
544|
545| // Source button toggle logic
546| sourceBtn.addEventListener("click", async () => {
547| const rendered = document.getElementById("file-rendered-content");
548| const raw = document.getElementById("file-raw-content");
549| if (!rendered || !raw) return;
550|
551| state.showingSource = !state.showingSource;
552| if (state.showingSource) {
553| sourceBtn.classList.add("active");
554| if (!state.cachedRawSource) {
555| const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
556| const rawData = await api(rawUrl);
557| state.cachedRawSource = rawData.raw;
558| }
559| raw.textContent = state.cachedRawSource;
560| rendered.style.display = "none";
561| raw.style.display = "block";
562| } else {
563| sourceBtn.classList.remove("active");
564| rendered.style.display = "block";
565| raw.style.display = "none";
566| }
567| });
568|
569| // Assemble
570| area.innerHTML = "";
571| area.appendChild(breadcrumb);
572| area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, editBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
573| if (fmSection) area.appendChild(fmSection);
574| area.appendChild(mdDiv);
575| area.appendChild(rawDiv);
576|
577| // Backlinks panel
578| if (data.is_markdown) {
579| renderBacklinksPanel(data.vault, data.path, area);
580| }
581|
582| // Highlight code blocks
583| area.querySelectorAll("pre code").forEach((block) => {
584| safeHighlight(block);
585| });
586|
587| // Wire up wikilinks
588| area.querySelectorAll(".wikilink").forEach((link) => {
589| link.addEventListener("click", (e) => {
590| e.preventDefault();
591| const v = link.getAttribute("data-vault");
592| const p = link.getAttribute("data-path");
593| if (v && p) openFile(v, p);
594| });
595| });
596|
597| safeCreateIcons();
598| area.scrollTop = 0;
599|
600| // Initialize outline/TOC for this document
601| OutlineManager.init();
602|}
603|
604|
605|function buildFrontmatterCard(frontmatter) {
606| // Helper: format date
607| function formatDate(iso) {
608| if (!iso) return "—";
609| const d = new Date(iso);
610| const date = d.toISOString().slice(0, 10);
611| const time = d.toTimeString().slice(0, 5);
612| return `${date} · ${time}`;
613| }
614|
615| // Extract boolean flags
616| const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] }));
617|
618| // Toggle state
619| let isOpen = true;
620|
621| // Build header with chevron
622| const chevron = el("span", { class: "fm-chevron open" });
623| chevron.innerHTML = '<i data-lucide="chevron-down" style="width:14px;height:14px"></i>';
624|
625| const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]);
626|
627| // ZONE 1: Top strip
628| const topBadges = [];
629|
630| // Title badge
631| const title = frontmatter.titre || frontmatter.title || "";
632| if (title) {
633| topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)]));
634| }
635|
636| // Status badge
637| if (frontmatter.statut) {
638| const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]);
639| topBadges.push(statusBadge);
640| }
641|
642| // Category badge
643| if (frontmatter.catégorie || frontmatter.categorie) {
644| const cat = frontmatter.catégorie || frontmatter.categorie;
645| const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]);
646| topBadges.push(catBadge);
647| }
648|
649| // Publish badge
650| if (frontmatter.publish) {
651| topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")]));
652| }
653|
654| // Favoris badge
655| if (frontmatter.favoris) {
656| topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")]));
657| }
658|
659| const acTop = el("div", { class: "ac-top" }, topBadges);
660|
661| // ZONE 2: Body 2 columns
662| const leftCol = el("div", { class: "ac-col" }, [
663| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]),
664| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("catégorie")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.catégorie || frontmatter.categorie || "—")])]),
665| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]),
666| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("aliases")]), el("span", { class: "ac-v muted" }, [document.createTextNode(frontmatter.aliases && frontmatter.aliases.length > 0 ? frontmatter.aliases.join(", ") : "[]")])]),
667| ]);
668|
669| const rightCol = el("div", { class: "ac-col" }, [
670| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("creation_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.creation_date))])]),
671| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("modification_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.modification_date))])]),
672| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]),
673| el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]),
674| ]);
675|
676| const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]);
677|
678| // ZONE 3: Tags row
679| const tagPills = [];
680| if (frontmatter.tags && frontmatter.tags.length > 0) {
681| frontmatter.tags.forEach((tag) => {
682| tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)]));
683| });
684| }
685|
686| const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]);
687|
688| // ZONE 4: Flags row
689| const flagChips = [];
690| booleanFlags.forEach((flag) => {
691| const chipClass = flag.value ? "flag-chip on" : "flag-chip off";
692| flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)]));
693| });
694|
695| const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]);
696|
697| // Assemble the card
698| const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]);
699|
700| // Toggle functionality
701| fmHeader.addEventListener("click", () => {
702| isOpen = !isOpen;
703| if (isOpen) {
704| acCard.style.display = "block";
705| chevron.classList.remove("closed");
706| chevron.classList.add("open");
707| } else {
708| acCard.style.display = "none";
709| chevron.classList.remove("open");
710| chevron.classList.add("closed");
711| }
712| safeCreateIcons();
713| });
714|
715| // Wrap in section
716| const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]);
717|
718| return fmSection;
719|}
720|
721|
722|// ---------------------------------------------------------------------------
723|// Helpers
724|// ---------------------------------------------------------------------------
725|// escapeHtml imported from utils.js above
726|
727|function el(tag, attrs, children) {
728| const e = document.createElement(tag);
729| if (attrs) {
730| Object.entries(attrs).forEach(([k, v]) => {
731| // Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug
732| if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) {
733| return;
734| }
735| e.setAttribute(k, v);
736| });
737| }
738| if (children) {
739| children.forEach((c) => {
740| if (c) e.appendChild(c);
741| });
742| }
743| return e;
744|}
745|
746|function icon(name, size) {
747| const i = document.createElement("i");
748| i.setAttribute("data-lucide", name);
749| i.style.width = size + "px";
750| i.style.height = size + "px";
751| i.classList.add("icon");
752| return i;
753|}
754|
755|function smallBadge(count) {
756| const s = document.createElement("span");
757| s.className = "badge-small";
758| s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px";
759| s.textContent = `(${count})`;
760| return s;
761|}
762|
763|function getContextMenuPositionFromElement(target) {
764| const rect = target.getBoundingClientRect();
765| return {
766| x: Math.min(rect.right - 8, window.innerWidth - 16),
767| y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16),
768| };
769|}
770|
771|function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) {
772| const button = document.createElement("button");
773| button.type = "button";
774| button.className = "tree-item-action-btn";
775| button.setAttribute("aria-label", "Afficher le menu dactions");
776| button.setAttribute("title", "Actions");
777| const iconEl = icon("more-vertical", 16);
778| button.appendChild(iconEl);
779| button.addEventListener("click", (e) => {
780| e.preventDefault();
781| e.stopPropagation();
782| const pos = getContextMenuPositionFromElement(button);
783| ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly);
784| });
785| itemEl.appendChild(button);
786| // Ensure Lucide icons are rendered for the button
787| setTimeout(() => {
788| safeCreateIcons();
789| }, 0);
790|}
791|
792|function attachTreeItemLongPress(itemEl, getMenuData) {
793| let pressTimer = null;
794| let pressHandled = false;
795| let startX = 0;
796| let startY = 0;
797| const longPressDelay = 550;
798| const moveThreshold = 10;
799|
800| const clearPressTimer = () => {
801| if (pressTimer) {
802| clearTimeout(pressTimer);
803| pressTimer = null;
804| }
805| };
806|
807| itemEl.addEventListener("touchstart", (e) => {
808| if (!e.touches || e.touches.length !== 1) return;
809| pressHandled = false;
810| startX = e.touches[0].clientX;
811| startY = e.touches[0].clientY;
812| clearPressTimer();
813| pressTimer = setTimeout(() => {
814| const data = getMenuData();
815| if (!data) return;
816| pressHandled = true;
817| ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly);
818| }, longPressDelay);
819| }, { passive: true });
820|
821| itemEl.addEventListener("touchmove", (e) => {
822| if (!e.touches || e.touches.length !== 1) return;
823| const dx = Math.abs(e.touches[0].clientX - startX);
824| const dy = Math.abs(e.touches[0].clientY - startY);
825| if (dx > moveThreshold || dy > moveThreshold) {
826| clearPressTimer();
827| }
828| }, { passive: true });
829|
830| itemEl.addEventListener("touchend", () => {
831| clearPressTimer();
832| }, { passive: true });
833|
834| itemEl.addEventListener("touchcancel", () => {
835| clearPressTimer();
836| }, { passive: true });
837|
838| itemEl.addEventListener("click", (e) => {
839| if (pressHandled) {
840| e.preventDefault();
841| e.stopPropagation();
842| setTimeout(() => {
843| pressHandled = false;
844| }, 0);
845| }
846| }, true);
847|}
848|
849|function getVaultIcon(vaultName, size = 16) {
850| const v = state.allVaults.find((val) => val.name === vaultName);
851| const type = v ? v.type : "VAULT";
852|
853| if (type === "DIR") {
854| const i = icon("folder", size);
855| i.style.color = "#eab308"; // yellow tint
856| return i;
857| } else {
858| const purple = "#8b5cf6";
859| const svgNS = "http://www.w3.org/2000/svg";
860| const svg = document.createElementNS(svgNS, "svg");
861| svg.setAttribute("xmlns", svgNS);
862| svg.setAttribute("width", size);
863| svg.setAttribute("height", size);
864| svg.setAttribute("viewBox", "0 0 24 24");
865| svg.setAttribute("fill", "none");
866| svg.setAttribute("stroke", purple);
867| svg.setAttribute("stroke-width", "2");
868| svg.setAttribute("stroke-linecap", "round");
869| svg.setAttribute("stroke-linejoin", "round");
870| svg.classList.add("icon");
871|
872| const path1 = document.createElementNS(svgNS, "path");
873| path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z");
874| const path2 = document.createElementNS(svgNS, "path");
875| path2.setAttribute("d", "M11 3 8 9l4 12");
876| const path3 = document.createElementNS(svgNS, "path");
877| path3.setAttribute("d", "M12 21l4-12-3-6");
878| const path4 = document.createElementNS(svgNS, "path");
879| path4.setAttribute("d", "M2 9h20");
880|
881| svg.appendChild(path1);
882| svg.appendChild(path2);
883| svg.appendChild(path3);
884| svg.appendChild(path4);
885| return svg;
886| }
887|}
888|
889|function makeBreadcrumbSpan(text, onClick) {
890| const s = document.createElement("span");
891| s.textContent = text;
892| if (onClick) {
893| s.addEventListener("click", async (event) => {
894| event.preventDefault();
895| if (s.dataset.busy === "true") return;
896| s.dataset.busy = "true";
897| s.style.pointerEvents = "none";
898| try {
899| await onClick(event);
900| } finally {
901| s.dataset.busy = "false";
902| s.style.pointerEvents = "";
903| }
904| });
905| }
906| return s;
907|}
908|
909|function appendHighlightedText(container, text, query, caseSensitive) {
910| container.textContent = "";
911| if (!query) {
912| container.appendChild(document.createTextNode(text));
913| return;
914| }
915|
916| const source = caseSensitive ? text : text.toLowerCase();
917| const needle = caseSensitive ? query : query.toLowerCase();
918| let start = 0;
919| let index = source.indexOf(needle, start);
920|
921| if (index === -1) {
922| container.appendChild(document.createTextNode(text));
923| return;
924| }
925|
926| while (index !== -1) {
927| if (index > start) {
928| container.appendChild(document.createTextNode(text.slice(start, index)));
929| }
930| const mark = el("mark", { class: "filter-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
931| container.appendChild(mark);
932| start = index + query.length;
933| index = source.indexOf(needle, start);
934| }
935|
936| if (start < text.length) {
937| container.appendChild(document.createTextNode(text.slice(start)));
938| }
939|}
940|
941|function highlightSearchText(container, text, query, caseSensitive) {
942| container.textContent = "";
943| if (!query || !text) {
944| container.appendChild(document.createTextNode(text || ""));
945| return;
946| }
947|
948| const source = caseSensitive ? text : text.toLowerCase();
949| const needle = caseSensitive ? query : query.toLowerCase();
950| let start = 0;
951| let index = source.indexOf(needle, start);
952|
953| if (index === -1) {
954| container.appendChild(document.createTextNode(text));
955| return;
956| }
957|
958| while (index !== -1) {
959| if (index > start) {
960| container.appendChild(document.createTextNode(text.slice(start, index)));
961| }
962| const mark = el("mark", { class: "search-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
963| container.appendChild(mark);
964| start = index + query.length;
965| index = source.indexOf(needle, start);
966| }
967|
968| if (start < text.length) {
969| container.appendChild(document.createTextNode(text.slice(start)));
970| }
971|}
972|
973|function showWelcome() {
974| hideProgressBar();
975|
976| // Restore or rebuild the dashboard with tabbed sections
977| const area = document.getElementById("content-area");
978| const home = document.getElementById("dashboard-home");
979|
980| if (area && !home) {
981| area.innerHTML = `
982| <div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
983| <!-- Dashboard Tabs -->
984| <div class="dashboard-tabs">
985| <button class="dashboard-tab active" data-tab="stats">
986| <i data-lucide="bar-chart-3" style="width:14px;height:14px"></i> Statistiques
987| </button>
988| <button class="dashboard-tab" data-tab="bookmarks">
989| <i data-lucide="bookmark" style="width:14px;height:14px"></i> Bookmarks
990| </button>
991| <button class="dashboard-tab" data-tab="recent">
992| <i data-lucide="clock" style="width:14px;height:14px"></i> Récents
993| </button>
994| <button class="dashboard-tab" data-tab="shared">
995| <i data-lucide="share-2" style="width:14px;height:14px"></i> Partagés
996| </button>
997| </div>
998|
999| <!-- Stats Panel -->
1000| <div id="dashboard-panel-stats" class="dashboard-panel active">
1001| <div id="dashboard-stats-grid" class="dashboard-stats-grid">
1002| <div class="dashboard-stats-loading">Chargement...</div>
1003| </div>
1004| <div id="dashboard-conflicts-container" style="margin-top:16px"></div>
1005| </div>
1006|
1007| <!-- Bookmarks Panel -->
1008| <div id="dashboard-panel-bookmarks" class="dashboard-panel">
1009| <div id="dashboard-bookmarks-grid" class="dashboard-recent-grid"></div>
1010| <div id="dashboard-bookmarks-empty" class="dashboard-recent-empty">
1011| <i data-lucide="pin"></i>
1012| <span>Aucun bookmark</span>
1013| <p>Épinglez des fichiers pour les retrouver ici.</p>
1014| </div>
1015| </div>
1016|
1017| <!-- Recent Panel -->
1018| <div id="dashboard-panel-recent" class="dashboard-panel">
1019| <div class="dashboard-header">
1020| <div class="dashboard-title-row">
1021| <span id="dashboard-count" class="dashboard-badge"></span>
1022| </div>
1023| </div>
1024| <div id="dashboard-recent-grid" class="dashboard-recent-grid"></div>
1025| <div id="dashboard-loading" class="dashboard-loading">
1026| <div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
1027| <div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
1028| </div>
1029| <div id="dashboard-recent-empty" class="dashboard-recent-empty hidden">
1030| <i data-lucide="inbox"></i>
1031| <span>Aucun fichier récent</span>
1032| <p>Ouvrez un fichier pour le voir apparaître ici</p>
1033| </div>
1034| </div>
1035|
1036| <!-- Shared Panel -->
1037| <div id="dashboard-panel-shared" class="dashboard-panel">
1038| <div id="dashboard-shared-grid" class="dashboard-recent-grid"></div>
1039| <div id="dashboard-shared-empty" class="dashboard-recent-empty">
1040| <i data-lucide="share-2"></i>
1041| <span>Aucun document partagé</span>
1042| <p>Partagez un document pour le voir apparaître ici</p>
1043| </div>
1044| </div>
1045| </div>`;
1046|
1047| // Re-initialize widgets and dashboard tabs
1048| if (typeof DashboardRecentWidget !== "undefined") {
1049| DashboardRecentWidget.init();
1050| }
1051| initDashboardTabs();
1052| safeCreateIcons();
1053| } else if (home) {
1054| // Dashboard already exists, show it with default tab
1055| home.style.display = "";
1056| // Reset tabs to default
1057| document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
1058| document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
1059| const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]');
1060| const defaultPanel = document.getElementById("dashboard-panel-stats");
1061| if (defaultTab) defaultTab.classList.add("active");
1062| if (defaultPanel) defaultPanel.classList.add("active");
1063| }
1064|
1065| // Load all widgets (they handle missing elements gracefully)
1066| if (typeof DashboardStatsWidget !== "undefined") {
1067| DashboardStatsWidget.load();
1068| }
1069| if (typeof DashboardConflictsWidget !== "undefined") {
1070| DashboardConflictsWidget.load();
1071| }
1072| if (typeof DashboardRecentWidget !== "undefined") {
1073| DashboardRecentWidget.load(state.selectedContextVault);
1074| }
1075| if (typeof DashboardBookmarkWidget !== "undefined") {
1076| DashboardBookmarkWidget.load(state.selectedContextVault);
1077| }
1078| if (typeof DashboardSharedWidget !== "undefined") {
1079| DashboardSharedWidget.load();
1080| }
1081|
1082| // Load saved searches sidebar
1083| loadSavedSearches();
1084|}
1085|
1086|async function loadSavedSearches() {
1087| const list = document.getElementById("saved-searches-list");
1088| const empty = document.getElementById("saved-searches-empty");
1089| if (!list) return;
1090| try {
1091| const searches = await api("/api/saved-searches");
1092| if (!searches.length) {
1093| list.innerHTML = "";
1094| if (empty) empty.style.display = "";
1095| return;
1096| }
1097| if (empty) empty.style.display = "none";
1098| list.innerHTML = searches.map(s => {
1099| const badges = [];
1100| if (s.case_sensitive) badges.push('<span class="search-filter-badge">Aa</span>');
1101| if (s.whole_word) badges.push('<span class="search-filter-badge">wd</span>');
1102| if (s.regex) badges.push('<span class="search-filter-badge">.*</span>');
1103| const pathFilters = [];
1104| if (s.include_paths) pathFilters.push(`<span class="saved-search-path" title="Inclure: ${escapeHtml(s.include_paths)}">📥 ${escapeHtml(s.include_paths)}</span>`);
1105| if (s.exclude_paths) pathFilters.push(`<span class="saved-search-path" title="Exclure: ${escapeHtml(s.exclude_paths)}">📤 ${escapeHtml(s.exclude_paths)}</span>`);
1106| const vaultStr = s.vault && s.vault !== "all" ? `<span class="saved-search-vault">📁 ${escapeHtml(s.vault)}</span>` : "";
1107| return `
1108| <div class="saved-search-item">
1109| <div class="saved-search-query">${escapeHtml(s.query)}</div>
1110| <div class="saved-search-meta">
1111| ${badges.join("")}
1112| ${vaultStr}
1113| </div>
1114| ${pathFilters.length ? '<div class="saved-search-filters">' + pathFilters.join(" ") + '</div>' : ""}
1115| <button class="saved-search-delete" data-id="${s.id}" title="Supprimer">✕</button>
1116| </div>
1117| `}).join("");
1118| list.querySelectorAll(".saved-search-item").forEach(item => {
1119| item.addEventListener("click", (e) => {
1120| if (e.target.classList.contains("saved-search-delete")) return;
1121| const idx = Array.from(list.children).indexOf(item);
1122| const s = searches[idx];
1123| if (!s) return;
1124| // Apply the saved search
1125| const input = document.getElementById("search-input");
1126| if (input) input.value = s.query;
1127| state.searchCaseSensitive = s.case_sensitive || false;
1128| state.searchWholeWord = s.whole_word || false;
1129| state.searchRegex = s.regex || false;
1130| if (typeof _updateToggleUI === "function") _updateToggleUI();
1131| if (s.include_paths) {
1132| const incl = document.getElementById("search-include-input");
1133| if (incl) incl.value = s.include_paths;
1134| }
1135| if (s.exclude_paths) {
1136| const excl = document.getElementById("search-exclude-input");
1137| if (excl) excl.value = s.exclude_paths;
1138| }
1139| // Execute the search — suppress dropdown from appearing
1140| AutocompleteDropdown.hide();
1141| AutocompleteDropdown._suppressNext = true;
1142| const vault = s.vault || "all";
1143| if (input) { input.dispatchEvent(new Event("input")); }
1144| clearTimeout(state.searchTimeout);
1145| state.advancedSearchOffset = 0;
1146| performAdvancedSearch(s.query, vault, null);
1147| });
1148| });
1149| list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => {
1150| e.stopPropagation();
1151| await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" });
1152| loadSavedSearches();
1153| }));
1154| safeCreateIcons();
1155| } catch (err) { /* silently ignore */ }
1156|}
1157|
1158|function showLoading() {
1159| const area = document.getElementById("content-area");
1160| area.innerHTML = `
1161| <div class="loading-indicator">
1162| <div class="loading-spinner"></div>
1163| <div>Recherche en cours...</div>
1164| </div>`;
1165| showProgressBar();
1166|}
1167|
1168|function showProgressBar() {
1169| const bar = document.getElementById("search-progress-bar");
1170| if (bar) bar.classList.add("active");
1171|}
1172|
1173|function hideProgressBar() {
1174| const bar = document.getElementById("search-progress-bar");
1175| if (bar) bar.classList.remove("active");
1176|}
1177|
1178|function goHome() {
1179| const searchInput = document.getElementById("search-input");
1180| if (searchInput) searchInput.value = "";
1181|
1182| document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
1183|
1184| state.currentVault = null;
1185| state.currentPath = null;
1186| state.showingSource = false;
1187| state.cachedRawSource = null;
1188|
1189| closeMobileSidebar();
1190| showWelcome();
1191|}
1192|
1193|
1194|// initEditor wires up the editor modal — editor functions (openEditor, closeEditor, saveFile, deleteFile) are in utils.js
1195|function initEditor() {
1196| const cancelBtn = document.getElementById("editor-cancel");
1197| const deleteBtn = document.getElementById("editor-delete");
1198| const saveBtn = document.getElementById("editor-save");
1199| const modal = document.getElementById("editor-modal");
1200|
1201| cancelBtn.addEventListener("click", closeEditor);
1202| deleteBtn.addEventListener("click", deleteFile);
1203| saveBtn.addEventListener("click", saveFile);
1204|
1205| // Close on overlay click
1206| modal.addEventListener("click", (e) => {
1207| if (e.target === modal) {
1208| closeEditor();
1209| }
1210| });
1211|
1212| // ESC to close
1213| document.addEventListener("keydown", (e) => {
1214| if (e.key === "Escape" && modal.classList.contains("active")) {
1215| closeEditor();
1216| }
1217| });
1218|
1219| // Fix mouse wheel scrolling in editor
1220| modal.addEventListener(
1221| "wheel",
1222| (e) => {
1223| const editorBody = document.getElementById("editor-body");
1224| if (editorBody && editorBody.contains(e.target)) {
1225| // Let the editor handle the scroll
1226| return;
1227| }
1228| // Prevent modal from scrolling if not in editor area
1229| e.preventDefault();
1230| },
1231| { passive: false },
1232| );
1233|}
1234|
1235|
1236|// ---------------------------------------------------------------------------
1237|// SSE Client — IndexUpdateManager
1238|// ---------------------------------------------------------------------------
1239|const IndexUpdateManager = (() => {
1240| let eventSource = null;
1241| let reconnectTimer = null;
1242| let reconnectDelay = 1000;
1243| const MAX_RECONNECT_DELAY = 30000;
1244| let recentEvents = [];
1245| const MAX_RECENT_EVENTS = 20;
1246| let connectionState = "disconnected"; // disconnected | connecting | connected
1247|
1248| function connect() {
1249| if (eventSource) {
1250| eventSource.close();
1251| }
1252| connectionState = "connecting";
1253| _updateBadge();
1254|
1255| eventSource = new EventSource("/api/events");
1256|
1257| eventSource.addEventListener("connected", (e) => {
1258| connectionState = "connected";
1259| reconnectDelay = 1000;
1260| _updateBadge();
1261| });
1262|
1263| eventSource.addEventListener("index_updated", (e) => {
1264| try {
1265| const data = JSON.parse(e.data);
1266| _addEvent("index_updated", data);
1267| _onIndexUpdated(data);
1268| } catch (err) {
1269| console.error("SSE parse error:", err);
1270| }
1271| });
1272|
1273| eventSource.addEventListener("index_reloaded", (e) => {
1274| try {
1275| const data = JSON.parse(e.data);
1276| _addEvent("index_reloaded", data);
1277| _onIndexReloaded(data);
1278| } catch (err) {
1279| console.error("SSE parse error:", err);
1280| }
1281| });
1282|
1283| eventSource.addEventListener("vault_added", (e) => {
1284| try {
1285| const data = JSON.parse(e.data);
1286| _addEvent("vault_added", data);
1287| showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info");
1288| loadVaults();
1289| loadTags();
1290| } catch (err) {
1291| console.error("SSE parse error:", err);
1292| }
1293| });
1294|
1295| eventSource.addEventListener("vault_removed", (e) => {
1296| try {
1297| const data = JSON.parse(e.data);
1298| _addEvent("vault_removed", data);
1299| showToast(`Vault "${data.vault}" supprimé`, "info");
1300| loadVaults();
1301| loadTags();
1302| } catch (err) {
1303| console.error("SSE parse error:", err);
1304| }
1305| });
1306|
1307| eventSource.addEventListener("index_start", (e) => {
1308| try {
1309| const data = JSON.parse(e.data);
1310| _addEvent("index_start", data);
1311| connectionState = "syncing";
1312| _updateBadge();
1313| showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info");
1314| } catch (err) {
1315| console.error("SSE parse error:", err);
1316| }
1317| });
1318|
1319| eventSource.addEventListener("index_progress", (e) => {
1320| try {
1321| const data = JSON.parse(e.data);
1322| _addEvent("index_progress", data);
1323| connectionState = "syncing";
1324| _updateBadge();
1325| loadVaults();
1326| loadTags();
1327| } catch (err) {
1328| console.error("SSE parse error:", err);
1329| }
1330| });
1331|
1332| eventSource.addEventListener("index_complete", (e) => {
1333| try {
1334| const data = JSON.parse(e.data);
1335| _addEvent("index_complete", data);
1336| connectionState = "connected";
1337| _updateBadge();
1338| showToast(`Indexation terminée (${data.total_files} fichiers)`, "success");
1339| loadVaults();
1340| loadTags();
1341| } catch (err) {
1342| console.error("SSE parse error:", err);
1343| }
1344| });
1345|
1346| eventSource.onerror = () => {
1347| connectionState = "disconnected";
1348| _updateBadge();
1349| eventSource.close();
1350| eventSource = null;
1351| _scheduleReconnect();
1352| };
1353| }
1354|
1355| function _scheduleReconnect() {
1356| if (reconnectTimer) clearTimeout(reconnectTimer);
1357| reconnectTimer = setTimeout(() => {
1358| reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
1359| connect();
1360| }, reconnectDelay);
1361| }
1362|
1363| function _addEvent(type, data) {
1364| recentEvents.unshift({
1365| type,
1366| data,
1367| timestamp: new Date().toISOString(),
1368| });
1369| if (recentEvents.length > MAX_RECENT_EVENTS) {
1370| recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
1371| }
1372| }
1373|
1374| async function _onIndexUpdated(data) {
1375| // Brief syncing state
1376| connectionState = "syncing";
1377| _updateBadge();
1378|
1379| const n = data.total_changes || 0;
1380| const vaults = (data.vaults || []).join(", ");
1381| // Toast removed: silent auto-indexing — no notification needed
1382|
1383| // Refresh sidebar and tags if affected vault matches current context
1384| const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault);
1385| if (affectsCurrentVault) {
1386| try {
1387| await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
1388| // Refresh current file if it was updated
1389| if (currentVault && state.currentPath) {
1390| const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === state.currentPath);
1391| if (changed) {
1392| openFile(state.currentVault, state.currentPath);
1393| }
1394| }
1395| } catch (err) {
1396| console.error("Error refreshing after index update:", err);
1397| }
1398| }
1399|
1400| // Refresh recent tab if it is active
1401| if (state.activeSidebarTab === "recent") {
1402| const vaultFilter = document.getElementById("recent-vault-filter");
1403| loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
1404| }
1405|
1406| setTimeout(() => {
1407| connectionState = "connected";
1408| _updateBadge();
1409| }, 1500);
1410| }
1411|
1412| async function _onIndexReloaded(data) {
1413| connectionState = "syncing";
1414| _updateBadge();
1415| showToast("Index complet rechargé", "info");
1416| try {
1417| await Promise.all([loadVaults(), loadTags()]);
1418| } catch (err) {
1419| console.error("Error refreshing after full reload:", err);
1420| }
1421| setTimeout(() => {
1422| connectionState = "connected";
1423| _updateBadge();
1424| }, 1500);
1425| }
1426|
1427| function _updateBadge() {
1428| const badge = document.getElementById("sync-badge");
1429| if (!badge) return;
1430| badge.className = "sync-badge sync-badge--" + connectionState;
1431| const labels = {
1432| disconnected: "Déconnecté",
1433| connecting: "Connexion...",
1434| connected: "Synchronisé",
1435| syncing: "Mise à jour...",
1436| };
1437| badge.title = labels[connectionState] || connectionState;
1438| }
1439|
1440| function disconnect() {
1441| if (eventSource) {
1442| eventSource.close();
1443| eventSource = null;
1444| }
1445| if (reconnectTimer) {
1446| clearTimeout(reconnectTimer);
1447| reconnectTimer = null;
1448| }
1449| connectionState = "disconnected";
1450| _updateBadge();
1451| }
1452|
1453| function getState() {
1454| return connectionState;
1455| }
1456|
1457| function getRecentEvents() {
1458| return recentEvents;
1459| }
1460|
1461| return { connect, disconnect, getState, getRecentEvents };
1462|})();
1463|
1464|function initSyncStatus() {
1465| const badge = document.getElementById("sync-badge");
1466| if (!badge) return;
1467|
1468| badge.addEventListener("click", (e) => {
1469| e.stopPropagation();
1470| toggleSyncPanel();
1471| });
1472|
1473| IndexUpdateManager.connect();
1474|}
1475|
1476|function toggleSyncPanel() {
1477| let panel = document.getElementById("sync-panel");
1478| if (panel) {
1479| panel.remove();
1480| return;
1481| }
1482| // Auto reconnect if disconnected when user opens the panel
1483| if (IndexUpdateManager.getState() === "disconnected") {
1484| IndexUpdateManager.connect();
1485| }
1486| panel = document.createElement("div");
1487| panel.id = "sync-panel";
1488| panel.className = "sync-panel";
1489| _renderSyncPanel(panel);
1490| document.body.appendChild(panel);
1491|
1492| // Close on outside click
1493| setTimeout(() => {
1494| document.addEventListener("click", _closeSyncPanelOutside, { once: true });
1495| }, 0);
1496|}
1497|
1498|function _closeSyncPanelOutside(e) {
1499| const panel = document.getElementById("sync-panel");
1500| if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
1501| panel.remove();
1502| }
1503|}
1504|
1505|function _renderSyncPanel(panel) {
1506| const state = IndexUpdateManager.getState();
1507| const events = IndexUpdateManager.getRecentEvents();
1508|
1509| const stateLabels = {
1510| disconnected: "Déconnecté",
1511| connecting: "Connexion...",
1512| connected: "Connecté",
1513| syncing: "Synchronisation...",
1514| };
1515|
1516| let html = `<div class="sync-panel__header">
1517| <span class="sync-panel__title">Synchronisation</span>
1518| <span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
1519| </div>`;
1520|
1521| if (events.length === 0) {
1522| html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
1523| } else {
1524| html += `<div class="sync-panel__events">`;
1525| events.slice(0, 10).forEach((ev) => {
1526| const time = new Date(ev.timestamp).toLocaleTimeString();
1527| const typeLabels = {
1528| index_updated: "Mise à jour",
1529| index_reloaded: "Rechargement",
1530| vault_added: "Vault ajouté",
1531| vault_removed: "Vault supprimé",
1532| index_start: "Démarrage index.",
1533| index_progress: "Vault indexé",
1534| index_complete: "Indexation tech.",
1535| };
1536| const label = typeLabels[ev.type] || ev.type;
1537| let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
1538| if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
1539| if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`;
1540| if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
1541| html += `<div class="sync-panel__event">
1542| <span class="sync-panel__event-type">${label}</span>
1543| <span class="sync-panel__event-detail">${detail}</span>
1544| <span class="sync-panel__event-time">${time}</span>
1545| </div>`;
1546| });
1547| html += `</div>`;
1548| }
1549|
1550| panel.innerHTML = html;
1551|}
1552|
1553|export { OutlineManager, ScrollSpyManager, ReadingProgressManager, openFile, buildFrontmatterCard, initEditor };
1554|
1555|