/* ObsiGate — Vanilla JS SPA */ (function () { "use strict"; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- let currentVault = null; let currentPath = null; let searchTimeout = null; let searchAbortController = null; let showingSource = false; let cachedRawSource = null; let allVaults = []; let selectedContextVault = "all"; let selectedTags = []; let editorView = null; let editorVault = null; let editorPath = null; let fallbackEditorEl = null; let sidebarFilterCaseSensitive = false; let searchCaseSensitive = false; let _iconDebounceTimer = null; let activeSidebarTab = "vaults"; let filterDebounce = null; // --------------------------------------------------------------------------- // File extension → Lucide icon mapping // --------------------------------------------------------------------------- const EXT_ICONS = { ".md": "file-text", ".txt": "file-text", ".log": "file-text", ".py": "file-code", ".js": "file-code", ".ts": "file-code", ".jsx": "file-code", ".tsx": "file-code", ".html": "file-code", ".css": "file-code", ".scss": "file-code", ".less": "file-code", ".json": "file-json", ".yaml": "file-cog", ".yml": "file-cog", ".toml": "file-cog", ".xml": "file-code", ".sh": "terminal", ".bash": "terminal", ".zsh": "terminal", ".bat": "terminal", ".cmd": "terminal", ".ps1": "terminal", ".java": "file-code", ".c": "file-code", ".cpp": "file-code", ".h": "file-code", ".hpp": "file-code", ".cs": "file-code", ".go": "file-code", ".rs": "file-code", ".rb": "file-code", ".php": "file-code", ".sql": "database", ".csv": "table", ".ini": "file-cog", ".cfg": "file-cog", ".conf": "file-cog", ".env": "file-cog", }; function getFileIcon(name) { const ext = "." + name.split(".").pop().toLowerCase(); return EXT_ICONS[ext] || "file"; } // --------------------------------------------------------------------------- // Safe CDN helpers // --------------------------------------------------------------------------- /** * Debounced icon creation — batches multiple rapid calls into one * DOM scan to avoid excessive reflows when building large trees. */ function safeCreateIcons() { if (typeof lucide === "undefined" || !lucide.createIcons) return; if (_iconDebounceTimer) return; // already scheduled _iconDebounceTimer = requestAnimationFrame(() => { _iconDebounceTimer = null; try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ } }); } /** Force-flush icon creation immediately (use sparingly). */ function flushIcons() { if (_iconDebounceTimer) { cancelAnimationFrame(_iconDebounceTimer); _iconDebounceTimer = null; } if (typeof lucide !== "undefined" && lucide.createIcons) { try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ } } } function safeHighlight(block) { if (typeof hljs !== "undefined" && hljs.highlightElement) { try { hljs.highlightElement(block); } catch (e) { /* CDN not loaded */ } } } // --------------------------------------------------------------------------- // Theme // --------------------------------------------------------------------------- function initTheme() { const saved = localStorage.getItem("obsigate-theme") || "dark"; applyTheme(saved); } function applyTheme(theme) { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("obsigate-theme", theme); // Update theme button icon and label const themeBtn = document.getElementById("theme-toggle"); const themeLabel = document.getElementById("theme-label"); if (themeBtn && themeLabel) { const icon = themeBtn.querySelector("i"); if (icon) { icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun"); } themeLabel.textContent = theme === "dark" ? "Sombre" : "Clair"; safeCreateIcons(); } // Swap highlight.js theme const darkSheet = document.getElementById("hljs-theme-dark"); const lightSheet = document.getElementById("hljs-theme-light"); if (darkSheet && lightSheet) { darkSheet.disabled = theme !== "dark"; lightSheet.disabled = theme !== "light"; } } function toggleTheme() { const current = document.documentElement.getAttribute("data-theme"); applyTheme(current === "dark" ? "light" : "dark"); } function initHeaderMenu() { const menuBtn = document.getElementById("header-menu-btn"); const menuDropdown = document.getElementById("header-menu-dropdown"); if (!menuBtn || !menuDropdown) return; menuBtn.addEventListener("click", (e) => { e.stopPropagation(); menuBtn.classList.toggle("active"); menuDropdown.classList.toggle("active"); }); // Close menu when clicking outside document.addEventListener("click", (e) => { if (!menuDropdown.contains(e.target) && e.target !== menuBtn) { menuBtn.classList.remove("active"); menuDropdown.classList.remove("active"); } }); // Prevent menu from closing when clicking inside menuDropdown.addEventListener("click", (e) => { e.stopPropagation(); }); } function closeHeaderMenu() { const menuBtn = document.getElementById("header-menu-btn"); const menuDropdown = document.getElementById("header-menu-dropdown"); if (!menuBtn || !menuDropdown) return; menuBtn.classList.remove("active"); menuDropdown.classList.remove("active"); } // --------------------------------------------------------------------------- // Custom Dropdowns // --------------------------------------------------------------------------- function initCustomDropdowns() { document.querySelectorAll('.custom-dropdown').forEach(dropdown => { const trigger = dropdown.querySelector('.custom-dropdown-trigger'); const options = dropdown.querySelectorAll('.custom-dropdown-option'); const hiddenInput = dropdown.querySelector('input[type="hidden"]'); const selectedText = dropdown.querySelector('.custom-dropdown-selected'); const menu = dropdown.querySelector('.custom-dropdown-menu'); if (!trigger) return; // Toggle dropdown trigger.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = dropdown.classList.contains('open'); // Close all other dropdowns document.querySelectorAll('.custom-dropdown.open').forEach(d => { if (d !== dropdown) d.classList.remove('open'); }); dropdown.classList.toggle('open', !isOpen); trigger.setAttribute('aria-expanded', !isOpen); // Position fixed menu for sidebar dropdowns if (!isOpen && dropdown.classList.contains('sidebar-dropdown') && menu) { const rect = trigger.getBoundingClientRect(); menu.style.top = `${rect.bottom + 4}px`; menu.style.left = `${rect.left}px`; menu.style.width = `${rect.width}px`; } }); // Handle option selection options.forEach(option => { option.addEventListener('click', (e) => { e.stopPropagation(); const value = option.getAttribute('data-value'); const text = option.textContent; // Update hidden input if (hiddenInput) { hiddenInput.value = value; // Trigger change event hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); } // Update selected text if (selectedText) { selectedText.textContent = text; } // Update visual selection options.forEach(opt => opt.classList.remove('selected')); option.classList.add('selected'); // Close dropdown dropdown.classList.remove('open'); trigger.setAttribute('aria-expanded', 'false'); }); }); }); // Close dropdowns when clicking outside document.addEventListener('click', () => { document.querySelectorAll('.custom-dropdown.open').forEach(dropdown => { dropdown.classList.remove('open'); const trigger = dropdown.querySelector('.custom-dropdown-trigger'); if (trigger) trigger.setAttribute('aria-expanded', 'false'); }); }); } // Helper to populate custom dropdown options function populateCustomDropdown(dropdownId, optionsList, defaultValue) { const dropdown = document.getElementById(dropdownId); if (!dropdown) return; const optionsContainer = dropdown.querySelector('.custom-dropdown-menu'); const hiddenInput = dropdown.querySelector('input[type="hidden"]'); const selectedText = dropdown.querySelector('.custom-dropdown-selected'); if (!optionsContainer) return; // Clear existing options (keep the first one if it's the default) optionsContainer.innerHTML = ''; // Add new options optionsList.forEach(opt => { const li = document.createElement('li'); li.className = 'custom-dropdown-option'; li.setAttribute('role', 'option'); li.setAttribute('data-value', opt.value); li.textContent = opt.text; if (opt.value === defaultValue) { li.classList.add('selected'); if (selectedText) selectedText.textContent = opt.text; if (hiddenInput) hiddenInput.value = opt.value; } optionsContainer.appendChild(li); }); // Re-initialize click handlers optionsContainer.querySelectorAll('.custom-dropdown-option').forEach(option => { option.addEventListener('click', (e) => { e.stopPropagation(); const value = option.getAttribute('data-value'); const text = option.textContent; if (hiddenInput) { hiddenInput.value = value; hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); } if (selectedText) { selectedText.textContent = text; } optionsContainer.querySelectorAll('.custom-dropdown-option').forEach(opt => opt.classList.remove('selected')); option.classList.add('selected'); dropdown.classList.remove('open'); const trigger = dropdown.querySelector('.custom-dropdown-trigger'); if (trigger) trigger.setAttribute('aria-expanded', 'false'); }); }); } // --------------------------------------------------------------------------- // Toast notifications // --------------------------------------------------------------------------- /** Display a brief toast message at the bottom of the viewport. */ function showToast(message, type) { type = type || "error"; let container = document.getElementById("toast-container"); if (!container) { container = document.createElement("div"); container.id = "toast-container"; container.className = "toast-container"; container.setAttribute("aria-live", "polite"); document.body.appendChild(container); } var toast = document.createElement("div"); toast.className = "toast toast-" + type; toast.textContent = message; container.appendChild(toast); // Trigger entrance animation requestAnimationFrame(function () { toast.classList.add("show"); }); setTimeout(function () { toast.classList.remove("show"); toast.addEventListener("transitionend", function () { toast.remove(); }); }, 3500); } // --------------------------------------------------------------------------- // API helpers // --------------------------------------------------------------------------- /** * Fetch JSON from an API endpoint with optional AbortSignal support. * Surfaces errors to the user via toast instead of silently failing. * * @param {string} path - API URL path. * @param {object} [opts] - Fetch options (may include signal). * @returns {Promise} Parsed JSON response. */ async function api(path, opts) { var res; try { res = await fetch(path, opts || {}); } catch (err) { if (err.name === "AbortError") throw err; // let callers handle abort showToast("Erreur réseau — vérifiez votre connexion"); throw err; } if (!res.ok) { var detail = ""; try { var body = await res.json(); detail = body.detail || ""; } catch (_) { /* no json body */ } showToast(detail || "Erreur API : " + res.status); throw new Error(detail || "API error: " + res.status); } return res.json(); } // --------------------------------------------------------------------------- // Sidebar toggle (desktop) // --------------------------------------------------------------------------- function initSidebarToggle() { const toggleBtn = document.getElementById("sidebar-toggle-btn"); const sidebar = document.getElementById("sidebar"); const resizeHandle = document.getElementById("sidebar-resize-handle"); if (!toggleBtn || !sidebar || !resizeHandle) return; // Restore saved state const savedState = localStorage.getItem("obsigate-sidebar-hidden"); if (savedState === "true") { sidebar.classList.add("hidden"); resizeHandle.classList.add("hidden"); toggleBtn.classList.add("active"); } toggleBtn.addEventListener("click", () => { const isHidden = sidebar.classList.toggle("hidden"); resizeHandle.classList.toggle("hidden", isHidden); toggleBtn.classList.toggle("active", isHidden); localStorage.setItem("obsigate-sidebar-hidden", isHidden ? "true" : "false"); }); } // --------------------------------------------------------------------------- // Mobile sidebar // --------------------------------------------------------------------------- function initMobile() { const hamburger = document.getElementById("hamburger-btn"); const overlay = document.getElementById("sidebar-overlay"); const sidebar = document.getElementById("sidebar"); hamburger.addEventListener("click", () => { sidebar.classList.toggle("mobile-open"); overlay.classList.toggle("active"); }); overlay.addEventListener("click", () => { sidebar.classList.remove("mobile-open"); overlay.classList.remove("active"); }); } function closeMobileSidebar() { const sidebar = document.getElementById("sidebar"); const overlay = document.getElementById("sidebar-overlay"); if (sidebar) sidebar.classList.remove("mobile-open"); if (overlay) overlay.classList.remove("active"); } // --------------------------------------------------------------------------- // Vault context switching // --------------------------------------------------------------------------- function initVaultContext() { const filter = document.getElementById("vault-filter"); const quickSelect = document.getElementById("vault-quick-select"); if (!filter || !quickSelect) return; filter.addEventListener("change", async () => { await setSelectedVaultContext(filter.value, { focusVault: filter.value !== "all" }); }); quickSelect.addEventListener("change", async () => { await setSelectedVaultContext(quickSelect.value, { focusVault: quickSelect.value !== "all" }); }); } async function setSelectedVaultContext(vaultName, options) { selectedContextVault = vaultName; showingSource = false; cachedRawSource = null; syncVaultSelectors(); await refreshSidebarForContext(); await refreshTagsForContext(); showWelcome(); if (options && options.focusVault && vaultName !== "all") { await focusVaultInSidebar(vaultName); } } function syncVaultSelectors() { const filter = document.getElementById("vault-filter"); const quickSelect = document.getElementById("vault-quick-select"); if (filter) filter.value = selectedContextVault; if (quickSelect) quickSelect.value = selectedContextVault; } function scrollTreeItemIntoView(element, alignToTop) { if (!element) return; const scrollContainer = document.getElementById("sidebar-panel-vaults"); if (!scrollContainer) return; const containerRect = scrollContainer.getBoundingClientRect(); const elementRect = element.getBoundingClientRect(); const isAbove = elementRect.top < containerRect.top; const isBelow = elementRect.bottom > containerRect.bottom; if (!isAbove && !isBelow && !alignToTop) return; const currentTop = scrollContainer.scrollTop; const offsetTop = element.offsetTop; const targetTop = alignToTop ? Math.max(0, offsetTop - 60) : Math.max(0, currentTop + (elementRect.top - containerRect.top) - (containerRect.height * 0.35)); scrollContainer.scrollTo({ top: targetTop, behavior: "smooth", }); } async function refreshSidebarForContext() { const container = document.getElementById("vault-tree"); container.innerHTML = ""; const vaultsToShow = selectedContextVault === "all" ? allVaults : allVaults.filter((v) => v.name === selectedContextVault); vaultsToShow.forEach((v) => { const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [ icon("chevron-right", 14), icon("database", 16), document.createTextNode(` ${v.name} `), smallBadge(v.file_count), ]); vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); container.appendChild(vaultItem); const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` }); container.appendChild(childContainer); }); safeCreateIcons(); } async function focusVaultInSidebar(vaultName) { switchSidebarTab("vaults"); const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`); if (!vaultItem) return; document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused")); vaultItem.classList.add("focused"); const childContainer = document.getElementById(`vault-children-${vaultName}`); if (childContainer && childContainer.classList.contains("collapsed")) { await toggleVault(vaultItem, vaultName, true); } scrollTreeItemIntoView(vaultItem, false); } async function refreshTagsForContext() { const vaultParam = selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(selectedContextVault)}`; const data = await api(`/api/tags${vaultParam}`); const filteredTags = TagFilterService.filterTags(data.tags); renderTagCloud(filteredTags); } // --------------------------------------------------------------------------- // Sidebar — Vault tree // --------------------------------------------------------------------------- async function loadVaults() { const vaults = await api("/api/vaults"); allVaults = vaults; const container = document.getElementById("vault-tree"); container.innerHTML = ""; // Prepare dropdown options const dropdownOptions = [ { value: "all", text: "Tous les vaults" }, ...vaults.map(v => ({ value: v.name, text: v.name })) ]; // Populate custom dropdowns populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all"); populateCustomDropdown("vault-quick-select-dropdown", dropdownOptions, "all"); vaults.forEach((v) => { // Sidebar tree entry const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [ icon("chevron-right", 14), icon("database", 16), document.createTextNode(` ${v.name} `), smallBadge(v.file_count), ]); vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); container.appendChild(vaultItem); const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` }); container.appendChild(childContainer); }); syncVaultSelectors(); safeCreateIcons(); } async function toggleVault(itemEl, vaultName, forceExpand) { const childContainer = document.getElementById(`vault-children-${vaultName}`); if (!childContainer) return; scrollTreeItemIntoView(itemEl, false); const shouldExpand = forceExpand || childContainer.classList.contains("collapsed"); if (shouldExpand) { // Expand — load children if empty if (childContainer.children.length === 0) { await loadDirectory(vaultName, "", childContainer); } childContainer.classList.remove("collapsed"); // Swap chevron const chevron = itemEl.querySelector("[data-lucide]"); if (chevron) chevron.setAttribute("data-lucide", "chevron-down"); safeCreateIcons(); } else { childContainer.classList.add("collapsed"); const chevron = itemEl.querySelector("[data-lucide]"); if (chevron) chevron.setAttribute("data-lucide", "chevron-right"); safeCreateIcons(); } } async function focusPathInSidebar(vaultName, targetPath, options) { switchSidebarTab("vaults"); const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`); if (!vaultItem) return; document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused")); vaultItem.classList.add("focused"); const vaultContainer = document.getElementById(`vault-children-${vaultName}`); if (!vaultContainer) return; if (vaultContainer.classList.contains("collapsed")) { await toggleVault(vaultItem, vaultName, true); } if (!targetPath) { // Clear any previous path selection document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected")); scrollTreeItemIntoView(vaultItem, options && options.alignToTop); return; } const segments = targetPath.split("/").filter(Boolean); let currentContainer = vaultContainer; let cumulativePath = ""; let lastTargetItem = null; for (let index = 0; index < segments.length; index++) { cumulativePath += (cumulativePath ? "/" : "") + segments[index]; let targetItem = null; try { targetItem = currentContainer.querySelector(`.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(cumulativePath)}"]`); } catch (e) { targetItem = null; } if (!targetItem) { return; } lastTargetItem = targetItem; const isLastSegment = index === segments.length - 1; if (!isLastSegment) { const nextContainer = document.getElementById(`dir-${vaultName}-${cumulativePath}`); if (nextContainer && nextContainer.classList.contains("collapsed")) { targetItem.click(); await new Promise((resolve) => setTimeout(resolve, 0)); } if (nextContainer) { currentContainer = nextContainer; } } } // Clear previous path selections and highlight the final target document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected")); if (lastTargetItem) { lastTargetItem.classList.add("path-selected"); } scrollTreeItemIntoView(lastTargetItem, options && options.alignToTop); } async function loadDirectory(vaultName, dirPath, container) { // Show inline loading indicator while fetching directory contents container.innerHTML = '
'; var data; try { const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`; data = await api(url); } catch (err) { container.innerHTML = '
Erreur de chargement
'; return; } container.innerHTML = ""; const fragment = document.createDocumentFragment(); data.items.forEach((item) => { if (item.type === "directory") { const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [ icon("chevron-right", 14), icon("folder", 16), document.createTextNode(` ${item.name} `), smallBadge(item.children_count), ]); fragment.appendChild(dirItem); const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); fragment.appendChild(subContainer); dirItem.addEventListener("click", async () => { scrollTreeItemIntoView(dirItem, false); if (subContainer.classList.contains("collapsed")) { if (subContainer.children.length === 0) { await loadDirectory(vaultName, item.path, subContainer); } subContainer.classList.remove("collapsed"); const chev = dirItem.querySelector("[data-lucide]"); if (chev) chev.setAttribute("data-lucide", "chevron-down"); safeCreateIcons(); } else { subContainer.classList.add("collapsed"); const chev = dirItem.querySelector("[data-lucide]"); if (chev) chev.setAttribute("data-lucide", "chevron-right"); safeCreateIcons(); } }); } else { const fileIconName = getFileIcon(item.name); const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [ icon(fileIconName, 16), document.createTextNode(` ${displayName}`), ]); fileItem.addEventListener("click", () => { scrollTreeItemIntoView(fileItem, false); openFile(vaultName, item.path); closeMobileSidebar(); }); fragment.appendChild(fileItem); } }); container.appendChild(fragment); safeCreateIcons(); } // --------------------------------------------------------------------------- // Sidebar filter // --------------------------------------------------------------------------- function initSidebarFilter() { const input = document.getElementById("sidebar-filter-input"); const caseBtn = document.getElementById("sidebar-filter-case-btn"); const clearBtn = document.getElementById("sidebar-filter-clear-btn"); input.addEventListener("input", () => { const hasText = input.value.length > 0; clearBtn.style.display = hasText ? "flex" : "none"; clearTimeout(filterDebounce); filterDebounce = setTimeout(async () => { const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); if (hasText) { if (activeSidebarTab === "vaults") { await performTreeSearch(q); } else { filterTagCloud(q); } } else { if (activeSidebarTab === "vaults") { await restoreSidebarTree(); } else { filterTagCloud(""); } } }, 220); }); caseBtn.addEventListener("click", async () => { sidebarFilterCaseSensitive = !sidebarFilterCaseSensitive; caseBtn.classList.toggle("active"); const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase(); if (input.value.trim()) { if (activeSidebarTab === "vaults") { await performTreeSearch(q); } else { filterTagCloud(q); } } }); clearBtn.addEventListener("click", async () => { input.value = ""; clearBtn.style.display = "none"; sidebarFilterCaseSensitive = false; caseBtn.classList.remove("active"); clearTimeout(filterDebounce); if (activeSidebarTab === "vaults") { await restoreSidebarTree(); } else { filterTagCloud(""); } }); clearBtn.style.display = "none"; } async function performTreeSearch(query) { if (!query) { await restoreSidebarTree(); return; } try { const vaultParam = selectedContextVault === "all" ? "all" : selectedContextVault; const url = `/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultParam)}`; const data = await api(url); renderFilteredSidebarResults(query, data.results); } catch (err) { console.error("Tree search error:", err); renderFilteredSidebarResults(query, []); } } async function restoreSidebarTree() { await refreshSidebarForContext(); if (currentVault) { focusPathInSidebar(currentVault, currentPath || "", { alignToTop: false }).catch(() => {}); } } function renderFilteredSidebarResults(query, results) { const container = document.getElementById("vault-tree"); container.innerHTML = ""; const grouped = new Map(); results.forEach((result) => { if (!grouped.has(result.vault)) { grouped.set(result.vault, []); } grouped.get(result.vault).push(result); }); if (grouped.size === 0) { container.appendChild(el("div", { class: "sidebar-filter-empty" }, [ document.createTextNode("Aucun répertoire ou fichier correspondant."), ])); return; } grouped.forEach((entries, vaultName) => { entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: "base" })); const vaultHeader = el("div", { class: "tree-item vault-item filter-results-header", "data-vault": vaultName }, [ icon("database", 16), document.createTextNode(` ${vaultName} `), smallBadge(entries.length), ]); container.appendChild(vaultHeader); const resultsWrapper = el("div", { class: "filter-results-group" }); entries.forEach((entry) => { const resultItem = el("div", { class: `tree-item filter-result-item filter-result-${entry.type}`, "data-vault": entry.vault, "data-path": entry.path, "data-type": entry.type, }, [ icon(entry.type === "directory" ? "folder" : getFileIcon(entry.name), 16), ]); const textWrap = el("div", { class: "filter-result-text" }); const primary = el("div", { class: "filter-result-primary" }); appendHighlightedText(primary, entry.name, query, sidebarFilterCaseSensitive); const secondary = el("div", { class: "filter-result-secondary" }); appendHighlightedText(secondary, entry.path, query, sidebarFilterCaseSensitive); textWrap.appendChild(primary); textWrap.appendChild(secondary); resultItem.appendChild(textWrap); resultItem.addEventListener("click", async () => { const input = document.getElementById("sidebar-filter-input"); const clearBtn = document.getElementById("sidebar-filter-clear-btn"); if (input) input.value = ""; if (clearBtn) clearBtn.style.display = "none"; await restoreSidebarTree(); if (entry.type === "directory") { await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true }); } else { await openFile(entry.vault, entry.path); await focusPathInSidebar(entry.vault, entry.path, { alignToTop: false }); } closeMobileSidebar(); }); resultsWrapper.appendChild(resultItem); }); container.appendChild(resultsWrapper); }); flushIcons(); } function filterSidebarTree(query) { const tree = document.getElementById("vault-tree"); const items = tree.querySelectorAll(".tree-item"); const containers = tree.querySelectorAll(".tree-children"); if (!query) { items.forEach((item) => item.classList.remove("filtered-out")); containers.forEach((c) => { c.classList.remove("filtered-out"); // Keep current collapsed state when clearing filter }); return; } // First pass: mark all as filtered out items.forEach((item) => item.classList.add("filtered-out")); containers.forEach((c) => c.classList.add("filtered-out")); // Second pass: find matching items and mark them + ancestors + descendants const matchingItems = new Set(); items.forEach((item) => { const text = sidebarFilterCaseSensitive ? item.textContent : item.textContent.toLowerCase(); const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase(); if (text.includes(searchQuery)) { matchingItems.add(item); item.classList.remove("filtered-out"); // Show all ancestor containers let parent = item.parentElement; while (parent && parent !== tree) { parent.classList.remove("filtered-out"); if (parent.classList.contains("tree-children")) { parent.classList.remove("collapsed"); } parent = parent.parentElement; } // If this is a directory (has a children container after it), show all descendants const nextEl = item.nextElementSibling; if (nextEl && nextEl.classList.contains("tree-children")) { nextEl.classList.remove("filtered-out"); nextEl.classList.remove("collapsed"); // Recursively show all children in this container showAllDescendants(nextEl); } } }); // Third pass: show items that are descendants of matching directories // and ensure their containers are visible matchingItems.forEach((item) => { const nextEl = item.nextElementSibling; if (nextEl && nextEl.classList.contains("tree-children")) { const children = nextEl.querySelectorAll(".tree-item"); children.forEach((child) => child.classList.remove("filtered-out")); } }); } function showAllDescendants(container) { const items = container.querySelectorAll(".tree-item"); items.forEach((item) => { item.classList.remove("filtered-out"); // If this item has children, also show them const nextEl = item.nextElementSibling; if (nextEl && nextEl.classList.contains("tree-children")) { nextEl.classList.remove("filtered-out"); nextEl.classList.remove("collapsed"); } }); // Also ensure all nested containers are visible const nestedContainers = container.querySelectorAll(".tree-children"); nestedContainers.forEach((c) => { c.classList.remove("filtered-out"); c.classList.remove("collapsed"); }); } function filterTagCloud(query) { const tags = document.querySelectorAll("#tag-cloud .tag-item"); tags.forEach((tag) => { const text = sidebarFilterCaseSensitive ? tag.textContent : tag.textContent.toLowerCase(); const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase(); if (!query || text.includes(searchQuery)) { tag.classList.remove("filtered-out"); } else { tag.classList.add("filtered-out"); } }); } // --------------------------------------------------------------------------- // Tag Filter Service // --------------------------------------------------------------------------- const TagFilterService = { defaultFilters: [ { pattern: "#<% ... %>", regex: "^#<%.*%>$", enabled: true }, { pattern: "#{{ ... }}", regex: "^#\\{\\{.*\\}\\}$", enabled: true }, { pattern: "#{ ... }", regex: "^#\\{.*\\}$", enabled: true } ], getConfig() { const stored = localStorage.getItem("obsigate-tag-filters"); if (stored) { try { return JSON.parse(stored); } catch (e) { return { tagFilters: this.defaultFilters }; } } return { tagFilters: this.defaultFilters }; }, saveConfig(config) { localStorage.setItem("obsigate-tag-filters", JSON.stringify(config)); }, patternToRegex(pattern) { let regex = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); regex = regex.replace(/\\\.\\\.\\\./g, '.*'); return regex; }, isTagFiltered(tag) { const config = this.getConfig(); const filters = config.tagFilters || this.defaultFilters; for (const filter of filters) { if (!filter.enabled) continue; try { const regex = new RegExp(`^${filter.regex}$`); if (regex.test(`#${tag}`)) { return true; } } catch (e) { console.warn("Invalid regex:", filter.regex, e); } } return false; }, filterTags(tags) { const filtered = {}; Object.entries(tags).forEach(([tag, count]) => { if (!this.isTagFiltered(tag)) { filtered[tag] = count; } }); return filtered; } }; // --------------------------------------------------------------------------- // Tags // --------------------------------------------------------------------------- async function loadTags() { const data = await api("/api/tags"); const filteredTags = TagFilterService.filterTags(data.tags); renderTagCloud(filteredTags); } function renderTagCloud(tags) { const cloud = document.getElementById("tag-cloud"); cloud.innerHTML = ""; const counts = Object.values(tags); if (counts.length === 0) return; const maxCount = Math.max(...counts); const minSize = 0.7; const maxSize = 1.25; Object.entries(tags).forEach(([tag, count]) => { const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0; const size = minSize + ratio * (maxSize - minSize); const tagEl = el("span", { class: "tag-item", style: `font-size:${size}rem` }, [ document.createTextNode(`#${tag}`), ]); tagEl.addEventListener("click", () => searchByTag(tag)); cloud.appendChild(tagEl); }); } function addTagFilter(tag) { if (!selectedTags.includes(tag)) { selectedTags.push(tag); performTagSearch(); } } function removeTagFilter(tag) { selectedTags = selectedTags.filter(t => t !== tag); if (selectedTags.length > 0) { performTagSearch(); } else { const input = document.getElementById("search-input"); if (input.value.trim()) { performSearch(input.value.trim(), document.getElementById("vault-filter").value, null); } else { showWelcome(); } } } function performTagSearch() { const input = document.getElementById("search-input"); const query = input.value.trim(); const vault = document.getElementById("vault-filter").value; performSearch(query, vault, selectedTags.length > 0 ? selectedTags.join(",") : null); } function buildSearchResultsHeader(data, query, tagFilter) { const header = el("div", { class: "search-results-header" }); const summaryText = el("span", { class: "search-results-summary-text" }); if (query && tagFilter) { summaryText.textContent = `${data.count} résultat(s) pour "${query}" avec les tags`; } else if (query) { summaryText.textContent = `${data.count} résultat(s) pour "${query}"`; } else if (tagFilter) { summaryText.textContent = `${data.count} fichier(s) avec les tags`; } else { summaryText.textContent = `${data.count} résultat(s)`; } header.appendChild(summaryText); if (selectedTags.length > 0) { const activeTags = el("div", { class: "search-results-active-tags" }); selectedTags.forEach((tag) => { const removeBtn = el("button", { class: "search-results-active-tag-remove", title: `Retirer ${tag} du filtre`, "aria-label": `Retirer ${tag} du filtre` }, [document.createTextNode("×")]); removeBtn.addEventListener("click", (e) => { e.stopPropagation(); removeTagFilter(tag); }); const chip = el("span", { class: "search-results-active-tag" }, [ document.createTextNode(`#${tag}`), removeBtn, ]); activeTags.appendChild(chip); }); header.appendChild(activeTags); } return header; } function searchByTag(tag) { addTagFilter(tag); } // --------------------------------------------------------------------------- // File viewer // --------------------------------------------------------------------------- async function openFile(vaultName, filePath) { currentVault = vaultName; currentPath = filePath; showingSource = false; cachedRawSource = null; // Highlight active document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); const selector = `.tree-item[data-vault="${vaultName}"][data-path="${CSS.escape(filePath)}"]`; try { const active = document.querySelector(selector); if (active) active.classList.add("active"); } catch (e) { /* selector might fail on special chars */ } // Show loading state while fetching const area = document.getElementById("content-area"); area.innerHTML = '
Chargement...
'; try { const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`; const data = await api(url); renderFile(data); } catch (err) { area.innerHTML = '

Impossible de charger le fichier.

'; } } function renderFile(data) { const area = document.getElementById("content-area"); // Breadcrumb const parts = data.path.split("/"); const breadcrumbEls = []; breadcrumbEls.push(makeBreadcrumbSpan(data.vault, () => { focusPathInSidebar(data.vault, "", { alignToTop: true }); })); let accumulated = ""; parts.forEach((part, i) => { breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")])); accumulated += (accumulated ? "/" : "") + part; const p = accumulated; if (i < parts.length - 1) { breadcrumbEls.push(makeBreadcrumbSpan(part, () => { focusPathInSidebar(data.vault, p, { alignToTop: true }); })); } else { breadcrumbEls.push(makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => { focusPathInSidebar(data.vault, data.path, { alignToTop: false }); })); } }); const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls); // Tags const tagsDiv = el("div", { class: "file-tags" }); (data.tags || []).forEach((tag) => { if (!TagFilterService.isTagFiltered(tag)) { const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); t.addEventListener("click", () => searchByTag(tag)); tagsDiv.appendChild(t); } }); // Action buttons const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [ icon("copy", 14), document.createTextNode("Copier"), ]); copyBtn.addEventListener("click", async () => { try { // Fetch raw content if not already cached if (!cachedRawSource) { const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; const rawData = await api(rawUrl); cachedRawSource = rawData.raw; } await navigator.clipboard.writeText(cachedRawSource); copyBtn.lastChild.textContent = "Copié !"; setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500); } catch (err) { console.error("Copy error:", err); showToast("Erreur lors de la copie"); } }); const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [ icon("code", 14), document.createTextNode("Source"), ]); const downloadBtn = el("button", { class: "btn-action", title: "Télécharger" }, [ icon("download", 14), document.createTextNode("Télécharger"), ]); downloadBtn.addEventListener("click", () => { const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; const a = document.createElement("a"); a.href = dlUrl; a.download = data.path.split("/").pop(); document.body.appendChild(a); a.click(); document.body.removeChild(a); }); const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [ icon("edit", 14), document.createTextNode("Éditer"), ]); editBtn.addEventListener("click", () => { openEditor(data.vault, data.path); }); // Frontmatter let fmSection = null; if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { const fmToggle = el("div", { class: "frontmatter-toggle" }, [ document.createTextNode("▶ Frontmatter"), ]); const fmContent = el("div", { class: "frontmatter-content" }, [ document.createTextNode(JSON.stringify(data.frontmatter, null, 2)), ]); fmToggle.addEventListener("click", () => { fmContent.classList.toggle("open"); fmToggle.textContent = fmContent.classList.contains("open") ? "▼ Frontmatter" : "▶ Frontmatter"; }); fmSection = el("div", {}, [fmToggle, fmContent]); } // Content container (rendered HTML) const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" }); mdDiv.innerHTML = data.html; // Raw source container (hidden initially) const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" }); // Source button toggle logic sourceBtn.addEventListener("click", async () => { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (!rendered || !raw) return; showingSource = !showingSource; if (showingSource) { sourceBtn.classList.add("active"); if (!cachedRawSource) { const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; const rawData = await api(rawUrl); cachedRawSource = rawData.raw; } raw.textContent = cachedRawSource; rendered.style.display = "none"; raw.style.display = "block"; } else { sourceBtn.classList.remove("active"); rendered.style.display = "block"; raw.style.display = "none"; } }); // Assemble area.innerHTML = ""; area.appendChild(breadcrumb); area.appendChild(el("div", { class: "file-header" }, [ el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, editBtn]), ])); if (fmSection) area.appendChild(fmSection); area.appendChild(mdDiv); area.appendChild(rawDiv); // Highlight code blocks area.querySelectorAll("pre code").forEach((block) => { safeHighlight(block); }); // Wire up wikilinks area.querySelectorAll(".wikilink").forEach((link) => { link.addEventListener("click", (e) => { e.preventDefault(); const v = link.getAttribute("data-vault"); const p = link.getAttribute("data-path"); if (v && p) openFile(v, p); }); }); safeCreateIcons(); area.scrollTop = 0; } // --------------------------------------------------------------------------- // Sidebar tabs // --------------------------------------------------------------------------- function initSidebarTabs() { document.querySelectorAll(".sidebar-tab").forEach((tab) => { tab.addEventListener("click", () => switchSidebarTab(tab.dataset.tab)); }); } function switchSidebarTab(tab) { 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) { filterInput.placeholder = tab === "vaults" ? "Filtrer fichiers..." : "Filtrer tags..."; } const query = filterInput ? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : ""; if (query) { if (tab === "vaults") performTreeSearch(query); else filterTagCloud(query); } } 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; openBtn.addEventListener("click", () => { modal.classList.add("active"); closeHeaderMenu(); safeCreateIcons(); }); closeBtn.addEventListener("click", closeHelpModal); modal.addEventListener("click", (e) => { if (e.target === modal) { closeHelpModal(); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modal.classList.contains("active")) { closeHelpModal(); } }); } function closeHelpModal() { const modal = document.getElementById("help-modal"); if (modal) modal.classList.remove("active"); } 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", () => { modal.classList.add("active"); closeHeaderMenu(); renderConfigFilters(); safeCreateIcons(); }); closeBtn.addEventListener("click", closeConfigModal); modal.addEventListener("click", (e) => { if (e.target === modal) { closeConfigModal(); } }); addBtn.addEventListener("click", addConfigFilter); patternInput.addEventListener("keypress", (e) => { if (e.key === "Enter") { addConfigFilter(); } }); patternInput.addEventListener("input", updateRegexPreview); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modal.classList.contains("active")) { closeConfigModal(); } }); } function closeConfigModal() { const modal = document.getElementById("config-modal"); if (modal) modal.classList.remove("active"); } 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"; } } // --------------------------------------------------------------------------- // Search // --------------------------------------------------------------------------- function initSearch() { const input = document.getElementById("search-input"); const caseBtn = document.getElementById("search-case-btn"); const clearBtn = document.getElementById("search-clear-btn"); // Initially hide clear button clearBtn.style.display = "none"; input.addEventListener("input", () => { const hasText = input.value.length > 0; clearBtn.style.display = hasText ? "flex" : "none"; clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { const q = input.value.trim(); const vault = document.getElementById("vault-filter").value; const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; if (q.length > 0 || tagFilter) { performSearch(q, vault, tagFilter); } else { showWelcome(); } }, 300); }); caseBtn.addEventListener("click", () => { searchCaseSensitive = !searchCaseSensitive; caseBtn.classList.toggle("active"); }); clearBtn.addEventListener("click", () => { input.value = ""; clearBtn.style.display = "none"; searchCaseSensitive = false; caseBtn.classList.remove("active"); showWelcome(); }); } async function performSearch(query, vaultFilter, tagFilter) { // Cancel any in-flight search request if (searchAbortController) { searchAbortController.abort(); } searchAbortController = new AbortController(); showLoading(); let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`; if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; try { const data = await api(url, { signal: searchAbortController.signal }); renderSearchResults(data, query, tagFilter); } catch (err) { if (err.name === "AbortError") return; // superseded by newer request showWelcome(); } finally { searchAbortController = null; } } function renderSearchResults(data, query, tagFilter) { const area = document.getElementById("content-area"); area.innerHTML = ""; const header = buildSearchResultsHeader(data, query, tagFilter); area.appendChild(header); if (data.results.length === 0) { area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [ document.createTextNode("Aucun résultat trouvé."), ])); return; } const container = el("div", { class: "search-results" }); data.results.forEach((r) => { // Create title with highlighting const titleDiv = el("div", { class: "search-result-title" }); if (query && query.trim()) { highlightSearchText(titleDiv, r.title, query, searchCaseSensitive); } else { titleDiv.textContent = r.title; } // Create snippet with highlighting const snippetDiv = el("div", { class: "search-result-snippet" }); if (query && query.trim() && r.snippet) { highlightSearchText(snippetDiv, r.snippet, query, searchCaseSensitive); } else { snippetDiv.textContent = r.snippet || ""; } const item = el("div", { class: "search-result-item" }, [ titleDiv, el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]), snippetDiv, ]); if (r.tags && r.tags.length > 0) { const tagsDiv = el("div", { class: "search-result-tags" }); r.tags.forEach((tag) => { if (!TagFilterService.isTagFiltered(tag)) { const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); tagEl.addEventListener("click", (e) => { e.stopPropagation(); addTagFilter(tag); }); tagsDiv.appendChild(tagEl); } }); if (tagsDiv.children.length > 0) { item.appendChild(tagsDiv); } } item.addEventListener("click", () => openFile(r.vault, r.path)); container.appendChild(item); }); area.appendChild(container); } // --------------------------------------------------------------------------- // Resizable sidebar (horizontal) // --------------------------------------------------------------------------- function initSidebarResize() { const handle = document.getElementById("sidebar-resize-handle"); const sidebar = document.getElementById("sidebar"); if (!handle || !sidebar) return; // Restore saved width const savedWidth = localStorage.getItem("obsigate-sidebar-width"); if (savedWidth) { sidebar.style.width = savedWidth + "px"; } let startX = 0; let startWidth = 0; function onMouseMove(e) { const newWidth = Math.min(500, Math.max(200, startWidth + (e.clientX - startX))); sidebar.style.width = newWidth + "px"; } function onMouseUp() { document.body.classList.remove("resizing"); handle.classList.remove("active"); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); localStorage.setItem("obsigate-sidebar-width", parseInt(sidebar.style.width)); } handle.addEventListener("mousedown", (e) => { e.preventDefault(); startX = e.clientX; startWidth = sidebar.getBoundingClientRect().width; document.body.classList.add("resizing"); handle.classList.add("active"); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); } // --------------------------------------------------------------------------- // Resizable tag section (vertical) // --------------------------------------------------------------------------- function initTagResize() { const handle = document.getElementById("tag-resize-handle"); const tagSection = document.getElementById("tag-cloud-section"); if (!handle || !tagSection) return; // Restore saved height const savedHeight = localStorage.getItem("obsigate-tag-height"); if (savedHeight) { tagSection.style.height = savedHeight + "px"; } let startY = 0; let startHeight = 0; function onMouseMove(e) { // Dragging up increases height, dragging down decreases const newHeight = Math.min(400, Math.max(60, startHeight - (e.clientY - startY))); tagSection.style.height = newHeight + "px"; } function onMouseUp() { document.body.classList.remove("resizing-v"); handle.classList.remove("active"); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); localStorage.setItem("obsigate-tag-height", parseInt(tagSection.style.height)); } handle.addEventListener("mousedown", (e) => { e.preventDefault(); startY = e.clientY; startHeight = tagSection.getBoundingClientRect().height; document.body.classList.add("resizing-v"); handle.classList.add("active"); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function el(tag, attrs, children) { const e = document.createElement(tag); if (attrs) { Object.entries(attrs).forEach(([k, v]) => e.setAttribute(k, v)); } if (children) { children.forEach((c) => { if (c) e.appendChild(c); }); } return e; } function icon(name, size) { const i = document.createElement("i"); i.setAttribute("data-lucide", name); i.style.width = size + "px"; i.style.height = size + "px"; i.classList.add("icon"); return i; } function smallBadge(count) { const s = document.createElement("span"); s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px"; s.textContent = `(${count})`; return s; } function makeBreadcrumbSpan(text, onClick) { const s = document.createElement("span"); s.textContent = text; if (onClick) s.addEventListener("click", onClick); return s; } function appendHighlightedText(container, text, query, caseSensitive) { container.textContent = ""; if (!query) { container.appendChild(document.createTextNode(text)); return; } const source = caseSensitive ? text : text.toLowerCase(); const needle = caseSensitive ? query : query.toLowerCase(); let start = 0; let index = source.indexOf(needle, start); if (index === -1) { container.appendChild(document.createTextNode(text)); return; } while (index !== -1) { if (index > start) { container.appendChild(document.createTextNode(text.slice(start, index))); } const mark = el("mark", { class: "filter-highlight" }, [ document.createTextNode(text.slice(index, index + query.length)), ]); container.appendChild(mark); start = index + query.length; index = source.indexOf(needle, start); } if (start < text.length) { container.appendChild(document.createTextNode(text.slice(start))); } } function highlightSearchText(container, text, query, caseSensitive) { container.textContent = ""; if (!query || !text) { container.appendChild(document.createTextNode(text || "")); return; } const source = caseSensitive ? text : text.toLowerCase(); const needle = caseSensitive ? query : query.toLowerCase(); let start = 0; let index = source.indexOf(needle, start); if (index === -1) { container.appendChild(document.createTextNode(text)); return; } while (index !== -1) { if (index > start) { container.appendChild(document.createTextNode(text.slice(start, index))); } const mark = el("mark", { class: "search-highlight" }, [ document.createTextNode(text.slice(index, index + query.length)), ]); container.appendChild(mark); start = index + query.length; index = source.indexOf(needle, start); } if (start < text.length) { container.appendChild(document.createTextNode(text.slice(start))); } } function showWelcome() { const area = document.getElementById("content-area"); area.innerHTML = `

ObsiGate

Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.

`; safeCreateIcons(); } function showLoading() { const area = document.getElementById("content-area"); area.innerHTML = `
Recherche en cours...
`; } function goHome() { const searchInput = document.getElementById("search-input"); if (searchInput) searchInput.value = ""; document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); currentVault = null; currentPath = null; showingSource = false; cachedRawSource = null; closeMobileSidebar(); showWelcome(); } // --------------------------------------------------------------------------- // Editor (CodeMirror) // --------------------------------------------------------------------------- async function openEditor(vaultName, filePath) { editorVault = vaultName; editorPath = filePath; const modal = document.getElementById("editor-modal"); const titleEl = document.getElementById("editor-title"); const bodyEl = document.getElementById("editor-body"); titleEl.textContent = `Édition: ${filePath.split("/").pop()}`; // Fetch raw content const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`; const rawData = await api(rawUrl); // Clear previous editor bodyEl.innerHTML = ""; if (editorView) { editorView.destroy(); editorView = null; } fallbackEditorEl = null; try { await waitForCodeMirror(); const { EditorView, EditorState, basicSetup, markdown, python, javascript, html, css, json, xml, sql, php, cpp, java, rust, oneDark, keymap } = window.CodeMirror; const currentTheme = document.documentElement.getAttribute("data-theme"); const fileExt = filePath.split(".").pop().toLowerCase(); const extensions = [ basicSetup, keymap.of([{ key: "Mod-s", run: () => { saveFile(); return true; } }]), EditorView.lineWrapping, ]; // Add language support based on file extension const langMap = { "md": markdown, "markdown": markdown, "py": python, "js": javascript, "jsx": javascript, "ts": javascript, "tsx": javascript, "mjs": javascript, "cjs": javascript, "html": html, "htm": html, "css": css, "scss": css, "less": css, "json": json, "xml": xml, "svg": xml, "sql": sql, "php": php, "cpp": cpp, "cc": cpp, "cxx": cpp, "c": cpp, "h": cpp, "hpp": cpp, "java": java, "rs": rust, "sh": javascript, // Using javascript for shell scripts as fallback "bash": javascript, "zsh": javascript, }; const langMode = langMap[fileExt]; if (langMode) { extensions.push(langMode()); } if (currentTheme === "dark") { extensions.push(oneDark); } const state = EditorState.create({ doc: rawData.raw, extensions: extensions, }); editorView = new EditorView({ state: state, parent: bodyEl, }); } catch (err) { console.error("CodeMirror init failed, falling back to textarea:", err); fallbackEditorEl = document.createElement("textarea"); fallbackEditorEl.className = "fallback-editor"; fallbackEditorEl.value = rawData.raw; bodyEl.appendChild(fallbackEditorEl); } modal.classList.add("active"); safeCreateIcons(); } async function waitForCodeMirror() { let attempts = 0; while (!window.CodeMirror && attempts < 50) { await new Promise(resolve => setTimeout(resolve, 100)); attempts++; } if (!window.CodeMirror) { throw new Error("CodeMirror failed to load"); } } function closeEditor() { const modal = document.getElementById("editor-modal"); modal.classList.remove("active"); if (editorView) { editorView.destroy(); editorView = null; } fallbackEditorEl = null; editorVault = null; editorPath = null; } async function saveFile() { if ((!editorView && !fallbackEditorEl) || !editorVault || !editorPath) return; const content = editorView ? editorView.state.doc.toString() : fallbackEditorEl.value; const saveBtn = document.getElementById("editor-save"); const originalHTML = saveBtn.innerHTML; try { saveBtn.disabled = true; saveBtn.innerHTML = ''; safeCreateIcons(); const response = await fetch( `/api/file/${encodeURIComponent(editorVault)}/save?path=${encodeURIComponent(editorPath)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), } ); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || "Erreur de sauvegarde"); } saveBtn.innerHTML = ''; safeCreateIcons(); setTimeout(() => { closeEditor(); if (currentVault === editorVault && currentPath === editorPath) { openFile(currentVault, currentPath); } }, 800); } catch (err) { console.error("Save error:", err); alert(`Erreur: ${err.message}`); saveBtn.innerHTML = originalHTML; saveBtn.disabled = false; safeCreateIcons(); } } async function deleteFile() { if (!editorVault || !editorPath) return; const deleteBtn = document.getElementById("editor-delete"); const originalHTML = deleteBtn.innerHTML; try { deleteBtn.disabled = true; deleteBtn.innerHTML = ''; safeCreateIcons(); const response = await fetch( `/api/file/${encodeURIComponent(editorVault)}?path=${encodeURIComponent(editorPath)}`, { method: "DELETE" } ); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || "Erreur de suppression"); } closeEditor(); showWelcome(); await refreshSidebarForContext(); await refreshTagsForContext(); } catch (err) { console.error("Delete error:", err); alert(`Erreur: ${err.message}`); deleteBtn.innerHTML = originalHTML; deleteBtn.disabled = false; safeCreateIcons(); } } function initEditor() { const cancelBtn = document.getElementById("editor-cancel"); const deleteBtn = document.getElementById("editor-delete"); const saveBtn = document.getElementById("editor-save"); const modal = document.getElementById("editor-modal"); cancelBtn.addEventListener("click", closeEditor); deleteBtn.addEventListener("click", deleteFile); saveBtn.addEventListener("click", saveFile); // Close on overlay click modal.addEventListener("click", (e) => { if (e.target === modal) { closeEditor(); } }); // ESC to close document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modal.classList.contains("active")) { closeEditor(); } }); // Fix mouse wheel scrolling in editor modal.addEventListener("wheel", (e) => { const editorBody = document.getElementById("editor-body"); if (editorBody && editorBody.contains(e.target)) { // Let the editor handle the scroll return; } // Prevent modal from scrolling if not in editor area e.preventDefault(); }, { passive: false }); } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- async function init() { initTheme(); initHeaderMenu(); initCustomDropdowns(); document.getElementById("theme-toggle").addEventListener("click", toggleTheme); document.getElementById("header-logo").addEventListener("click", goHome); initSearch(); initSidebarToggle(); initMobile(); initVaultContext(); initSidebarTabs(); initHelpModal(); initConfigModal(); initSidebarFilter(); initSidebarResize(); initEditor(); try { await Promise.all([loadVaults(), loadTags()]); } catch (err) { console.error("Failed to initialize ObsiGate:", err); showToast("Erreur lors de l'initialisation"); } safeCreateIcons(); } document.addEventListener("DOMContentLoaded", init); })();