// config.js — extracted from app.js (3872-4865) import { api, AuthManager, initMfaSettings } from './auth.js'; import { state } from './state.js'; import { el, icon, openFile, filterSavedSearches } from './viewer.js'; import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js'; import { escapeHtml, safeCreateIcons } from './utils.js'; import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js'; import { t, setLocale, getLocale } from './i18n.js'; import { getModelCapabilities, renderCapabilityBadges, refreshAIPickers } from './ai.js'; import { isTauriEnv, invoke, pickAndAddVault, pickAndAddDir, removeRoot } from './desktop.js'; let _recentTimestampTimer = null; let _recentFilesCache = []; let _recentRefreshTimer = null; let _recentQuery = ""; let _aiSessionsCache = []; let _aiQuery = ""; export async function loadRecentFiles(vaultFilter) { const listEl = document.getElementById("recent-list"); const emptyEl = document.getElementById("recent-empty"); if (!listEl) return; let url = "/api/recent?mode=modified"; if (vaultFilter) url += `&vault=${encodeURIComponent(vaultFilter)}`; try { const data = await api(url); _recentFilesCache = data.files || []; renderRecentList(_applyRecentFilter(_recentFilesCache)); } catch (err) { console.error("Failed to load recent files:", err); listEl.innerHTML = ""; if (emptyEl) { emptyEl.classList.remove("hidden"); } } } function renderRecentList(files) { const listEl = document.getElementById("recent-list"); const emptyEl = document.getElementById("recent-empty"); if (!listEl) return; listEl.innerHTML = ""; if (!files || files.length === 0) { if (_recentFilesCache.length && _recentQuery) { listEl.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("sidebar.no_results"))])); } if (emptyEl) { emptyEl.classList.remove("hidden"); safeCreateIcons(); } return; } if (emptyEl) emptyEl.classList.add("hidden"); files.forEach((f) => { const item = el("div", { class: "recent-item", "data-vault": f.vault, "data-path": f.path }); // Header row: time + vault badge const header = el("div", { class: "recent-item-header" }); const timeSpan = el("span", { class: "recent-time" }, [icon("clock", 11), document.createTextNode(f.mtime_human)]); const badge = el("span", { class: "recent-vault-badge" }, [document.createTextNode(f.vault)]); header.appendChild(timeSpan); header.appendChild(badge); item.appendChild(header); // Title const titleEl = el("div", { class: "recent-item-title" }, [document.createTextNode(f.title || f.path.split("/").pop())]); item.appendChild(titleEl); // Path breadcrumb const pathParts = f.path.split("/"); if (pathParts.length > 1) { const pathEl = el("div", { class: "recent-item-path" }, [document.createTextNode(pathParts.slice(0, -1).join(" / "))]); item.appendChild(pathEl); } // Preview if (f.preview) { const previewEl = el("div", { class: "recent-item-preview" }, [document.createTextNode(f.preview)]); item.appendChild(previewEl); } // Tags if (f.tags && f.tags.length > 0) { const tagsEl = el("div", { class: "recent-item-tags" }); f.tags.forEach((t) => { tagsEl.appendChild(el("span", { class: "tag-pill" }, [document.createTextNode(t)])); }); item.appendChild(tagsEl); } // Click handler item.addEventListener("click", () => { openFile(f.vault, f.path); closeMobileSidebar(); }); listEl.appendChild(item); }); safeCreateIcons(); } function _humanizeDelta(mtime) { const delta = Date.now() / 1000 - mtime; if (delta < 60) return t("config.just_now"); if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`; if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`; if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`; return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" }); } function _refreshRecentTimestamps() { if (state.activeSidebarTab !== "recent" || !_recentFilesCache.length) return; const items = document.querySelectorAll(".recent-item"); items.forEach((item, i) => { if (i < _recentFilesCache.length) { const timeSpan = item.querySelector(".recent-time"); if (timeSpan) { // keep the icon, update text const textNode = timeSpan.lastChild; if (textNode && textNode.nodeType === Node.TEXT_NODE) { textNode.textContent = _humanizeDelta(_recentFilesCache[i].mtime); } } } }); } export function _populateRecentVaultFilter() { const select = document.getElementById("recent-vault-filter"); if (!select) return; // keep first option "Tous les vaults" while (select.options.length > 1) select.remove(1); state.allVaults.forEach((v) => { const opt = document.createElement("option"); opt.value = v.name; opt.textContent = v.name; select.appendChild(opt); }); syncVaultSelectors(); } function initRecentTab() { const select = document.getElementById("recent-vault-filter"); if (select) { select.addEventListener("change", async () => { const val = select.value || "all"; await setSelectedVaultContext(val, { focusVault: val !== "all" }); }); } // Periodic timestamp refresh (every 60s) _recentTimestampTimer = setInterval(_refreshRecentTimestamps, 60000); } // --------------------------------------------------------------------------- // AI history sidebar tab (#96) // --------------------------------------------------------------------------- function _formatAIDate(ts) { if (!ts) return ""; try { return new Date(ts).toLocaleString(); } catch { return ""; } } /** Accent- and case-insensitive normalization for the sidebar filters (#98/#99). */ function _sidebarNorm(value) { return String(value || "") .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase(); } /** Apply the current sidebar query to the recent-files list (#99). */ function _applyRecentFilter(files) { const q = _sidebarNorm(_recentQuery); if (!q) return files; return (files || []).filter((f) => { const tags = Array.isArray(f.tags) ? f.tags.join(" ") : ""; return _sidebarNorm(f.title).includes(q) || _sidebarNorm(f.path).includes(q) || _sidebarNorm(f.vault).includes(q) || _sidebarNorm(f.preview).includes(q) || _sidebarNorm(tags).includes(q); }); } /** Filter the sidebar recent-files list by the sidebar filter input (#99). */ export function filterRecentFiles(query) { _recentQuery = (query || "").trim(); renderRecentList(_applyRecentFilter(_recentFilesCache)); } /** Apply the current sidebar query to a session list, resolving the mode label. */ function _applyAIFilter(sessions) { const q = _sidebarNorm(_aiQuery); if (!q) return sessions; return (sessions || []).filter((s) => { const modeLabel = t("bookslm.mode_" + (s.mode || "general")); return _sidebarNorm(s.title).includes(q) || _sidebarNorm(s.preview).includes(q) || _sidebarNorm(s.directory).includes(q) || _sidebarNorm(s.context).includes(q) || _sidebarNorm(modeLabel).includes(q); }); } /** Filter the sidebar AI history list by the sidebar filter input (#98). */ export function filterAIHistory(query) { _aiQuery = (query || "").trim(); renderAIHistoryList(_applyAIFilter(_aiSessionsCache)); } export async function loadAISessionList() { const listEl = document.getElementById("ai-history-list"); const emptyEl = document.getElementById("ai-history-empty"); if (!listEl) return; let sessions = []; try { const data = await api("/api/ai/bookslm/history"); sessions = data.sessions || []; } catch (err) { console.error("Failed to load AI history:", err); } _aiSessionsCache = sessions; renderAIHistoryList(_applyAIFilter(_aiSessionsCache)); } function renderAIHistoryList(sessions) { const listEl = document.getElementById("ai-history-list"); const emptyEl = document.getElementById("ai-history-empty"); if (!listEl) return; listEl.innerHTML = ""; if (!sessions || sessions.length === 0) { if (_aiSessionsCache.length && _aiQuery) { listEl.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("bookslm.history_no_match"))])); } if (emptyEl) { emptyEl.classList.remove("hidden"); safeCreateIcons(); } return; } if (emptyEl) emptyEl.classList.add("hidden"); sessions.forEach((s) => { const item = el("div", { class: "recent-item", "data-session-id": s.id }); const header = el("div", { class: "recent-item-header" }); const timeSpan = el("span", { class: "recent-time" }, [icon("clock", 11), document.createTextNode(_formatAIDate(s.updatedAt))]); header.appendChild(timeSpan); header.appendChild(el("span", { class: "recent-vault-badge" }, [document.createTextNode(t("bookslm.mode_" + (s.mode || "general"))) ])); item.appendChild(header); const titleEl = el("div", { class: "recent-item-title" }, [document.createTextNode(s.title || t("bookslm.untitled"))]); item.appendChild(titleEl); if (s.preview) { item.appendChild(el("div", { class: "recent-item-preview" }, [document.createTextNode(s.preview)])); } item.addEventListener("click", () => { closeMobileSidebar(); import("./bookslm.js").then((m) => m.default.openWithSession(s.id)).catch((e) => console.error(e)); }); listEl.appendChild(item); }); safeCreateIcons(); } // --------------------------------------------------------------------------- // Sidebar tabs // --------------------------------------------------------------------------- function initSidebarTabs() { document.querySelectorAll(".sidebar-tab").forEach((tab) => { tab.addEventListener("click", () => switchSidebarTab(tab.dataset.tab)); }); // #96 — Refresh the sidebar AI history list whenever a conversation changes. window.addEventListener("bookslm:history-updated", () => { if (state.activeSidebarTab === "ai") loadAISessionList(); }); } function switchSidebarTab(tab) { state.activeSidebarTab = tab; document.querySelectorAll(".sidebar-tab").forEach((btn) => { const isActive = btn.dataset.tab === tab; btn.classList.toggle("active", isActive); btn.setAttribute("aria-selected", isActive ? "true" : "false"); }); document.querySelectorAll(".sidebar-tab-panel").forEach((panel) => { const isActive = panel.id === `sidebar-panel-${tab}`; panel.classList.toggle("active", isActive); }); const filterInput = document.getElementById("sidebar-filter-input"); if (filterInput) { const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: t("sidebar.filter_recent"), saved: t("sidebar.filter_saved"), ai: t("sidebar.filter_ai"), }; filterInput.placeholder = placeholders[tab] || ""; } const query = filterInput ? (state.sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : ""; if (query) { if (tab === "vaults") performTreeSearch(query); else if (tab === "tags") filterTagCloud(query); else if (tab === "recent") filterRecentFiles(query); else if (tab === "saved") filterSavedSearches(query); else if (tab === "ai") filterAIHistory(query); } // Auto-load recent files when switching to the recent tab if (tab === "recent") { _populateRecentVaultFilter(); if (filterInput) filterRecentFiles(filterInput.value.trim()); const vaultFilter = document.getElementById("recent-vault-filter"); loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); } // #99 — Re-apply the current sidebar query on the saved-searches tab. if (tab === "saved" && filterInput) { filterSavedSearches(filterInput.value.trim()); } // #96/#98 — Auto-load the AI conversation history when entering its tab, // re-applying the current sidebar filter query. if (tab === "ai") { if (filterInput) _aiQuery = filterInput.value.trim(); loadAISessionList(); } } function initHelpModal() { const openBtn = document.getElementById("help-open-btn"); const closeBtn = document.getElementById("help-close"); const modal = document.getElementById("help-modal"); if (!openBtn || !closeBtn || !modal) return; let helpNavInitialized = false; openBtn.addEventListener("click", () => { modal.classList.add("active"); closeHeaderMenu(); safeCreateIcons(); // Reset TOC to CSS default (mobile: hidden, desktop: visible) const nav = document.getElementById("help-nav"); if (nav) nav.style.display = ''; if (!helpNavInitialized) { initHelpNavigation(); helpNavInitialized = true; } renderGuideMermaid(); }); closeBtn.addEventListener("click", closeHelpModal); modal.addEventListener("click", (e) => { if (e.target === modal) { closeHelpModal(); } }); // Help TOC hamburger + search clear (#87, ex-onclick inline in index.html). var helpHamburger = document.getElementById("help-hamburger"); if (helpHamburger) { helpHamburger.addEventListener("click", function() { var n = document.getElementById("help-nav"); if (n) { var d = n.style.display; n.style.display = d === "none" || d === "" ? "flex" : "none"; } }); } var helpSearch = document.getElementById("help-nav-search"); var helpSearchClear = document.getElementById("help-search-clear"); if (helpSearchClear && helpSearch) { helpSearchClear.addEventListener("click", function() { helpSearch.value = ""; helpSearch.dispatchEvent(new Event("input")); helpSearch.focus(); }); } document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modal.classList.contains("active")) { closeHelpModal(); } }); // Guide downloads (#105) — markdown / pdf, current language. const dlMd = document.getElementById("help-download-md"); const dlPdf = document.getElementById("help-download-pdf"); [dlMd, dlPdf].forEach((btn) => { if (!btn) return; btn.addEventListener("click", () => { downloadGuide(btn.id === "help-download-md" ? "md" : "pdf"); }); }); } // Render any Mermaid blocks inside the guide modal (the architecture diagram, // #105). The viewer's own pipeline only touches document views, so the help // modal is enriched here — once per modal open, cheap on repeats. function renderGuideMermaid() { const modal = document.getElementById("help-modal"); if (!modal || modal.dataset.mermaidRendered === "1") return; import("./mermaid-viewer.js") .then((m) => m.renderMermaidBlocks(modal)) .then(() => { // Only mark done when the source block actually became a rendered // diagram — if the Mermaid CDN wasn't ready yet, retry on next open. if (!modal.querySelector("code.language-mermaid")) { modal.dataset.mermaidRendered = "1"; } }) .catch(() => { /* CDN offline: keep the code block readable */ }); } // Fetch the generated guide (auth headers + cookie) and trigger the browser // download, mirroring viewer.downloadExport(). async function downloadGuide(format) { showToast(t("viewer.export_start"), "info"); try { const headers = AuthManager.getAuthHeaders ? AuthManager.getAuthHeaders() || {} : {}; const res = await fetch( `/api/guide/download?format=${format}&lang=${encodeURIComponent(getLocale() || "fr")}`, { credentials: "include", headers }, ); if (!res.ok) { let detail = ""; try { detail = (await res.json()).detail || ""; } catch (_) { /* ignore */ } throw new Error(detail || "HTTP " + res.status); } const blob = await res.blob(); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `ObsiGate-Guide-${getLocale() || "fr"}.${format}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(a.href), 1000); showToast(t("viewer.export_done"), "success"); } catch (err) { console.error("Guide export error:", err); showToast(t("viewer.export_error") + " " + err.message, "error"); } } function initEditorPocBtn() { var btn = document.getElementById("editor-poc-btn"); if (!btn) return; btn.addEventListener("click", function() { window.open("/editor-poc", "_blank"); }); } function initHelpNavigation() { const helpBody = document.getElementById("help-body"); const navList = document.getElementById("help-nav-list"); const nav = document.getElementById("help-nav"); const searchInput = document.getElementById("help-nav-search"); const helpContent = document.querySelector(".help-content"); if (!helpBody || !navList || !nav) return; const allLinks = [...navList.querySelectorAll(".help-nav-link")]; const allSections = [...document.querySelectorAll(".help-section")]; const sectionMap = {}; allSections.forEach(s => { sectionMap[s.id] = s; }); // Build text index for content search const sectionTextCache = {}; allSections.forEach(s => { sectionTextCache[s.id] = (s.textContent || '').toLowerCase(); }); // ── Mobile: simple display toggle ── function isMobile() { return window.innerWidth <= 768; } function toggleTOC() { if (nav.style.display === 'none' || nav.style.display === '') { nav.style.display = 'flex'; if (searchInput) setTimeout(() => searchInput.focus(), 150); } else { nav.style.display = 'none'; } } // ── Scroll to section ── function scrollToSection(targetId) { const section = sectionMap[targetId]; if (!section) return; section.scrollIntoView({ behavior: "smooth", block: "start" }); section.setAttribute("tabindex", "-1"); if (isMobile()) nav.style.display = 'none'; } // ── Focus the content pane ── function focusContent() { const activeLink = navList.querySelector(".help-nav-link.active:not(.hidden)"); if (activeLink) { const targetId = activeLink.getAttribute("href").substring(1); const section = sectionMap[targetId]; if (section) { const h2 = section.querySelector("h2, h3"); if (h2) { h2.setAttribute("tabindex", "-1"); h2.focus({ preventScroll: true }); return; } } } if (helpContent) { helpContent.setAttribute("tabindex", "-1"); helpContent.focus({ preventScroll: true }); } } // ── Focus TOC active link ── function focusTOC() { if (isMobile() && (nav.style.display === 'none' || nav.style.display === '')) { nav.style.display = 'flex'; } const activeLink = navList.querySelector(".help-nav-link.active:not(.hidden)"); if (activeLink) { activeLink.focus(); return; } const firstLink = navList.querySelector(".help-nav-link:not(.hidden)"); if (firstLink) firstLink.focus(); } // ── Nav link click handler ── allLinks.forEach((link) => { link.addEventListener("click", (e) => { e.preventDefault(); const targetId = link.getAttribute("href").substring(1); scrollToSection(targetId); }); }); // ── Keyboard navigation inside nav list ── navList.addEventListener("keydown", (e) => { const items = [...navList.querySelectorAll(".help-nav-link:not(.hidden)")]; if (!items.length) return; const currentIdx = items.indexOf(document.activeElement); if (e.key === "ArrowDown") { e.preventDefault(); const next = (currentIdx + 1) % items.length; items[next].focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); const prev = (currentIdx - 1 + items.length) % items.length; items[prev].focus(); } else if (e.key === "ArrowRight") { e.preventDefault(); focusContent(); } else if (e.key === "ArrowLeft") { e.preventDefault(); const prev = (currentIdx - 1 + items.length) % items.length; items[prev].focus(); } else if (e.key === "Home") { e.preventDefault(); items[0].focus(); } else if (e.key === "End") { e.preventDefault(); items[items.length - 1].focus(); } else if (e.key === "Enter" && currentIdx >= 0) { e.preventDefault(); const targetId = items[currentIdx].getAttribute("href").substring(1); scrollToSection(targetId); setTimeout(() => focusContent(), 400); } }); navList.querySelectorAll("li").forEach((li) => li.setAttribute("role", "none")); // ── Keyboard navigation in content pane ── if (helpContent) { helpContent.setAttribute("tabindex", "-1"); helpContent.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft") { e.preventDefault(); focusTOC(); } else if (e.key === "ArrowUp" && document.activeElement === helpContent) { e.preventDefault(); focusTOC(); } }); } // ── Keyboard navigation in search input ── if (searchInput) { searchInput.addEventListener("keydown", (e) => { if (e.key === "ArrowDown") { e.preventDefault(); const firstLink = navList.querySelector(".help-nav-link:not(.hidden)"); if (firstLink) firstLink.focus(); } }); } // ── Search: filter TOC + hide non-matching sections + highlight text ── let currentQuery = ""; if (searchInput) { searchInput.addEventListener("input", () => { currentQuery = searchInput.value.toLowerCase().trim(); applySearchFilter(); }); searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape" && currentQuery) { searchInput.value = ""; currentQuery = ""; applySearchFilter(); } }); } function clearHighlights() { allSections.forEach(section => { section.querySelectorAll(".help-search-highlight").forEach(el => { const parent = el.parentNode; parent.replaceChild(document.createTextNode(el.textContent), el); parent.normalize(); }); }); } function highlightText(root, query) { if (!query || query.length < 2) return; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false); const textNodes = []; while (walker.nextNode()) { const node = walker.currentNode; // Skip nodes inside script, style, or already-highlighted if (node.parentElement && !node.parentElement.closest("script, style, .help-search-highlight, .help-nav")) { textNodes.push(node); } } const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); textNodes.forEach(node => { const text = node.textContent; if (!regex.test(text)) return; regex.lastIndex = 0; const frag = document.createDocumentFragment(); let lastIdx = 0; let match; while ((match = regex.exec(text)) !== null) { if (match.index > lastIdx) { frag.appendChild(document.createTextNode(text.slice(lastIdx, match.index))); } const mark = document.createElement("mark"); mark.className = "help-search-highlight"; mark.textContent = match[0]; frag.appendChild(mark); lastIdx = regex.lastIndex; if (match[0].length === 0) regex.lastIndex++; } if (lastIdx < text.length) { frag.appendChild(document.createTextNode(text.slice(lastIdx))); } node.parentNode.replaceChild(frag, node); }); } function applySearchFilter() { const q = currentQuery; // Clear previous highlights clearHighlights(); // Filter TOC links allLinks.forEach((link) => { const text = link.textContent.toLowerCase(); if (!q || text.includes(q)) { link.classList.remove("hidden"); } else { link.classList.add("hidden"); } }); // Filter content sections + highlight allSections.forEach((section) => { if (!q) { section.classList.remove("help-hidden"); return; } const sectionText = sectionTextCache[section.id] || ''; const sectionTitle = (section.querySelector("h2, h3")?.textContent || '').toLowerCase(); if (sectionTitle.includes(q) || sectionText.includes(q)) { section.classList.remove("help-hidden"); // Highlight matching text in this section highlightText(section, q); } else { section.classList.add("help-hidden"); } }); updateActiveLink(); ensureNoResultsMessage(q); } function ensureNoResultsMessage(q) { const existing = document.getElementById("help-no-results"); const visibleSections = allSections.filter(s => !s.classList.contains("help-hidden")); if (q && visibleSections.length === 0) { if (!existing) { const msg = document.createElement("div"); msg.id = "help-no-results"; msg.style.cssText = "padding:40px;text-align:center;color:var(--text-muted);font-size:0.9rem"; msg.innerHTML = `
🔍

Aucun résultat pour "${q}"

Essayez un autre terme

`; if (helpContent) helpContent.appendChild(msg); } } else if (existing) { existing.remove(); } } // ── Scroll spy ── function updateActiveLink() { const visibleLinks = allLinks.filter(l => !l.classList.contains("hidden")); const visibleSections = allSections.filter(s => !s.classList.contains("help-hidden")); let activeId = null; for (const section of visibleSections) { const rect = section.getBoundingClientRect(); const contentRect = helpContent ? helpContent.getBoundingClientRect() : null; if (rect.top < (contentRect ? contentRect.top + 120 : 200)) { activeId = section.id; } } visibleLinks.forEach((link) => { const isMatch = link.getAttribute("href") === `#${activeId}`; link.classList.toggle("active", isMatch); }); } const observer = new IntersectionObserver( (entries) => { let bestEntry = null; for (const entry of entries) { if (entry.isIntersecting) { if (!bestEntry || entry.boundingClientRect.top < bestEntry.boundingClientRect.top) { bestEntry = entry; } } } if (bestEntry) { const id = bestEntry.target.getAttribute("id"); allLinks.forEach((link) => { const isMatch = link.getAttribute("href") === `#${id}`; link.classList.toggle("active", isMatch); }); } }, { root: helpBody, rootMargin: "-10% 0px -75% 0px", threshold: [0, 0.25, 0.5], } ); allSections.forEach((section) => observer.observe(section)); // ── Keyboard: Ctrl+↑/↓ to jump between sections ── helpBody.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === "ArrowDown" || e.key === "ArrowUp")) { e.preventDefault(); const visibleSections = allSections.filter(s => !s.classList.contains("help-hidden")); const currentSection = visibleSections.find((s) => { const rect = s.getBoundingClientRect(); const contentRect = helpContent ? helpContent.getBoundingClientRect() : null; return rect.top >= (contentRect ? contentRect.top : 0) && rect.top < (contentRect ? contentRect.top + 120 : 120); }); if (!currentSection) return; const currentIdx = visibleSections.indexOf(currentSection); if (e.key === "ArrowDown" && currentIdx < visibleSections.length - 1) { visibleSections[currentIdx + 1].scrollIntoView({ behavior: "smooth", block: "start" }); } else if (e.key === "ArrowUp" && currentIdx > 0) { visibleSections[currentIdx - 1].scrollIntoView({ behavior: "smooth", block: "start" }); } } }); } function closeHelpModal() { const modal = document.getElementById("help-modal"); if (modal) modal.classList.remove("active"); } // ── Vaults & root directories — desktop only (#159) ────────────────────── // Lists the roots the Tauri shell injects into the backend (vaults + // directories) and offers add/remove. The whole section — including its TOC // entry — is hidden outside the desktop app. async function renderDesktopRoots() { const section = document.getElementById("cfg-desktop-roots"); const navLink = document.querySelector('#config-nav a[href="#cfg-desktop-roots"]'); if (!section) return; if (!isTauriEnv()) { section.style.display = "none"; if (navLink && navLink.parentElement) navLink.parentElement.style.display = "none"; return; } section.style.display = ""; if (navLink && navLink.parentElement) navLink.parentElement.style.display = ""; const vaults = (await invoke("list_vaults")) || []; const dirs = (await invoke("list_dirs")) || []; const list = document.getElementById("desktop-roots-list"); if (!list) return; const row = (name, path, kind) => '
' + '' + '
' + escapeHtml(name) + "
" + '
' + escapeHtml(path) + "
" + '
"; if (!vaults.length && !dirs.length) { list.innerHTML = '
' + escapeHtml(t("desktop.roots_empty")) + "
"; } else { list.innerHTML = vaults.map((v) => row(v.name, v.path, "vault")).join("") + dirs.map((d) => row(d.name, d.path, "dir")).join(""); for (const btn of list.querySelectorAll("button[data-root]")) { // onclick (not addEventListener): the modal re-renders on every open. btn.onclick = () => removeRoot(btn.dataset.root, t("desktop.remove_root_confirm", { name: btn.dataset.root })); } } const addVaultBtn = document.getElementById("cfg-desktop-add-vault"); const addDirBtn = document.getElementById("cfg-desktop-add-dir"); if (addVaultBtn) addVaultBtn.onclick = () => pickAndAddVault(); if (addDirBtn) addDirBtn.onclick = () => pickAndAddDir(); safeCreateIcons(); } function initConfigModal() { const openBtn = document.getElementById("config-open-btn"); const closeBtn = document.getElementById("config-close"); const modal = document.getElementById("config-modal"); const addBtn = document.getElementById("config-add-btn"); const patternInput = document.getElementById("config-pattern-input"); if (!openBtn || !closeBtn || !modal) return; openBtn.addEventListener("click", async () => { modal.classList.add("active"); closeHeaderMenu(); // BUG-071/#114: reset the TOC to the CSS default (mobile: hidden drawer, // desktop: visible sidebar) like the help modal does on open. Clear the // stale inline display and the drawer-open class so a previous mobile // session cannot leave the nav stuck open. var configNavOnOpen = document.getElementById("config-nav"); if (configNavOnOpen) configNavOnOpen.style.display = ''; modal.classList.remove("config-toc-open"); var configHamburgerOnOpen = document.getElementById("config-hamburger"); if (configHamburgerOnOpen) configHamburgerOnOpen.classList.remove("active"); renderConfigFilters(); loadConfigFields(); loadDiagnostics(); loadAbout(); await loadHiddenFilesSettings(); loadWebhooksUI(); loadTokensUI(); loadSharesUI(); loadToolKeys(); renderDesktopRoots(); safeCreateIcons(); }); closeBtn.addEventListener("click", closeConfigModal); modal.addEventListener("click", (e) => { if (e.target === modal) { // #114: a tap on the backdrop (or outside the drawer) first closes the // TOC drawer; only a second tap closes the whole modal. if (modal.classList.contains("config-toc-open")) { _setConfigNav(false); return; } closeConfigModal(); } }); addBtn.addEventListener("click", addConfigFilter); patternInput.addEventListener("keypress", (e) => { if (e.key === "Enter") { addConfigFilter(); } }); patternInput.addEventListener("input", updateRegexPreview); // Frontend config fields — save to localStorage on change ["cfg-debounce", "cfg-results-per-page", "cfg-min-query", "cfg-timeout"].forEach((id) => { const input = document.getElementById(id); if (input) input.addEventListener("change", saveFrontendConfig); }); // Backend save button const saveBtn = document.getElementById("cfg-save-backend"); if (saveBtn) saveBtn.addEventListener("click", saveBackendConfig); // Force reindex const reindexBtn = document.getElementById("cfg-reindex"); if (reindexBtn) reindexBtn.addEventListener("click", forceReindex); // Reset defaults const resetBtn = document.getElementById("cfg-reset-defaults"); if (resetBtn) resetBtn.addEventListener("click", resetConfigDefaults); // Refresh diagnostics const diagBtn = document.getElementById("cfg-refresh-diag"); if (diagBtn) diagBtn.addEventListener("click", loadDiagnostics); // Hidden files configuration const saveHiddenBtn = document.getElementById("cfg-save-hidden-files"); if (saveHiddenBtn) saveHiddenBtn.addEventListener("click", saveHiddenFilesSettings); // AI Keys const saveAIKeysBtn = document.getElementById("cfg-save-ai-keys"); if (saveAIKeysBtn) saveAIKeysBtn.addEventListener("click", saveAIKeys); const testAIKeysBtn = document.getElementById("cfg-test-ai-keys"); if (testAIKeysBtn) testAIKeysBtn.addEventListener("click", testAIKeys); // Provider search filter (#104) const aiProviderSearch = document.getElementById("cfg-ai-search"); if (aiProviderSearch) { aiProviderSearch.addEventListener("input", () => filterAIProviders(aiProviderSearch.value)); } // Tool & connected-source keys (#103) const saveToolKeysBtn = document.getElementById("cfg-save-tool-keys"); if (saveToolKeysBtn) saveToolKeysBtn.addEventListener("click", saveToolKeys); // Default provider/model selection const aiDefaultProviderSel = document.getElementById("cfg-ai-default-provider"); if (aiDefaultProviderSel) { aiDefaultProviderSel.addEventListener("change", async () => { let cfg = {}; try { cfg = await api("/api/config"); } catch(e) {} const models = cfg.ai_default_models || {}; await _populateAIModelOptions(aiDefaultProviderSel.value, models[aiDefaultProviderSel.value] || ""); }); } const aiDefaultModelSel = document.getElementById("cfg-ai-default-model"); if (aiDefaultModelSel) { aiDefaultModelSel.addEventListener("change", () => { const providerSel = document.getElementById("cfg-ai-default-provider"); _renderConfigModelCaps(providerSel ? providerSel.value : null, aiDefaultModelSel.value); }); } // Load existing keys/defaults on modal open document.getElementById("config-open-btn").addEventListener("click", loadAIKeys); document.getElementById("config-open-btn").addEventListener("click", loadAIDefaults); // Profile menu button: open config modal and scroll to profile section var profileBtn = document.getElementById("profile-open-btn"); if (profileBtn) { profileBtn.addEventListener("click", function() { var modal = document.getElementById("config-modal"); if (modal) modal.classList.add("active"); setTimeout(function() { var el = document.getElementById("cfg-profile"); if (el) el.scrollIntoView({ behavior: 'smooth' }); }, 300); }); } // Logout button — wired here with addEventListener (#87, no inline onclick) // Config nav search var cfgSearch = document.getElementById("config-nav-search"); if (cfgSearch) { cfgSearch.addEventListener("input", function() { var q = cfgSearch.value.toLowerCase(); document.querySelectorAll("#config-scroll .config-section").forEach(function(s) { var text = s.textContent.toLowerCase(); s.style.display = (q && !text.includes(q)) ? "none" : ""; }); document.querySelectorAll("#config-nav .help-nav-link").forEach(function(a) { var li = a.closest("li"); if (li) li.style.display = (q && !a.textContent.toLowerCase().includes(q)) ? "none" : ""; }); }); } // Config search clear button (#87, ex-onclick inline). var cfgSearchClear = document.getElementById("config-search-clear"); if (cfgSearchClear && cfgSearch) { cfgSearchClear.addEventListener("click", function() { cfgSearch.value = ""; cfgSearch.dispatchEvent(new Event("input")); cfgSearch.focus(); }); } // BUG-071/#114: mobile table of contents. #config-nav shares the .help-nav // rule that hides it below 768px, but — unlike the help modal — the config // modal had no toggle to reveal it, leaving mobile users with no way to // reach a section. The header hamburger opens it as a left slide-over // drawer (backdrop via .config-toc-open on the modal); picking a section // smooth-scrolls inside the modal and collapses it on mobile. var configNav = document.getElementById("config-nav"); var configHamburger = document.getElementById("config-hamburger"); function _isConfigMobile() { return window.innerWidth <= 768; } function _setConfigNav(open) { if (!configNav) return; configNav.style.display = open ? "flex" : "none"; modal.classList.toggle("config-toc-open", !!open && _isConfigMobile()); if (configHamburger) configHamburger.classList.toggle("active", !!open); } if (configHamburger) { configHamburger.addEventListener("click", function(e) { e.stopPropagation(); var hidden = !configNav || configNav.style.display === "none" || configNav.style.display === ""; _setConfigNav(hidden); }); } // #114: close button inside the drawer header. var configTocClose = document.getElementById("config-toc-close"); if (configTocClose) { configTocClose.addEventListener("click", function(e) { e.stopPropagation(); _setConfigNav(false); }); } if (configNav) { configNav.querySelectorAll(".help-nav-link").forEach(function(a) { a.addEventListener("click", function(e) { var hash = a.getAttribute("href"); if (!hash || hash.charAt(0) !== "#") return; var target = document.getElementById(hash.slice(1)); if (!target) return; e.preventDefault(); configNav.querySelectorAll(".help-nav-link").forEach(function(o) { o.classList.remove("active"); }); a.classList.add("active"); if (typeof target.scrollIntoView === "function") { target.scrollIntoView({ behavior: "smooth", block: "start" }); } if (_isConfigMobile()) _setConfigNav(false); }); }); } document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modal.classList.contains("active")) { // #114: Escape closes the TOC drawer first, then the modal. if (modal.classList.contains("config-toc-open")) { _setConfigNav(false); return; } closeConfigModal(); } }); // Load saved frontend config on startup applyFrontendConfig(); // Init theme picker initThemePicker(); // Init profile initProfile(); // Init MFA settings (security tab) initMfaSettings(); } function closeConfigModal() { const modal = document.getElementById("config-modal"); if (modal) { modal.classList.remove("active"); // #114: never leave the drawer-open class (backdrop) behind. modal.classList.remove("config-toc-open"); const nav = document.getElementById("config-nav"); if (nav) nav.style.display = ''; } } // --- Config field helpers --- const _FRONTEND_CONFIG_KEY = "obsigate-perf-config"; function _getFrontendConfig() { try { return JSON.parse(localStorage.getItem(_FRONTEND_CONFIG_KEY) || "{}"); } catch { return {}; } } function applyFrontendConfig() { const cfg = _getFrontendConfig(); if (cfg.debounce_ms) { /* applied dynamically in debounce setTimeout */ } if (cfg.results_per_page) { /* used as ADVANCED_SEARCH_LIMIT override */ } if (cfg.min_query_length) { /* used as MIN_SEARCH_LENGTH override */ } if (cfg.search_timeout_ms) { /* used as SEARCH_TIMEOUT_MS override */ } } export function _getEffective(key, fallback) { const cfg = _getFrontendConfig(); return cfg[key] !== undefined ? cfg[key] : fallback; } async function loadConfigFields() { // Frontend fields from localStorage const cfg = _getFrontendConfig(); _setField("cfg-debounce", cfg.debounce_ms || 300); _setField("cfg-results-per-page", cfg.results_per_page || 50); _setField("cfg-min-query", cfg.min_query_length || 2); _setField("cfg-timeout", cfg.search_timeout_ms || 30000); // Backend fields from API try { const data = await api("/api/config"); _setField("cfg-workers", data.search_workers); _setField("cfg-max-content", data.max_content_size); _setField("cfg-title-boost", data.title_boost); _setField("cfg-tag-boost", data.tag_boost); _setField("cfg-prefix-exp", data.prefix_max_expansions); _setField("cfg-recent-limit", data.recent_files_limit || 20); _setField("cfg-max-backups", data.max_backups_per_file || 10); // Watcher config _setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false); _setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true); _setField("cfg-watcher-interval", data.watcher_polling_interval || 5); _setField("cfg-watcher-debounce", data.watcher_debounce || 2); } catch (err) { console.error("Failed to load backend config:", err); } } function _setField(id, value) { const el = document.getElementById(id); if (el && value !== undefined) el.value = value; } function _setCheckbox(id, checked) { const el = document.getElementById(id); if (el) el.checked = !!checked; } function _getCheckbox(id) { const el = document.getElementById(id); return el ? el.checked : false; } function _getFieldNum(id, fallback) { const el = document.getElementById(id); if (!el) return fallback; const v = parseFloat(el.value); return isNaN(v) ? fallback : v; } function saveFrontendConfig() { const cfg = { debounce_ms: _getFieldNum("cfg-debounce", 300), results_per_page: _getFieldNum("cfg-results-per-page", 50), min_query_length: _getFieldNum("cfg-min-query", 2), search_timeout_ms: _getFieldNum("cfg-timeout", 30000), }; localStorage.setItem(_FRONTEND_CONFIG_KEY, JSON.stringify(cfg)); showToast(t("toast.config_client_saved"), "success"); } async function saveBackendConfig() { const body = { search_workers: _getFieldNum("cfg-workers", 2), max_content_size: _getFieldNum("cfg-max-content", 100000), title_boost: _getFieldNum("cfg-title-boost", 3.0), tag_boost: _getFieldNum("cfg-tag-boost", 2.0), prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50), recent_files_limit: _getFieldNum("cfg-recent-limit", 20), max_backups_per_file: _getFieldNum("cfg-max-backups", 10), watcher_enabled: _getCheckbox("cfg-watcher-enabled"), watcher_use_polling: _getCheckbox("cfg-watcher-polling"), watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0), watcher_debounce: _getFieldNum("cfg-watcher-debounce", 2.0), }; try { const res = await fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (res.ok) { showToast(t("toast.config_saved"), "success"); } else { const errorData = await res.json().catch(() => ({})); showToast(errorData.detail || "Erreur de sauvegarde", "error"); } } catch (err) { console.error("Failed to save backend config:", err); showToast(t("config.save_error"), "error"); } } async function forceReindex() { const btn = document.getElementById("cfg-reindex"); if (btn) { btn.disabled = true; btn.textContent = t('config.reindexing'); } try { await api("/api/index/reload"); showToast(t("toast.reindex_done"), "success"); loadDiagnostics(); await Promise.all([loadVaults(), loadTags()]); } catch (err) { console.error("Reindex error:", err); showToast(t("config.reindex_error"), "error"); } finally { if (btn) { btn.disabled = false; btn.textContent = t('config.force_reindex'); } } } async function resetConfigDefaults() { // Reset frontend localStorage.removeItem(_FRONTEND_CONFIG_KEY); // Reset backend try { await fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ search_workers: 2, debounce_ms: 300, results_per_page: 50, min_query_length: 2, search_timeout_ms: 30000, max_content_size: 100000, title_boost: 3.0, path_boost: 1.5, tag_boost: 2.0, prefix_max_expansions: 50, snippet_context_chars: 120, max_snippet_highlights: 5, }), }); } catch (err) { console.error("Reset config error:", err); } loadConfigFields(); showToast(t("toast.config_reset"), "success"); } async function loadDiagnostics() { const container = document.getElementById("config-diagnostics"); if (!container) return; container.innerHTML = '
Chargement...
'; try { const data = await api("/api/diagnostics"); renderDiagnostics(container, data); } catch (err) { container.innerHTML = '
Erreur de chargement
'; } } function renderDiagnostics(container, data) { container.innerHTML = ""; const sections = [ { title: "Index", rows: [ [t("about.files_indexed"), data.index.total_files], [t("dashboard.stats_tags"), data.index.total_tags], [t("dashboard.stats_vaults"), Object.keys(data.index.vaults).join(", ")], ], }, { title: "Index inversé", rows: [ ["Tokens uniques", data.inverted_index.unique_tokens.toLocaleString()], ["Postings total", data.inverted_index.total_postings.toLocaleString()], ["Documents", data.inverted_index.documents], ["Mémoire estimée", data.inverted_index.memory_estimate_mb + " MB"], ["Index prêt", data.inverted_index.is_ready ? "Oui" : "Non"], ], }, { title: "Moteur de recherche", rows: [ ["Executor actif", data.search_executor.active ? "Oui" : "Non"], ["Workers max", data.search_executor.max_workers], ], }, ]; sections.forEach((section) => { const div = document.createElement("div"); div.className = "config-diag-section"; const title = document.createElement("div"); title.className = "config-diag-section-title"; title.textContent = section.title; div.appendChild(title); section.rows.forEach(([label, value]) => { const row = document.createElement("div"); row.className = "config-diag-row"; row.innerHTML = `${label}${value}`; div.appendChild(row); }); container.appendChild(div); }); } // --- About Section --- function loadAbout() { const container = document.getElementById("config-about"); if (!container) return; // Fetch health info for version api("/api/health").then((health) => { container.innerHTML = ""; const sections = [ { title: t("about.section_app"), rows: [ [t("about.name"), "ObsiGate"], [t("about.version"), health.version || "—"], [t("about.status"), health.status || "—"], ], }, { title: t("about.section_env"), rows: [ [t("about.vaults_configured"), health.vaults || "—"], [t("about.files_indexed"), health.total_files || "—"], [t("about.browser"), navigator.userAgent.split(" ").pop()], [t("about.platform"), navigator.platform || "—"], [t("about.language"), navigator.language || "—"], ], }, { title: t("about.section_components"), rows: [ [t("about.backend"), "FastAPI (Python)"], [t("about.markdown_renderer"), "mistune"], [t("about.file_watcher"), "watchdog"], [t("about.frontend"), "Vanilla JavaScript"], [t("about.icons"), "Lucide Icons"], [t("about.syntax_highlight"), "highlight.js"], [t("about.editor"), "CodeMirror 6"], ], }, ]; sections.forEach((section) => { const div = document.createElement("div"); div.className = "config-diag-section"; const title = document.createElement("div"); title.className = "config-diag-section-title"; title.textContent = section.title; div.appendChild(title); section.rows.forEach(([label, value]) => { const row = document.createElement("div"); row.className = "config-diag-row"; row.innerHTML = `${label}${value}`; div.appendChild(row); }); container.appendChild(div); }); }).catch(() => { container.innerHTML = '
Erreur de chargement
'; }); } // --- Hidden Files Configuration --- async function loadHiddenFilesSettings() { const container = document.getElementById("hidden-files-vault-list"); if (!container) return; container.innerHTML = '
Chargement...
'; try { const settings = await api("/api/vaults/settings/all"); renderHiddenFilesSettings(container, settings); } catch (err) { console.error("Failed to load hidden files settings:", err); container.innerHTML = '
Erreur de chargement
'; } } function renderHiddenFilesSettings(container, allSettings) { container.innerHTML = ""; if (!state.allVaults || state.allVaults.length === 0) { container.innerHTML = '
Aucun vault configuré
'; return; } state.allVaults.forEach((vault) => { const settings = allSettings[vault.name] || { hideHiddenFiles: false }; const vaultCard = el("div", { class: "hidden-files-vault-card", "data-vault": vault.name }); // Vault header const header = el("div", { class: "hidden-files-vault-header" }, [el("h3", {}, [document.createTextNode(vault.name)]), el("span", { class: "hidden-files-vault-type" }, [document.createTextNode(vault.type || "VAULT")])]); // Hide hidden files toggle const toggleRow = el("div", { class: "config-row" }, [ el("label", { class: "config-label", for: `hide-hidden-${vault.name}` }, [document.createTextNode("Masquer les fichiers/dossiers cachés")]), el("label", { class: "config-toggle" }, [ el("input", { type: "checkbox", id: `hide-hidden-${vault.name}`, "data-vault": vault.name, checked: settings.hideHiddenFiles ? "true" : false, }), el("span", { class: "config-toggle-slider" }), ]), el("span", { class: "config-hint" }, [document.createTextNode("Masquer les fichiers/dossiers commençant par un point dans l'interface (ils restent indexés et cherchables)")]), ]); vaultCard.appendChild(header); vaultCard.appendChild(toggleRow); container.appendChild(vaultCard); }); } async function saveHiddenFilesSettings() { const btn = document.getElementById("cfg-save-hidden-files"); if (btn) { btn.disabled = true; btn.textContent = t("config.saving") + "..."; } try { const vaultCards = document.querySelectorAll(".hidden-files-vault-card"); const promises = []; vaultCards.forEach((card) => { const vaultName = card.dataset.vault; const hideHiddenFiles = document.getElementById(`hide-hidden-${vaultName}`)?.checked || false; const settings = { hideHiddenFiles, }; promises.push( api(`/api/vaults/${encodeURIComponent(vaultName)}/settings`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(settings), }), ); }); await Promise.all(promises); // Reload vault settings to update the cache await loadVaultSettings(); showToast(t("config.settings_saved"), "success"); // Refresh the UI to apply the filter await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); } catch (err) { console.error("Failed to save hidden files settings:", err); const errorMsg = err.message || t("config.error_unknown"); showToast(t("config.error_prefix") + ": " + errorMsg, "error"); } finally { if (btn) { btn.disabled = false; btn.textContent = "💾 " + t("config.save"); } } } // ── Webhooks UI ── async function loadWebhooksUI() { const list = document.getElementById("webhooks-list"); if (!list) return; try { const webhooks = await api("/api/webhooks"); renderWebhooksUI(webhooks); } catch { list.innerHTML = '
Admin uniquement
'; } } function renderWebhooksUI(webhooks) { const list = document.getElementById("webhooks-list"); if (!list) return; if (!webhooks.length) { list.innerHTML = '
Aucun webhook configuré.
'; return; } list.innerHTML = webhooks.map(w => `
${escapeHtml(w.name)} ${escapeHtml(w.url)} ${(w.events||[]).join(", ")}
`).join(""); list.querySelectorAll(".webhook-delete").forEach(b => b.addEventListener("click", async () => { await api(`/api/webhooks/${b.dataset.id}`, { method: "DELETE" }); loadWebhooksUI(); })); } document.addEventListener("click", function(e) { if (e.target.id === "webhook-add-btn") { const name = document.getElementById("webhook-name-input").value.trim(); const url = document.getElementById("webhook-url-input").value.trim(); if (!url) { showToast(t("config.url_required"), "error"); return; } api("/api/webhooks", { method: "POST", body: JSON.stringify({ name: name || "Webhook", url, events: ["file_created","file_deleted","file_modified","file_renamed"] }) }).then(() => { loadWebhooksUI(); document.getElementById("webhook-name-input").value = ""; document.getElementById("webhook-url-input").value = ""; }).catch(err => showToast(err.message, "error")); } }); // ── API / MCP tokens UI (#107) ── let _tokensBound = false; async function loadTokensUI() { const list = document.getElementById("tokens-list"); if (!list) return; try { const data = await api("/api/auth/tokens"); renderTokensUI(data.tokens || []); bindTokenEvents(); } catch (err) { list.innerHTML = '
' + escapeHtml(t("config.error_prefix") + ": " + (err.message || "")) + "
"; } } function _formatTokenDate(unixSec) { if (!unixSec) return ""; return new Date(unixSec * 1000).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); } function _tokenExpiryLabel(tok) { if (!tok.expires_at) return t("config.token_expiry_never"); const map = { "1d": "config.token_expiry_1d", "30d": "config.token_expiry_30d", "180d": "config.token_expiry_180d", "365d": "config.token_expiry_365d" }; return map[tok.expiry_key] ? t(map[tok.expiry_key]) : _formatTokenDate(tok.expires_at); } function renderTokensUI(tokens) { const list = document.getElementById("tokens-list"); if (!list) return; if (!tokens.length) { list.innerHTML = '
' + escapeHtml(t("config.tokens_empty")) + "
"; return; } list.innerHTML = tokens.map(tok => { const expired = tok.expired || (tok.expires_at && tok.expires_at * 1000 < Date.now()); const status = expired ? '' + escapeHtml(t("config.token_status_expired")) + "" : '' + escapeHtml(t("config.token_status_active")) + ""; const meta = [ t("config.token_created") + " " + _formatTokenDate(tok.created_at), t("config.token_expires") + " " + _tokenExpiryLabel(tok), tok.last_used_at ? t("config.token_last_used") + " " + _formatTokenDate(tok.last_used_at) : t("config.token_never_used") ].join(" · "); return '
' + '' + escapeHtml(tok.name) + "" + status + '' + escapeHtml(meta) + "" + '' + "
"; }).join(""); list.querySelectorAll(".token-delete").forEach(btn => btn.addEventListener("click", async () => { const name = btn.dataset.name; if (!confirm(t("config.token_revoke_confirm") + " \"" + name + "\" ?")) return; try { await api("/api/auth/tokens/" + btn.dataset.jti, { method: "DELETE" }); showToast(t("config.token_revoked_toast"), "success"); loadTokensUI(); } catch (err) { showToast(err.message || t("config.error_unknown"), "error"); } })); } function bindTokenEvents() { if (_tokensBound) return; _tokensBound = true; const createBtn = document.getElementById("token-create-btn"); if (!createBtn) return; createBtn.addEventListener("click", async () => { const name = document.getElementById("token-name-input").value.trim(); const expiry = document.getElementById("token-expiry-select").value; if (!name) { showToast(t("config.token_name_required"), "error"); return; } createBtn.disabled = true; try { const res = await api("/api/auth/tokens", { method: "POST", body: JSON.stringify({ name, expiry }) }); const area = document.getElementById("token-secret-area"); const ta = document.getElementById("token-secret-value"); ta.value = res.token; area.classList.remove("hidden"); ta.select(); document.getElementById("token-name-input").value = ""; showToast(t("config.token_created_toast"), "success"); loadTokensUI(); } catch (err) { showToast(err.message || t("config.error_unknown"), "error"); } finally { createBtn.disabled = false; } }); const copyBtn = document.getElementById("token-copy-btn"); if (copyBtn) copyBtn.addEventListener("click", async () => { const ta = document.getElementById("token-secret-value"); try { await navigator.clipboard.writeText(ta.value); } catch { ta.select(); document.execCommand("copy"); } showToast(t("config.token_copied"), "success"); }); const dismissBtn = document.getElementById("token-dismiss-btn"); if (dismissBtn) dismissBtn.addEventListener("click", () => { document.getElementById("token-secret-area").classList.add("hidden"); document.getElementById("token-secret-value").value = ""; }); } // ── Shares UI ── async function loadSharesUI() { const list = document.getElementById("shares-list"); if (!list) return; try { const shares = await api("/api/shares"); renderSharesUI(shares); } catch { list.innerHTML = '
Chargement...
'; } } function renderSharesUI(shares) { const list = document.getElementById("shares-list"); if (!list) return; if (!shares.length) { list.innerHTML = '
Aucun partage actif.
'; return; } list.innerHTML = shares.map(s => `
${escapeHtml(s.vault)}/${escapeHtml(s.path)} ${s.url} ${s.access_count} vue(s)${s.expires_at ? ' · Expire' : ''}
`).join(""); list.querySelectorAll(".share-revoke").forEach(b => b.addEventListener("click", async () => { await api(`/api/share/${b.dataset.id}`, { method: "DELETE" }); loadSharesUI(); })); } // ── Share Dialog (professional) ── export async function openShareDialog(vault, path) { // First check if already shared let existingShare = null; try { const shares = await api("/api/shares"); existingShare = shares.find(s => s.vault === vault && s.path === path); } catch (e) { /* ignore */ } const div = document.createElement("div"); div.className = "share-dialog-overlay"; const renderContent = () => { if (existingShare) { const url = window.location.origin + existingShare.url; const expiresInfo = existingShare.expires_at ? `

Expire le ${new Date(existingShare.expires_at).toLocaleDateString("fr-FR")}

` : '

Sans expiration

'; div.innerHTML = `

📤 Document partagé

${escapeHtml(vault)}/${escapeHtml(path)}

${expiresInfo}

${existingShare.access_count} vue(s)

`; const shareUrlInput = div.querySelector(".share-url-input"); if (shareUrlInput) shareUrlInput.addEventListener("click", function() { shareUrlInput.select(); }); div.querySelector(".share-copy-btn").addEventListener("click", async () => { try { await navigator.clipboard.writeText(url); } catch (e) { // Fallback for non-HTTPS contexts const ta = document.createElement("textarea"); ta.value = url; ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); } showToast(t("config.link_copied"), "success"); div.remove(); }); div.querySelector(".share-revoke-btn").addEventListener("click", async () => { try { await api(`/api/share/${existingShare.id}`, { method: "DELETE" }); showToast(t("config.share_revoked"), "success"); existingShare = null; renderContent(); } catch (err) { showToast("Erreur: " + err.message, "error"); } }); } else { div.innerHTML = `

📤 Partager ce document

${escapeHtml(vault)}/${escapeHtml(path)}

Ce lien sera accessible publiquement, sans authentification.

`; div.querySelector(".share-create-btn").addEventListener("click", async () => { try { const expiry = document.getElementById("share-expiry")?.value; const share = await api(`/api/share/${encodeURIComponent(vault)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null }), }); existingShare = share; renderContent(); showToast(t("config.link_created"), "success"); } catch (err) { showToast("Erreur: " + err.message, "error"); } }); } div.querySelector(".share-close-btn").addEventListener("click", () => div.remove()); div.addEventListener("click", (e) => { if (e.target === div) div.remove(); }); }; renderContent(); document.body.appendChild(div); } function renderConfigFilters() { const config = TagFilterService.getConfig(); const filters = config.tagFilters || TagFilterService.defaultFilters; const container = document.getElementById("config-filters-list"); container.innerHTML = ""; filters.forEach((filter, index) => { const badge = el("div", { class: `config-filter-badge ${!filter.enabled ? "disabled" : ""}` }, [ el("span", {}, [document.createTextNode(filter.pattern)]), el( "button", { class: "config-filter-toggle", title: filter.enabled ? "Désactiver" : "Activer", type: "button", }, [document.createTextNode(filter.enabled ? "✓" : "○")], ), el( "button", { class: "config-filter-remove", title: "Supprimer", type: "button", }, [document.createTextNode("×")], ), ]); const toggleBtn = badge.querySelector(".config-filter-toggle"); const removeBtn = badge.querySelector(".config-filter-remove"); toggleBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleConfigFilter(index); }); removeBtn.addEventListener("click", (e) => { e.stopPropagation(); removeConfigFilter(index); }); container.appendChild(badge); }); } function toggleConfigFilter(index) { const config = TagFilterService.getConfig(); const filters = config.tagFilters || TagFilterService.defaultFilters; if (filters[index]) { filters[index].enabled = !filters[index].enabled; config.tagFilters = filters; TagFilterService.saveConfig(config); renderConfigFilters(); refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); } } function removeConfigFilter(index) { const config = TagFilterService.getConfig(); let filters = config.tagFilters || TagFilterService.defaultFilters; filters = filters.filter((_, i) => i !== index); config.tagFilters = filters; TagFilterService.saveConfig(config); renderConfigFilters(); refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); } function addConfigFilter() { const input = document.getElementById("config-pattern-input"); const pattern = input.value.trim(); if (!pattern) return; const regex = TagFilterService.patternToRegex(pattern); const config = TagFilterService.getConfig(); const filters = config.tagFilters || TagFilterService.defaultFilters; const newFilter = { pattern, regex, enabled: true }; filters.push(newFilter); config.tagFilters = filters; TagFilterService.saveConfig(config); input.value = ""; renderConfigFilters(); refreshTagsForContext().catch((err) => console.error("Error refreshing tags:", err)); updateRegexPreview(); } function updateRegexPreview() { const input = document.getElementById("config-pattern-input"); const preview = document.getElementById("config-regex-preview"); const code = document.getElementById("config-regex-code"); const pattern = input.value.trim(); if (pattern) { const regex = TagFilterService.patternToRegex(pattern); code.textContent = `^${regex}$`; preview.style.display = "block"; } else { preview.style.display = "none"; } } // ── AI Keys management (#104 — accordion redesign) ── const AI_KEY_MAP = { "cfg-deepseek-key": "DEEPSEEK_API_KEY", "cfg-openrouter-key": "OPENROUTER_API_KEY", "cfg-gemini-key": "GEMINI_API_KEY", "cfg-nvidia-key": "NVIDIA_API_KEY", "cfg-qwencloud-key": "QWENCLOUD_API_KEY", "cfg-xiaomi-key": "XIAOMI_API_KEY", "cfg-mistral-key": "MISTRAL_API_KEY", }; const AI_PROVIDER_NAMES = ["deepseek","openrouter","gemini","nvidia","qwencloud","xiaomi","mistral"]; // Display metadata for the provider accordion cards (#104). const AI_PROVIDER_META = { deepseek: { name: "DeepSeek", placeholder: "sk-..." }, openrouter: { name: "OpenRouter", placeholder: "sk-or-..." }, gemini: { name: "Gemini", placeholder: "AIza..." }, nvidia: { name: "NVIDIA", placeholder: "nvapi-..." }, qwencloud: { name: "QwenCloud", placeholder: "sk-..." }, xiaomi: { name: "Xiaomi", placeholder: "xm-..." }, mistral: { name: "Mistral", placeholder: "sk-..." }, }; function _renderAIProviderCards() { const host = document.getElementById("cfg-ai-providers"); if (!host) return; host.innerHTML = ""; for (const p of AI_PROVIDER_NAMES) { const meta = AI_PROVIDER_META[p] || { name: p, placeholder: "sk-..." }; const card = el("div", { class: "ai-provider-card", "data-provider": p }); // Header row (div + role=button so the per-provider delete button can // live inside without nesting two interactive elements). const head = el("div", { class: "ai-provider-head", role: "button", tabindex: "0", "aria-expanded": "false", }); head.appendChild(el("span", { class: "ai-provider-logo", "aria-hidden": "true" }, [document.createTextNode(meta.name.charAt(0))])); head.appendChild(el("span", { class: "ai-provider-name" }, [document.createTextNode(meta.name)])); head.appendChild(el("span", { class: "ai-provider-badge", id: `cfg-${p}-badge` })); const delBtn = el("button", { type: "button", class: "ai-provider-delete", id: `cfg-${p}-delete`, title: t("config.ai_delete_key_title"), }); delBtn.style.display = "none"; delBtn.appendChild(icon("trash-2", 14)); delBtn.addEventListener("click", (e) => { e.stopPropagation(); deleteAIKey(`cfg-${p}-key`); }); head.appendChild(delBtn); const chevron = icon("chevron-down", 16); chevron.classList.add("ai-provider-chevron"); head.appendChild(chevron); const toggle = () => _toggleAICard(card); head.addEventListener("click", toggle); head.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } }); // Collapsible body: API key (60%) + model (40%), labels above inputs. const body = el("div", { class: "ai-provider-body hidden" }); const fields = el("div", { class: "ai-provider-fields" }); const keyField = el("div", { class: "ai-field ai-field-key" }); keyField.appendChild(el("label", { class: "ai-field-label", for: `cfg-${p}-key` }, [document.createTextNode(`${meta.name} API Key`)])); const keyInput = document.createElement("input"); keyInput.type = "password"; keyInput.id = `cfg-${p}-key`; keyInput.className = "config-input"; keyInput.placeholder = meta.placeholder; keyInput.autocomplete = "off"; keyField.appendChild(keyInput); const modelField = el("div", { class: "ai-field ai-field-model" }); modelField.appendChild(el("label", { class: "ai-field-label", for: `cfg-${p}-model` }, [document.createTextNode(t("config.ai_model"))])); const modelSel = document.createElement("select"); modelSel.id = `cfg-${p}-model`; modelSel.className = "config-select"; modelSel.innerHTML = ''; modelField.appendChild(modelSel); fields.appendChild(keyField); fields.appendChild(modelField); body.appendChild(fields); card.appendChild(head); card.appendChild(body); host.appendChild(card); } safeCreateIcons(); } function _toggleAICard(card) { const open = card.classList.toggle("open"); const body = card.querySelector(".ai-provider-body"); if (body) body.classList.toggle("hidden", !open); const head = card.querySelector(".ai-provider-head"); if (head) head.setAttribute("aria-expanded", open ? "true" : "false"); } /** Filter the provider accordion cards by the section search input (#104). */ export function filterAIProviders(query) { const host = document.getElementById("cfg-ai-providers"); const emptyMsg = document.getElementById("cfg-ai-providers-empty"); if (!host) return; const q = _sidebarNorm(query); let visible = 0; host.querySelectorAll(".ai-provider-card").forEach((card) => { const p = card.dataset.provider; const meta = AI_PROVIDER_META[p] || { name: p }; const match = !q || _sidebarNorm(meta.name).includes(q) || p.includes(q); card.style.display = match ? "" : "none"; if (match) visible++; }); if (emptyMsg) emptyMsg.classList.toggle("hidden", visible > 0); } function _setAIKeyBadge(inputId, hasKey) { const provider = inputId.replace("-key", ""); const badge = document.getElementById(`${provider}-badge`); const delBtn = document.getElementById(`${provider}-delete`); if (badge) { badge.textContent = hasKey ? "\u2713 " + t("config.ai_status_configured") : t("config.ai_status_not_configured"); badge.classList.toggle("configured", !!hasKey); } if (delBtn) delBtn.style.display = hasKey ? "inline-flex" : "none"; } async function loadAIKeys() { _renderAIProviderCards(); try { const data = await api("/api/config/ai-keys"); for (const [inputId, envName] of Object.entries(AI_KEY_MAP)) { const input = document.getElementById(inputId); const val = data[envName] || ""; if (input) { const meta = AI_PROVIDER_META[inputId.replace("-key", "")] || {}; input.placeholder = val || meta.placeholder || "sk-..."; } _setAIKeyBadge(inputId, !!val); } } catch(e) {} } async function loadAIDefaults() { const providerSel = document.getElementById("cfg-ai-default-provider"); if (!providerSel) return; if (!providerSel.dataset.populated) { providerSel.innerHTML = ''; AI_PROVIDER_NAMES.forEach(function(p) { providerSel.innerHTML += ''; }); providerSel.dataset.populated = "1"; } let cfg = {}; try { cfg = await api("/api/config"); } catch(e) {} const provider = cfg.ai_default_provider || "deepseek"; providerSel.value = provider; const models = cfg.ai_default_models || {}; await _populateAIModelOptions(provider, models[provider] || ""); } async function _populateAIModelOptions(provider, selected) { const modelSel = document.getElementById("cfg-ai-default-model"); if (!modelSel) return; modelSel.innerHTML = ''; if (!provider) { _renderConfigModelCaps(null, null); return; } try { const m = await api("/api/config/ai-models?provider=" + encodeURIComponent(provider)); const list = (m && m.models) || []; list.forEach(function(md) { modelSel.innerHTML += ''; }); if (selected && list.indexOf(selected) === -1) { modelSel.innerHTML += ''; } modelSel.value = selected || ""; _renderConfigModelCaps(provider, modelSel.value); } catch(e) { console.warn("Failed to load default models for", provider, e); } } async function _renderConfigModelCaps(provider, model) { const host = document.getElementById("cfg-ai-default-model-caps"); if (!host) return; host.innerHTML = ""; if (!provider || !model) return; const caps = await getModelCapabilities(provider, model); if (!caps) return; host.appendChild(renderCapabilityBadges(caps)); } async function saveAIKeys() { const keys = {}; for (const [id, name] of Object.entries(AI_KEY_MAP)) { const input = document.getElementById(id); if (input && input.value.trim()) keys[name] = input.value.trim(); } // Collect default provider + per-provider default models const providerSel = document.getElementById("cfg-ai-default-provider"); const modelSel = document.getElementById("cfg-ai-default-model"); const defaults = {}; const models = {}; for (const p of AI_PROVIDER_NAMES) { const sel = document.getElementById("cfg-" + p + "-model"); if (sel && sel.value) models[p] = sel.value; } if (providerSel && providerSel.value) { defaults.ai_default_provider = providerSel.value; if (modelSel && modelSel.value) models[providerSel.value] = modelSel.value; } if (Object.keys(models).length) defaults.ai_default_models = models; if (!Object.keys(keys).length && !Object.keys(defaults).length) { showToast(t("config.no_keys"), "warning"); return; } try { if (Object.keys(keys).length) { await api("/api/config/ai-keys", { method: "POST", body: JSON.stringify(keys) }); } if (Object.keys(defaults).length) { await api("/api/config", { method: "POST", body: JSON.stringify(defaults) }); } showToast(t("config.api_keys_saved"), "success"); Object.keys(AI_KEY_MAP).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; }); loadAIKeys(); loadAIDefaults(); // The assistant sidebar lists the providers it found when the picker was // built: rebuild it so a newly configured provider shows up immediately. refreshAIPickers(); } catch(e) { showToast("Erreur: " + e.message, "error"); } } async function deleteAIKey(inputId) { const envName = AI_KEY_MAP[inputId]; if (!envName) return; const provider = envName.replace("_API_KEY", "").replace(/_/g, " "); if (!confirm("Supprimer la cl\u00e9 API " + provider + " ?\nLe fournisseur ne sera plus disponible.")) return; try { await api("/api/config/ai-keys/" + envName, { method: "DELETE" }); showToast("Cl\u00e9 " + provider + " supprim\u00e9e", "success"); loadAIKeys(); // Drop the removed provider from the assistant sidebar's provider list. refreshAIPickers(); } catch(e) { showToast("Erreur: " + e.message, "error"); } } async function testAIKeys() { const status = document.getElementById("cfg-ai-status"); if (status) status.textContent = "Test en cours..."; try { const results = await api("/api/config/ai-keys/test", { method: "POST" }); var msg = Object.entries(results).map(([k,v]) => k + ": " + v).join(" | "); if (status) status.textContent = msg; for (const p of AI_PROVIDER_NAMES) { const inputId = "cfg-" + p + "-key"; if (results[p] === "ok") { _setAIKeyBadge(inputId, true); } else if (results[p] && results[p].includes("non")) { _setAIKeyBadge(inputId, false); } } for (const p of AI_PROVIDER_NAMES) { if (results[p] === "ok") { try { const m = await api("/api/config/ai-models?provider=" + p); var sel = document.getElementById("cfg-" + p + "-model"); if (sel && m.models && m.models.length) { sel.innerHTML = ''; m.models.forEach(function(md) { sel.innerHTML += ''; }); } } catch(e) { console.warn("Failed to fetch models for", p, e); } } } } catch(e) { if (status) status.textContent = "Erreur: " + e.message; } } // ── Tool & connected-source keys (#103) ── const TOOL_KEY_MAP = { "cfg-tavily-key": "OBSIGATE_TAVILY_API_KEY", "cfg-brave-key": "OBSIGATE_BRAVE_API_KEY", "cfg-serpapi-key": "OBSIGATE_SERPAPI_API_KEY", "cfg-exa-key": "OBSIGATE_EXA_API_KEY", "cfg-gitea-url": "OBSIGATE_GITEA_URL", "cfg-gitea-token": "OBSIGATE_GITEA_TOKEN", "cfg-github-token": "OBSIGATE_GITHUB_TOKEN", }; function _ensureToolKeyUI() { for (const [inputId] of Object.entries(TOOL_KEY_MAP)) { const input = document.getElementById(inputId); if (!input) continue; const row = input.closest(".config-row"); if (!row || row.dataset.toolKeyEnhanced) continue; row.dataset.toolKeyEnhanced = "1"; row.style.cssText += "display:flex;align-items:center;gap:8px;flex-wrap:wrap;"; const badge = document.createElement("span"); badge.id = inputId + "-badge"; badge.style.cssText = "font-size:11px;padding:2px 8px;border-radius:10px;white-space:nowrap;"; row.appendChild(badge); const delBtn = document.createElement("button"); delBtn.type = "button"; delBtn.id = inputId + "-delete"; delBtn.className = "config-btn-secondary"; delBtn.style.cssText = "font-size:11px;padding:4px 10px;color:var(--danger,#e74c3c);border-color:var(--danger,#e74c3c);cursor:pointer;display:none;"; delBtn.textContent = "\u00d7 " + t("config.delete_key"); delBtn.addEventListener("click", () => deleteToolKey(inputId)); row.appendChild(delBtn); } } function _setToolKeyBadge(inputId, hasKey) { const badge = document.getElementById(inputId + "-badge"); const delBtn = document.getElementById(inputId + "-delete"); if (badge) { if (hasKey) { badge.textContent = "\u2713 " + t("config.key_set"); badge.style.background = "var(--success-bg, #27ae6022)"; badge.style.color = "var(--success, #27ae60)"; badge.style.border = "1px solid var(--success, #27ae60)"; } else { badge.textContent = t("config.key_unset"); badge.style.background = "var(--muted-bg, #ffffff10)"; badge.style.color = "var(--text-muted, #888)"; badge.style.border = "1px solid var(--border, #444)"; } } if (delBtn) delBtn.style.display = hasKey ? "inline-block" : "none"; } async function loadToolKeys() { _ensureToolKeyUI(); try { const data = await api("/api/config/tool-keys"); for (const [inputId, name] of Object.entries(TOOL_KEY_MAP)) { const input = document.getElementById(inputId); const val = data[name] || ""; if (input && !input.value.trim()) input.placeholder = val || input.placeholder; _setToolKeyBadge(inputId, !!val); } } catch(e) { /* non-admin: the section stays inert */ } } async function saveToolKeys() { const keys = {}; for (const [id, name] of Object.entries(TOOL_KEY_MAP)) { const input = document.getElementById(id); if (input && input.value.trim()) keys[name] = input.value.trim(); } if (!Object.keys(keys).length) { showToast(t("config.no_keys"), "warning"); return; } try { await api("/api/config/tool-keys", { method: "POST", body: JSON.stringify(keys) }); showToast(t("config.api_keys_saved"), "success"); Object.keys(TOOL_KEY_MAP).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; }); loadToolKeys(); } catch(e) { showToast("Erreur: " + e.message, "error"); } } async function deleteToolKey(inputId) { const name = TOOL_KEY_MAP[inputId]; if (!name) return; if (!confirm(t("config.delete_key_confirm") + " " + name + " ?")) return; try { await api("/api/config/tool-keys/" + name, { method: "DELETE" }); showToast(t("config.key_deleted") + " " + name, "success"); loadToolKeys(); } catch(e) { showToast("Erreur: " + e.message, "error"); } } export { initSidebarTabs, initConfigModal, initConfigModal as initConfigPanel, initHelpModal, closeHelpModal, initRecentTab, initEditorPocBtn, loadAbout as initAboutSection, loadHiddenFilesSettings as initHiddenFilesConfig, initThemePicker, initAboutModal, initProfile, switchSidebarTab, populateVersions, loadAIKeys, saveAIKeys, }; // Populate every version display (header badge, About modal, help guide footer) // from a single source of truth so they always match. function populateVersions(version) { var v = version || window.__OBSIGATE_VERSION || '—'; var els = [ document.getElementById('version-badge'), document.getElementById('about-version'), document.getElementById('help-version'), ]; for (var i = 0; i < els.length; i++) { if (els[i]) els[i].textContent = v; } return v; } // ── Theme Picker ───────────────────────────────────────────────────── function initThemePicker() { import('./themes.js').then(function(mod) { var THEMES = mod.THEMES; var AVAILABLE_MODES = mod.AVAILABLE_MODES; var applyTheme = mod.applyTheme; var getCurrentTheme = mod.getCurrentTheme; var getCurrentMode = mod.getCurrentMode; var exportAllThemes = mod.exportAllThemes; var importTheme = mod.importTheme; var deleteCustomTheme = mod.deleteCustomTheme; var loadCustomThemes = mod.loadCustomThemes; var grid = document.getElementById('theme-grid'); if (!grid) return; // Mode label map (uses i18n t() when available, fallback to English) var modeLabels = { 'dark': function() { return t('theme.dark'); }, 'light': function() { return t('theme.light'); }, 'high-contrast': function() { return t('theme.high_contrast'); }, 'sepia': function() { return t('theme.sepia'); } }; function renderCards() { var themeKeys = Object.keys(THEMES); var current = getCurrentTheme(); var mode = getCurrentMode(); grid.innerHTML = ''; themeKeys.forEach(function(key) { var th = THEMES[key]; // Use dark mode for preview if mode not resolved yet var v = th.modes[mode] || th.modes.dark || th.modes.light; if (!v) return; var card = document.createElement('div'); card.className = 'theme-card' + (key === current ? ' active' : ''); var isCustom = th.desc === 'Custom theme'; card.innerHTML = '
' + '
' + '
' + '
' + '
' + '
' + '
' + th.name + (isCustom ? ' ✦' : '') + '
' + '
' + th.desc + '
'; card.addEventListener('click', function() { applyTheme(key, mode); renderCards(); }); // Right-click to delete custom themes if (isCustom) { card.addEventListener('contextmenu', function(e) { e.preventDefault(); if (confirm(t('theme.delete_confirm'))) { deleteCustomTheme(key); renderCards(); } }); } grid.appendChild(card); }); // Mode toggle — 4 modes var toggle = document.createElement('div'); toggle.className = 'theme-mode-toggle'; AVAILABLE_MODES.forEach(function(m) { var btn = document.createElement('button'); btn.className = 'theme-mode-btn' + (m === mode ? ' active' : ''); btn.textContent = (modeLabels[m] || function() { return m; })(); btn.addEventListener('click', function() { applyTheme(getCurrentTheme(), m); renderCards(); }); toggle.appendChild(btn); }); grid.appendChild(toggle); // Import/Export buttons var actions = document.createElement('div'); actions.className = 'theme-actions'; var exportBtn = document.createElement('button'); exportBtn.className = 'theme-action-btn'; exportBtn.textContent = t('theme.export_all'); exportBtn.addEventListener('click', function() { var json = exportAllThemes(); var blob = new Blob([json], { type: 'application/json' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'obsigate-themes.json'; a.click(); URL.revokeObjectURL(url); }); var importBtn = document.createElement('button'); importBtn.className = 'theme-action-btn'; importBtn.textContent = t('theme.import'); importBtn.addEventListener('click', function() { var input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.addEventListener('change', function(e) { var file = e.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = function(ev) { var content = ev.target.result; // Support single theme or all-themes format try { var data = JSON.parse(content); if (data.themes) { // Multi-theme export: import each var count = 0; Object.keys(data.themes).forEach(function(k) { var res = importTheme(JSON.stringify(data.themes[k])); if (res.ok) count++; }); alert(t('theme.import_success', { count: count })); } else { var res = importTheme(content); if (res.ok) { alert(t('theme.import_success', { count: 1 })); } else { alert(t('theme.import_error') + ': ' + res.error); } } } catch(ex) { alert(t('theme.import_error') + ': ' + ex.message); } renderCards(); }; reader.readAsText(file); }); input.click(); }); actions.appendChild(exportBtn); actions.appendChild(importBtn); grid.appendChild(actions); } renderCards(); }); } // ── About Modal ────────────────────────────────────────────────────── function initAboutModal() { var overlay = document.getElementById('about-overlay'); var closeBtn = document.getElementById('about-close'); if (!overlay) return; // Open button var openBtn = document.getElementById('about-open-btn'); if (openBtn) { openBtn.addEventListener('click', function() { overlay.classList.add('active'); populateAbout(); }); } // Close closeBtn.addEventListener('click', function() { overlay.classList.remove('active'); }); overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.classList.remove('active'); }); document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.classList.contains('active')) overlay.classList.remove('active'); }); function populateAbout() { // Live stats + version from health endpoint (single source of truth). // Fallback silencieux : si le fetch échoue, on garde la version pré-chargée // (window.__OBSIGATE_VERSION) au lieu d'un "—" trompeur. fetch('/api/health') .then(function(r) { return r.json(); }) .then(function(d) { var fEl = document.getElementById('about-stat-files'); var vEl = document.getElementById('about-stat-vaults'); if (fEl && d.total_files) fEl.textContent = d.total_files; if (vEl && d.vaults) vEl.textContent = d.vaults; // Single source of truth: populate the header badge, About and // help-guide footer with the same version value. if (d && (d.version || d.git_describe)) { populateVersions((d && d.version) || window.__OBSIGATE_VERSION); } var commitEl = document.getElementById('about-commit'); if (commitEl) commitEl.textContent = (d && d.git_describe) || (d && d.git_commit) || '—'; }) .catch(function() { populateVersions(); }); // Build date (today) var now = new Date(); var months = [t('month.january'),t('month.february'),t('month.march'),t('month.april'),t('month.may'),t('month.june'),t('month.july'),t('month.august'),t('month.september'),t('month.october'),t('month.november'),t('month.december')]; var dateEl = document.getElementById('about-build-date'); if (dateEl) dateEl.textContent = months[now.getMonth()] + ' ' + now.getFullYear(); // Random joke var jokes = [ t('about.joke_0'), t('about.joke_1'), t('about.joke_2'), t('about.joke_3'), t('about.joke_4'), t('about.joke_5'), t('about.joke_6'), t('about.joke_7'), t('about.joke_8'), t('about.joke_9') ]; var jokeEl = document.getElementById('about-joke'); if (jokeEl) jokeEl.textContent = jokes[Math.floor(Math.random() * jokes.length)]; } } // ── Profile ────────────────────────────────────────────────────────── // #113 — profile picture: pick/import an image, center-crop it to a 256 px // square JPEG, persist it on the account (PATCH /api/auth/me) and show it in // the sidebar circle (#sidebar-user-avatar). Initials remain the fallback. const _AVATAR_MAX_FILE_BYTES = 8 * 1024 * 1024; const _AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"]; const _AVATAR_SIZE = 256; function _profileInitials(name) { var parts = String(name || "").trim().split(/\s+/).filter(Boolean); if (!parts.length) return "?"; var first = parts[0][0] || ""; var last = parts.length > 1 ? parts[parts.length - 1][0] : ""; return (first + last).toUpperCase() || "?"; } /** Center-crop the picked image to a square and export it as a JPEG data-URL. */ function _resizeAvatarFile(file) { return new Promise(function (resolve, reject) { var url = URL.createObjectURL(file); var img = new Image(); img.onload = function () { URL.revokeObjectURL(url); try { var canvas = document.createElement("canvas"); canvas.width = _AVATAR_SIZE; canvas.height = _AVATAR_SIZE; var ctx = canvas.getContext("2d"); if (!ctx) throw new Error("canvas"); ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, _AVATAR_SIZE, _AVATAR_SIZE); var side = Math.min(img.naturalWidth, img.naturalHeight); var sx = (img.naturalWidth - side) / 2; var sy = (img.naturalHeight - side) / 2; ctx.drawImage(img, sx, sy, side, side, 0, 0, _AVATAR_SIZE, _AVATAR_SIZE); resolve(canvas.toDataURL("image/jpeg", 0.85)); } catch (err) { reject(err); } }; img.onerror = function () { URL.revokeObjectURL(url); reject(new Error("image decode failed")); }; img.src = url; }); } function _profileAvatarError(key) { var err = document.getElementById("profile-avatar-error"); if (!err) return; if (!key) { err.textContent = ""; err.classList.add("hidden"); return; } err.textContent = t(key); err.classList.remove("hidden"); } /** Paint the settings preview (image or initials) for the given avatar. */ function _renderProfileAvatar(dataUrl) { var img = document.getElementById("profile-avatar-img"); var initialsEl = document.getElementById("profile-avatar-initials"); var removeBtn = document.getElementById("profile-avatar-remove"); if (!img || !initialsEl) return; if (dataUrl) { img.src = dataUrl; img.hidden = false; initialsEl.textContent = ""; if (removeBtn) removeBtn.hidden = false; } else { img.removeAttribute("src"); img.hidden = true; var user = AuthManager.getUser() || {}; var name = localStorage.getItem("obsigate-name") || user.display_name || user.username || ""; initialsEl.textContent = _profileInitials(name); if (removeBtn) removeBtn.hidden = true; } } /** PATCH /api/auth/me with an avatar value (data-URL, or "" to clear). */ async function _patchProfileAvatar(value) { var headers = { "Content-Type": "application/json" }; var authHeaders = AuthManager.getAuthHeaders ? AuthManager.getAuthHeaders() : null; if (authHeaders) headers = Object.assign(headers, authHeaders); var res = await fetch("/api/auth/me", { method: "PATCH", headers: headers, credentials: "include", body: JSON.stringify({ avatar: value }), }); if (!res.ok) { var detail = ""; try { detail = (await res.json()).detail || ""; } catch (e) { /* no json body */ } throw new Error(detail || "HTTP " + res.status); } return res.json(); } function initProfile() { var langSel = document.getElementById('profile-lang'); var nameInp = document.getElementById('profile-name'); var saveBtn = document.getElementById('profile-save'); var logoutBtn = document.getElementById('profile-logout'); var savedEl = document.getElementById('profile-saved'); if (!langSel || !saveBtn) return; // Load saved profile try { var lang = getLocale(); var name = localStorage.getItem('obsigate-name') || ''; if (langSel) langSel.value = lang; if (nameInp) nameInp.value = name; } catch(e) {} saveBtn.addEventListener('click', async function() { try { var newLang = langSel.value; await setLocale(newLang); // Live-reload all translations if (nameInp) localStorage.setItem('obsigate-name', nameInp.value.trim()); // Persist language to server (survives clear site data) try { await fetch('/api/auth/me', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ language: newLang }), }); } catch (e) { /* non-bloquant */ } if (savedEl) { savedEl.style.display = 'inline'; setTimeout(function() { savedEl.style.display = 'none'; }, 2000); } } catch(e) {} }); // Logout button (#87, ex-onclick inline in index.html). if (logoutBtn && window.handleLogout) { logoutBtn.addEventListener('click', function() { window.handleLogout(); }); } // ── Avatar (#113) ──────────────────────────────────────────────── var avatarField = document.getElementById('profile-avatar-field'); var avatarInput = document.getElementById('profile-avatar-input'); var chooseBtn = document.getElementById('profile-avatar-choose'); var overlayBtn = document.getElementById('profile-avatar-overlay'); var previewBox = document.getElementById('profile-avatar-preview'); var removeBtn = document.getElementById('profile-avatar-remove'); var presetsBox = document.getElementById('profile-avatar-presets'); if (!avatarField || !avatarInput || !chooseBtn) return; // The avatar only exists on a real account — hide it when auth is off. if (!AuthManager.isAuthEnabled || !AuthManager.isAuthEnabled()) { avatarField.hidden = true; return; } _renderProfileAvatar((AuthManager.getUser() || {}).avatar || null); // Refresh from the server so the preview survives a page reload. fetch('/api/auth/me', { credentials: 'include' }) .then(function (res) { return res.ok ? res.json() : null; }) .then(function (user) { if (!user) return; AuthManager.updateCachedUser(user); _renderProfileAvatar(user.avatar || null); }) .catch(function () { /* non-bloquant */ }); // ── Preset avatars (#117) ──────────────────────────────────────── var PRESET_STORAGE_KEY = 'obsigate-avatar-preset'; function markActivePreset(name) { if (!presetsBox) return; presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) { var on = !!name && btn.getAttribute('data-avatar') === name; btn.classList.toggle('active', on); btn.setAttribute('aria-checked', on ? 'true' : 'false'); }); } function clearActivePreset() { try { localStorage.removeItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ } markActivePreset(null); } function rememberActivePreset(name) { try { localStorage.setItem(PRESET_STORAGE_KEY, name); } catch (e) { /* ignore */ } markActivePreset(name); } var savedPreset = null; try { savedPreset = localStorage.getItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ } markActivePreset(savedPreset); if (presetsBox) { presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) { btn.addEventListener('click', async function () { var preset = btn.getAttribute('data-avatar'); if (!preset) return; _profileAvatarError(null); var all = presetsBox.querySelectorAll('.profile-avatar-preset'); all.forEach(function (b) { b.disabled = true; }); try { // Load the bundled image, then reuse the upload pipeline (center-crop // + 256 px JPEG) so it stores exactly like an imported picture. var res = await fetch('/static/icons/avatar/' + encodeURIComponent(preset), { credentials: 'include' }); if (!res.ok) throw new Error('HTTP ' + res.status); var blob = await res.blob(); var dataUrl = await _resizeAvatarFile(blob); var user = await _patchProfileAvatar(dataUrl); AuthManager.updateCachedUser(user); _renderProfileAvatar(user.avatar || dataUrl); AuthManager.renderUserSection(); rememberActivePreset(preset); showToast(t('config.avatar_updated'), 'success'); } catch (err) { _profileAvatarError('config.avatar_upload_failed'); console.error('Avatar preset failed:', err); } finally { all.forEach(function (b) { b.disabled = false; }); } }); }); } function openPicker() { _profileAvatarError(null); avatarInput.click(); } chooseBtn.addEventListener('click', openPicker); if (overlayBtn) overlayBtn.addEventListener('click', openPicker); if (previewBox) { previewBox.addEventListener('click', function (e) { if (e.target.closest('button')) return; openPicker(); }); } avatarInput.addEventListener('change', async function () { var file = avatarInput.files && avatarInput.files[0]; avatarInput.value = ""; // allow re-picking the same file if (!file) return; _profileAvatarError(null); if (_AVATAR_TYPES.indexOf(file.type) === -1) { _profileAvatarError('config.avatar_invalid_type'); return; } if (file.size > _AVATAR_MAX_FILE_BYTES) { _profileAvatarError('config.avatar_too_large'); return; } chooseBtn.disabled = true; try { var dataUrl = await _resizeAvatarFile(file); var user = await _patchProfileAvatar(dataUrl); AuthManager.updateCachedUser(user); _renderProfileAvatar(user.avatar || dataUrl); AuthManager.renderUserSection(); clearActivePreset(); // an imported photo is not a preset showToast(t('config.avatar_updated'), 'success'); } catch (err) { _profileAvatarError('config.avatar_upload_failed'); console.error('Avatar upload failed:', err); } finally { chooseBtn.disabled = false; } }); if (removeBtn) { removeBtn.addEventListener('click', async function () { _profileAvatarError(null); removeBtn.disabled = true; try { var user = await _patchProfileAvatar(""); AuthManager.updateCachedUser(user); _renderProfileAvatar(null); AuthManager.renderUserSection(); clearActivePreset(); showToast(t('config.avatar_removed'), 'success'); } catch (err) { _profileAvatarError('config.avatar_upload_failed'); console.error('Avatar removal failed:', err); } finally { removeBtn.disabled = false; } }); } }