/* ObsiGate — Viewer module */ import { api } from './auth.js'; import { state } from './state.js'; import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon } from './utils.js'; import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js'; import { syncActiveFileTreeItem, searchByTag, TagFilterService } from './sidebar.js'; import { AutocompleteDropdown, performAdvancedSearch } from './search.js'; import { initDashboardTabs } from './sync.js'; import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js'; // --------------------------------------------------------------------------- // Outline/TOC Manager // --------------------------------------------------------------------------- const OutlineManager = { /** * Slugify text to create valid IDs */ slugify(text) { return ( text .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^\p{L}\p{N}\s-]/gu, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .trim() || "heading" ); }, /** * Parse headings from markdown content */ parseHeadings() { const contentArea = document.querySelector(".md-content"); if (!contentArea) return []; const headings = []; const h2s = contentArea.querySelectorAll("h2"); const h3s = contentArea.querySelectorAll("h3"); const allHeadings = [...h2s, ...h3s].sort((a, b) => { return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; }); const usedIds = new Map(); allHeadings.forEach((heading) => { const text = heading.textContent.trim(); if (!text) return; const level = parseInt(heading.tagName[1]); let id = this.slugify(text); // Handle duplicate IDs if (usedIds.has(id)) { const count = usedIds.get(id) + 1; usedIds.set(id, count); id = `${id}-${count}`; } else { usedIds.set(id, 1); } // Inject ID into heading if not present if (!heading.id) { heading.id = id; } else { id = heading.id; } headings.push({ id, level, text, element: heading, }); }); return headings; }, /** * Render outline list */ renderOutline(headings) { const outlineList = document.getElementById("outline-list"); const outlineEmpty = document.getElementById("outline-empty"); if (!outlineList) return; outlineList.innerHTML = ""; if (!headings || headings.length === 0) { outlineList.hidden = true; if (outlineEmpty) { outlineEmpty.hidden = false; safeCreateIcons(); } return; } outlineList.hidden = false; if (outlineEmpty) outlineEmpty.hidden = true; headings.forEach((heading) => { const item = el( "a", { class: `outline-item level-${heading.level}`, href: `#${heading.id}`, "data-heading-id": heading.id, role: "link", }, [document.createTextNode(heading.text)], ); item.addEventListener("click", (e) => { e.preventDefault(); this.scrollToHeading(heading.id); }); outlineList.appendChild(item); }); state.headingsCache = headings; }, /** * Scroll to heading with smooth behavior */ scrollToHeading(headingId) { const heading = document.getElementById(headingId); if (!heading) return; const contentArea = document.getElementById("content-area"); if (!contentArea) return; // Calculate offset for fixed header (if any) const headerHeight = 80; const headingTop = heading.offsetTop; contentArea.scrollTo({ top: headingTop - headerHeight, behavior: "smooth", }); // Update active state immediately this.setActiveHeading(headingId); }, /** * Set active heading in outline */ setActiveHeading(headingId) { if (state.activeHeadingId === headingId) return; state.activeHeadingId = headingId; const items = document.querySelectorAll(".outline-item"); items.forEach((item) => { if (item.getAttribute("data-heading-id") === headingId) { item.classList.add("active"); item.setAttribute("aria-current", "location"); // Scroll outline item into view item.scrollIntoView({ block: "nearest", behavior: "smooth" }); } else { item.classList.remove("active"); item.removeAttribute("aria-current"); } }); }, /** * Initialize outline for current document */ init() { const headings = this.parseHeadings(); this.renderOutline(headings); ScrollSpyManager.init(headings); ReadingProgressManager.init(); }, /** * Cleanup */ destroy() { ScrollSpyManager.destroy(); ReadingProgressManager.destroy(); state.headingsCache = []; state.activeHeadingId = null; }, }; // --------------------------------------------------------------------------- // Scroll Spy Manager // --------------------------------------------------------------------------- const ScrollSpyManager = { observer: null, headings: [], init(headings) { this.destroy(); this.headings = headings; if (!headings || headings.length === 0) return; const contentArea = document.getElementById("content-area"); if (!contentArea) return; const options = { root: contentArea, rootMargin: "-20% 0px -70% 0px", threshold: [0, 0.3, 0.5, 1.0], }; this.observer = new IntersectionObserver((entries) => { // Find the most visible heading let mostVisible = null; let maxRatio = 0; entries.forEach((entry) => { if (entry.isIntersecting && entry.intersectionRatio > maxRatio) { maxRatio = entry.intersectionRatio; mostVisible = entry.target; } }); if (mostVisible && mostVisible.id) { OutlineManager.setActiveHeading(mostVisible.id); } }, options); // Observe all headings headings.forEach((heading) => { if (heading.element) { this.observer.observe(heading.element); } }); }, destroy() { if (this.observer) { this.observer.disconnect(); this.observer = null; } this.headings = []; }, }; // --------------------------------------------------------------------------- // Reading Progress Manager // --------------------------------------------------------------------------- const ReadingProgressManager = { scrollHandler: null, init() { this.destroy(); const contentArea = document.getElementById("content-area"); if (!contentArea) return; this.scrollHandler = this.throttle(() => { this.updateProgress(); }, 100); contentArea.addEventListener("scroll", this.scrollHandler); this.updateProgress(); }, updateProgress() { const contentArea = document.getElementById("content-area"); const progressFill = document.getElementById("reading-progress-fill"); const progressText = document.getElementById("reading-progress-text"); if (!contentArea || !progressFill || !progressText) return; const scrollTop = contentArea.scrollTop; const scrollHeight = contentArea.scrollHeight; const clientHeight = contentArea.clientHeight; const maxScroll = scrollHeight - clientHeight; const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0; progressFill.style.width = `${percentage}%`; progressText.textContent = `${percentage}%`; }, throttle(func, delay) { let lastCall = 0; return function (...args) { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; func.apply(this, args); } }; }, destroy() { const contentArea = document.getElementById("content-area"); if (contentArea && this.scrollHandler) { contentArea.removeEventListener("scroll", this.scrollHandler); } this.scrollHandler = null; // Reset progress const progressFill = document.getElementById("reading-progress-fill"); const progressText = document.getElementById("reading-progress-text"); if (progressFill) progressFill.style.width = "0%"; if (progressText) progressText.textContent = "0%"; }, }; // --------------------------------------------------------------------------- // File viewer // --------------------------------------------------------------------------- export async function openFile(vaultName, filePath) { state.currentVault = vaultName; state.currentPath = filePath; state.showingSource = false; state.cachedRawSource = null; // Highlight active syncActiveFileTreeItem(vaultName, filePath); // Show loading state while fetching const area = document.getElementById("content-area"); area.innerHTML = '
Chargement...
'; try { const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`; const data = await api(url); renderFile(data); } catch (err) { area.innerHTML = '

Impossible de charger le fichier.

'; } } async function renderBacklinksPanel(vault, path, container) { try { const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`); if (!data.backlinks || data.backlinks.length === 0) return; const panel = el("div", { class: "backlinks-panel" }); const header = el("div", { class: "backlinks-header" }, [ icon("link", 14), document.createTextNode(` ${data.total} lien(s) entrant(s)`), ]); panel.appendChild(header); const list = el("div", { class: "backlinks-list" }); data.backlinks.forEach((bl) => { const item = el("div", { class: "backlink-item" }); const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]); const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]); item.appendChild(icon(getFileIcon(bl.path), 12)); item.appendChild(vaultBadge); item.appendChild(titleEl); item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path)); item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); }); list.appendChild(item); }); panel.appendChild(list); container.appendChild(panel); } catch (err) { // Silently ignore — backlinks are optional console.debug("Backlinks fetch failed:", err); } } export function renderFile(data) { const area = document.getElementById("content-area"); // Handle unsupported (binary) files if (data.unsupported) { const sizeStr = data.size_bytes ? data.size_bytes < 1024 ? `${data.size_bytes} o` : data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko` : `${(data.size_bytes / 1048576).toFixed(1)} Mo` : ""; area.innerHTML = `
${escapeHtml(data.path.split("/").pop())}
Ce fichier est binaire et ne peut pas être affiché.
${sizeStr ? `
Taille : ${sizeStr}
` : ""}
`; lucide.createIcons(); document.getElementById("unsupported-download-btn").addEventListener("click", () => { const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; window.open(dlUrl, "_blank"); }); return; } // Breadcrumb const parts = data.path.split("/"); const breadcrumbEls = []; breadcrumbEls.push( makeBreadcrumbSpan(data.vault, () => { focusPathInSidebar(data.vault, "", { alignToTop: "center" }); }), ); let accumulated = ""; parts.forEach((part, i) => { breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")])); accumulated += (accumulated ? "/" : "") + part; const p = accumulated; if (i < parts.length - 1) { breadcrumbEls.push( makeBreadcrumbSpan(part, () => { focusPathInSidebar(data.vault, p, { alignToTop: "center" }); }), ); } else { breadcrumbEls.push( makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => { focusPathInSidebar(data.vault, data.path, { alignToTop: "center" }); }), ); } }); const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls); // Tags const tagsDiv = el("div", { class: "file-tags" }); (data.tags || []).forEach((tag) => { if (!TagFilterService.isTagFiltered(tag)) { const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); t.addEventListener("click", () => searchByTag(tag)); tagsDiv.appendChild(t); } }); // Action buttons const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]); copyBtn.addEventListener("click", async () => { try { // Fetch raw content if not already cached if (!state.cachedRawSource) { const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; const rawData = await api(rawUrl); state.cachedRawSource = rawData.raw; } await navigator.clipboard.writeText(state.cachedRawSource); copyBtn.lastChild.textContent = "Copié !"; setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500); } catch (err) { console.error("Copy error:", err); showToast("Erreur lors de la copie", "error"); } }); const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]); // MD download button const mdBtn = el("button", { class: "btn-action", title: "Télécharger en .md" }, [icon("file-text", 14), document.createTextNode(".md")]); mdBtn.addEventListener("click", () => { const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; const a = document.createElement("a"); a.href = dlUrl; a.download = data.path.split("/").pop(); document.body.appendChild(a); a.click(); document.body.removeChild(a); }); // PDF download button const pdfBtn = el("button", { class: "btn-action", title: "Télécharger en PDF" }, [icon("file", 14), document.createTextNode("PDF")]); pdfBtn.addEventListener("click", () => { const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`; window.open(pdfUrl, "_blank"); }); const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]); editBtn.addEventListener("click", () => { openEditor(data.vault, data.path); }); const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenêtre" }, [icon("external-link", 14), document.createTextNode("pop-out")]); openNewWindowBtn.addEventListener("click", () => { const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}`; 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"); }); const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]); tocBtn.addEventListener("click", () => { RightSidebarManager.toggle(); }); // Share button — check if already shared const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]); // Check if already shared and color the button (async () => { try { const shares = await api("/api/shares"); if (shares.some(s => s.vault === data.vault && s.path === data.path)) { shareBtn.classList.add("shared"); shareBtn.title = "Document partagé — cliquer pour gérer"; } } catch (e) { /* ignore */ } })(); shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path)); // Bookmark button — check if already bookmarked const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: "Ajouter/Retirer des bookmarks" }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]); // Check bookmark status and color the button (async () => { try { const bms = await api("/api/bookmarks"); if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) { bookmarkBtn.classList.add("active"); bookmarkBtn.title = "Retirer des bookmarks"; } } catch (e) { /* ignore */ } })(); bookmarkBtn.addEventListener("click", async () => { try { 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 }) }); bookmarkBtn.classList.toggle("active", res.bookmarked); bookmarkBtn.title = res.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks"; showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success"); if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(); } catch (err) { showToast("Erreur: " + err.message, "error"); } }); // Frontmatter — Accent Card let fmSection = null; if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { fmSection = buildFrontmatterCard(data.frontmatter); } // Content container (rendered HTML) const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" }); mdDiv.innerHTML = data.html; // Raw source container (hidden initially) const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" }); // Source button toggle logic sourceBtn.addEventListener("click", async () => { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (!rendered || !raw) return; state.showingSource = !state.showingSource; if (state.showingSource) { sourceBtn.classList.add("active"); if (!state.cachedRawSource) { const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; const rawData = await api(rawUrl); state.cachedRawSource = rawData.raw; } raw.textContent = state.cachedRawSource; rendered.style.display = "none"; raw.style.display = "block"; } else { sourceBtn.classList.remove("active"); rendered.style.display = "block"; raw.style.display = "none"; } }); // Assemble area.innerHTML = ""; area.appendChild(breadcrumb); 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])])); if (fmSection) area.appendChild(fmSection); area.appendChild(mdDiv); area.appendChild(rawDiv); // Backlinks panel if (data.is_markdown) { renderBacklinksPanel(data.vault, data.path, area); } // Highlight code blocks area.querySelectorAll("pre code").forEach((block) => { safeHighlight(block); }); // Wire up wikilinks area.querySelectorAll(".wikilink").forEach((link) => { link.addEventListener("click", (e) => { e.preventDefault(); const v = link.getAttribute("data-vault"); const p = link.getAttribute("data-path"); if (v && p) openFile(v, p); }); }); safeCreateIcons(); area.scrollTop = 0; // Initialize outline/TOC for this document OutlineManager.init(); } // --------------------------------------------------------------------------- // Helpers (escapeHtml imported from utils.js) // --------------------------------------------------------------------------- export function el(tag, attrs, children) { const e = document.createElement(tag); if (attrs) { Object.entries(attrs).forEach(([k, v]) => { // Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) { return; } e.setAttribute(k, v); }); } if (children) { children.forEach((c) => { if (c) e.appendChild(c); }); } return e; } export function icon(name, size) { const i = document.createElement("i"); i.setAttribute("data-lucide", name); i.style.width = size + "px"; i.style.height = size + "px"; i.classList.add("icon"); return i; } export function smallBadge(count) { const s = document.createElement("span"); s.className = "badge-small"; s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px"; s.textContent = `(${count})`; return s; } function getContextMenuPositionFromElement(target) { const rect = target.getBoundingClientRect(); return { x: Math.min(rect.right - 8, window.innerWidth - 16), y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16), }; } export function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) { const button = document.createElement("button"); button.type = "button"; button.className = "tree-item-action-btn"; button.setAttribute("aria-label", "Afficher le menu d’actions"); button.setAttribute("title", "Actions"); const iconEl = icon("more-vertical", 16); button.appendChild(iconEl); button.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); const pos = getContextMenuPositionFromElement(button); ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly); }); itemEl.appendChild(button); // Ensure Lucide icons are rendered for the button setTimeout(() => { safeCreateIcons(); }, 0); } export function attachTreeItemLongPress(itemEl, getMenuData) { let pressTimer = null; let pressHandled = false; let startX = 0; let startY = 0; const longPressDelay = 550; const moveThreshold = 10; const clearPressTimer = () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } }; itemEl.addEventListener("touchstart", (e) => { if (!e.touches || e.touches.length !== 1) return; pressHandled = false; startX = e.touches[0].clientX; startY = e.touches[0].clientY; clearPressTimer(); pressTimer = setTimeout(() => { const data = getMenuData(); if (!data) return; pressHandled = true; ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly); }, longPressDelay); }, { passive: true }); itemEl.addEventListener("touchmove", (e) => { if (!e.touches || e.touches.length !== 1) return; const dx = Math.abs(e.touches[0].clientX - startX); const dy = Math.abs(e.touches[0].clientY - startY); if (dx > moveThreshold || dy > moveThreshold) { clearPressTimer(); } }, { passive: true }); itemEl.addEventListener("touchend", () => { clearPressTimer(); }, { passive: true }); itemEl.addEventListener("touchcancel", () => { clearPressTimer(); }, { passive: true }); itemEl.addEventListener("click", (e) => { if (pressHandled) { e.preventDefault(); e.stopPropagation(); setTimeout(() => { pressHandled = false; }, 0); } }, true); } export function getVaultIcon(vaultName, size = 16) { const v = state.allVaults.find((val) => val.name === vaultName); const type = v ? v.type : "VAULT"; if (type === "DIR") { const i = icon("folder", size); i.style.color = "#eab308"; // yellow tint return i; } else { const purple = "#8b5cf6"; const svgNS = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(svgNS, "svg"); svg.setAttribute("xmlns", svgNS); svg.setAttribute("width", size); svg.setAttribute("height", size); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", purple); svg.setAttribute("stroke-width", "2"); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); svg.classList.add("icon"); const path1 = document.createElementNS(svgNS, "path"); path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z"); const path2 = document.createElementNS(svgNS, "path"); path2.setAttribute("d", "M11 3 8 9l4 12"); const path3 = document.createElementNS(svgNS, "path"); path3.setAttribute("d", "M12 21l4-12-3-6"); const path4 = document.createElementNS(svgNS, "path"); path4.setAttribute("d", "M2 9h20"); svg.appendChild(path1); svg.appendChild(path2); svg.appendChild(path3); svg.appendChild(path4); return svg; } } function makeBreadcrumbSpan(text, onClick) { const s = document.createElement("span"); s.textContent = text; if (onClick) { s.addEventListener("click", async (event) => { event.preventDefault(); if (s.dataset.busy === "true") return; s.dataset.busy = "true"; s.style.pointerEvents = "none"; try { await onClick(event); } finally { s.dataset.busy = "false"; s.style.pointerEvents = ""; } }); } return s; } export function appendHighlightedText(container, text, query, caseSensitive) { container.textContent = ""; if (!query) { container.appendChild(document.createTextNode(text)); return; } const source = caseSensitive ? text : text.toLowerCase(); const needle = caseSensitive ? query : query.toLowerCase(); let start = 0; let index = source.indexOf(needle, start); if (index === -1) { container.appendChild(document.createTextNode(text)); return; } while (index !== -1) { if (index > start) { container.appendChild(document.createTextNode(text.slice(start, index))); } const mark = el("mark", { class: "filter-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]); container.appendChild(mark); start = index + query.length; index = source.indexOf(needle, start); } if (start < text.length) { container.appendChild(document.createTextNode(text.slice(start))); } } export function highlightSearchText(container, text, query, caseSensitive) { container.textContent = ""; if (!query || !text) { container.appendChild(document.createTextNode(text || "")); return; } const source = caseSensitive ? text : text.toLowerCase(); const needle = caseSensitive ? query : query.toLowerCase(); let start = 0; let index = source.indexOf(needle, start); if (index === -1) { container.appendChild(document.createTextNode(text)); return; } while (index !== -1) { if (index > start) { container.appendChild(document.createTextNode(text.slice(start, index))); } const mark = el("mark", { class: "search-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]); container.appendChild(mark); start = index + query.length; index = source.indexOf(needle, start); } if (start < text.length) { container.appendChild(document.createTextNode(text.slice(start))); } } export function showWelcome() { hideProgressBar(); // Restore or rebuild the dashboard with tabbed sections const area = document.getElementById("content-area"); const home = document.getElementById("dashboard-home"); if (area && !home) { area.innerHTML = `
Chargement...
Aucun bookmark

Épinglez des fichiers pour les retrouver ici.

Aucun document partagé

Partagez un document pour le voir apparaître ici

`; // Re-initialize widgets and dashboard tabs if (typeof DashboardRecentWidget !== "undefined") { DashboardRecentWidget.init(); } initDashboardTabs(); safeCreateIcons(); } else if (home) { // Dashboard already exists, show it with default tab home.style.display = ""; // Reset tabs to default document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]'); const defaultPanel = document.getElementById("dashboard-panel-stats"); if (defaultTab) defaultTab.classList.add("active"); if (defaultPanel) defaultPanel.classList.add("active"); } // Load all widgets (they handle missing elements gracefully) if (typeof DashboardStatsWidget !== "undefined") { DashboardStatsWidget.load(); } if (typeof DashboardConflictsWidget !== "undefined") { DashboardConflictsWidget.load(); } if (typeof DashboardRecentWidget !== "undefined") { DashboardRecentWidget.load(state.selectedContextVault); } if (typeof DashboardBookmarkWidget !== "undefined") { DashboardBookmarkWidget.load(state.selectedContextVault); } if (typeof DashboardSharedWidget !== "undefined") { DashboardSharedWidget.load(); } // Load saved searches sidebar loadSavedSearches(); } async function loadSavedSearches() { const list = document.getElementById("saved-searches-list"); const empty = document.getElementById("saved-searches-empty"); if (!list) return; try { const searches = await api("/api/saved-searches"); if (!searches.length) { list.innerHTML = ""; if (empty) empty.style.display = ""; return; } if (empty) empty.style.display = "none"; list.innerHTML = searches.map(s => { const badges = []; if (s.case_sensitive) badges.push('Aa'); if (s.whole_word) badges.push('wd'); if (s.regex) badges.push('.*'); const pathFilters = []; if (s.include_paths) pathFilters.push(`📥 ${escapeHtml(s.include_paths)}`); if (s.exclude_paths) pathFilters.push(`📤 ${escapeHtml(s.exclude_paths)}`); const vaultStr = s.vault && s.vault !== "all" ? `📁 ${escapeHtml(s.vault)}` : ""; return `
${escapeHtml(s.query)}
${badges.join("")} ${vaultStr}
${pathFilters.length ? '
' + pathFilters.join(" ") + '
' : ""}
`}).join(""); list.querySelectorAll(".saved-search-item").forEach(item => { item.addEventListener("click", (e) => { if (e.target.classList.contains("saved-search-delete")) return; const idx = Array.from(list.children).indexOf(item); const s = searches[idx]; if (!s) return; // Apply the saved search const input = document.getElementById("search-input"); if (input) input.value = s.query; state.searchCaseSensitive = s.case_sensitive || false; state.searchWholeWord = s.whole_word || false; state.searchRegex = s.regex || false; if (typeof _updateToggleUI === "function") _updateToggleUI(); if (s.include_paths) { const incl = document.getElementById("search-include-input"); if (incl) incl.value = s.include_paths; } if (s.exclude_paths) { const excl = document.getElementById("search-exclude-input"); if (excl) excl.value = s.exclude_paths; } // Execute the search — suppress dropdown from appearing AutocompleteDropdown.hide(); AutocompleteDropdown._suppressNext = true; const vault = s.vault || "all"; if (input) { input.dispatchEvent(new Event("input")); } clearTimeout(state.searchTimeout); advancedSearchOffset = 0; performAdvancedSearch(s.query, vault, null); }); }); list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => { e.stopPropagation(); await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" }); loadSavedSearches(); })); safeCreateIcons(); } catch (err) { /* silently ignore */ } } export function showLoading() { const area = document.getElementById("content-area"); area.innerHTML = `
Recherche en cours...
`; showProgressBar(); } export function showProgressBar() { const bar = document.getElementById("search-progress-bar"); if (bar) bar.classList.add("active"); } export function hideProgressBar() { const bar = document.getElementById("search-progress-bar"); if (bar) bar.classList.remove("active"); } function goHome() { const searchInput = document.getElementById("search-input"); if (searchInput) searchInput.value = ""; document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); state.currentVault = null; state.currentPath = null; state.showingSource = false; state.cachedRawSource = null; closeMobileSidebar(); showWelcome(); } // --------------------------------------------------------------------------- // SSE Client — IndexUpdateManager // --------------------------------------------------------------------------- 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 (state.currentVault && state.currentPath) { const changed = (data.changes || []).some((c) => c.vault === state.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 }; })(); 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 = `
Synchronisation ${stateLabels[state] || state}
`; if (events.length === 0) { html += `
Aucun événement récent
`; } else { html += `
`; 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 += `
${label} ${detail} ${time}
`; }); html += `
`; } panel.innerHTML = html; }