// 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) => 'Expire le ${new Date(existingShare.expires_at).toLocaleDateString("fr-FR")}
` : 'Sans expiration
'; div.innerHTML = ` `; 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 = ` `; 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 = '