/* ObsiGate — Vanilla JS SPA */ (function () { "use strict"; const APP_VERSION = "1.5.0"; // --------------------------------------------------------------------------- // 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 searchWholeWord = false; let searchRegex = false; let searchFilterVisible = false; let _iconDebounceTimer = null; let activeSidebarTab = "vaults"; let filterDebounce = null; // Vault settings cache for hideHiddenFiles let vaultSettings = {}; // Advanced search state let advancedSearchOffset = 0; let advancedSearchTotal = 0; let advancedSearchSort = "relevance"; let advancedSearchLastQuery = ""; let suggestAbortController = null; let dropdownActiveIndex = -1; let dropdownItems = []; let currentSearchId = 0; // Advanced search constants const SEARCH_HISTORY_KEY = "obsigate_search_history"; const MAX_HISTORY_ENTRIES = 50; const SUGGEST_DEBOUNCE_MS = 150; const ADVANCED_SEARCH_LIMIT = 50; const MIN_SEARCH_LENGTH = 2; const SEARCH_TIMEOUT_MS = 30000; // Outline/TOC state let outlineObserver = null; let activeHeadingId = null; let headingsCache = []; let rightSidebarVisible = true; let rightSidebarWidth = 280; // --------------------------------------------------------------------------- // File extension → Lucide icon mapping // --------------------------------------------------------------------------- const EXT_ICONS = { // Text files ".md": "file-text", ".txt": "file-text", ".log": "file-text", ".readme": "file-text", ".rst": "file-text", ".adoc": "file-text", // Web development ".html": "file-code", ".htm": "file-code", ".css": "file-code", ".scss": "file-code", ".sass": "file-code", ".less": "file-code", ".js": "file-code", ".jsx": "file-code", ".ts": "file-code", ".tsx": "file-code", ".vue": "file-code", ".svelte": "file-code", // Programming languages ".py": "file-code", ".java": "file-code", ".c": "file-code", ".cpp": "file-code", ".cc": "file-code", ".cxx": "file-code", ".h": "file-code", ".hpp": "file-code", ".cs": "file-code", ".go": "file-code", ".rs": "file-code", ".rb": "file-code", ".php": "file-code", ".swift": "file-code", ".kt": "file-code", ".scala": "file-code", ".r": "file-code", ".m": "file-code", ".pl": "file-code", ".lua": "file-code", ".dart": "file-code", ".nim": "file-code", ".zig": "file-code", ".odin": "file-code", ".v": "file-code", ".cr": "file-code", ".ex": "file-code", ".exs": "file-code", ".elm": "file-code", ".purs": "file-code", ".hs": "file-code", ".ml": "file-code", ".ocaml": "file-code", ".fs": "file-code", ".fsx": "file-code", ".vb": "file-code", ".pas": "file-code", ".pp": "file-code", ".inc": "file-code", // Data formats ".json": "file-json", ".yaml": "file-cog", ".yml": "file-cog", ".toml": "file-cog", ".xml": "file-code", ".csv": "table", ".tsv": "table", ".sql": "database", ".db": "database", ".sqlite": "database", ".sqlite3": "database", ".parquet": "database", ".avro": "database", // Configuration files ".ini": "file-cog", ".cfg": "file-cog", ".conf": "file-cog", ".env": "file-cog", ".dockerfile": "file-cog", ".gitignore": "file-cog", ".gitattributes": "file-cog", ".editorconfig": "file-cog", ".eslintrc": "file-cog", ".prettierrc": "file-cog", ".babelrc": "file-cog", ".tsconfig": "file-cog", "package.json": "file-cog", "package-lock.json": "file-cog", "yarn.lock": "file-cog", "composer.json": "file-cog", "requirements.txt": "file-cog", "pipfile": "file-cog", "gemfile": "file-cog", "cargo.toml": "file-cog", "go.mod": "file-cog", "go.sum": "file-cog", "pom.xml": "file-cog", "build.gradle": "file-cog", "cmakelists.txt": "file-cog", "makefile": "file-cog", // Shell scripts ".sh": "terminal", ".bash": "terminal", ".zsh": "terminal", ".fish": "terminal", ".bat": "terminal", ".cmd": "terminal", ".ps1": "terminal", ".psm1": "terminal", ".psd1": "terminal", // Document formats ".pdf": "file-text", ".doc": "file-text", ".docx": "file-text", ".rtf": "file-text", ".odt": "file-text", ".tex": "file-text", ".latex": "file-text", // Image files ".png": "file-image", ".jpg": "file-image", ".jpeg": "file-image", ".gif": "file-image", ".svg": "file-image", ".webp": "file-image", ".bmp": "file-image", ".ico": "file-image", ".tiff": "file-image", ".tif": "file-image", // Audio files ".mp3": "file-music", ".wav": "file-music", ".flac": "file-music", ".aac": "file-music", ".ogg": "file-music", ".m4a": "file-music", ".wma": "file-music", // Video files ".mp4": "play", ".avi": "play", ".mov": "play", ".wmv": "play", ".flv": "play", ".webm": "play", ".mkv": "play", ".m4v": "play", ".3gp": "play", // Archive files ".zip": "file-archive", ".rar": "file-archive", ".7z": "file-archive", ".tar": "file-archive", ".gz": "file-archive", ".tgz": "file-archive", ".bz2": "file-archive", ".xz": "file-archive", ".deb": "file-archive", ".rpm": "file-archive", ".dmg": "file-archive", ".pkg": "file-archive", ".msi": "file-archive", ".exe": "file-archive", // Font files ".ttf": "file-type", ".otf": "file-type", ".woff": "file-type", ".woff2": "file-type", ".eot": "file-type", // Other common files ".key": "file-cog", ".pem": "file-cog", ".crt": "file-cog", ".cert": "file-cog", ".p12": "file-cog", ".pfx": "file-cog", ".lock": "file-cog", ".tmp": "file", ".bak": "file", ".old": "file", ".orig": "file", ".save": "file", }; function getFileIcon(name) { const ext = "." + name.split(".").pop().toLowerCase(); return EXT_ICONS[ext] || "file"; } // --------------------------------------------------------------------------- // Search History Service (localStorage, LIFO, max 50, dedup) // --------------------------------------------------------------------------- const SearchHistory = { _load() { try { const raw = localStorage.getItem(SEARCH_HISTORY_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } }, _save(entries) { try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(entries)); } catch {} }, getAll() { return this._load(); }, add(query) { if (!query || !query.trim()) return; const q = query.trim(); let entries = this._load().filter((e) => e !== q); entries.unshift(q); if (entries.length > MAX_HISTORY_ENTRIES) entries = entries.slice(0, MAX_HISTORY_ENTRIES); this._save(entries); }, remove(query) { const entries = this._load().filter((e) => e !== query); this._save(entries); }, clear() { this._save([]); }, filter(prefix) { if (!prefix) return this.getAll().slice(0, 8); const lp = prefix.toLowerCase(); return this._load() .filter((e) => e.toLowerCase().includes(lp)) .slice(0, 8); }, }; // --------------------------------------------------------------------------- // Query Parser — extracts operators (tag:, #, vault:, title:, path:, ext:) // --------------------------------------------------------------------------- const QueryParser = { parse(raw) { const result = { tags: [], vault: null, title: null, path: null, ext: null, freeText: "" }; if (!raw) return result; const tokens = this._tokenize(raw); const freeTokens = []; for (const tok of tokens) { const lower = tok.toLowerCase(); if (lower.startsWith("tag:")) { const v = tok.slice(4).replace(/"/g, "").trim().replace(/^#/, ""); if (v) result.tags.push(v); } else if (lower.startsWith("#") && tok.length > 1) { result.tags.push(tok.slice(1)); } else if (lower.startsWith("vault:")) { result.vault = tok.slice(6).replace(/"/g, "").trim(); } else if (lower.startsWith("title:")) { result.title = tok.slice(6).replace(/"/g, "").trim(); } else if (lower.startsWith("path:")) { result.path = tok.slice(5).replace(/"/g, "").trim(); } else if (lower.startsWith("ext:")) { result.ext = tok.slice(4).replace(/"/g, "").trim().replace(/^\./, "").toLowerCase(); } else { freeTokens.push(tok); } } result.freeText = freeTokens.join(" "); return result; }, _tokenize(raw) { const tokens = []; let i = 0; const n = raw.length; while (i < n) { while (i < n && raw[i] === " ") i++; if (i >= n) break; if (raw[i] !== '"') { let j = i; while (j < n && raw[j] !== " ") { if (raw[j] === '"') { j++; while (j < n && raw[j] !== '"') j++; if (j < n) j++; } else j++; } tokens.push(raw.slice(i, j).replace(/"/g, "")); i = j; } else { i++; let j = i; while (j < n && raw[j] !== '"') j++; tokens.push(raw.slice(i, j)); i = j + 1; } } return tokens; }, /** Detect the current operator context at cursor for autocomplete */ getContext(raw, cursorPos) { const before = raw.slice(0, cursorPos); // Check if we're typing a tag: or # value const tagMatch = before.match(/(?:tag:|#)([\w-]*)$/i); if (tagMatch) return { type: "tag", prefix: tagMatch[1] }; // Check if typing title: const titleMatch = before.match(/title:([\w-]*)$/i); if (titleMatch) return { type: "title", prefix: titleMatch[1] }; // Default: free text const words = before.trim().split(/\s+/); const lastWord = words[words.length - 1] || ""; return { type: "text", prefix: lastWord }; }, }; // --------------------------------------------------------------------------- // Autocomplete Dropdown Controller // --------------------------------------------------------------------------- const AutocompleteDropdown = { _dropdown: null, _historySection: null, _titlesSection: null, _tagsSection: null, _historyList: null, _titlesList: null, _tagsList: null, _emptyEl: null, _suggestTimer: null, init() { this._dropdown = document.getElementById("search-dropdown"); this._historySection = document.getElementById("search-dropdown-history"); this._titlesSection = document.getElementById("search-dropdown-titles"); this._tagsSection = document.getElementById("search-dropdown-tags"); this._historyList = document.getElementById("search-dropdown-history-list"); this._titlesList = document.getElementById("search-dropdown-titles-list"); this._tagsList = document.getElementById("search-dropdown-tags-list"); this._emptyEl = document.getElementById("search-dropdown-empty"); // Clear history button const clearBtn = document.getElementById("search-dropdown-clear-history"); if (clearBtn) { clearBtn.addEventListener("click", (e) => { e.stopPropagation(); SearchHistory.clear(); this.hide(); }); } // Close dropdown on outside click document.addEventListener("click", (e) => { if (this._dropdown && !this._dropdown.contains(e.target) && e.target.id !== "search-input") { this.hide(); } }); }, show() { if (this._dropdown) this._dropdown.hidden = false; }, hide() { if (this._dropdown) this._dropdown.hidden = true; dropdownActiveIndex = -1; dropdownItems = []; }, isVisible() { return this._dropdown && !this._dropdown.hidden; }, /** Populate and show the dropdown with history, title suggestions, and tag suggestions */ async populate(inputValue, cursorPos) { if (this._suppressNext) { this._suppressNext = false; return; } // Cancel previous suggestion request if (suggestAbortController) { suggestAbortController.abort(); suggestAbortController = null; } const ctx = QueryParser.getContext(inputValue, cursorPos); const vault = document.getElementById("vault-filter").value; // History — always show filtered history const historyItems = SearchHistory.filter(inputValue).slice(0, 5); this._renderHistory(historyItems, inputValue); // Title and tag suggestions from API (debounced) — always fetch both clearTimeout(this._suggestTimer); const prefix = ctx.prefix; if (prefix && prefix.length >= 2) { // Only show placeholder if lists are empty (avoid flashing on fast typing) const hasTitles = this._titlesList.children.length > 0 && !this._titlesList.querySelector(".search-dropdown__item--loading"); const hasTags = this._tagsList.children.length > 0 && !this._tagsList.querySelector(".search-dropdown__item--loading"); if (!hasTitles) { this._titlesList.innerHTML = '
  • Recherche...
  • '; } if (!hasTags) { this._tagsList.innerHTML = '
  • Recherche...
  • '; } this._titlesSection.hidden = false; this._tagsSection.hidden = false; this.show(); this._suggestTimer = setTimeout(() => this._fetchSuggestions(prefix, vault), 150); } else { this._renderTitles([], ""); this._renderTags([], ""); this._titlesSection.hidden = true; this._tagsSection.hidden = true; } // Show/hide sections this._historySection.hidden = historyItems.length === 0; const hasContent = historyItems.length > 0; if (hasContent || (prefix && prefix.length >= 2)) { this.show(); } else { this.hide(); } this._collectItems(); }, async _fetchSuggestions(prefix, vault) { suggestAbortController = new AbortController(); // Fetch titles try { const titlesRes = await api(`/api/suggest?q=${encodeURIComponent(prefix)}&vault=${encodeURIComponent(vault)}&limit=5`, { signal: suggestAbortController.signal }); this._renderTitles(titlesRes.suggestions || [], prefix); this._titlesSection.hidden = !(titlesRes.suggestions || []).length; if (titlesRes.suggestions?.length) this.show(); } catch (err) { if (err.name === "AbortError") return; this._titlesSection.hidden = true; } // Fetch tags — keep section always visible to confirm it works try { const tagsRes = await api(`/api/tags/suggest?q=${encodeURIComponent(prefix)}&vault=${encodeURIComponent(vault)}&limit=5`, { signal: suggestAbortController.signal }); const items = tagsRes.suggestions || []; if (items.length > 0) { this._renderTags(items, prefix); } else { this._tagsList.innerHTML = '
  • Aucun tag
  • '; } this._tagsSection.hidden = false; this.show(); } catch (err) { if (err.name === "AbortError") return; this._tagsList.innerHTML = '
  • Erreur chargement
  • '; this._tagsSection.hidden = false; } this._collectItems(); }, _renderHistory(items, query) { this._historyList.innerHTML = ""; items.forEach((entry) => { const li = el("li", { class: "search-dropdown__item search-dropdown__item--history", role: "option" }); const iconEl = el("span", { class: "search-dropdown__icon" }); iconEl.innerHTML = ''; const textEl = el("span", { class: "search-dropdown__text" }); textEl.textContent = entry; li.appendChild(iconEl); li.appendChild(textEl); li.addEventListener("click", () => { const input = document.getElementById("search-input"); input.value = entry; input.dispatchEvent(new Event("input", { bubbles: true })); this.hide(); _triggerAdvancedSearch(entry); }); this._historyList.appendChild(li); }); }, _renderTitles(items, prefix) { this._titlesList.innerHTML = ""; items.forEach((item) => { const li = el("li", { class: "search-dropdown__item search-dropdown__item--title", role: "option" }); const iconEl = el("span", { class: "search-dropdown__icon" }); iconEl.innerHTML = ''; const textEl = el("span", { class: "search-dropdown__text" }); if (prefix) { this._highlightText(textEl, item.title, prefix); } else { textEl.textContent = item.title; } const metaEl = el("span", { class: "search-dropdown__meta" }); metaEl.textContent = item.vault; li.appendChild(iconEl); li.appendChild(textEl); li.appendChild(metaEl); li.addEventListener("click", () => { this.hide(); TabManager.openPreview(item.vault, item.path); }); this._titlesList.appendChild(li); }); }, _renderTags(items, prefix) { this._tagsList.innerHTML = ""; items.forEach((item) => { const li = el("li", { class: "search-dropdown__item search-dropdown__item--tag", role: "option" }); const iconEl = el("span", { class: "search-dropdown__icon" }); iconEl.innerHTML = ''; const textEl = el("span", { class: "search-dropdown__text" }); if (prefix) { this._highlightText(textEl, item.tag, prefix); } else { textEl.textContent = item.tag; } const badge = el("span", { class: "search-dropdown__badge" }); badge.textContent = item.count; li.appendChild(iconEl); li.appendChild(textEl); li.appendChild(badge); li.addEventListener("click", () => { const input = document.getElementById("search-input"); const current = input.value; const cursorPos = input.selectionStart; const ctx = QueryParser.getContext(current, cursorPos); if (ctx.type === "tag") { // Replace the partial tag prefix const before = current.slice(0, cursorPos - ctx.prefix.length); input.value = before + item.tag + " "; } else { // Replace the last word with tag: operator const words = current.trim().split(/\s+/); if (words.length > 0 && ctx.prefix && ctx.prefix.length > 0) { words[words.length - 1] = ""; // remove last partial word } const base = words.filter(w => w).join(" "); input.value = (base ? base + " " : "") + "tag:" + item.tag + " "; } input.dispatchEvent(new Event("input", { bubbles: true })); this.hide(); input.focus(); _triggerAdvancedSearch(input.value); }); this._tagsList.appendChild(li); }); }, _highlightText(container, text, query) { const lower = text.toLowerCase(); const needle = query.toLowerCase(); const pos = lower.indexOf(needle); if (pos === -1) { container.textContent = text; return; } container.appendChild(document.createTextNode(text.slice(0, pos))); const markEl = el("mark", {}, [document.createTextNode(text.slice(pos, pos + query.length))]); container.appendChild(markEl); container.appendChild(document.createTextNode(text.slice(pos + query.length))); }, _collectItems() { dropdownItems = Array.from(this._dropdown.querySelectorAll(".search-dropdown__item")); dropdownActiveIndex = -1; dropdownItems.forEach((item) => item.classList.remove("active")); }, navigateDown() { if (!this.isVisible() || dropdownItems.length === 0) return; if (dropdownActiveIndex >= 0) dropdownItems[dropdownActiveIndex].classList.remove("active"); dropdownActiveIndex = (dropdownActiveIndex + 1) % dropdownItems.length; dropdownItems[dropdownActiveIndex].classList.add("active"); dropdownItems[dropdownActiveIndex].scrollIntoView({ block: "nearest" }); }, navigateUp() { if (!this.isVisible() || dropdownItems.length === 0) return; if (dropdownActiveIndex >= 0) dropdownItems[dropdownActiveIndex].classList.remove("active"); dropdownActiveIndex = dropdownActiveIndex <= 0 ? dropdownItems.length - 1 : dropdownActiveIndex - 1; dropdownItems[dropdownActiveIndex].classList.add("active"); dropdownItems[dropdownActiveIndex].scrollIntoView({ block: "nearest" }); }, selectActive() { if (dropdownActiveIndex >= 0 && dropdownActiveIndex < dropdownItems.length) { dropdownItems[dropdownActiveIndex].click(); return true; } return false; }, }; // --------------------------------------------------------------------------- // Search Chips Controller — renders active filter chips from parsed query // --------------------------------------------------------------------------- const SearchChips = { _container: null, init() { this._container = document.getElementById("search-chips"); }, update(parsed) { if (!this._container) return; this._container.innerHTML = ""; let hasChips = false; parsed.tags.forEach((tag) => { this._addChip("tag", `tag:${tag}`, tag); hasChips = true; }); if (parsed.vault) { this._addChip("vault", `vault:${parsed.vault}`, parsed.vault); hasChips = true; } if (parsed.title) { this._addChip("title", `title:${parsed.title}`, parsed.title); hasChips = true; } if (parsed.path) { this._addChip("path", `path:${parsed.path}`, parsed.path); hasChips = true; } if (parsed.ext) { this._addChip("ext", `ext:${parsed.ext}`, parsed.ext); hasChips = true; } this._container.hidden = !hasChips; }, clear() { if (!this._container) return; this._container.innerHTML = ""; this._container.hidden = true; }, _addChip(type, fullOperator, displayText) { const chip = el("span", { class: `search-chip search-chip--${type}` }); const label = el("span", { class: "search-chip__label" }); label.textContent = fullOperator; const removeBtn = el("button", { class: "search-chip__remove", title: "Retirer ce filtre", type: "button" }); removeBtn.innerHTML = ''; removeBtn.addEventListener("click", () => { // Remove this operator from the input const input = document.getElementById("search-input"); const raw = input.value; // Remove the operator text from the query const escaped = fullOperator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); input.value = raw.replace(new RegExp("\\s*" + escaped + "\\s*", "i"), " ").trim(); _triggerAdvancedSearch(input.value); }); chip.appendChild(label); chip.appendChild(removeBtn); this._container.appendChild(chip); safeCreateIcons(); }, }; // --------------------------------------------------------------------------- // Helper: trigger advanced search from input value // --------------------------------------------------------------------------- function _triggerAdvancedSearch(rawQuery) { const q = (rawQuery || "").trim(); const vault = document.getElementById("vault-filter").value; const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; advancedSearchOffset = 0; if (q.length > 0 || tagFilter) { SearchHistory.add(q); performAdvancedSearch(q, vault, tagFilter); } else { SearchChips.clear(); showWelcome(); } } // --------------------------------------------------------------------------- // 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 */ } } } // --------------------------------------------------------------------------- // Outline/TOC Manager // --------------------------------------------------------------------------- const OutlineManager = { /** * Slugify text to create valid IDs */ slugify(text) { return ( text .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^\p{L}\p{N}\s-]/gu, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .trim() || "heading" ); }, /** * Parse headings from markdown content */ parseHeadings() { const contentArea = document.querySelector(".md-content"); if (!contentArea) return []; const headings = []; const h2s = contentArea.querySelectorAll("h2"); const h3s = contentArea.querySelectorAll("h3"); const allHeadings = [...h2s, ...h3s].sort((a, b) => { return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; }); const usedIds = new Map(); allHeadings.forEach((heading) => { const text = heading.textContent.trim(); if (!text) return; const level = parseInt(heading.tagName[1]); let id = this.slugify(text); // Handle duplicate IDs if (usedIds.has(id)) { const count = usedIds.get(id) + 1; usedIds.set(id, count); id = `${id}-${count}`; } else { usedIds.set(id, 1); } // Inject ID into heading if not present if (!heading.id) { heading.id = id; } else { id = heading.id; } headings.push({ id, level, text, element: heading, }); }); return headings; }, /** * Render outline list */ renderOutline(headings) { const outlineList = document.getElementById("outline-list"); const outlineEmpty = document.getElementById("outline-empty"); if (!outlineList) return; outlineList.innerHTML = ""; if (!headings || headings.length === 0) { outlineList.hidden = true; if (outlineEmpty) { outlineEmpty.hidden = false; safeCreateIcons(); } return; } outlineList.hidden = false; if (outlineEmpty) outlineEmpty.hidden = true; headings.forEach((heading) => { const item = el( "a", { class: `outline-item level-${heading.level}`, href: `#${heading.id}`, "data-heading-id": heading.id, role: "link", }, [document.createTextNode(heading.text)], ); item.addEventListener("click", (e) => { e.preventDefault(); this.scrollToHeading(heading.id); }); outlineList.appendChild(item); }); headingsCache = headings; }, /** * Scroll to heading with smooth behavior */ scrollToHeading(headingId) { const heading = document.getElementById(headingId); if (!heading) return; const contentArea = document.getElementById("content-area"); if (!contentArea) return; // Calculate offset for fixed header (if any) const headerHeight = 80; const headingTop = heading.offsetTop; contentArea.scrollTo({ top: headingTop - headerHeight, behavior: "smooth", }); // Update active state immediately this.setActiveHeading(headingId); }, /** * Set active heading in outline */ setActiveHeading(headingId) { if (activeHeadingId === headingId) return; activeHeadingId = headingId; const items = document.querySelectorAll(".outline-item"); items.forEach((item) => { if (item.getAttribute("data-heading-id") === headingId) { item.classList.add("active"); item.setAttribute("aria-current", "location"); // Scroll outline item into view item.scrollIntoView({ block: "nearest", behavior: "smooth" }); } else { item.classList.remove("active"); item.removeAttribute("aria-current"); } }); }, /** * Initialize outline for current document */ init() { const headings = this.parseHeadings(); this.renderOutline(headings); ScrollSpyManager.init(headings); ReadingProgressManager.init(); }, /** * Cleanup */ destroy() { ScrollSpyManager.destroy(); ReadingProgressManager.destroy(); headingsCache = []; activeHeadingId = null; }, }; // --------------------------------------------------------------------------- // Scroll Spy Manager // --------------------------------------------------------------------------- const ScrollSpyManager = { observer: null, headings: [], init(headings) { this.destroy(); this.headings = headings; if (!headings || headings.length === 0) return; const contentArea = document.getElementById("content-area"); if (!contentArea) return; const options = { root: contentArea, rootMargin: "-20% 0px -70% 0px", threshold: [0, 0.3, 0.5, 1.0], }; this.observer = new IntersectionObserver((entries) => { // Find the most visible heading let mostVisible = null; let maxRatio = 0; entries.forEach((entry) => { if (entry.isIntersecting && entry.intersectionRatio > maxRatio) { maxRatio = entry.intersectionRatio; mostVisible = entry.target; } }); if (mostVisible && mostVisible.id) { OutlineManager.setActiveHeading(mostVisible.id); } }, options); // Observe all headings headings.forEach((heading) => { if (heading.element) { this.observer.observe(heading.element); } }); }, destroy() { if (this.observer) { this.observer.disconnect(); this.observer = null; } this.headings = []; }, }; // --------------------------------------------------------------------------- // Reading Progress Manager // --------------------------------------------------------------------------- const ReadingProgressManager = { scrollHandler: null, init() { this.destroy(); const contentArea = document.getElementById("content-area"); if (!contentArea) return; this.scrollHandler = this.throttle(() => { this.updateProgress(); }, 100); contentArea.addEventListener("scroll", this.scrollHandler); this.updateProgress(); }, updateProgress() { const contentArea = document.getElementById("content-area"); const progressFill = document.getElementById("reading-progress-fill"); const progressText = document.getElementById("reading-progress-text"); if (!contentArea || !progressFill || !progressText) return; const scrollTop = contentArea.scrollTop; const scrollHeight = contentArea.scrollHeight; const clientHeight = contentArea.clientHeight; const maxScroll = scrollHeight - clientHeight; const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0; progressFill.style.width = `${percentage}%`; progressText.textContent = `${percentage}%`; }, throttle(func, delay) { let lastCall = 0; return function (...args) { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; func.apply(this, args); } }; }, destroy() { const contentArea = document.getElementById("content-area"); if (contentArea && this.scrollHandler) { contentArea.removeEventListener("scroll", this.scrollHandler); } this.scrollHandler = null; // Reset progress const progressFill = document.getElementById("reading-progress-fill"); const progressText = document.getElementById("reading-progress-text"); if (progressFill) progressFill.style.width = "0%"; if (progressText) progressText.textContent = "0%"; }, }; // --------------------------------------------------------------------------- // Right Sidebar Manager // --------------------------------------------------------------------------- const RightSidebarManager = { init() { this.loadState(); this.initToggle(); this.initResize(); }, loadState() { const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible"); const savedWidth = localStorage.getItem("obsigate-right-sidebar-width"); if (savedVisible !== null) { rightSidebarVisible = savedVisible === "true"; } if (savedWidth) { rightSidebarWidth = parseInt(savedWidth) || 280; } this.applyState(); }, applyState() { const sidebar = document.getElementById("right-sidebar"); const handle = document.getElementById("right-sidebar-resize-handle"); const tocBtn = document.getElementById("toc-toggle-btn"); const headerToggleBtn = document.getElementById("right-sidebar-toggle-btn"); if (!sidebar) return; if (rightSidebarVisible) { sidebar.classList.remove("hidden"); sidebar.style.width = `${rightSidebarWidth}px`; if (handle) handle.classList.remove("hidden"); if (tocBtn) { tocBtn.classList.add("active"); tocBtn.title = "Masquer le sommaire"; } if (headerToggleBtn) { headerToggleBtn.title = "Masquer le panneau"; headerToggleBtn.setAttribute("aria-label", "Masquer le panneau"); } } else { sidebar.classList.add("hidden"); if (handle) handle.classList.add("hidden"); if (tocBtn) { tocBtn.classList.remove("active"); tocBtn.title = "Afficher le sommaire"; } if (headerToggleBtn) { headerToggleBtn.title = "Afficher le panneau"; headerToggleBtn.setAttribute("aria-label", "Afficher le panneau"); } } // Update icons safeCreateIcons(); }, toggle() { rightSidebarVisible = !rightSidebarVisible; localStorage.setItem("obsigate-right-sidebar-visible", rightSidebarVisible); this.applyState(); }, initToggle() { const toggleBtn = document.getElementById("right-sidebar-toggle-btn"); if (toggleBtn) { toggleBtn.addEventListener("click", () => this.toggle()); } }, initResize() { const handle = document.getElementById("right-sidebar-resize-handle"); const sidebar = document.getElementById("right-sidebar"); if (!handle || !sidebar) return; let isResizing = false; let startX = 0; let startWidth = 0; const onMouseDown = (e) => { isResizing = true; startX = e.clientX; startWidth = sidebar.offsetWidth; handle.classList.add("active"); document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; }; const onMouseMove = (e) => { if (!isResizing) return; const delta = startX - e.clientX; let newWidth = startWidth + delta; // Constrain width newWidth = Math.max(200, Math.min(400, newWidth)); sidebar.style.width = `${newWidth}px`; rightSidebarWidth = newWidth; }; const onMouseUp = () => { if (!isResizing) return; isResizing = false; handle.classList.remove("active"); document.body.style.cursor = ""; document.body.style.userSelect = ""; localStorage.setItem("obsigate-right-sidebar-width", rightSidebarWidth); }; handle.addEventListener("mousedown", onMouseDown); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }, }; // --------------------------------------------------------------------------- // 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) { console.log("showToast called with:", message, type); type = type || "info"; 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 { // Inject auth header if authenticated const authHeaders = AuthManager.getAuthHeaders(); const mergedOpts = opts || {}; // Auto-set Content-Type for JSON bodies if (mergedOpts.body && typeof mergedOpts.body === "string" && !mergedOpts.headers?.["Content-Type"]) { mergedOpts.headers = { ...mergedOpts.headers, "Content-Type": "application/json" }; } if (authHeaders) { mergedOpts.headers = { ...mergedOpts.headers, ...authHeaders }; } mergedOpts.credentials = "include"; res = await fetch(path, mergedOpts); } catch (err) { if (err.name === "AbortError") throw err; // let callers handle abort showToast("Erreur réseau — vérifiez votre connexion", "error"); throw err; } if (res.status === 401 && AuthManager._authEnabled) { // Token expired — try refresh try { await AuthManager.refreshAccessToken(); // Retry the request with new token const retryHeaders = AuthManager.getAuthHeaders(); const retryOpts = opts || {}; retryOpts.headers = { ...retryOpts.headers, ...retryHeaders }; retryOpts.credentials = "include"; res = await fetch(path, retryOpts); } catch (refreshErr) { AuthManager.clearSession(); AuthManager.showLoginScreen(); throw new Error("Session expirée"); } } if (!res.ok) { var detail = ""; try { var body = await res.json(); detail = body.detail || ""; } catch (_) { /* no json body */ } showToast(detail || "Erreur API : " + res.status, "error"); throw new Error(detail || "API error: " + res.status); } return res.json(); } // --------------------------------------------------------------------------- // AuthManager — Authentication state & token management // --------------------------------------------------------------------------- const AuthManager = { ACCESS_TOKEN_KEY: "obsigate_access_token", TOKEN_EXPIRY_KEY: "obsigate_token_expiry", USER_KEY: "obsigate_user", _authEnabled: false, // ── Token storage (sessionStorage) ───────────────────────────── saveToken(tokenData) { const expiresAt = Date.now() + tokenData.expires_in * 1000; sessionStorage.setItem(this.ACCESS_TOKEN_KEY, tokenData.access_token); sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString()); if (tokenData.user) { sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user)); } }, getToken() { return sessionStorage.getItem(this.ACCESS_TOKEN_KEY); }, getUser() { const raw = sessionStorage.getItem(this.USER_KEY); return raw ? JSON.parse(raw) : null; }, isTokenExpired() { const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY); if (!expiry) return true; // Renew 60s before expiration return Date.now() > parseInt(expiry) - 60000; }, clearSession() { sessionStorage.removeItem(this.ACCESS_TOKEN_KEY); sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY); sessionStorage.removeItem(this.USER_KEY); }, getAuthHeaders() { const token = this.getToken(); if (!token || !this._authEnabled) return null; return { Authorization: "Bearer " + token }; }, // ── API calls ────────────────────────────────────────────────── async login(username, password, rememberMe) { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ username, password, remember_me: rememberMe || false }), }); if (!response.ok) { const err = await response.json(); throw new Error(err.detail || "Erreur de connexion"); } const data = await response.json(); this.saveToken(data); return data.user; }, async logout() { try { const token = this.getToken(); await fetch("/api/auth/logout", { method: "POST", headers: token ? { Authorization: "Bearer " + token } : {}, credentials: "include", }); } catch (e) { /* continue even if API fails */ } this.clearSession(); this.showLoginScreen(); }, async refreshAccessToken() { const response = await fetch("/api/auth/refresh", { method: "POST", credentials: "include", }); if (!response.ok) { this.clearSession(); throw new Error("Session expirée"); } const data = await response.json(); const expiry = Date.now() + data.expires_in * 1000; sessionStorage.setItem(this.ACCESS_TOKEN_KEY, data.access_token); sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiry.toString()); return data.access_token; }, // ── UI controls ──────────────────────────────────────────────── showLoginScreen() { const app = document.getElementById("app"); const login = document.getElementById("login-screen"); if (app) app.classList.add("hidden"); if (login) { login.classList.remove("hidden"); const usernameInput = document.getElementById("login-username"); if (usernameInput) usernameInput.focus(); } }, showApp() { const login = document.getElementById("login-screen"); const app = document.getElementById("app"); if (login) login.classList.add("hidden"); if (app) app.classList.remove("hidden"); this.renderUserMenu(); }, renderUserMenu() { const user = this.getUser(); const userMenu = document.getElementById("user-menu"); if (!userMenu) return; if (!user || !this._authEnabled) { userMenu.innerHTML = ""; return; } userMenu.innerHTML = '' + (user.display_name || user.username) + "" + ''; safeCreateIcons(); const logoutBtn = document.getElementById("logout-btn"); if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout()); const adminRow = document.getElementById("admin-menu-row"); if (adminRow) { if (user.role === "admin") { adminRow.classList.remove("hidden"); // Important: use an inline function to ensure we don't bind multiple identical listeners on rerenders, or clean up before adminRow.onclick = () => { closeHeaderMenu(); AdminPanel.show(); }; } else { adminRow.classList.add("hidden"); } } }, // ── Initialization ────────────────────────────────────────────── async checkAuthStatus() { try { const res = await fetch("/api/auth/status"); const data = await res.json(); this._authEnabled = data.auth_enabled; return data; } catch (e) { this._authEnabled = false; return { auth_enabled: false }; } }, async initAuth() { const status = await this.checkAuthStatus(); if (!status.auth_enabled) { // Auth disabled — show app immediately this.showApp(); return true; } // Auth enabled — check for existing session if (this.getToken() && !this.isTokenExpired()) { this.showApp(); return true; } // Try silent refresh try { await this.refreshAccessToken(); // Fetch user info const token = this.getToken(); const res = await fetch("/api/auth/me", { headers: { Authorization: "Bearer " + token }, credentials: "include", }); if (res.ok) { const user = await res.json(); sessionStorage.setItem(this.USER_KEY, JSON.stringify(user)); this.showApp(); return true; } } catch (e) { /* silent refresh failed */ } // No valid session — show login this.showLoginScreen(); return false; }, }; // --------------------------------------------------------------------------- // Login form handler // --------------------------------------------------------------------------- function initLoginForm() { const form = document.getElementById("login-form"); if (!form) return; form.addEventListener("submit", async (e) => { e.preventDefault(); const username = document.getElementById("login-username").value; const password = document.getElementById("login-password").value; const rememberMe = document.getElementById("remember-me").checked; const errorEl = document.getElementById("login-error"); const btn = document.getElementById("login-btn"); btn.disabled = true; btn.querySelector(".btn-spinner").classList.remove("hidden"); btn.querySelector(".btn-text").textContent = "Connexion..."; errorEl.classList.add("hidden"); try { await AuthManager.login(username, password, rememberMe); AuthManager.showApp(); // Load app data after successful login try { await Promise.all([loadVaults(), loadTags()]); // Start SSE sync now that auth cookie is set IndexUpdateManager.connect(); // Show dashboard showWelcome(); } catch (err) { console.error("Failed to load data after login:", err); } safeCreateIcons(); } catch (err) { errorEl.textContent = err.message; errorEl.classList.remove("hidden"); document.getElementById("login-password").value = ""; document.getElementById("login-password").focus(); } finally { btn.disabled = false; btn.querySelector(".btn-spinner").classList.add("hidden"); btn.querySelector(".btn-text").textContent = "Se connecter"; } }); // Toggle password visibility const toggleBtn = document.getElementById("toggle-password"); if (toggleBtn) { toggleBtn.addEventListener("click", () => { const input = document.getElementById("login-password"); input.type = input.type === "password" ? "text" : "password"; }); } } // --------------------------------------------------------------------------- // Admin Panel — User management (admin only) // --------------------------------------------------------------------------- const AdminPanel = { _modal: null, _allVaults: [], show() { this._createModal(); this._modal.classList.add("active"); this._loadUsers(); }, hide() { if (this._modal) this._modal.classList.remove("active"); }, _createModal() { if (this._modal) return; this._modal = document.createElement("div"); this._modal.className = "editor-modal"; this._modal.id = "admin-modal"; this._modal.innerHTML = `
    ⚙️ Administration — Utilisateurs
    `; document.body.appendChild(this._modal); safeCreateIcons(); document.getElementById("admin-close").addEventListener("click", () => this.hide()); document.getElementById("admin-add-user").addEventListener("click", () => this._showUserForm(null)); }, async _loadUsers() { try { const users = await api("/api/auth/admin/users"); // Also load available vaults try { const vaultsData = await api("/api/vaults"); this._allVaults = vaultsData.map((v) => v.name); } catch (e) { this._allVaults = []; } this._renderUsers(users); } catch (err) { document.getElementById("admin-users-list").innerHTML = '

    Erreur : ' + err.message + "

    "; } }, _renderUsers(users) { const container = document.getElementById("admin-users-list"); if (!users.length) { container.innerHTML = '

    Aucun utilisateur.

    '; return; } let html = '' + "" + ""; users.forEach((u) => { const vaults = u.vaults.includes("*") ? "Toutes" : u.vaults.join(", ") || "Aucune"; const status = u.active ? "✅" : "🔴"; const lastLogin = u.last_login ? new Date(u.last_login).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "Jamais"; html += "" + "" + '" + '" + "" + "" + '"; }); html += "
    UtilisateurRôleVaultsStatutDernière connexionActions
    " + u.username + "" + (u.display_name && u.display_name !== u.username ? "
    " + u.display_name + "" : "") + "
    ' + u.role + "' + vaults + "" + status + "" + lastLogin + "' + '' + '' + "
    "; container.innerHTML = html; // Bind action buttons container.querySelectorAll('[data-action="edit"]').forEach((btn) => { btn.addEventListener("click", () => { const user = users.find((u) => u.username === btn.dataset.username); if (user) this._showUserForm(user); }); }); container.querySelectorAll('[data-action="delete"]').forEach((btn) => { btn.addEventListener("click", () => this._deleteUser(btn.dataset.username)); }); }, _showUserForm(user) { const isEdit = !!user; const title = isEdit ? "Modifier : " + user.username : "Nouvel utilisateur"; const vaultCheckboxes = this._allVaults .map((v) => { const checked = user && (user.vaults.includes(v) || user.vaults.includes("*")) ? "checked" : ""; return '"; }) .join(""); const allVaultsChecked = user && user.vaults.includes("*") ? "checked" : ""; // Create form modal overlay const overlay = document.createElement("div"); overlay.className = "admin-form-overlay"; overlay.innerHTML = `

    ${title}

    ${!isEdit ? '
    ' : ""}
    ${vaultCheckboxes}
    ${isEdit ? '
    " : ""}
    `; this._modal.appendChild(overlay); document.getElementById("admin-form-cancel").addEventListener("click", () => overlay.remove()); document.getElementById("admin-user-form").addEventListener("submit", async (e) => { e.preventDefault(); const form = e.target; const allVaults = document.getElementById("admin-all-vaults").checked; const selectedVaults = allVaults ? ["*"] : Array.from(form.querySelectorAll('input[name="vault"]:checked')).map((cb) => cb.value); try { if (isEdit) { const updates = { display_name: form.display_name.value || null, role: form.role.value, vaults: selectedVaults, }; if (form.password.value) updates.password = form.password.value; const activeCheckbox = form.querySelector('input[name="active"]'); if (activeCheckbox) updates.active = activeCheckbox.checked; await api("/api/auth/admin/users/" + user.username, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updates), }); } else { await api("/api/auth/admin/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: form.username.value, password: form.password.value, display_name: form.display_name.value || null, role: form.role.value, vaults: selectedVaults, }), }); } overlay.remove(); this._loadUsers(); showToast(isEdit ? "Utilisateur modifié" : "Utilisateur créé", "success"); } catch (err) { showToast(err.message, "error"); } }); }, async _deleteUser(username) { const currentUser = AuthManager.getUser(); if (currentUser && currentUser.username === username) { showToast("Impossible de supprimer son propre compte", "error"); return; } if (!confirm("Supprimer l'utilisateur \"" + username + '" ?')) return; try { await api("/api/auth/admin/users/" + username, { method: "DELETE" }); this._loadUsers(); showToast("Utilisateur supprimé", "success"); } catch (err) { showToast(err.message, "error"); } }, }; // --------------------------------------------------------------------------- // 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(); // Synchroniser le dashboard et les fichiers récents if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.load) { DashboardRecentWidget.load(vaultName); } if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) { DashboardBookmarkWidget.load(vaultName); } if (activeSidebarTab === "recent") { loadRecentFiles(vaultName === "all" ? null : vaultName); } 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"); const recentFilter = document.getElementById("recent-vault-filter"); const dashboardFilter = document.getElementById("dashboard-vault-filter"); const contextText = document.getElementById("vault-context-text"); if (filter) filter.value = selectedContextVault; if (quickSelect) quickSelect.value = selectedContextVault; if (recentFilter) recentFilter.value = selectedContextVault === "all" ? "" : selectedContextVault; if (dashboardFilter) dashboardFilter.value = selectedContextVault; // Mise à jour visuelle des dropdowns personnalisés updateCustomDropdownVisual("vault-filter-dropdown", selectedContextVault); updateCustomDropdownVisual("vault-quick-select-dropdown", selectedContextVault); // Update vault context indicator if (contextText) { contextText.textContent = selectedContextVault === "all" ? "All" : selectedContextVault; } } /** * Updates the visual state of a custom dropdown based on its current value. */ function updateCustomDropdownVisual(dropdownId, value) { const dropdown = document.getElementById(dropdownId); if (!dropdown) return; const selectedText = dropdown.querySelector(".custom-dropdown-selected"); const options = dropdown.querySelectorAll(".custom-dropdown-option"); options.forEach((opt) => { const optValue = opt.getAttribute("data-value"); if (optValue === value) { opt.classList.add("selected"); if (selectedText) selectedText.textContent = opt.textContent; } else { opt.classList.remove("selected"); } }); } 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 shouldCenter = alignToTop === "center"; const centeredTop = Math.max(0, currentTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2)); const targetTop = shouldCenter ? centeredTop : 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), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]); vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); vaultItem.addEventListener("contextmenu", (e) => { e.preventDefault(); const isReadonly = false; ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); }); attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); 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); } // --------------------------------------------------------------------------- // Helper: Check if path should be displayed based on hideHiddenFiles setting // --------------------------------------------------------------------------- function shouldDisplayPath(path, vaultName) { // Get hideHiddenFiles setting for this vault (default: false = show all) const settings = vaultSettings[vaultName] || { hideHiddenFiles: false }; if (!settings.hideHiddenFiles) { // Show all files return true; } // Check if any segment of the path starts with a dot (hidden) const segments = path.split("/").filter(Boolean); for (const segment of segments) { if (segment.startsWith(".")) { return false; // Hide this path } } return true; // Show this path } async function loadVaultSettings() { try { const settings = await api("/api/vaults/settings/all"); vaultSettings = settings; } catch (err) { console.error("Failed to load vault settings:", err); vaultSettings = {}; } } // --------------------------------------------------------------------------- // 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"); // Populate standard selects _populateRecentVaultFilter(); if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.populateVaultFilter) { DashboardRecentWidget.populateVaultFilter(); } vaults.forEach((v) => { // Sidebar tree entry const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]); vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name)); vaultItem.addEventListener("contextmenu", (e) => { e.preventDefault(); const isReadonly = false; ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); }); attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); container.appendChild(vaultItem); const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` }); container.appendChild(childContainer); }); syncVaultSelectors(); safeCreateIcons(); } /** * Refreshes the sidebar tree while preserving the expanded state of vaults and folders. * Optimized to avoid a full sidebar wipe and minimize visible loading states. */ /** * Incrementally update a directory container without wiping existing DOM. * Only adds new items, removes deleted ones, and updates changed ones. */ async function incrementalLoadDirectory(vaultName, dirPath, container) { let data; try { const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`; data = await api(url); } catch (err) { // Server unavailable — keep existing content return; } // Build a map of existing DOM elements by path const existingItems = {}; const existingChildren = {}; // path -> child container (for directories) for (let i = 0; i < container.children.length; i++) { const child = container.children[i]; if (child.classList.contains("tree-item") && child.dataset.path) { existingItems[child.dataset.path] = child; // The next sibling should be the tree-children container for this directory if (i + 1 < container.children.length) { const next = container.children[i + 1]; if (next.classList.contains("tree-children")) { existingChildren[child.dataset.path] = next; } } } } const fragment = document.createDocumentFragment(); data.items.forEach((item) => { if (!shouldDisplayPath(item.path, vaultName)) return; const existing = existingItems[item.path]; if (existing) { // Item already exists — reuse it, but update text/badge if needed const textEl = existing.querySelector(".tree-item-text"); const displayName = item.type === "file" && item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; if (textEl && textEl.textContent !== displayName) { textEl.textContent = displayName; } // Update badge for directories if (item.type === "directory") { const badge = existing.querySelector(".badge-small"); const newBadge = `(${item.children_count})`; if (badge && badge.textContent !== newBadge) { badge.textContent = newBadge; } else if (!badge) { existing.appendChild(smallBadge(item.children_count)); } } fragment.appendChild(existing); // Also re-add the child container for directories if (item.type === "directory" && existingChildren[item.path]) { fragment.appendChild(existingChildren[item.path]); } else if (item.type === "directory") { // Directory existed but no child container — create one const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); fragment.appendChild(subContainer); } } else { // New item — create it 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), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); 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(); } }); dirItem.addEventListener("contextmenu", (e) => { e.preventDefault(); ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false); }); } 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), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); fileItem.addEventListener("click", () => { scrollTreeItemIntoView(fileItem, false); TabManager.openPreview(vaultName, item.path); closeMobileSidebar(); }); fileItem.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(vaultName, item.path); }); fileItem.addEventListener("contextmenu", (e) => { e.preventDefault(); ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false); }); fragment.appendChild(fileItem); } } }); // Replace container content in a single batch operation to avoid flash container.textContent = ""; container.appendChild(fragment); } async function refreshSidebarTreePreservingState() { // 1. Capture expanded states const expandedVaults = Array.from(document.querySelectorAll(".vault-item")) .filter((v) => { const children = document.getElementById(`vault-children-${v.dataset.vault}`); return children && !children.classList.contains("collapsed"); }) .map((v) => v.dataset.vault); const expandedDirs = Array.from(document.querySelectorAll(".tree-item[data-path]")) .filter((item) => { const vault = item.dataset.vault; const path = item.dataset.path; const children = document.getElementById(`dir-${vault}-${path}`); return children && !children.classList.contains("collapsed"); }) .map((item) => ({ vault: item.dataset.vault, path: item.dataset.path })); const selectedItem = document.querySelector(".tree-item.path-selected"); const selectedState = selectedItem ? { vault: selectedItem.dataset.vault, path: selectedItem.dataset.path } : null; // 2. Soft update: vault names/counts without wiping the tree try { const vaults = await api("/api/vaults"); allVaults = vaults; vaults.forEach((v) => { const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`); if (vItem) { const badge = vItem.querySelector(".badge-small"); if (badge) badge.textContent = `(${v.file_count})`; } }); } catch (e) { console.warn("Soft vault refresh failed, falling back to full reload", e); await loadVaults(); return; } // 3. Incrementally update expanded vaults (no DOM wipe) for (const vName of expandedVaults) { const container = document.getElementById(`vault-children-${vName}`); if (container) { await incrementalLoadDirectory(vName, "", container); } } // 4. Incrementally update expanded directories (parents first, no DOM wipe) expandedDirs.sort((a, b) => a.path.split("/").length - b.path.split("/").length); for (const dir of expandedDirs) { const container = document.getElementById(`dir-${dir.vault}-${dir.path}`); if (container) { try { await incrementalLoadDirectory(dir.vault, dir.path, container); container.classList.remove("collapsed"); const dItem = document.querySelector(`.tree-item[data-vault="${CSS.escape(dir.vault)}"][data-path="${CSS.escape(dir.path)}"]`); if (dItem) { const chev = dItem.querySelector("[data-lucide]"); if (chev) chev.setAttribute("data-lucide", "chevron-down"); } } catch (e) { console.error(`Failed to refresh directory ${dir.vault}/${dir.path}`, e); } } } // 5. Restore selection if (selectedState) { await focusPathInSidebar(selectedState.vault, selectedState.path, { alignToTop: false }); } 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 expandDirectoryInSidebar(vaultName, dirPath, dirItem) { const subContainer = document.getElementById(`dir-${vaultName}-${dirPath}`); if (!subContainer) return null; if (subContainer.children.length === 0) { await loadDirectory(vaultName, dirPath, subContainer); } subContainer.classList.remove("collapsed"); if (dirItem) { const chevron = dirItem.querySelector("[data-lucide]"); if (chevron) chevron.setAttribute("data-lucide", "chevron-down"); } safeCreateIcons(); return subContainer; } 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 = await expandDirectoryInSidebar(vaultName, cumulativePath, targetItem); if (nextContainer) { currentContainer = nextContainer; } } } if (lastTargetItem && options && options.expandTarget) { await expandDirectoryInSidebar(vaultName, targetPath, lastTargetItem); } // 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); } function getParentDirectoryPath(filePath) { if (!filePath) return ""; const segments = filePath.split("/").filter(Boolean); if (segments.length <= 1) return ""; segments.pop(); return segments.join("/"); } function syncActiveFileTreeItem(vaultName, filePath) { document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); if (!vaultName || !filePath) return; const selector = `.tree-item[data-vault="${CSS.escape(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 */ } } async function loadDirectory(vaultName, dirPath, container) { // Only show the loading spinner if the container is currently empty const isEmpty = container.children.length === 0; if (isEmpty) { 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) => { // Apply client-side filtering for hidden files if (!shouldDisplayPath(item.path, vaultName)) { return; // Skip this 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), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); 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(); } }); dirItem.addEventListener("contextmenu", (e) => { e.preventDefault(); const isReadonly = false; ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly); }); } 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), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); fileItem.addEventListener("click", () => { scrollTreeItemIntoView(fileItem, false); TabManager.openPreview(vaultName, item.path); closeMobileSidebar(); }); fileItem.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(vaultName, item.path); }); fileItem.addEventListener("contextmenu", (e) => { e.preventDefault(); const isReadonly = false; ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly); }); 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 }, [getVaultIcon(vaultName, 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, expandTarget: true }); } else { await TabManager.openPreview(entry.vault, entry.path); await focusPathInSidebar(entry.vault, getParentDirectoryPath(entry.path), { alignToTop: true, expandTarget: true }); syncActiveFileTreeItem(entry.vault, entry.path); } 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) { // 1. Escape ALL special regex characters // We use a broader set including * and . let regex = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // 2. Convert escaped '*' to '.*' (wildcard) regex = regex.replace(/\\\*/g, ".*"); // 3. Convert escaped '...' (or any sequence of 2+ dots like ..) to '.*' // We also handle optional whitespace around it to make it more user-friendly regex = regex.replace(/\s*\\\.{2,}\s*/g, ".*"); return regex; }, isTagFiltered(tag) { const config = this.getConfig(); const filters = config.tagFilters || this.defaultFilters; const tagWithHash = `#${tag}`; for (const filter of filters) { if (!filter.enabled) continue; try { // Robustly handle regex with or without ^/$ let patternStr = filter.regex; if (!patternStr.startsWith("^")) patternStr = "^" + patternStr; if (!patternStr.endsWith("$")) patternStr = patternStr + "$"; const regex = new RegExp(patternStr); if (regex.test(tagWithHash)) { 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()) { performAdvancedSearch(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; performAdvancedSearch(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 syncActiveFileTreeItem(vaultName, filePath); // Show loading state while fetching const area = document.getElementById("content-area"); area.innerHTML = '
    Chargement...
    '; try { const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`; const data = await api(url); renderFile(data); } catch (err) { area.innerHTML = '

    Impossible de charger le fichier.

    '; } } async function renderBacklinksPanel(vault, path, container) { try { const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`); if (!data.backlinks || data.backlinks.length === 0) return; const panel = el("div", { class: "backlinks-panel" }); const header = el("div", { class: "backlinks-header" }, [ icon("link", 14), document.createTextNode(` ${data.total} lien(s) entrant(s)`), ]); panel.appendChild(header); const list = el("div", { class: "backlinks-list" }); data.backlinks.forEach((bl) => { const item = el("div", { class: "backlink-item" }); const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]); const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]); item.appendChild(icon(getFileIcon(bl.path), 12)); item.appendChild(vaultBadge); item.appendChild(titleEl); item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path)); item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); }); list.appendChild(item); }); panel.appendChild(list); container.appendChild(panel); } catch (err) { // Silently ignore — backlinks are optional console.debug("Backlinks fetch failed:", err); } } function renderFile(data) { const area = document.getElementById("content-area"); // Handle unsupported (binary) files if (data.unsupported) { const sizeStr = data.size_bytes ? data.size_bytes < 1024 ? `${data.size_bytes} o` : data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko` : `${(data.size_bytes / 1048576).toFixed(1)} Mo` : ""; area.innerHTML = `
    ${escapeHtml(data.path.split("/").pop())}
    Ce fichier est binaire et ne peut pas être affiché.
    ${sizeStr ? `
    Taille : ${sizeStr}
    ` : ""}
    `; lucide.createIcons(); document.getElementById("unsupported-download-btn").addEventListener("click", () => { const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; window.open(dlUrl, "_blank"); }); return; } // Breadcrumb const parts = data.path.split("/"); const breadcrumbEls = []; breadcrumbEls.push( makeBreadcrumbSpan(data.vault, () => { focusPathInSidebar(data.vault, "", { alignToTop: "center" }); }), ); let accumulated = ""; parts.forEach((part, i) => { breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")])); accumulated += (accumulated ? "/" : "") + part; const p = accumulated; if (i < parts.length - 1) { breadcrumbEls.push( makeBreadcrumbSpan(part, () => { focusPathInSidebar(data.vault, p, { alignToTop: "center" }); }), ); } else { breadcrumbEls.push( makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => { focusPathInSidebar(data.vault, data.path, { alignToTop: "center" }); }), ); } }); const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls); // Tags const tagsDiv = el("div", { class: "file-tags" }); (data.tags || []).forEach((tag) => { if (!TagFilterService.isTagFiltered(tag)) { const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); t.addEventListener("click", () => searchByTag(tag)); tagsDiv.appendChild(t); } }); // Action buttons const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]); copyBtn.addEventListener("click", async () => { try { // Fetch raw content if not already cached if (!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", "error"); } }); const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]); // MD download button const mdBtn = el("button", { class: "btn-action", title: "Télécharger en .md" }, [icon("file-text", 14), document.createTextNode(".md")]); mdBtn.addEventListener("click", () => { const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; const a = document.createElement("a"); a.href = dlUrl; a.download = data.path.split("/").pop(); document.body.appendChild(a); a.click(); document.body.removeChild(a); }); // PDF download button const pdfBtn = el("button", { class: "btn-action", title: "Télécharger en PDF" }, [icon("file", 14), document.createTextNode("PDF")]); pdfBtn.addEventListener("click", () => { const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`; window.open(pdfUrl, "_blank"); }); const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]); editBtn.addEventListener("click", () => { openEditor(data.vault, data.path); }); const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenêtre" }, [icon("external-link", 14), document.createTextNode("pop-out")]); openNewWindowBtn.addEventListener("click", () => { const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}`; window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no"); }); const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]); tocBtn.addEventListener("click", () => { RightSidebarManager.toggle(); }); // Share button — check if already shared const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]); // Check if already shared and color the button (async () => { try { const shares = await api("/api/shares"); if (shares.some(s => s.vault === data.vault && s.path === data.path)) { shareBtn.classList.add("shared"); shareBtn.title = "Document partagé — cliquer pour gérer"; } } catch (e) { /* ignore */ } })(); shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path)); // Bookmark button — check if already bookmarked const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: "Ajouter/Retirer des bookmarks" }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]); // Check bookmark status and color the button (async () => { try { const bms = await api("/api/bookmarks"); if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) { bookmarkBtn.classList.add("active"); bookmarkBtn.title = "Retirer des bookmarks"; } } catch (e) { /* ignore */ } })(); bookmarkBtn.addEventListener("click", async () => { try { const res = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: data.vault, path: data.path, title: data.title }) }); bookmarkBtn.classList.toggle("active", res.bookmarked); bookmarkBtn.title = res.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks"; showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success"); if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(); } catch (err) { showToast("Erreur: " + err.message, "error"); } }); // Frontmatter — Accent Card let fmSection = null; if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { fmSection = buildFrontmatterCard(data.frontmatter); } // Content container (rendered HTML) const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" }); mdDiv.innerHTML = data.html; // Raw source container (hidden initially) const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" }); // Source button toggle logic sourceBtn.addEventListener("click", async () => { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (!rendered || !raw) return; 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, mdBtn, pdfBtn, editBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])])); if (fmSection) area.appendChild(fmSection); area.appendChild(mdDiv); area.appendChild(rawDiv); // Backlinks panel if (data.is_markdown) { renderBacklinksPanel(data.vault, data.path, area); } // Highlight code blocks area.querySelectorAll("pre code").forEach((block) => { safeHighlight(block); }); // Wire up wikilinks area.querySelectorAll(".wikilink").forEach((link) => { link.addEventListener("click", (e) => { e.preventDefault(); const v = link.getAttribute("data-vault"); const p = link.getAttribute("data-path"); if (v && p) openFile(v, p); }); }); safeCreateIcons(); area.scrollTop = 0; // Initialize outline/TOC for this document OutlineManager.init(); } // --------------------------------------------------------------------------- // Recent files // --------------------------------------------------------------------------- let _recentRefreshTimer = null; let _recentTimestampTimer = null; let _recentFilesCache = []; // --------------------------------------------------------------------------- // Dashboard Recent Files Widget // --------------------------------------------------------------------------- // ── Dashboard Stats Widget ── const DashboardStatsWidget = { async load() { const grid = document.getElementById("dashboard-stats-grid"); if (!grid) return; grid.innerHTML = '
    Chargement...
    '; try { const data = await api("/api/dashboard"); this.render(data); } catch (err) { grid.innerHTML = `
    Erreur: ${escapeHtml(err.message)}
    `; } }, render(data) { const grid = document.getElementById("dashboard-stats-grid"); if (!grid) return; const fmtSize = (bytes) => bytes < 1024 ? `${bytes} o` : bytes < 1048576 ? `${(bytes/1024).toFixed(1)} Ko` : bytes < 1073741824 ? `${(bytes/1048576).toFixed(1)} Mo` : `${(bytes/1073741824).toFixed(1)} Go`; const items = [ { icon: "files", label: "Fichiers", value: data.total_files.toLocaleString() }, { icon: "tags", label: "Tags uniques", value: data.total_tags.toLocaleString() }, { icon: "hard-drive", label: "Taille totale", value: fmtSize(data.total_size_bytes) }, { icon: "folder-open", label: "Vaults", value: data.vaults.length.toString() }, ]; grid.innerHTML = items.map(i => `
    ${i.value} ${i.label}
    `).join(""); safeCreateIcons(); } }; // ── Dashboard Shared Widget ── const DashboardSharedWidget = { async load() { const grid = document.getElementById("dashboard-shared-grid"); const empty = document.getElementById("dashboard-shared-empty"); if (!grid) return; try { const shares = await api("/api/shares"); if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; } if (empty) empty.style.display = "none"; grid.innerHTML = shares.map(s => `
    ${escapeHtml(s.path.split("/").pop().replace(/\.md$/i, ""))} ${escapeHtml(s.vault)}
    ${s.access_count || 0} vue(s) ${s.expires_at ? `Expire le ${new Date(s.expires_at).toLocaleDateString("fr-FR")}` : ""}
    `).join(""); lucide.createIcons(); grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => { e.stopPropagation(); const url = b.dataset.url; try { await navigator.clipboard.writeText(url); } catch { 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("Lien copié !", "success"); })); grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => { e.stopPropagation(); const card = b.closest(".shared-card"); if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path); })); grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => { e.stopPropagation(); await api(`/api/share/${b.dataset.id}`, { method: "DELETE" }); showToast("Partage révoqué", "success"); this.load(); })); grid.querySelectorAll(".shared-card").forEach(card => card.addEventListener("click", () => { TabManager.openPreview(card.dataset.vault, card.dataset.path); })); } catch (err) { if (empty) empty.style.display = ""; } } }; // ── Dashboard Conflicts Widget ── const DashboardConflictsWidget = { async load() { const container = document.getElementById("dashboard-conflicts-container"); if (!container) return; try { const data = await api("/api/conflicts"); if (data.total === 0) { container.innerHTML = ""; return; } this.render(data.conflicts, container); } catch (err) { container.innerHTML = ""; } }, render(conflicts, container) { container.innerHTML = `

    Conflits de synchronisation

    ${conflicts.length}
    ${conflicts.map(c => `
    ${escapeHtml(c.vault)} ${escapeHtml(c.conflict_path.split("/").pop())} Conflit du ${c.conflict_date.replace(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})/, "$3/$2/$1 $4:$5")}
    `).join("")}
    `; lucide.createIcons(); container.querySelectorAll(".keep-local").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_local"))); container.querySelectorAll(".keep-conflict").forEach(btn => btn.addEventListener("click", () => this._resolve(btn.dataset, "keep_conflict"))); }, async _resolve(d, action) { try { await api("/api/conflicts/resolve", { method: "POST", body: JSON.stringify({ vault: d.vault, conflict_path: d.conflict, original_path: d.original, action }) }); showToast("Conflit résolu", "success"); this.load(); } catch (err) { showToast("Erreur: " + err.message, "error"); } } }; const DashboardRecentWidget = { _cache: [], _currentFilter: "", async load(vaultFilter = "") { const v = vaultFilter || selectedContextVault || "all"; this._currentFilter = v; this.showLoading(); let url = "/api/recent?mode=opened"; if (v !== "all") url += `&vault=${encodeURIComponent(v)}`; try { const data = await api(url); this._cache = data.files || []; this.render(); } catch (err) { console.error("Dashboard: Failed to load recent files:", err); this.showError(); } }, async toggleBookmark(vault, path, title, card) { try { const data = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault, path, title }), }); // Refresh both widgets to keep sync DashboardBookmarkWidget.load(); // Update current card icon if it exists if (card) { const btn = card.querySelector(".dashboard-card-bookmark-btn"); if (btn) { btn.classList.toggle("active", data.bookmarked); const icon = btn.querySelector("i"); if (icon) icon.setAttribute("data-lucide", data.bookmarked ? "bookmark" : "bookmark-plus"); safeCreateIcons(); } } // Check if we need to refresh the current list to reflect bookmark status across all cards // To avoid flickering, just update the cache and re-render if needed or do a silent refresh this._cache.forEach(f => { if (f.vault === vault && f.path === path) f.bookmarked = data.bookmarked; }); } catch (err) { console.error("Failed to toggle bookmark:", err); showToast("Erreur lors de l'épinglage", "error"); } }, showLoading() { const grid = document.getElementById("dashboard-recent-grid"); const loading = document.getElementById("dashboard-loading"); const empty = document.getElementById("dashboard-recent-empty"); const count = document.getElementById("dashboard-count"); if (grid) grid.innerHTML = ""; if (loading) loading.classList.add("active"); if (empty) empty.classList.add("hidden"); if (count) count.textContent = ""; }, render() { const grid = document.getElementById("dashboard-recent-grid"); const loading = document.getElementById("dashboard-loading"); const empty = document.getElementById("dashboard-recent-empty"); const count = document.getElementById("dashboard-count"); if (loading) loading.classList.remove("active"); if (!this._cache || this._cache.length === 0) { this.showEmpty(); return; } if (empty) empty.classList.add("hidden"); if (count) count.textContent = `${this._cache.length} fichier${this._cache.length > 1 ? "s" : ""}`; if (!grid) return; grid.innerHTML = ""; this._cache.forEach((f, index) => { const card = this._createCard(f, index); grid.appendChild(card); }); safeCreateIcons(); }, _createCard(file, index) { const card = document.createElement("div"); card.className = "dashboard-card"; card.setAttribute("data-vault", file.vault); card.setAttribute("data-path", file.path); card.style.animationDelay = `${Math.min(index * 50, 400)}ms`; // Header with icon and vault badge const header = document.createElement("div"); header.className = "dashboard-card-header"; const iconContainer = document.createElement("div"); iconContainer.className = "dashboard-card-icon"; const fileIconName = getFileIcon(file.path); try { iconContainer.appendChild(icon(fileIconName, 24)); } catch (e) { console.error("Error creating icon:", fileIconName, e); // Fallback to default file icon iconContainer.appendChild(icon("file", 24)); } const badge = document.createElement("span"); badge.className = "dashboard-vault-badge"; badge.textContent = file.vault; const bookmarkBtn = document.createElement("button"); bookmarkBtn.className = `dashboard-card-bookmark-btn ${file.bookmarked ? "active" : ""}`; bookmarkBtn.title = file.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks"; bookmarkBtn.innerHTML = ``; bookmarkBtn.addEventListener("click", (e) => { e.stopPropagation(); this.toggleBookmark(file.vault, file.path, file.title, card); }); header.appendChild(iconContainer); header.appendChild(badge); header.appendChild(bookmarkBtn); card.appendChild(header); // Title const title = document.createElement("h3"); title.className = "dashboard-card-title"; title.textContent = file.title || file.path.split("/").pop(); title.title = file.title || file.path; card.appendChild(title); // Path (compact) const pathParts = file.path.split("/"); if (pathParts.length > 1) { const path = document.createElement("div"); path.className = "dashboard-card-path"; path.textContent = pathParts.slice(0, -1).join(" / "); path.title = file.path; card.appendChild(path); } // Footer with time and tags const footer = document.createElement("div"); footer.className = "dashboard-card-footer"; const time = document.createElement("span"); time.className = "dashboard-card-time"; time.innerHTML = ` ${file.mtime_human || this._humanizeDelta(file.mtime)}`; footer.appendChild(time); // Tags if (file.tags && file.tags.length > 0) { const tags = document.createElement("div"); tags.className = "dashboard-card-tags"; file.tags.slice(0, 3).forEach((tag) => { const tagEl = document.createElement("span"); tagEl.className = "tag-pill"; tagEl.textContent = tag; tags.appendChild(tagEl); }); footer.appendChild(tags); } card.appendChild(footer); // Click handler card.addEventListener("click", () => { openFile(file.vault, file.path); }); return card; }, showEmpty() { const grid = document.getElementById("dashboard-recent-grid"); const loading = document.getElementById("dashboard-loading"); const empty = document.getElementById("dashboard-recent-empty"); const count = document.getElementById("dashboard-count"); if (grid) grid.innerHTML = ""; if (loading) loading.classList.remove("active"); if (empty) empty.classList.remove("hidden"); if (count) count.textContent = "0 fichiers"; safeCreateIcons(); }, showError() { this.showEmpty(); const empty = document.getElementById("dashboard-recent-empty"); if (empty) { const msg = empty.querySelector("span"); if (msg) msg.textContent = "Erreur de chargement"; } }, _humanizeDelta(mtime) { const delta = Date.now() / 1000 - mtime; if (delta < 60) return "à l'instant"; 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" }); }, populateVaultFilter() { const select = document.getElementById("dashboard-vault-filter"); if (!select) return; // Keep first option "Tous les vaults" while (select.options.length > 1) select.remove(1); if (typeof allVaults !== "undefined" && Array.isArray(allVaults)) { allVaults.forEach((v) => { const opt = document.createElement("option"); opt.value = v.name; opt.textContent = v.name; select.appendChild(opt); }); } syncVaultSelectors(); }, init() { const select = document.getElementById("dashboard-vault-filter"); if (select) { select.addEventListener("change", async () => { await setSelectedVaultContext(select.value, { focusVault: select.value !== "all" }); }); } this.populateVaultFilter(); }, }; // --------------------------------------------------------------------------- // Dashboard Bookmarks Widget // --------------------------------------------------------------------------- const DashboardBookmarkWidget = { _cache: [], _currentFilter: "", async load(vaultFilter = "") { const v = vaultFilter || selectedContextVault || "all"; this._currentFilter = v; this.showLoading(); let url = "/api/bookmarks"; if (v !== "all") url += `?vault=${encodeURIComponent(v)}`; try { const data = await api(url); this._cache = data.files || []; this.render(); } catch (err) { console.error("Dashboard: Failed to load bookmarks:", err); this.showEmpty(); } }, showLoading() { const grid = document.getElementById("dashboard-bookmarks-grid"); const empty = document.getElementById("dashboard-bookmarks-empty"); const section = document.getElementById("dashboard-bookmarks-section"); if (grid) grid.innerHTML = ""; if (empty) empty.classList.add("hidden"); }, render() { const grid = document.getElementById("dashboard-bookmarks-grid"); const empty = document.getElementById("dashboard-bookmarks-empty"); const section = document.getElementById("dashboard-bookmarks-section"); if (!this._cache || this._cache.length === 0) { if (grid) grid.innerHTML = ""; if (empty) empty.classList.remove("hidden"); return; } if (empty) empty.classList.add("hidden"); if (!grid) return; grid.innerHTML = ""; this._cache.forEach((f, idx) => { const card = DashboardRecentWidget._createCard(f, idx); grid.appendChild(card); }); safeCreateIcons(); }, showEmpty() { const grid = document.getElementById("dashboard-bookmarks-grid"); const empty = document.getElementById("dashboard-bookmarks-empty"); if (grid) grid.innerHTML = ""; if (empty) empty.classList.remove("hidden"); } }; 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(_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 (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 "à l'instant"; 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 (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); } } } }); } 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); 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); } // --------------------------------------------------------------------------- // 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) { const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "" }; filterInput.placeholder = placeholders[tab] || ""; } const query = filterInput ? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : ""; if (query) { if (tab === "vaults") performTreeSearch(query); else if (tab === "tags") filterTagCloud(query); } // Auto-load recent files when switching to the recent tab if (tab === "recent") { _populateRecentVaultFilter(); const vaultFilter = document.getElementById("recent-vault-filter"); loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); } } 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(); initHelpNavigation(); }); 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 initHelpNavigation() { const helpContent = document.querySelector(".help-content"); const helpBody = document.getElementById("help-body"); const navLinks = document.querySelectorAll(".help-nav-link"); if (!helpContent || !helpBody || !navLinks.length) return; // Handle nav link clicks navLinks.forEach((link) => { link.addEventListener("click", (e) => { e.preventDefault(); const targetId = link.getAttribute("href").substring(1); const targetSection = document.getElementById(targetId); if (targetSection) { targetSection.scrollIntoView({ behavior: "smooth", block: "start" }); } }); }); // Scroll spy - update active nav link based on scroll position const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const id = entry.target.getAttribute("id"); navLinks.forEach((link) => { if (link.getAttribute("href") === `#${id}`) { navLinks.forEach((l) => l.classList.remove("active")); link.classList.add("active"); } }); } }); }, { root: helpBody, rootMargin: "-20% 0px -70% 0px", threshold: 0, }, ); // Observe all sections document.querySelectorAll(".help-section").forEach((section) => { observer.observe(section); }); } 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", async () => { modal.classList.add("active"); closeHeaderMenu(); renderConfigFilters(); loadConfigFields(); loadDiagnostics(); loadAbout(); await loadHiddenFilesSettings(); loadWebhooksUI(); loadSharesUI(); 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); // Frontend config fields — save to localStorage on change ["cfg-debounce", "cfg-results-per-page", "cfg-min-query", "cfg-timeout"].forEach((id) => { const input = document.getElementById(id); if (input) input.addEventListener("change", saveFrontendConfig); }); // Backend save button const saveBtn = document.getElementById("cfg-save-backend"); if (saveBtn) saveBtn.addEventListener("click", saveBackendConfig); // Force reindex const reindexBtn = document.getElementById("cfg-reindex"); if (reindexBtn) reindexBtn.addEventListener("click", forceReindex); // Reset defaults const resetBtn = document.getElementById("cfg-reset-defaults"); if (resetBtn) resetBtn.addEventListener("click", resetConfigDefaults); // Refresh diagnostics const diagBtn = document.getElementById("cfg-refresh-diag"); if (diagBtn) diagBtn.addEventListener("click", loadDiagnostics); // Hidden files configuration const saveHiddenBtn = document.getElementById("cfg-save-hidden-files"); if (saveHiddenBtn) saveHiddenBtn.addEventListener("click", saveHiddenFilesSettings); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modal.classList.contains("active")) { closeConfigModal(); } }); // Load saved frontend config on startup applyFrontendConfig(); } function closeConfigModal() { const modal = document.getElementById("config-modal"); if (modal) modal.classList.remove("active"); } // --- Config field helpers --- const _FRONTEND_CONFIG_KEY = "obsigate-perf-config"; function _getFrontendConfig() { try { return JSON.parse(localStorage.getItem(_FRONTEND_CONFIG_KEY) || "{}"); } catch { return {}; } } function applyFrontendConfig() { const cfg = _getFrontendConfig(); if (cfg.debounce_ms) { /* applied dynamically in debounce setTimeout */ } if (cfg.results_per_page) { /* used as ADVANCED_SEARCH_LIMIT override */ } if (cfg.min_query_length) { /* used as MIN_SEARCH_LENGTH override */ } if (cfg.search_timeout_ms) { /* used as SEARCH_TIMEOUT_MS override */ } } function _getEffective(key, fallback) { const cfg = _getFrontendConfig(); return cfg[key] !== undefined ? cfg[key] : fallback; } async function loadConfigFields() { // Frontend fields from localStorage const cfg = _getFrontendConfig(); _setField("cfg-debounce", cfg.debounce_ms || 300); _setField("cfg-results-per-page", cfg.results_per_page || 50); _setField("cfg-min-query", cfg.min_query_length || 2); _setField("cfg-timeout", cfg.search_timeout_ms || 30000); // Backend fields from API try { const data = await api("/api/config"); _setField("cfg-workers", data.search_workers); _setField("cfg-max-content", data.max_content_size); _setField("cfg-title-boost", data.title_boost); _setField("cfg-tag-boost", data.tag_boost); _setField("cfg-prefix-exp", data.prefix_max_expansions); _setField("cfg-recent-limit", data.recent_files_limit || 20); // Watcher config _setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false); _setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true); _setField("cfg-watcher-interval", data.watcher_polling_interval || 5); _setField("cfg-watcher-debounce", data.watcher_debounce || 2); } catch (err) { console.error("Failed to load backend config:", err); } } function _setField(id, value) { const el = document.getElementById(id); if (el && value !== undefined) el.value = value; } function _setCheckbox(id, checked) { const el = document.getElementById(id); if (el) el.checked = !!checked; } function _getCheckbox(id) { const el = document.getElementById(id); return el ? el.checked : false; } function _getFieldNum(id, fallback) { const el = document.getElementById(id); if (!el) return fallback; const v = parseFloat(el.value); return isNaN(v) ? fallback : v; } function saveFrontendConfig() { const cfg = { debounce_ms: _getFieldNum("cfg-debounce", 300), results_per_page: _getFieldNum("cfg-results-per-page", 50), min_query_length: _getFieldNum("cfg-min-query", 2), search_timeout_ms: _getFieldNum("cfg-timeout", 30000), }; localStorage.setItem(_FRONTEND_CONFIG_KEY, JSON.stringify(cfg)); showToast("Paramètres client sauvegardés", "success"); } async function saveBackendConfig() { const body = { search_workers: _getFieldNum("cfg-workers", 2), max_content_size: _getFieldNum("cfg-max-content", 100000), title_boost: _getFieldNum("cfg-title-boost", 3.0), tag_boost: _getFieldNum("cfg-tag-boost", 2.0), prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50), recent_files_limit: _getFieldNum("cfg-recent-limit", 20), watcher_enabled: _getCheckbox("cfg-watcher-enabled"), watcher_use_polling: _getCheckbox("cfg-watcher-polling"), watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0), watcher_debounce: _getFieldNum("cfg-watcher-debounce", 2.0), }; try { const res = await fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (res.ok) { showToast("Configuration backend sauvegardée", "success"); } else { const errorData = await res.json().catch(() => ({})); showToast(errorData.detail || "Erreur de sauvegarde", "error"); } } catch (err) { console.error("Failed to save backend config:", err); showToast("Erreur de sauvegarde", "error"); } } async function forceReindex() { const btn = document.getElementById("cfg-reindex"); if (btn) { btn.disabled = true; btn.textContent = "Réindexation..."; } try { await api("/api/index/reload"); showToast("Réindexation terminée", "success"); loadDiagnostics(); await Promise.all([loadVaults(), loadTags()]); } catch (err) { console.error("Reindex error:", err); showToast("Erreur de réindexation", "error"); } finally { if (btn) { btn.disabled = false; btn.textContent = "Forcer réindexation"; } } } async function resetConfigDefaults() { // Reset frontend localStorage.removeItem(_FRONTEND_CONFIG_KEY); // Reset backend try { await fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ search_workers: 2, debounce_ms: 300, results_per_page: 50, min_query_length: 2, search_timeout_ms: 30000, max_content_size: 100000, title_boost: 3.0, path_boost: 1.5, tag_boost: 2.0, prefix_max_expansions: 50, snippet_context_chars: 120, max_snippet_highlights: 5, }), }); } catch (err) { console.error("Reset config error:", err); } loadConfigFields(); showToast("Configuration réinitialisée", "success"); } async function loadDiagnostics() { const container = document.getElementById("config-diagnostics"); if (!container) return; container.innerHTML = '
    Chargement...
    '; try { const data = await api("/api/diagnostics"); renderDiagnostics(container, data); } catch (err) { container.innerHTML = '
    Erreur de chargement
    '; } } function renderDiagnostics(container, data) { container.innerHTML = ""; const sections = [ { title: "Index", rows: [ ["Fichiers indexés", data.index.total_files], ["Tags uniques", data.index.total_tags], ["Vaults", Object.keys(data.index.vaults).join(", ")], ], }, { title: "Index inversé", rows: [ ["Tokens uniques", data.inverted_index.unique_tokens.toLocaleString()], ["Postings total", data.inverted_index.total_postings.toLocaleString()], ["Documents", data.inverted_index.documents], ["Mémoire estimée", data.inverted_index.memory_estimate_mb + " MB"], ["Stale", data.inverted_index.is_stale ? "Oui" : "Non"], ], }, { title: "Moteur de recherche", rows: [ ["Executor actif", data.search_executor.active ? "Oui" : "Non"], ["Workers max", data.search_executor.max_workers], ], }, ]; sections.forEach((section) => { const div = document.createElement("div"); div.className = "config-diag-section"; const title = document.createElement("div"); title.className = "config-diag-section-title"; title.textContent = section.title; div.appendChild(title); section.rows.forEach(([label, value]) => { const row = document.createElement("div"); row.className = "config-diag-row"; row.innerHTML = `${label}${value}`; div.appendChild(row); }); container.appendChild(div); }); } // --- About Section --- function loadAbout() { const container = document.getElementById("config-about"); if (!container) return; // Fetch health info for version api("/api/health").then((health) => { container.innerHTML = ""; const sections = [ { title: "Application", rows: [ ["Nom", "ObsiGate"], ["Version", APP_VERSION], ["Version API", health.version || "—"], ["Statut", health.status || "—"], ], }, { title: "Environnement", rows: [ ["Vaults configurés", health.vaults || "—"], ["Fichiers indexés", health.total_files || "—"], ["Navigateur", navigator.userAgent.split(" ").pop()], ["Plateforme", navigator.platform || "—"], ["Langue", navigator.language || "—"], ], }, { title: "Composants", rows: [ ["Backend", "FastAPI (Python)"], ["Rendu Markdown", "mistune"], ["Surveillance fichiers", "watchdog"], ["Frontend", "Vanilla JavaScript"], ["Icônes", "Lucide Icons"], ["Coloration syntaxe", "highlight.js"], ["Éditeur", "CodeMirror 6"], ], }, ]; sections.forEach((section) => { const div = document.createElement("div"); div.className = "config-diag-section"; const title = document.createElement("div"); title.className = "config-diag-section-title"; title.textContent = section.title; div.appendChild(title); section.rows.forEach(([label, value]) => { const row = document.createElement("div"); row.className = "config-diag-row"; row.innerHTML = `${label}${value}`; div.appendChild(row); }); container.appendChild(div); }); }).catch(() => { container.innerHTML = '
    Erreur de chargement
    '; }); } // --- Hidden Files Configuration --- async function loadHiddenFilesSettings() { const container = document.getElementById("hidden-files-vault-list"); if (!container) return; container.innerHTML = '
    Chargement...
    '; try { const settings = await api("/api/vaults/settings/all"); renderHiddenFilesSettings(container, settings); } catch (err) { console.error("Failed to load hidden files settings:", err); container.innerHTML = '
    Erreur de chargement
    '; } } function renderHiddenFilesSettings(container, allSettings) { container.innerHTML = ""; if (!allVaults || allVaults.length === 0) { container.innerHTML = '
    Aucun vault configuré
    '; return; } allVaults.forEach((vault) => { const settings = allSettings[vault.name] || { hideHiddenFiles: false }; const vaultCard = el("div", { class: "hidden-files-vault-card", "data-vault": vault.name }); // Vault header const header = el("div", { class: "hidden-files-vault-header" }, [el("h3", {}, [document.createTextNode(vault.name)]), el("span", { class: "hidden-files-vault-type" }, [document.createTextNode(vault.type || "VAULT")])]); // Hide hidden files toggle const toggleRow = el("div", { class: "config-row" }, [ el("label", { class: "config-label", for: `hide-hidden-${vault.name}` }, [document.createTextNode("Masquer les fichiers/dossiers cachés")]), el("label", { class: "config-toggle" }, [ el("input", { type: "checkbox", id: `hide-hidden-${vault.name}`, "data-vault": vault.name, checked: settings.hideHiddenFiles ? "true" : false, }), el("span", { class: "config-toggle-slider" }), ]), el("span", { class: "config-hint" }, [document.createTextNode("Masquer les fichiers/dossiers commençant par un point dans l'interface (ils restent indexés et cherchables)")]), ]); vaultCard.appendChild(header); vaultCard.appendChild(toggleRow); container.appendChild(vaultCard); }); } async function saveHiddenFilesSettings() { const btn = document.getElementById("cfg-save-hidden-files"); if (btn) { btn.disabled = true; btn.textContent = "Sauvegarde..."; } try { const vaultCards = document.querySelectorAll(".hidden-files-vault-card"); const promises = []; vaultCards.forEach((card) => { const vaultName = card.dataset.vault; const hideHiddenFiles = document.getElementById(`hide-hidden-${vaultName}`)?.checked || false; const settings = { hideHiddenFiles, }; promises.push( api(`/api/vaults/${encodeURIComponent(vaultName)}/settings`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(settings), }), ); }); await Promise.all(promises); // Reload vault settings to update the cache await loadVaultSettings(); showToast("✓ Paramètres sauvegardés", "success"); // Refresh the UI to apply the filter await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); } catch (err) { console.error("Failed to save hidden files settings:", err); const errorMsg = err.message || "Erreur inconnue"; showToast(`Erreur: ${errorMsg}`, "error"); } finally { if (btn) { btn.disabled = false; btn.textContent = "💾 Sauvegarder"; } } } // ── Webhooks UI ── async function loadWebhooksUI() { const list = document.getElementById("webhooks-list"); if (!list) return; try { const webhooks = await api("/api/webhooks"); renderWebhooksUI(webhooks); } catch { list.innerHTML = '
    Admin uniquement
    '; } } function renderWebhooksUI(webhooks) { const list = document.getElementById("webhooks-list"); if (!list) return; if (!webhooks.length) { list.innerHTML = '
    Aucun webhook configuré.
    '; return; } list.innerHTML = webhooks.map(w => `
    ${escapeHtml(w.name)} ${escapeHtml(w.url)} ${(w.events||[]).join(", ")}
    `).join(""); list.querySelectorAll(".webhook-delete").forEach(b => b.addEventListener("click", async () => { await api(`/api/webhooks/${b.dataset.id}`, { method: "DELETE" }); loadWebhooksUI(); })); } document.addEventListener("click", function(e) { if (e.target.id === "webhook-add-btn") { const name = document.getElementById("webhook-name-input").value.trim(); const url = document.getElementById("webhook-url-input").value.trim(); if (!url) { showToast("URL requise", "error"); return; } api("/api/webhooks", { method: "POST", body: JSON.stringify({ name: name || "Webhook", url, events: ["file_created","file_deleted","file_modified","file_renamed"] }) }).then(() => { loadWebhooksUI(); document.getElementById("webhook-name-input").value = ""; document.getElementById("webhook-url-input").value = ""; }).catch(err => showToast(err.message, "error")); } }); // ── Shares UI ── async function loadSharesUI() { const list = document.getElementById("shares-list"); if (!list) return; try { const shares = await api("/api/shares"); renderSharesUI(shares); } catch { list.innerHTML = '
    Chargement...
    '; } } function renderSharesUI(shares) { const list = document.getElementById("shares-list"); if (!list) return; if (!shares.length) { list.innerHTML = '
    Aucun partage actif.
    '; return; } list.innerHTML = shares.map(s => `
    `).join(""); list.querySelectorAll(".share-revoke").forEach(b => b.addEventListener("click", async () => { await api(`/api/share/${b.dataset.id}`, { method: "DELETE" }); loadSharesUI(); })); } // ── Share Dialog (professional) ── async function openShareDialog(vault, path) { // First check if already shared let existingShare = null; try { const shares = await api("/api/shares"); existingShare = shares.find(s => s.vault === vault && s.path === path); } catch (e) { /* ignore */ } const div = document.createElement("div"); div.className = "share-dialog-overlay"; const renderContent = () => { if (existingShare) { const url = window.location.origin + existingShare.url; const expiresInfo = existingShare.expires_at ? `

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

    ` : '

    Sans expiration

    '; div.innerHTML = `

    📤 Document partagé

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

    ${expiresInfo}

    ${existingShare.access_count} vue(s)

    `; 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("Lien copié !", "success"); div.remove(); }); div.querySelector(".share-revoke-btn").addEventListener("click", async () => { try { await api(`/api/share/${existingShare.id}`, { method: "DELETE" }); showToast("Partage révoqué", "success"); existingShare = null; renderContent(); } catch (err) { showToast("Erreur: " + err.message, "error"); } }); } else { div.innerHTML = `

    📤 Partager ce document

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

    Ce lien sera accessible publiquement, sans authentification.

    `; div.querySelector(".share-create-btn").addEventListener("click", async () => { try { const expiry = document.getElementById("share-expiry")?.value; const share = await api(`/api/share/${encodeURIComponent(vault)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null }), }); existingShare = share; renderContent(); showToast("Lien créé !", "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"; } } // --------------------------------------------------------------------------- // Search (enhanced with autocomplete, keyboard nav, global shortcuts) // --------------------------------------------------------------------------- // ── Search toggle state ── function initSearch() { const input = document.getElementById("search-input"); if (!input) return; const caseBtn = document.getElementById("search-case-btn"); const wordBtn = document.getElementById("search-word-btn"); const regexBtn = document.getElementById("search-regex-btn"); const filterBtn = document.getElementById("search-filter-btn"); const clearBtn = document.getElementById("search-clear-btn"); const filterRow = document.getElementById("search-filter-row"); const prevBtn = document.getElementById("search-prev-btn"); const nextBtn = document.getElementById("search-next-btn"); const countEl = document.getElementById("search-match-count"); function _updateToggleUI() { caseBtn.classList.toggle("active", searchCaseSensitive); wordBtn.classList.toggle("active", searchWholeWord); regexBtn.classList.toggle("active", searchRegex); filterBtn.classList.toggle("active", searchFilterVisible); } // Toggle buttons caseBtn.addEventListener("click", () => { searchCaseSensitive = !searchCaseSensitive; _updateToggleUI(); _research(); }); if (wordBtn) wordBtn.addEventListener("click", () => { searchWholeWord = !searchWholeWord; _updateToggleUI(); _research(); }); if (regexBtn) regexBtn.addEventListener("click", () => { searchRegex = !searchRegex; _updateToggleUI(); _research(); }); if (filterBtn) filterBtn.addEventListener("click", () => { searchFilterVisible = !searchFilterVisible; if (filterRow) filterRow.style.display = searchFilterVisible ? "flex" : "none"; _updateToggleUI(); }); // ── Result navigation (up/down arrows + Enter) ── let _searchResultIdx = -1; let _searchResultItems = []; function _updateResultHighlight() { _searchResultItems.forEach((el, i) => { el.classList.toggle("search-result-active", i === _searchResultIdx); }); if (_searchResultIdx >= 0 && _searchResultIdx < _searchResultItems.length) { _searchResultItems[_searchResultIdx].scrollIntoView({ block: "nearest", behavior: "smooth" }); } const countEl = document.getElementById("search-match-count"); if (countEl) countEl.textContent = _searchResultIdx >= 0 ? `${_searchResultIdx + 1}/${_searchResultItems.length}` : `0/${_searchResultItems.length}`; } function _refreshResultItems() { _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); _searchResultIdx = _searchResultItems.length > 0 ? 0 : -1; _updateResultHighlight(); } window.navigateSearchResults = function(delta) { _searchResultItems = Array.from(document.querySelectorAll(".search-result-item")); if (_searchResultItems.length === 0) return; _searchResultIdx = Math.max(0, Math.min(_searchResultItems.length - 1, _searchResultIdx + delta)); _updateResultHighlight(); }; if (prevBtn) prevBtn.addEventListener("click", () => navigateSearchResults(-1)); if (nextBtn) nextBtn.addEventListener("click", () => navigateSearchResults(1)); function _research() { const q = input.value.trim(); if (q.length >= _getEffective("min_query_length", 2)) { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { const vault = document.getElementById("vault-filter").value; const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; advancedSearchOffset = 0; performAdvancedSearch(q, vault, tagFilter); }, _getEffective("debounce_ms", 300)); } } // Keyboard shortcuts document.addEventListener("keydown", (e) => { if (e.altKey && !e.ctrlKey && !e.metaKey) { if (e.key === "c" || e.key === "C") { e.preventDefault(); caseBtn.click(); } else if (e.key === "w" || e.key === "W") { e.preventDefault(); if (wordBtn) wordBtn.click(); } else if (e.key === "r" || e.key === "R") { e.preventDefault(); if (regexBtn) regexBtn.click(); } else if (e.key === "f" || e.key === "F") { e.preventDefault(); if (filterBtn) filterBtn.click(); input.focus(); } } }); // Initialize sub-controllers AutocompleteDropdown.init(); SearchChips.init(); // Initially hide clear button if (clearBtn) clearBtn.style.display = "none"; // --- Input handler: debounced search + autocomplete dropdown --- input.addEventListener("input", () => { const hasText = input.value.length > 0; clearBtn.style.display = hasText ? "flex" : "none"; // Show autocomplete dropdown while typing AutocompleteDropdown.populate(input.value, input.selectionStart); // Debounced search execution clearTimeout(searchTimeout); searchTimeout = setTimeout( () => { const q = input.value.trim(); const vault = document.getElementById("vault-filter").value; const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; advancedSearchOffset = 0; if (q.length >= _getEffective("min_query_length", MIN_SEARCH_LENGTH) || tagFilter) { performAdvancedSearch(q, vault, tagFilter); } else if (q.length === 0) { SearchChips.clear(); showWelcome(); } }, _getEffective("debounce_ms", 300), ); }); // --- Focus handler: show history dropdown --- input.addEventListener("focus", () => { if (input.value.length === 0) { const historyItems = SearchHistory.filter("").slice(0, 5); if (historyItems.length > 0) { AutocompleteDropdown.populate("", 0); } } }); // --- Keyboard navigation in dropdown --- input.addEventListener("keydown", (e) => { if (AutocompleteDropdown.isVisible()) { if (e.key === "ArrowDown") { e.preventDefault(); AutocompleteDropdown.navigateDown(); } else if (e.key === "ArrowUp") { e.preventDefault(); AutocompleteDropdown.navigateUp(); } else if (e.key === "Enter") { // First: check dropdown suggestions (higher priority than search results) if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { e.preventDefault(); return; } // Second: navigate search results if visible const results = document.querySelectorAll(".search-result-item"); if (results.length > 0 && _searchResultIdx >= 0) { const el = results[_searchResultIdx]; if (el) { const vault = el.dataset.vault; const path = el.dataset.path; if (vault && path) { TabManager.openPreview(vault, path); e.preventDefault(); return; } } } // Third: execute search AutocompleteDropdown.hide(); const q = input.value.trim(); if (q) { SearchHistory.add(q); clearTimeout(searchTimeout); advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; performAdvancedSearch(q, vault, tagFilter); } e.preventDefault(); } else if (e.key === "ArrowDown" && !AutocompleteDropdown.isVisible()) { // Navigate search results when dropdown is closed if (window.navigateSearchResults) { window.navigateSearchResults(1); e.preventDefault(); } } else if (e.key === "ArrowUp" && !AutocompleteDropdown.isVisible()) { if (window.navigateSearchResults) { window.navigateSearchResults(-1); e.preventDefault(); } } else if (e.key === "Escape") { AutocompleteDropdown.hide(); e.stopPropagation(); } } else if (e.key === "Enter") { if (AutocompleteDropdown.isVisible() && AutocompleteDropdown.selectActive()) { e.preventDefault(); return; } const q = input.value.trim(); if (q) { SearchHistory.add(q); clearTimeout(searchTimeout); advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; const tagFilter = selectedTags.length > 0 ? selectedTags.join(",") : null; performAdvancedSearch(q, vault, tagFilter); } e.preventDefault(); } }); clearBtn.addEventListener("click", () => { input.value = ""; if (clearBtn) clearBtn.style.display = "none"; searchCaseSensitive = false; searchWholeWord = false; searchRegex = false; _updateToggleUI(); SearchChips.clear(); AutocompleteDropdown.hide(); showWelcome(); }); // --- Global keyboard shortcuts --- document.addEventListener("keydown", (e) => { // Ctrl+K or Cmd+K: focus search if ((e.ctrlKey || e.metaKey) && e.key === "k") { e.preventDefault(); input.focus(); input.select(); } // "/" key: focus search (when not in an input/textarea) if (e.key === "/" && !_isInputFocused()) { e.preventDefault(); input.focus(); } // Escape: blur search input and close dropdown if (e.key === "Escape" && document.activeElement === input) { AutocompleteDropdown.hide(); input.blur(); } }); } /** Check if user is focused on an input/textarea/contenteditable */ function _isInputFocused() { const tag = document.activeElement?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; return document.activeElement?.isContentEditable === true; } // --- Backward-compatible search (existing /api/search endpoint) --- async function performSearch(query, vaultFilter, tagFilter) { if (searchAbortController) searchAbortController.abort(); searchAbortController = new AbortController(); const searchId = ++currentSearchId; 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 }); if (searchId !== currentSearchId) return; renderSearchResults(data, query, tagFilter); } catch (err) { if (err.name === "AbortError") return; if (searchId !== currentSearchId) return; showWelcome(); } finally { hideProgressBar(); if (searchId === currentSearchId) searchAbortController = null; } } // --- Advanced search with TF-IDF, facets, pagination --- async function performAdvancedSearch(query, vaultFilter, tagFilter, offset, sort) { if (searchAbortController) searchAbortController.abort(); searchAbortController = new AbortController(); const searchId = ++currentSearchId; showLoading(); const ofs = offset !== undefined ? offset : advancedSearchOffset; const sortBy = sort || advancedSearchSort; advancedSearchLastQuery = query; // Update chips from parsed query const parsed = QueryParser.parse(query); SearchChips.update(parsed); const effectiveLimit = _getEffective("results_per_page", ADVANCED_SEARCH_LIMIT); let url = `/api/search/advanced?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}&limit=${effectiveLimit}&offset=${ofs}&sort=${sortBy}`; if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; if (searchCaseSensitive) url += "&case_sensitive=true"; if (searchWholeWord) url += "&whole_word=true"; if (searchRegex) url += "®ex=true"; const includeEl = document.getElementById("search-include-input"); const excludeEl = document.getElementById("search-exclude-input"); if (includeEl?.value.trim()) url += `&include_paths=${encodeURIComponent(includeEl.value.trim())}`; if (excludeEl?.value.trim()) url += `&exclude_paths=${encodeURIComponent(excludeEl.value.trim())}`; // Search timeout — abort if server takes too long const timeoutId = setTimeout( () => { if (searchAbortController) searchAbortController.abort(); }, _getEffective("search_timeout_ms", SEARCH_TIMEOUT_MS), ); try { const data = await api(url, { signal: searchAbortController.signal }); clearTimeout(timeoutId); if (searchId !== currentSearchId) return; advancedSearchTotal = data.total; advancedSearchOffset = ofs; renderAdvancedSearchResults(data, query, tagFilter); } catch (err) { clearTimeout(timeoutId); if (err.name === "AbortError") return; if (searchId !== currentSearchId) return; showWelcome(); } finally { hideProgressBar(); if (searchId === currentSearchId) searchAbortController = null; } } // --- Legacy search results renderer (kept for backward compat) --- 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) => { // Apply client-side filtering for hidden files if (!shouldDisplayPath(r.path, r.vault)) { return; // Skip this result } const titleDiv = el("div", { class: "search-result-title" }); if (query && query.trim()) { highlightSearchText(titleDiv, r.title, query, searchCaseSensitive); } else { titleDiv.textContent = r.title; } const snippetDiv = el("div", { class: "search-result-snippet" }); if (r.snippet && r.snippet.includes("")) { snippetDiv.innerHTML = r.snippet; } else 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", "data-vault": r.vault, "data-path": r.path }, [ el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]), 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", () => TabManager.openPreview(r.vault, r.path)); item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); container.appendChild(item); }); area.appendChild(container); } // --- Advanced search results renderer (facets, highlighted snippets, pagination, sort) --- function renderAdvancedSearchResults(data, query, tagFilter) { const area = document.getElementById("content-area"); area.innerHTML = ""; // Update match counter const countEl = document.getElementById("search-match-count"); if (countEl) countEl.textContent = `${data.total > 0 ? "1" : "0"}/${data.total}`; // Header with result count and sort controls const header = el("div", { class: "search-results-header" }); const summaryText = el("span", { class: "search-results-summary-text" }); const parsed = QueryParser.parse(query); const freeText = parsed.freeText; if (freeText && tagFilter) { summaryText.textContent = `${data.total} résultat(s) pour "${freeText}" avec filtres`; } else if (freeText) { summaryText.textContent = `${data.total} résultat(s) pour "${freeText}"`; } else if (parsed.tags.length > 0 || tagFilter) { summaryText.textContent = `${data.total} fichier(s) avec filtres`; } else { summaryText.textContent = `${data.total} résultat(s)`; } if (data.query_time_ms !== undefined && data.query_time_ms > 0) { const timeBadge = el("span", { class: "search-time-badge" }); timeBadge.textContent = `(${data.query_time_ms} ms)`; summaryText.appendChild(timeBadge); } header.appendChild(summaryText); // Active filter badges const filtersRow = el("div", { class: "search-filters-row" }); if (searchCaseSensitive) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("Aa")])); if (searchWholeWord) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode("wd")])); if (searchRegex) filtersRow.appendChild(el("span", { class: "search-filter-badge" }, [document.createTextNode(".*")])); const inclEl = document.getElementById("search-include-input"); const exclEl = document.getElementById("search-exclude-input"); if (inclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("incl: " + inclEl.value.trim())])); if (exclEl?.value.trim()) filtersRow.appendChild(el("span", { class: "search-filter-badge path" }, [document.createTextNode("excl: " + exclEl.value.trim())])); if (filtersRow.children.length > 0) header.appendChild(filtersRow); // Sort controls const sortDiv = el("div", { class: "search-sort" }); const btnRelevance = el("button", { class: "search-sort__btn" + (advancedSearchSort === "relevance" ? " active" : ""), type: "button" }); btnRelevance.textContent = "Pertinence"; btnRelevance.addEventListener("click", () => { advancedSearchSort = "relevance"; advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, 0, "relevance"); }); const btnDate = el("button", { class: "search-sort__btn" + (advancedSearchSort === "modified" ? " active" : ""), type: "button" }); btnDate.textContent = "Date"; btnDate.addEventListener("click", () => { advancedSearchSort = "modified"; advancedSearchOffset = 0; const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, 0, "modified"); }); sortDiv.appendChild(btnRelevance); sortDiv.appendChild(btnDate); header.appendChild(sortDiv); // Save search button const saveBtn = el("button", { class: "search-save-btn", type: "button", title: "Sauvegarder cette recherche" }); saveBtn.innerHTML = ' Sauver'; saveBtn.addEventListener("click", async () => { const inclEl = document.getElementById("search-include-input"); const exclEl = document.getElementById("search-exclude-input"); try { await api("/api/saved-searches", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: query, vault: document.getElementById("vault-filter")?.value || "all", case_sensitive: searchCaseSensitive, whole_word: searchWholeWord, regex: searchRegex, include_paths: inclEl?.value || "", exclude_paths: exclEl?.value || "", }), }); showToast("Recherche sauvegardée", "success"); } catch (err) { showToast("Erreur: " + err.message, "error"); } }); header.appendChild(saveBtn); area.appendChild(header); // Active sidebar tag chips 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`, }, [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); }); area.appendChild(activeTags); } // Facets panel if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) { const facetsDiv = el("div", { class: "search-facets" }); // Vault facets const vaultFacets = data.facets.vaults || {}; if (Object.keys(vaultFacets).length > 1) { const group = el("div", { class: "search-facets__group" }); const label = el("span", { class: "search-facets__label" }); label.textContent = "Vaults"; group.appendChild(label); for (const [vaultName, count] of Object.entries(vaultFacets)) { const item = el("span", { class: "search-facets__item" }); item.innerHTML = `${vaultName} ${count}`; item.addEventListener("click", () => { const input = document.getElementById("search-input"); // Add vault: operator const current = input.value.replace(/vault:\S+\s*/gi, "").trim(); input.value = current + " vault:" + vaultName; _triggerAdvancedSearch(input.value); }); group.appendChild(item); } facetsDiv.appendChild(group); } // Tag facets const tagFacets = data.facets.tags || {}; if (Object.keys(tagFacets).length > 0) { const group = el("div", { class: "search-facets__group" }); const label = el("span", { class: "search-facets__label" }); label.textContent = "Tags"; group.appendChild(label); const entries = Object.entries(tagFacets).slice(0, 12); for (const [tagName, count] of entries) { const item = el("span", { class: "search-facets__item" }); item.innerHTML = `#${tagName} ${count}`; item.addEventListener("click", () => { addTagFilter(tagName); }); group.appendChild(item); } facetsDiv.appendChild(group); } area.appendChild(facetsDiv); } // Empty state if (data.results.length === 0) { area.appendChild(el("p", { style: "color:var(--text-muted);margin-top:20px" }, [document.createTextNode("Aucun résultat trouvé.")])); return; } // Results list const container = el("div", { class: "search-results" }); data.results.forEach((r) => { // Apply client-side filtering for hidden files if (!shouldDisplayPath(r.path, r.vault)) { return; // Skip this result } const titleDiv = el("div", { class: "search-result-title" }); if (freeText) { highlightSearchText(titleDiv, r.title, freeText, searchCaseSensitive); } else { titleDiv.textContent = r.title; } // Snippet — use HTML from backend (already has tags) const snippetDiv = el("div", { class: "search-result-snippet search-result__snippet" }); if (r.snippet && r.snippet.includes("")) { snippetDiv.innerHTML = r.snippet; } else if (freeText && r.snippet) { highlightSearchText(snippetDiv, r.snippet, freeText, searchCaseSensitive); } else { snippetDiv.textContent = r.snippet || ""; } // Score badge const scoreEl = el("span", { class: "search-result-score", style: "font-size:0.7rem;color:var(--text-muted);margin-left:8px" }); scoreEl.textContent = `score: ${r.score}`; const vaultPath = el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path), scoreEl]); const item = el("div", { class: "search-result-item", "data-vault": r.vault, "data-path": r.path }, [ el("span", { class: "search-result-ext" }, [document.createTextNode(r.extension || (r.path || "").split(".").pop() || "")]), titleDiv, vaultPath, 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", () => TabManager.openPreview(r.vault, r.path)); item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); }); container.appendChild(item); }); area.appendChild(container); // Pagination if (data.total > ADVANCED_SEARCH_LIMIT) { const paginationDiv = el("div", { class: "search-pagination" }); const prevBtn = el("button", { class: "search-pagination__btn", type: "button" }); prevBtn.textContent = "← Précédent"; prevBtn.disabled = advancedSearchOffset === 0; prevBtn.addEventListener("click", () => { advancedSearchOffset = Math.max(0, advancedSearchOffset - ADVANCED_SEARCH_LIMIT); const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, advancedSearchOffset); document.getElementById("content-area").scrollTop = 0; }); const info = el("span", { class: "search-pagination__info" }); const from = advancedSearchOffset + 1; const to = Math.min(advancedSearchOffset + ADVANCED_SEARCH_LIMIT, data.total); info.textContent = `${from}–${to} sur ${data.total}`; const nextBtn = el("button", { class: "search-pagination__btn", type: "button" }); nextBtn.textContent = "Suivant →"; nextBtn.disabled = advancedSearchOffset + ADVANCED_SEARCH_LIMIT >= data.total; nextBtn.addEventListener("click", () => { advancedSearchOffset += ADVANCED_SEARCH_LIMIT; const vault = document.getElementById("vault-filter").value; performAdvancedSearch(query, vault, tagFilter, advancedSearchOffset); document.getElementById("content-area").scrollTop = 0; }); paginationDiv.appendChild(prevBtn); paginationDiv.appendChild(info); paginationDiv.appendChild(nextBtn); area.appendChild(paginationDiv); } safeCreateIcons(); // Initialize result navigation (select first result) setTimeout(() => { if (window.navigateSearchResults) window.navigateSearchResults(0); }, 50); } // --------------------------------------------------------------------------- // 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); }); } // --------------------------------------------------------------------------- // Frontmatter Accent Card Builder // --------------------------------------------------------------------------- function buildFrontmatterCard(frontmatter) { // Helper: format date function formatDate(iso) { if (!iso) return "—"; const d = new Date(iso); const date = d.toISOString().slice(0, 10); const time = d.toTimeString().slice(0, 5); return `${date} · ${time}`; } // Extract boolean flags const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] })); // Toggle state let isOpen = true; // Build header with chevron const chevron = el("span", { class: "fm-chevron open" }); chevron.innerHTML = ''; const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]); // ZONE 1: Top strip const topBadges = []; // Title badge const title = frontmatter.titre || frontmatter.title || ""; if (title) { topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)])); } // Status badge if (frontmatter.statut) { const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]); topBadges.push(statusBadge); } // Category badge if (frontmatter.catégorie || frontmatter.categorie) { const cat = frontmatter.catégorie || frontmatter.categorie; const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]); topBadges.push(catBadge); } // Publish badge if (frontmatter.publish) { topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")])); } // Favoris badge if (frontmatter.favoris) { topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")])); } const acTop = el("div", { class: "ac-top" }, topBadges); // ZONE 2: Body 2 columns const leftCol = el("div", { class: "ac-col" }, [ el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("catégorie")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.catégorie || frontmatter.categorie || "—")])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("aliases")]), el("span", { class: "ac-v muted" }, [document.createTextNode(frontmatter.aliases && frontmatter.aliases.length > 0 ? frontmatter.aliases.join(", ") : "[]")])]), ]); const rightCol = el("div", { class: "ac-col" }, [ el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("creation_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.creation_date))])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("modification_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.modification_date))])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]), el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]), ]); const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]); // ZONE 3: Tags row const tagPills = []; if (frontmatter.tags && frontmatter.tags.length > 0) { frontmatter.tags.forEach((tag) => { tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)])); }); } const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]); // ZONE 4: Flags row const flagChips = []; booleanFlags.forEach((flag) => { const chipClass = flag.value ? "flag-chip on" : "flag-chip off"; flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)])); }); const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]); // Assemble the card const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]); // Toggle functionality fmHeader.addEventListener("click", () => { isOpen = !isOpen; if (isOpen) { acCard.style.display = "block"; chevron.classList.remove("closed"); chevron.classList.add("open"); } else { acCard.style.display = "none"; chevron.classList.remove("open"); chevron.classList.add("closed"); } safeCreateIcons(); }); // Wrap in section const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]); return fmSection; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function escapeHtml(str) { if (!str) return ""; return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function el(tag, attrs, children) { const e = document.createElement(tag); if (attrs) { Object.entries(attrs).forEach(([k, v]) => { // Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) { return; } e.setAttribute(k, v); }); } if (children) { children.forEach((c) => { if (c) e.appendChild(c); }); } return e; } 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.className = "badge-small"; s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px"; s.textContent = `(${count})`; return s; } function getContextMenuPositionFromElement(target) { const rect = target.getBoundingClientRect(); return { x: Math.min(rect.right - 8, window.innerWidth - 16), y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16), }; } function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) { const button = document.createElement("button"); button.type = "button"; button.className = "tree-item-action-btn"; button.setAttribute("aria-label", "Afficher le menu d’actions"); button.setAttribute("title", "Actions"); const iconEl = icon("more-vertical", 16); button.appendChild(iconEl); button.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); const pos = getContextMenuPositionFromElement(button); ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly); }); itemEl.appendChild(button); // Ensure Lucide icons are rendered for the button setTimeout(() => { safeCreateIcons(); }, 0); } function attachTreeItemLongPress(itemEl, getMenuData) { let pressTimer = null; let pressHandled = false; let startX = 0; let startY = 0; const longPressDelay = 550; const moveThreshold = 10; const clearPressTimer = () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } }; itemEl.addEventListener("touchstart", (e) => { if (!e.touches || e.touches.length !== 1) return; pressHandled = false; startX = e.touches[0].clientX; startY = e.touches[0].clientY; clearPressTimer(); pressTimer = setTimeout(() => { const data = getMenuData(); if (!data) return; pressHandled = true; ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly); }, longPressDelay); }, { passive: true }); itemEl.addEventListener("touchmove", (e) => { if (!e.touches || e.touches.length !== 1) return; const dx = Math.abs(e.touches[0].clientX - startX); const dy = Math.abs(e.touches[0].clientY - startY); if (dx > moveThreshold || dy > moveThreshold) { clearPressTimer(); } }, { passive: true }); itemEl.addEventListener("touchend", () => { clearPressTimer(); }, { passive: true }); itemEl.addEventListener("touchcancel", () => { clearPressTimer(); }, { passive: true }); itemEl.addEventListener("click", (e) => { if (pressHandled) { e.preventDefault(); e.stopPropagation(); setTimeout(() => { pressHandled = false; }, 0); } }, true); } function getVaultIcon(vaultName, size = 16) { const v = allVaults.find((val) => val.name === vaultName); const type = v ? v.type : "VAULT"; if (type === "DIR") { const i = icon("folder", size); i.style.color = "#eab308"; // yellow tint return i; } else { const purple = "#8b5cf6"; const svgNS = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(svgNS, "svg"); svg.setAttribute("xmlns", svgNS); svg.setAttribute("width", size); svg.setAttribute("height", size); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", purple); svg.setAttribute("stroke-width", "2"); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); svg.classList.add("icon"); const path1 = document.createElementNS(svgNS, "path"); path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z"); const path2 = document.createElementNS(svgNS, "path"); path2.setAttribute("d", "M11 3 8 9l4 12"); const path3 = document.createElementNS(svgNS, "path"); path3.setAttribute("d", "M12 21l4-12-3-6"); const path4 = document.createElementNS(svgNS, "path"); path4.setAttribute("d", "M2 9h20"); svg.appendChild(path1); svg.appendChild(path2); svg.appendChild(path3); svg.appendChild(path4); return svg; } } function makeBreadcrumbSpan(text, onClick) { const s = document.createElement("span"); s.textContent = text; if (onClick) { s.addEventListener("click", async (event) => { event.preventDefault(); if (s.dataset.busy === "true") return; s.dataset.busy = "true"; s.style.pointerEvents = "none"; try { await onClick(event); } finally { s.dataset.busy = "false"; s.style.pointerEvents = ""; } }); } return s; } 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() { hideProgressBar(); // Restore or rebuild the dashboard with tabbed sections const area = document.getElementById("content-area"); const home = document.getElementById("dashboard-home"); if (area && !home) { area.innerHTML = `
    Chargement...
    Aucun bookmark

    Épinglez des fichiers pour les retrouver ici.

    Aucun document partagé

    Partagez un document pour le voir apparaître ici

    `; // Re-initialize widgets and dashboard tabs if (typeof DashboardRecentWidget !== "undefined") { DashboardRecentWidget.init(); } initDashboardTabs(); safeCreateIcons(); } else if (home) { // Dashboard already exists, show it with default tab home.style.display = ""; // Reset tabs to default document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]'); const defaultPanel = document.getElementById("dashboard-panel-stats"); if (defaultTab) defaultTab.classList.add("active"); if (defaultPanel) defaultPanel.classList.add("active"); } // Load all widgets (they handle missing elements gracefully) if (typeof DashboardStatsWidget !== "undefined") { DashboardStatsWidget.load(); } if (typeof DashboardConflictsWidget !== "undefined") { DashboardConflictsWidget.load(); } if (typeof DashboardRecentWidget !== "undefined") { DashboardRecentWidget.load(selectedContextVault); } if (typeof DashboardBookmarkWidget !== "undefined") { DashboardBookmarkWidget.load(selectedContextVault); } if (typeof DashboardSharedWidget !== "undefined") { DashboardSharedWidget.load(); } // Load saved searches sidebar loadSavedSearches(); } async function loadSavedSearches() { const list = document.getElementById("saved-searches-list"); const empty = document.getElementById("saved-searches-empty"); if (!list) return; try { const searches = await api("/api/saved-searches"); if (!searches.length) { list.innerHTML = ""; if (empty) empty.style.display = ""; return; } if (empty) empty.style.display = "none"; list.innerHTML = searches.map(s => { const badges = []; if (s.case_sensitive) badges.push('Aa'); if (s.whole_word) badges.push('wd'); if (s.regex) badges.push('.*'); const pathFilters = []; if (s.include_paths) pathFilters.push(`📥 ${escapeHtml(s.include_paths)}`); if (s.exclude_paths) pathFilters.push(`📤 ${escapeHtml(s.exclude_paths)}`); const vaultStr = s.vault && s.vault !== "all" ? `📁 ${escapeHtml(s.vault)}` : ""; return `
    ${escapeHtml(s.query)}
    ${badges.join("")} ${vaultStr}
    ${pathFilters.length ? '
    ' + pathFilters.join(" ") + '
    ' : ""}
    `}).join(""); list.querySelectorAll(".saved-search-item").forEach(item => { item.addEventListener("click", (e) => { if (e.target.classList.contains("saved-search-delete")) return; const idx = Array.from(list.children).indexOf(item); const s = searches[idx]; if (!s) return; // Apply the saved search const input = document.getElementById("search-input"); if (input) input.value = s.query; searchCaseSensitive = s.case_sensitive || false; searchWholeWord = s.whole_word || false; searchRegex = s.regex || false; if (typeof _updateToggleUI === "function") _updateToggleUI(); if (s.include_paths) { const incl = document.getElementById("search-include-input"); if (incl) incl.value = s.include_paths; } if (s.exclude_paths) { const excl = document.getElementById("search-exclude-input"); if (excl) excl.value = s.exclude_paths; } // Execute the search — suppress dropdown from appearing AutocompleteDropdown.hide(); AutocompleteDropdown._suppressNext = true; const vault = s.vault || "all"; if (input) { input.dispatchEvent(new Event("input")); } clearTimeout(searchTimeout); advancedSearchOffset = 0; performAdvancedSearch(s.query, vault, null); }); }); list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => { e.stopPropagation(); await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" }); loadSavedSearches(); })); safeCreateIcons(); } catch (err) { /* silently ignore */ } } function showLoading() { const area = document.getElementById("content-area"); area.innerHTML = `
    Recherche en cours...
    `; showProgressBar(); } function showProgressBar() { const bar = document.getElementById("search-progress-bar"); if (bar) bar.classList.add("active"); } function hideProgressBar() { const bar = document.getElementById("search-progress-bar"); if (bar) bar.classList.remove("active"); } function goHome() { const searchInput = document.getElementById("search-input"); if (searchInput) searchInput.value = ""; document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); 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 }, ); } // --------------------------------------------------------------------------- // SSE Client — IndexUpdateManager // --------------------------------------------------------------------------- const IndexUpdateManager = (() => { let eventSource = null; let reconnectTimer = null; let reconnectDelay = 1000; const MAX_RECONNECT_DELAY = 30000; let recentEvents = []; const MAX_RECENT_EVENTS = 20; let connectionState = "disconnected"; // disconnected | connecting | connected function connect() { if (eventSource) { eventSource.close(); } connectionState = "connecting"; _updateBadge(); eventSource = new EventSource("/api/events"); eventSource.addEventListener("connected", (e) => { connectionState = "connected"; reconnectDelay = 1000; _updateBadge(); }); eventSource.addEventListener("index_updated", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_updated", data); _onIndexUpdated(data); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_reloaded", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_reloaded", data); _onIndexReloaded(data); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("vault_added", (e) => { try { const data = JSON.parse(e.data); _addEvent("vault_added", data); showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info"); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("vault_removed", (e) => { try { const data = JSON.parse(e.data); _addEvent("vault_removed", data); showToast(`Vault "${data.vault}" supprimé`, "info"); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_start", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_start", data); connectionState = "syncing"; _updateBadge(); showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info"); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_progress", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_progress", data); connectionState = "syncing"; _updateBadge(); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_complete", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_complete", data); connectionState = "connected"; _updateBadge(); showToast(`Indexation terminée (${data.total_files} fichiers)`, "success"); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.onerror = () => { connectionState = "disconnected"; _updateBadge(); eventSource.close(); eventSource = null; _scheduleReconnect(); }; } function _scheduleReconnect() { if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); connect(); }, reconnectDelay); } function _addEvent(type, data) { recentEvents.unshift({ type, data, timestamp: new Date().toISOString(), }); if (recentEvents.length > MAX_RECENT_EVENTS) { recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS); } } async function _onIndexUpdated(data) { // Brief syncing state connectionState = "syncing"; _updateBadge(); const n = data.total_changes || 0; const vaults = (data.vaults || []).join(", "); // Toast removed: silent auto-indexing — no notification needed // Refresh sidebar and tags if affected vault matches current context const affectsCurrentVault = selectedContextVault === "all" || (data.vaults || []).includes(selectedContextVault); if (affectsCurrentVault) { try { await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); // Refresh current file if it was updated if (currentVault && currentPath) { const changed = (data.changes || []).some((c) => c.vault === currentVault && c.path === currentPath); if (changed) { openFile(currentVault, currentPath); } } } catch (err) { console.error("Error refreshing after index update:", err); } } // Refresh recent tab if it is active if (activeSidebarTab === "recent") { const vaultFilter = document.getElementById("recent-vault-filter"); loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); } setTimeout(() => { connectionState = "connected"; _updateBadge(); }, 1500); } async function _onIndexReloaded(data) { connectionState = "syncing"; _updateBadge(); showToast("Index complet rechargé", "info"); try { await Promise.all([loadVaults(), loadTags()]); } catch (err) { console.error("Error refreshing after full reload:", err); } setTimeout(() => { connectionState = "connected"; _updateBadge(); }, 1500); } function _updateBadge() { const badge = document.getElementById("sync-badge"); if (!badge) return; badge.className = "sync-badge sync-badge--" + connectionState; const labels = { disconnected: "Déconnecté", connecting: "Connexion...", connected: "Synchronisé", syncing: "Mise à jour...", }; badge.title = labels[connectionState] || connectionState; } function disconnect() { if (eventSource) { eventSource.close(); eventSource = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } connectionState = "disconnected"; _updateBadge(); } function getState() { return connectionState; } function getRecentEvents() { return recentEvents; } return { connect, disconnect, getState, getRecentEvents }; })(); function initSyncStatus() { const badge = document.getElementById("sync-badge"); if (!badge) return; badge.addEventListener("click", (e) => { e.stopPropagation(); toggleSyncPanel(); }); IndexUpdateManager.connect(); } function toggleSyncPanel() { let panel = document.getElementById("sync-panel"); if (panel) { panel.remove(); return; } // Auto reconnect if disconnected when user opens the panel if (IndexUpdateManager.getState() === "disconnected") { IndexUpdateManager.connect(); } panel = document.createElement("div"); panel.id = "sync-panel"; panel.className = "sync-panel"; _renderSyncPanel(panel); document.body.appendChild(panel); // Close on outside click setTimeout(() => { document.addEventListener("click", _closeSyncPanelOutside, { once: true }); }, 0); } function _closeSyncPanelOutside(e) { const panel = document.getElementById("sync-panel"); if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") { panel.remove(); } } function _renderSyncPanel(panel) { const state = IndexUpdateManager.getState(); const events = IndexUpdateManager.getRecentEvents(); const stateLabels = { disconnected: "Déconnecté", connecting: "Connexion...", connected: "Connecté", syncing: "Synchronisation...", }; let html = `
    Synchronisation ${stateLabels[state] || state}
    `; if (events.length === 0) { html += `
    Aucun événement récent
    `; } else { html += `
    `; events.slice(0, 10).forEach((ev) => { const time = new Date(ev.timestamp).toLocaleTimeString(); const typeLabels = { index_updated: "Mise à jour", index_reloaded: "Rechargement", vault_added: "Vault ajouté", vault_removed: "Vault supprimé", index_start: "Démarrage index.", index_progress: "Vault indexé", index_complete: "Indexation tech.", }; const label = typeLabels[ev.type] || ev.type; let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || ""; if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`; if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`; if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`; html += `
    ${label} ${detail} ${time}
    `; }); html += `
    `; } panel.innerHTML = html; } // --------------------------------------------------------------------------- // Context Menu Manager // --------------------------------------------------------------------------- const ContextMenuManager = { _menu: null, _targetElement: null, _targetVault: null, _targetPath: null, _targetType: null, init() { this._menu = document.createElement('div'); this._menu.className = 'context-menu'; this._menu.id = 'context-menu'; document.body.appendChild(this._menu); document.addEventListener('click', () => this.hide()); document.addEventListener('contextmenu', (e) => { if (!e.target.closest('.tree-item')) { this.hide(); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') this.hide(); }); document.addEventListener('scroll', () => this.hide(), true); }, show(x, y, vault, path, type, isReadonly) { this._targetVault = vault; this._targetPath = path; this._targetType = type; this._menu.innerHTML = ''; // Copy path — available for all types const pathToCopy = type === 'vault' ? vault : `${vault}/${path}`; this._addItem('clipboard-copy', 'Copier le chemin', () => this._copyPath(pathToCopy), false); // Graph view — available for all types const graphPath = type === 'vault' ? '' : path; this._addItem('git-graph', 'Vue Graphique', () => GraphViewManager.open(vault, graphPath, type), false); this._addSeparator(); if (type === 'vault') { this._addItem('folder-plus', 'Nouveau dossier', () => this._createDirectory(), isReadonly); this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly); } else if (type === 'directory') { this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly); this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly); this._addSeparator(); this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly); } else if (type === 'file') { this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly); this._addSeparator(); this._addItem('bookmark-plus', 'Ajouter aux bookmarks', () => this._toggleBookmark(), false); } this._menu.classList.add('active'); const rect = this._menu.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let finalX = x; let finalY = y; if (x + rect.width > viewportWidth) { finalX = viewportWidth - rect.width - 10; } if (y + rect.height > viewportHeight) { finalY = viewportHeight - rect.height - 10; } this._menu.style.left = `${finalX}px`; this._menu.style.top = `${finalY}px`; safeCreateIcons(); }, hide() { if (this._menu) { this._menu.classList.remove('active'); } }, _addItem(icon, label, callback, disabled) { const item = document.createElement('div'); item.className = 'context-menu-item' + (disabled ? ' disabled' : ''); item.innerHTML = ` ${label} `; if (!disabled) { item.addEventListener('click', (e) => { e.stopPropagation(); this.hide(); callback(); }); } else { item.title = 'Vault en lecture seule'; } this._menu.appendChild(item); }, _addSeparator() { const sep = document.createElement('div'); sep.className = 'context-menu-separator'; this._menu.appendChild(sep); }, _createDirectory() { FileOperations.showCreateDirectoryModal(this._targetVault, this._targetPath); }, _createFile() { FileOperations.showCreateFileModal(this._targetVault, this._targetPath); }, _renameItem() { FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType); }, _deleteDirectory() { FileOperations.confirmDeleteDirectory(this._targetVault, this._targetPath); }, _deleteFile() { FileOperations.confirmDeleteFile(this._targetVault, this._targetPath); }, _copyPath(path) { // Try modern clipboard API first, fall back to execCommand for non-secure contexts if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(path).then(() => { showToast(`Chemin copié : ${path}`, 'success'); }).catch(() => { this._copyPathFallback(path); }); } else { this._copyPathFallback(path); } }, _copyPathFallback(path) { const textarea = document.createElement('textarea'); textarea.value = path; textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; textarea.style.top = '-9999px'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); try { const success = document.execCommand('copy'); if (success) { showToast(`Chemin copié : ${path}`, 'success'); } else { showToast('Erreur lors de la copie', 'error'); } } catch (e) { showToast('Erreur lors de la copie', 'error'); } document.body.removeChild(textarea); }, async _toggleBookmark() { try { const data = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: this._targetVault, path: this._targetPath, title: this._targetPath.split("/").pop() }), }); showToast(data.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success"); if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) { DashboardBookmarkWidget.load(); } } catch (err) { showToast("Erreur: " + err.message, "error"); } } }; // --------------------------------------------------------------------------- // File Operations Manager // --------------------------------------------------------------------------- const FileOperations = { showCreateDirectoryModal(vault, parentPath) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

    Créer un dossier

    `; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); const input = modal.querySelector('#dir-name-input'); const errorDiv = modal.querySelector('#dir-error'); const createBtn = modal.querySelector('#dir-create-btn'); const cancelBtn = modal.querySelector('#dir-cancel-btn'); input.focus(); const validateName = (name) => { if (!name.trim()) return 'Le nom ne peut pas être vide'; if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; return null; }; input.addEventListener('input', () => { const error = validateName(input.value); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; input.classList.add('error'); } else { errorDiv.style.display = 'none'; input.classList.remove('error'); } }); const create = async () => { const name = input.value.trim(); const error = validateName(name); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; return; } const path = parentPath ? `${parentPath}/${name}` : name; createBtn.disabled = true; createBtn.textContent = 'Création...'; try { await api(`/api/directory/${encodeURIComponent(vault)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }), }); showToast(`Dossier "${name}" créé`, 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); } catch (err) { showToast(err.message || 'Erreur lors de la création', 'error'); createBtn.disabled = false; createBtn.textContent = 'Créer'; } }; createBtn.addEventListener('click', create); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') create(); if (e.key === 'Escape') this._closeModal(overlay); }); }, showCreateFileModal(vault, parentPath) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

    Créer un fichier

    `; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); const input = modal.querySelector('#file-name-input'); const extSelect = modal.querySelector('#file-ext-select'); const errorDiv = modal.querySelector('#file-error'); const createBtn = modal.querySelector('#file-create-btn'); const cancelBtn = modal.querySelector('#file-cancel-btn'); input.focus(); const validateName = (name) => { if (!name.trim()) return 'Le nom ne peut pas être vide'; if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; return null; }; input.addEventListener('input', () => { const error = validateName(input.value); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; input.classList.add('error'); } else { errorDiv.style.display = 'none'; input.classList.remove('error'); } }); const create = async () => { let name = input.value.trim(); const error = validateName(name); if (error) { errorDiv.textContent = error; errorDiv.style.display = 'block'; return; } const ext = extSelect.value; if (!name.endsWith(ext)) { name += ext; } const path = parentPath ? `${parentPath}/${name}` : name; createBtn.disabled = true; createBtn.textContent = 'Création...'; try { await api(`/api/file/${encodeURIComponent(vault)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, content: '' }), }); showToast(`Fichier "${name}" créé`, 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); openFile(vault, path); } catch (err) { showToast(err.message || 'Erreur lors de la création', 'error'); createBtn.disabled = false; createBtn.textContent = 'Créer'; } }; createBtn.addEventListener('click', create); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') create(); if (e.key === 'Escape') this._closeModal(overlay); }); }, async startInlineRename(vault, path, type) { const item = document.querySelector(`.tree-item[data-vault="${CSS.escape(vault)}"][data-path="${CSS.escape(path)}"]`); if (!item) { showToast('Élément introuvable dans l’arborescence', 'error'); return; } const textNode = Array.from(item.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim()); if (!textNode) { showToast('Impossible de renommer cet élément', 'error'); return; } const originalText = textNode.textContent; const trimmedOriginal = originalText.trim(); const currentName = path.split('/').pop() || trimmedOriginal; const baseName = type === 'file' ? currentName.replace(/(\.[^./\\]+)$/i, '') : currentName; const extension = type === 'file' ? (currentName.match(/(\.[^./\\]+)$/i)?.[1] || '') : ''; const input = document.createElement('input'); input.type = 'text'; input.className = 'sidebar-item-input'; input.value = baseName; textNode.textContent = ' '; const badge = item.querySelector('.badge-small'); if (badge) { item.insertBefore(input, badge); } else { item.appendChild(input); } const restore = () => { input.remove(); textNode.textContent = originalText; }; const validateName = (name) => { if (!name.trim()) return 'Le nom ne peut pas être vide'; if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |'; return null; }; const submit = async () => { const name = input.value.trim(); const error = validateName(name); if (error) { showToast(error, 'error'); input.focus(); input.select(); return; } const newName = `${name}${extension}`; if (newName === currentName) { restore(); return; } input.disabled = true; try { const endpoint = type === 'directory' ? `/api/directory/${encodeURIComponent(vault)}` : `/api/file/${encodeURIComponent(vault)}`; const result = await api(endpoint, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, new_name: newName }), }); const nextPath = result.new_path; await refreshSidebarTreePreservingState(); if (type === 'file' && currentVault === vault && currentPath === path) { await openFile(vault, nextPath); } else if (type === 'directory' && currentVault === vault && currentPath && (currentPath === path || currentPath.startsWith(`${path}/`))) { const suffix = currentPath === path ? '' : currentPath.slice(path.length); currentPath = `${nextPath}${suffix}`; await focusPathInSidebar(vault, currentPath, { alignToTop: false }); } showToast(type === 'directory' ? 'Dossier renommé' : 'Fichier renommé', 'success'); } catch (err) { input.disabled = false; showToast(err.message || 'Erreur lors du renommage', 'error'); input.focus(); input.select(); return; } }; input.addEventListener('click', (e) => e.stopPropagation()); input.addEventListener('keydown', async (e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); await submit(); } if (e.key === 'Escape') { e.preventDefault(); restore(); } }); input.addEventListener('blur', async () => { if (!input.disabled) { await submit(); } }); input.focus(); input.setSelectionRange(0, input.value.length); }, confirmDeleteDirectory(vault, path) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

    Supprimer le dossier

    `; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); safeCreateIcons(); const confirmBtn = modal.querySelector('#del-confirm-btn'); const cancelBtn = modal.querySelector('#del-cancel-btn'); const deleteDir = async () => { confirmBtn.disabled = true; confirmBtn.textContent = 'Suppression...'; try { const result = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { method: 'DELETE', }); showToast(`Dossier supprimé (${result.deleted_count} fichiers)`, 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); if (currentVault === vault && currentPath && currentPath.startsWith(path)) { showWelcome(); } } catch (err) { showToast(err.message || 'Erreur lors de la suppression', 'error'); confirmBtn.disabled = false; confirmBtn.textContent = 'Supprimer définitivement'; } }; confirmBtn.addEventListener('click', deleteDir); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); }, confirmDeleteFile(vault, path) { const overlay = this._createModalOverlay(); const modal = document.createElement('div'); modal.className = 'obsigate-modal'; modal.innerHTML = `

    Supprimer le fichier

    `; overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); safeCreateIcons(); const confirmBtn = modal.querySelector('#del-confirm-btn'); const cancelBtn = modal.querySelector('#del-cancel-btn'); const deleteFile = async () => { confirmBtn.disabled = true; confirmBtn.textContent = 'Suppression...'; try { await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { method: 'DELETE', }); showToast('Fichier supprimé', 'success'); this._closeModal(overlay); await refreshSidebarTreePreservingState(); if (currentVault === vault && currentPath === path) { showWelcome(); } } catch (err) { showToast(err.message || 'Erreur lors de la suppression', 'error'); confirmBtn.disabled = false; confirmBtn.textContent = 'Supprimer définitivement'; } }; confirmBtn.addEventListener('click', deleteFile); cancelBtn.addEventListener('click', () => this._closeModal(overlay)); }, _createModalOverlay() { const overlay = document.createElement('div'); overlay.className = 'obsigate-modal-overlay'; overlay.addEventListener('click', (e) => { if (e.target === overlay) { this._closeModal(overlay); } }); return overlay; }, _closeModal(overlay) { overlay.classList.remove('active'); setTimeout(() => overlay.remove(), 200); } }; // --------------------------------------------------------------------------- // Find in Page Manager // --------------------------------------------------------------------------- const FindInPageManager = { isOpen: false, searchTerm: "", matches: [], currentIndex: -1, options: { caseSensitive: false, wholeWord: false, useRegex: false, }, debounceTimer: null, previousFocus: null, init() { const bar = document.getElementById("find-in-page-bar"); const input = document.getElementById("find-input"); const prevBtn = document.getElementById("find-prev"); const nextBtn = document.getElementById("find-next"); const closeBtn = document.getElementById("find-close"); const caseSensitiveBtn = document.getElementById("find-case-sensitive"); const wholeWordBtn = document.getElementById("find-whole-word"); const regexBtn = document.getElementById("find-regex"); if (!bar || !input) return; // Keyboard shortcuts document.addEventListener("keydown", (e) => { // Ctrl+F or Cmd+F to open if ((e.ctrlKey || e.metaKey) && e.key === "f") { e.preventDefault(); this.open(); } // Escape to close if (e.key === "Escape" && this.isOpen) { e.preventDefault(); this.close(); } // Enter to go to next if (e.key === "Enter" && this.isOpen && document.activeElement === input) { e.preventDefault(); if (e.shiftKey) { this.goToPrevious(); } else { this.goToNext(); } } // F3 for next/previous if (e.key === "F3" && this.isOpen) { e.preventDefault(); if (e.shiftKey) { this.goToPrevious(); } else { this.goToNext(); } } }); // Input event with debounce input.addEventListener("input", (e) => { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { this.search(e.target.value); }, 250); }); // Navigation buttons prevBtn.addEventListener("click", () => this.goToPrevious()); nextBtn.addEventListener("click", () => this.goToNext()); // Close button closeBtn.addEventListener("click", () => this.close()); // Option toggles caseSensitiveBtn.addEventListener("click", () => { this.options.caseSensitive = !this.options.caseSensitive; caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); this.saveState(); if (this.searchTerm) this.search(this.searchTerm); }); wholeWordBtn.addEventListener("click", () => { this.options.wholeWord = !this.options.wholeWord; wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); this.saveState(); if (this.searchTerm) this.search(this.searchTerm); }); regexBtn.addEventListener("click", () => { this.options.useRegex = !this.options.useRegex; regexBtn.setAttribute("aria-pressed", this.options.useRegex); this.saveState(); if (this.searchTerm) this.search(this.searchTerm); }); // Load saved state this.loadState(); }, open() { const bar = document.getElementById("find-in-page-bar"); const input = document.getElementById("find-input"); if (!bar || !input) return; this.previousFocus = document.activeElement; this.isOpen = true; bar.hidden = false; input.focus(); input.select(); safeCreateIcons(); }, close() { const bar = document.getElementById("find-in-page-bar"); if (!bar) return; this.isOpen = false; bar.hidden = true; this.clearHighlights(); this.matches = []; this.currentIndex = -1; this.searchTerm = ""; // Restore previous focus if (this.previousFocus && this.previousFocus.focus) { this.previousFocus.focus(); } }, search(term) { this.searchTerm = term; this.clearHighlights(); this.hideError(); if (!term || term.trim().length === 0) { this.updateCounter(); this.updateNavButtons(); return; } const contentArea = document.querySelector(".md-content"); if (!contentArea) { this.updateCounter(); this.updateNavButtons(); return; } try { const regex = this.createRegex(term); this.matches = []; this.findMatches(contentArea, regex); this.currentIndex = this.matches.length > 0 ? 0 : -1; this.highlightMatches(); this.updateCounter(); this.updateNavButtons(); if (this.matches.length > 0) { this.scrollToMatch(0); } } catch (err) { this.showError(err.message); this.matches = []; this.currentIndex = -1; this.updateCounter(); this.updateNavButtons(); } }, createRegex(term) { let pattern = term; if (!this.options.useRegex) { // Escape special regex characters pattern = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } if (this.options.wholeWord) { pattern = "\\b" + pattern + "\\b"; } const flags = this.options.caseSensitive ? "g" : "gi"; return new RegExp(pattern, flags); }, findMatches(container, regex) { const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { // Skip code blocks, scripts, styles const parent = node.parentElement; if (!parent) return NodeFilter.FILTER_REJECT; const tagName = parent.tagName.toLowerCase(); if (["code", "pre", "script", "style"].includes(tagName)) { return NodeFilter.FILTER_REJECT; } // Skip empty text nodes if (!node.textContent || node.textContent.trim().length === 0) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; }, }); let node; while ((node = walker.nextNode())) { const text = node.textContent; let match; regex.lastIndex = 0; // Reset regex while ((match = regex.exec(text)) !== null) { this.matches.push({ node: node, index: match.index, length: match[0].length, text: match[0], }); // Prevent infinite loop with zero-width matches if (match.index === regex.lastIndex) { regex.lastIndex++; } } } }, highlightMatches() { const matchesByNode = new Map(); this.matches.forEach((match, idx) => { if (!matchesByNode.has(match.node)) { matchesByNode.set(match.node, []); } matchesByNode.get(match.node).push({ match, idx }); }); matchesByNode.forEach((entries, node) => { if (!node || !node.parentNode) return; const text = node.textContent || ""; let cursor = 0; const fragment = document.createDocumentFragment(); entries.sort((a, b) => a.match.index - b.match.index); entries.forEach(({ match, idx }) => { if (match.index > cursor) { fragment.appendChild(document.createTextNode(text.substring(cursor, match.index))); } const matchText = text.substring(match.index, match.index + match.length); const mark = document.createElement("mark"); mark.className = idx === this.currentIndex ? "find-highlight find-highlight-active" : "find-highlight"; mark.textContent = matchText; mark.setAttribute("data-find-index", idx); fragment.appendChild(mark); match.element = mark; cursor = match.index + match.length; }); if (cursor < text.length) { fragment.appendChild(document.createTextNode(text.substring(cursor))); } node.parentNode.replaceChild(fragment, node); }); }, clearHighlights() { const contentArea = document.querySelector(".md-content"); if (!contentArea) return; const marks = contentArea.querySelectorAll("mark.find-highlight"); marks.forEach((mark) => { if (!mark.parentNode) return; const text = mark.textContent; const textNode = document.createTextNode(text); mark.parentNode.replaceChild(textNode, mark); }); // Normalize text nodes to merge adjacent text nodes contentArea.normalize(); }, goToNext() { if (this.matches.length === 0) return; // Remove active class from current if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); } // Move to next (with wrapping) this.currentIndex = (this.currentIndex + 1) % this.matches.length; // Add active class to new current if (this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.add("find-highlight-active"); } this.scrollToMatch(this.currentIndex); this.updateCounter(); }, goToPrevious() { if (this.matches.length === 0) return; // Remove active class from current if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.remove("find-highlight-active"); } // Move to previous (with wrapping) this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1; // Add active class to new current if (this.matches[this.currentIndex].element) { this.matches[this.currentIndex].element.classList.add("find-highlight-active"); } this.scrollToMatch(this.currentIndex); this.updateCounter(); }, scrollToMatch(index) { if (index < 0 || index >= this.matches.length) return; const match = this.matches[index]; if (!match.element) return; const contentArea = document.getElementById("content-area"); if (!contentArea) { match.element.scrollIntoView({ behavior: "smooth", block: "center" }); return; } // Calculate position with offset for header const elementTop = match.element.offsetTop; const offset = 100; // Offset for header contentArea.scrollTo({ top: elementTop - offset, behavior: "smooth", }); }, updateCounter() { const counter = document.getElementById("find-counter"); if (!counter) return; const count = this.matches.length; if (count === 0) { counter.textContent = "0 occurrence"; } else if (count === 1) { counter.textContent = "1 occurrence"; } else { counter.textContent = `${count} occurrences`; } }, updateNavButtons() { const prevBtn = document.getElementById("find-prev"); const nextBtn = document.getElementById("find-next"); if (!prevBtn || !nextBtn) return; const hasMatches = this.matches.length > 0; prevBtn.disabled = !hasMatches; nextBtn.disabled = !hasMatches; }, showError(message) { const errorEl = document.getElementById("find-error"); if (!errorEl) return; errorEl.textContent = message; errorEl.hidden = false; }, hideError() { const errorEl = document.getElementById("find-error"); if (!errorEl) return; errorEl.hidden = true; }, saveState() { try { const state = { options: this.options, }; localStorage.setItem("obsigate-find-in-page-state", JSON.stringify(state)); } catch (e) { // Ignore localStorage errors } }, loadState() { try { const saved = localStorage.getItem("obsigate-find-in-page-state"); if (saved) { const state = JSON.parse(saved); if (state.options) { this.options = { ...this.options, ...state.options }; // Update button states const caseSensitiveBtn = document.getElementById("find-case-sensitive"); const wholeWordBtn = document.getElementById("find-whole-word"); const regexBtn = document.getElementById("find-regex"); if (caseSensitiveBtn) caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive); if (wholeWordBtn) wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord); if (regexBtn) regexBtn.setAttribute("aria-pressed", this.options.useRegex); } } } catch (e) { // Ignore localStorage errors } }, }; // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- async function init() { initTheme(); initHeaderMenu(); initCustomDropdowns(); document.getElementById("theme-toggle").addEventListener("click", toggleTheme); document.getElementById("header-logo").addEventListener("click", goHome); const refreshBtn = document.getElementById("header-refresh-btn"); if (refreshBtn) refreshBtn.addEventListener("click", goHome); initSearch(); initSidebarToggle(); initMobile(); initVaultContext(); initSidebarTabs(); initHelpModal(); initConfigModal(); initSidebarFilter(); initSidebarResize(); initEditor(); initLoginForm(); initRecentTab(); RightSidebarManager.init(); FindInPageManager.init(); ContextMenuManager.init(); // Check auth status first const authOk = await AuthManager.initAuth(); if (authOk) { // Start SSE sync AFTER auth is established (cookie available) initSyncStatus(); try { await Promise.all([loadVaultSettings(), loadVaults(), loadTags()]); // Initialize dashboard widgets now that vaults are loaded if (typeof DashboardRecentWidget !== "undefined") { DashboardRecentWidget.init(); } // Check for popup mode query parameter const urlParams = new URLSearchParams(window.location.search); if (urlParams.get("popup") === "true") { document.body.classList.add("popup-mode"); } // Handle direct deep-link to file via #file=... if (window.location.hash && window.location.hash.startsWith("#file=")) { const hashVal = window.location.hash.substring(6); const sepIndex = hashVal.indexOf(":"); if (sepIndex > -1) { const vault = decodeURIComponent(hashVal.substring(0, sepIndex)); const path = decodeURIComponent(hashVal.substring(sepIndex + 1)); openFile(vault, path); } } else if (urlParams.get("popup") !== "true") { // Default to dashboard if no deep link and not in popup mode showWelcome(); } } catch (err) { console.error("Failed to initialize ObsiGate:", err); showToast("Erreur lors de l'initialisation", "error"); } } safeCreateIcons(); } // --------------------------------------------------------------------------- // PWA Service Worker Registration // --------------------------------------------------------------------------- function registerServiceWorker() { if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker .register("/sw.js") .then((registration) => { console.log("[PWA] Service Worker registered successfully:", registration.scope); // Check for updates periodically setInterval(() => { registration.update(); }, 60000); // Check every minute // Handle service worker updates registration.addEventListener("updatefound", () => { const newWorker = registration.installing; newWorker.addEventListener("statechange", () => { if (newWorker.state === "installed" && navigator.serviceWorker.controller) { // New service worker available showUpdateNotification(); } }); }); }) .catch((error) => { console.log("[PWA] Service Worker registration failed:", error); }); }); } } function showUpdateNotification() { const message = document.createElement("div"); message.className = "pwa-update-notification"; message.innerHTML = `
    Une nouvelle version d'ObsiGate est disponible !
    `; document.body.appendChild(message); // Auto-dismiss after 30 seconds setTimeout(() => { if (message.parentElement) { message.remove(); } }, 30000); } // Handle install prompt let deferredPrompt; window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); deferredPrompt = e; // Show install button if desired const installBtn = document.getElementById("pwa-install-btn"); if (installBtn) { installBtn.style.display = "block"; installBtn.addEventListener("click", async () => { if (deferredPrompt) { deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; console.log(`[PWA] User response to install prompt: ${outcome}`); deferredPrompt = null; installBtn.style.display = "none"; } }); } }); // Log when app is installed window.addEventListener("appinstalled", () => { console.log("[PWA] ObsiGate has been installed"); showToast("ObsiGate installé avec succès !", "success"); }); document.addEventListener("DOMContentLoaded", () => { init(); registerServiceWorker(); initDashboardTabs(); }); // ── Dashboard tab switching (runs on page load and after rebuild) ── function initDashboardTabs() { document.querySelectorAll(".dashboard-tab").forEach(tab => { // Remove existing listeners by cloning const newTab = tab.cloneNode(true); tab.parentNode.replaceChild(newTab, tab); newTab.addEventListener("click", function() { document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); this.classList.add("active"); const panel = document.getElementById("dashboard-panel-" + this.dataset.tab); if (panel) panel.classList.add("active"); }); }); } // --------------------------------------------------------------------------- // Tab Manager — Multi-file tab support // --------------------------------------------------------------------------- const TabManager = { _tabs: [], _activeTabId: null, _previewTabId: null, // single-click preview tab (temporary, replaced on next preview) _tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } } _tabBar: null, _tabList: null, _dirtyTabs: new Set(), init() { this._tabBar = document.getElementById("tab-bar"); this._tabList = document.getElementById("tab-list"); }, /** Open a file as a preview tab (single-click). * Replaces any existing preview tab. If the file is already * open as a persistent tab, just activates it. */ async openPreview(vault, path) { const tabId = `${vault}::${path}`; // If already open as persistent tab, just activate it const existing = this._tabs.find(t => t.id === tabId && !t.preview); if (existing) { this.activate(tabId); return; } // Close existing preview tab if (this._previewTabId && this._previewTabId !== tabId) { this.close(this._previewTabId); } // If already open as preview, just focus it const previewExisting = this._tabs.find(t => t.id === tabId && t.preview); if (previewExisting) { this.activate(tabId); return; } // Create preview tab const name = path.split("/").pop().replace(/\.md$/i, ""); const icon = getFileIcon(name + ".md"); this._tabs.push({ id: tabId, vault, path, name, icon, preview: true }); this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; this._previewTabId = tabId; this._renderTabs(); this.activate(tabId); }, /** Convert a preview tab to a persistent tab (double-click). * If already persistent, opens a new duplicate (same file, different tab). */ async openPersistent(vault, path) { const tabId = `${vault}::${path}`; // If it's already a preview tab, convert it to persistent const previewTab = this._tabs.find(t => t.id === tabId && t.preview); if (previewTab) { previewTab.preview = false; if (this._previewTabId === tabId) { this._previewTabId = null; } this._renderTabs(); this.activate(tabId); return; } // If already persistent, just focus it const existing = this._tabs.find(t => t.id === tabId && !t.preview); if (existing) { this.activate(tabId); return; } // Create a new persistent tab this.open(vault, path); }, /** Open a file in a tab (or focus existing) */ async open(vault, path, options = {}) { const tabId = `${vault}::${path}`; // If already open, just focus it const existing = this._tabs.find(t => t.id === tabId); if (existing) { // Convert preview to persistent if needed if (existing.preview) { existing.preview = false; if (this._previewTabId === tabId) this._previewTabId = null; this._renderTabs(); } this.activate(tabId); return; } // Create new tab const name = path.split("/").pop().replace(/\.md$/i, ""); const icon = getFileIcon(name + ".md"); this._tabs.push({ id: tabId, vault, path, name, icon }); this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; this._renderTabs(); this.activate(tabId); }, /** Activate a specific tab */ async activate(tabId) { if (this._activeTabId === tabId && this._tabs.length > 0) return; // Save current tab state if (this._activeTabId && this._tabCache[this._activeTabId]) { this._saveCurrentTabState(); } this._activeTabId = tabId; this._renderTabs(); // Load tab content const cache = this._tabCache[tabId]; if (!cache) return; // Update global state currentVault = cache.vault; currentPath = cache.path; syncActiveFileTreeItem(cache.vault, cache.path); const area = document.getElementById("content-area"); if (cache.data) { // Use cached data this._restoreTabContent(cache, area); } else { // Fetch file content area.innerHTML = '
    Chargement...
    '; try { const data = await api(`/api/file/${encodeURIComponent(cache.vault)}?path=${encodeURIComponent(cache.path)}`); cache.data = data; cache.title = data.title; renderFile(cache.data); // Restore source view if needed if (cache.sourceView) { await this._toggleSourceView(cache, area); } if (cache.scrollTop) { area.scrollTop = cache.scrollTop; } } catch (err) { area.innerHTML = `
    Erreur: ${escapeHtml(err.message)}
    `; } } // Update URL hash if (history.pushState) { history.pushState(null, "", `#/file/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`); } // Hide dashboard const dashboard = document.getElementById("dashboard-home"); if (dashboard) dashboard.style.display = "none"; }, /** Close a tab */ close(tabId) { const idx = this._tabs.findIndex(t => t.id === tabId); if (idx === -1) return; this._tabs.splice(idx, 1); delete this._tabCache[tabId]; this._dirtyTabs.delete(tabId); if (this._tabs.length === 0) { this._activeTabId = null; this._showDashboard(); this._tabBar.hidden = true; } else if (this._activeTabId === tabId) { // Activate adjacent tab const newIdx = Math.min(idx, this._tabs.length - 1); this.activate(this._tabs[newIdx].id); } this._renderTabs(); }, /** Close all tabs */ closeAll() { this._tabs = []; this._tabCache = {}; this._dirtyTabs.clear(); this._activeTabId = null; this._showDashboard(); this._tabBar.hidden = true; }, /** Close tabs to the right */ closeRight(tabId) { const idx = this._tabs.findIndex(t => t.id === tabId); if (idx === -1) return; const toClose = this._tabs.slice(idx + 1); for (const tab of toClose) { delete this._tabCache[tab.id]; this._dirtyTabs.delete(tab.id); } this._tabs = this._tabs.slice(0, idx + 1); if (!this._tabs.find(t => t.id === this._activeTabId)) { this.activate(tabId); } this._renderTabs(); }, /** Close other tabs */ closeOthers(tabId) { const tab = this._tabs.find(t => t.id === tabId); if (!tab) return; for (const t of this._tabs) { if (t.id !== tabId) { delete this._tabCache[t.id]; this._dirtyTabs.delete(t.id); } } this._tabs = [tab]; this.activate(tabId); this._renderTabs(); }, /** Reorder tabs by drag and drop */ moveTab(fromIdx, toIdx) { if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return; const tab = this._tabs.splice(fromIdx, 1)[0]; this._tabs.splice(toIdx, 0, tab); this._renderTabs(); }, /** Save current tab state before switching */ _saveCurrentTabState() { const cache = this._tabCache[this._activeTabId]; if (!cache) return; const area = document.getElementById("content-area"); const rendered = document.getElementById("file-rendered-content"); cache.scrollTop = area.scrollTop; cache.sourceView = rendered ? rendered.style.display === "none" : false; }, /** Restore tab content from cache */ _restoreTabContent(cache, area) { renderFile(cache.data); if (cache.sourceView) { this._restoreSourceView(cache, area); } if (cache.scrollTop) { area.scrollTop = cache.scrollTop; } }, async _toggleSourceView(cache, area) { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (!rendered || !raw) return; if (!cache.rawSource) { const rawData = await api(`/api/file/${encodeURIComponent(cache.vault)}/raw?path=${encodeURIComponent(cache.path)}`); cache.rawSource = rawData.raw; } raw.textContent = cache.rawSource; rendered.style.display = "none"; raw.style.display = "block"; }, _restoreSourceView(cache, area) { requestAnimationFrame(() => { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (rendered && raw && cache.rawSource) { raw.textContent = cache.rawSource; rendered.style.display = "none"; raw.style.display = "block"; } }); }, _showDashboard() { const area = document.getElementById("content-area"); // Save dashboard DOM before clearing (it may have been removed from DOM by renderFile) let dashboard = document.getElementById("dashboard-home"); if (!dashboard) { // Dashboard was destroyed — rebuild via showWelcome area.innerHTML = ""; showWelcome(); return; } area.innerHTML = ""; dashboard.style.display = ""; area.appendChild(dashboard); // Refresh widgets after restoring if (typeof DashboardStatsWidget !== "undefined") DashboardStatsWidget.load(); if (typeof DashboardConflictsWidget !== "undefined") DashboardConflictsWidget.load(); if (typeof DashboardRecentWidget !== "undefined") DashboardRecentWidget.load(selectedContextVault); if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(selectedContextVault); if (history.pushState) { history.pushState(null, "", "#"); } }, /** Render the tab bar */ _renderTabs() { if (!this._tabList) return; this._tabList.innerHTML = ""; if (this._tabs.length === 0) { this._tabBar.hidden = true; return; } this._tabBar.hidden = false; this._tabs.forEach((tab, idx) => { const el = document.createElement("div"); el.className = "tab-item" + (tab.id === this._activeTabId ? " active" : "") + (tab.preview ? " preview" : ""); el.draggable = true; el.dataset.tabId = tab.id; el.dataset.index = idx; // Icon const iconEl = document.createElement("i"); iconEl.setAttribute("data-lucide", tab.icon); iconEl.className = "tab-icon"; iconEl.style.width = "14px"; iconEl.style.height = "14px"; el.appendChild(iconEl); // Name const nameEl = document.createElement("span"); nameEl.className = "tab-name"; nameEl.textContent = tab.name; nameEl.title = `${tab.vault}/${tab.path}`; el.appendChild(nameEl); // Close button const closeEl = document.createElement("span"); closeEl.className = "tab-close"; closeEl.innerHTML = ''; closeEl.addEventListener("click", (e) => { e.stopPropagation(); this.close(tab.id); }); el.appendChild(closeEl); // Click to activate el.addEventListener("click", () => this.activate(tab.id)); // Double-click to close el.addEventListener("dblclick", (e) => { e.preventDefault(); this.close(tab.id); }); // Middle-click to close el.addEventListener("mousedown", (e) => { if (e.button === 1) { e.preventDefault(); this.close(tab.id); } }); // Context menu on tab el.addEventListener("contextmenu", (e) => { e.preventDefault(); this._showTabContextMenu(e.clientX, e.clientY, tab.id); }); // Drag and drop el.addEventListener("dragstart", (e) => { e.dataTransfer.setData("text/plain", String(idx)); el.classList.add("dragging"); }); el.addEventListener("dragend", () => { el.classList.remove("dragging"); document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); }); el.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; const rect = el.getBoundingClientRect(); const mid = rect.left + rect.width / 2; document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); const indicator = document.createElement("div"); indicator.className = "tab-drop-indicator"; if (e.clientX < mid) { el.before(indicator); } else { el.after(indicator); } }); el.addEventListener("drop", (e) => { e.preventDefault(); document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); const fromIdx = parseInt(e.dataTransfer.getData("text/plain")); const rect = el.getBoundingClientRect(); const mid = rect.left + rect.width / 2; const toIdx = e.clientX < mid ? idx : idx + 1; if (fromIdx !== toIdx && fromIdx !== toIdx - 1) { this.moveTab(fromIdx, toIdx); } }); this._tabList.appendChild(el); }); safeCreateIcons(); }, _showTabContextMenu(x, y, tabId) { const existing = document.getElementById("tab-context-menu"); if (existing) existing.remove(); const menu = document.createElement("div"); menu.id = "tab-context-menu"; menu.className = "context-menu active"; menu.style.left = x + "px"; menu.style.top = y + "px"; menu.innerHTML = `
    Fermer
    Fermer les autres
    Fermer à droite
    Fermer tout
    `; document.body.appendChild(menu); safeCreateIcons(); menu.addEventListener("click", (e) => { const action = e.target.closest(".context-menu-item")?.dataset.action; if (action === "close") this.close(tabId); else if (action === "closeOthers") this.closeOthers(tabId); else if (action === "closeRight") this.closeRight(tabId); else if (action === "closeAll") this.closeAll(); menu.remove(); }); const closeMenu = () => menu.remove(); document.addEventListener("click", closeMenu, { once: true }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { menu.remove(); } }, { once: true }); }, }; // ---- Modify openFile to use TabManager ---- const _originalOpenFile = openFile; openFile = function(vault, path) { TabManager.open(vault, path); }; // ---- Keyboard shortcuts for tabs ---- document.addEventListener("keydown", (e) => { if (e.ctrlKey || e.metaKey) { if (e.key === "w" || e.key === "W") { e.preventDefault(); if (TabManager._activeTabId) { TabManager.close(TabManager._activeTabId); } } else if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); const tabs = TabManager._tabs; const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId); if (currentIdx >= 0 && tabs.length > 1) { const nextIdx = (currentIdx + 1) % tabs.length; TabManager.activate(tabs[nextIdx].id); } } else if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); const tabs = TabManager._tabs; const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId); if (currentIdx >= 0 && tabs.length > 1) { const prevIdx = (currentIdx - 1 + tabs.length) % tabs.length; TabManager.activate(tabs[prevIdx].id); } } } }); // ---- Modify init to include TabManager ---- const _origInit2 = init; init = function() { _origInit2(); TabManager.init(); }; // --------------------------------------------------------------------------- // Graph View Manager — Interactive file/folder relationship visualization // --------------------------------------------------------------------------- const GraphViewManager = { _canvas: null, _ctx: null, _nodes: [], _edges: [], _offsetX: 0, _offsetY: 0, _zoom: 1, _dragging: false, _dragNode: null, _panning: false, _panStartX: 0, _panStartY: 0, _animFrame: null, _vault: null, _path: null, _nodePositions: {}, _width: 0, _height: 0, async open(vault, path, type) { this._vault = vault; this._path = path; const modal = document.getElementById("graph-modal"); const title = document.getElementById("graph-title"); const info = document.getElementById("graph-info"); const canvas = document.getElementById("graph-canvas"); if (!modal || !canvas) return; title.textContent = `Vue Graphique — ${vault}${path ? "/" + path : ""}`; info.textContent = "Chargement..."; modal.classList.add("active"); this._canvas = canvas; this._ctx = canvas.getContext("2d"); this._resetView(); // Fetch graph data try { const data = await api(`/api/graph/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}&depth=1`); this._nodes = data.nodes || []; this._edges = data.edges || []; info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens`; this._initLayout(); this._startRender(); } catch (err) { info.textContent = "Erreur de chargement"; console.error("Graph error:", err); } safeCreateIcons(); }, close() { const modal = document.getElementById("graph-modal"); if (modal) modal.classList.remove("active"); if (this._animFrame) { cancelAnimationFrame(this._animFrame); this._animFrame = null; } }, _resetView() { this._offsetX = 0; this._offsetY = 0; this._zoom = 1; this._nodePositions = {}; }, _initLayout() { const w = this._canvas.parentElement.clientWidth; const h = this._canvas.parentElement.clientHeight; this._canvas.width = w * devicePixelRatio; this._canvas.height = h * devicePixelRatio; this._canvas.style.width = w + "px"; this._canvas.style.height = h + "px"; this._width = w; this._height = h; this._ctx.scale(devicePixelRatio, devicePixelRatio); // Position nodes in a circle initially const cx = w / 2; const cy = h / 2; const radius = Math.min(w, h) * 0.35; this._nodes.forEach((node, i) => { const angle = (2 * Math.PI * i) / this._nodes.length; this._nodePositions[node.id] = { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle), vx: 0, vy: 0, }; }); }, _startRender() { const self = this; let lastTime = 0; const loop = (time) => { const dt = Math.min((time - lastTime) / 1000, 0.1); lastTime = time; self._simulate(dt); self._draw(); self._animFrame = requestAnimationFrame(loop); }; this._animFrame = requestAnimationFrame(loop); }, _simulate(dt) { if (this._dragging || this._dragNode) return; const positions = this._nodePositions; const cx = this._width / 2; const cy = this._height / 2; // Spring forces (edges) for (const edge of this._edges) { const a = positions[edge.source]; const b = positions[edge.target]; if (!a || !b) continue; const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const targetLen = 80; const force = (dist - targetLen) * 0.01; const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } // Repulsion between all nodes for (const n1 of this._nodes) { for (const n2 of this._nodes) { if (n1.id === n2.id) continue; const a = positions[n1.id]; const b = positions[n2.id]; if (!a || !b) continue; const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = 2000 / (dist * dist); const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx -= fx; a.vy -= fy; } } // Center gravity for (const node of this._nodes) { const p = positions[node.id]; if (!p) continue; p.vx += (cx - p.x) * 0.001; p.vy += (cy - p.y) * 0.001; } // Apply velocities with damping for (const node of this._nodes) { const p = positions[node.id]; if (!p) continue; p.vx *= 0.9; p.vy *= 0.9; p.x += p.vx * dt * 60; p.y += p.vy * dt * 60; } }, _draw() { const ctx = this._ctx; const w = this._width; const h = this._height; ctx.save(); ctx.clearRect(0, 0, w, h); // Apply transform (pan + zoom) ctx.translate(this._offsetX, this._offsetY); ctx.scale(this._zoom, this._zoom); // Draw edges for (const edge of this._edges) { const a = this._nodePositions[edge.source]; const b = this._nodePositions[edge.target]; if (!a || !b) continue; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.strokeStyle = edge.relation === "wikilink" ? "var(--accent-color, #2563eb)" : "var(--text-muted, #888)"; ctx.lineWidth = edge.relation === "wikilink" ? 2 : 1; ctx.setLineDash(edge.relation === "wikilink" ? [4, 4] : []); ctx.stroke(); } ctx.setLineDash([]); // Draw nodes for (const node of this._nodes) { const p = this._nodePositions[node.id]; if (!p) continue; const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100)); // Node circle ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); switch (node.type) { case "directory": ctx.fillStyle = "#5b9bd5"; break; case "file": ctx.fillStyle = (node.path || "").endsWith(".md") ? "#70ad47" : "#c0c0c0"; break; default: ctx.fillStyle = "#ffc000"; break; } ctx.fill(); ctx.strokeStyle = "var(--bg-primary, #1e1e1e)"; ctx.lineWidth = 1.5; ctx.stroke(); // Node label const label = node.name.length > 20 ? node.name.slice(0, 18) + "..." : node.name; ctx.font = `${11 / this._zoom}px -apple-system, sans-serif`; ctx.fillStyle = "var(--text-primary, #ddd)"; ctx.textAlign = "center"; ctx.fillText(label, p.x, p.y + r + 12 / this._zoom); } ctx.restore(); }, _getNodeAt(screenX, screenY) { const x = (screenX - this._offsetX) / this._zoom; const y = (screenY - this._offsetY) / this._zoom; for (const node of this._nodes) { const p = this._nodePositions[node.id]; if (!p) continue; const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100)); const dx = x - p.x; const dy = y - p.y; if (dx * dx + dy * dy <= r * r + 100) { return { node, pos: p }; } } return null; }, _onMouseDown(e) { const rect = this._canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const hit = this._getNodeAt(mx, my); if (hit) { this._dragging = true; this._dragNode = hit; this._canvas.style.cursor = "grabbing"; } else { this._panning = true; this._panStartX = e.clientX - this._offsetX; this._panStartY = e.clientY - this._offsetY; this._canvas.style.cursor = "grabbing"; } }, _onMouseMove(e) { const rect = this._canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; if (this._dragging && this._dragNode) { this._dragNode.pos.x = (mx - this._offsetX) / this._zoom; this._dragNode.pos.y = (my - this._offsetY) / this._zoom; this._dragNode.pos.vx = 0; this._dragNode.pos.vy = 0; } else if (this._panning) { this._offsetX = e.clientX - this._panStartX; this._offsetY = e.clientY - this._panStartY; } else { const hit = this._getNodeAt(mx, my); this._canvas.style.cursor = hit ? "pointer" : "grab"; this._canvas.title = hit ? `Ouvrir: ${hit.node.path || hit.node.name}` : ""; } }, _onMouseUp(e) { if (this._dragging && this._dragNode) { // Check if it was a click (not a drag) const rect = this._canvas.getBoundingClientRect(); const node = this._dragNode.node; this._dragging = false; this._dragNode = null; this._canvas.style.cursor = "grab"; // If it's a file, open it on click if (node.type === "file") { this.close(); openFile(this._vault, node.path); } else if (node.type === "directory" || node.type === "vault") { // Expand into this directory this.close(); this.open(this._vault, node.path, node.type); } } this._panning = false; }, _onWheel(e) { e.preventDefault(); const rect = this._canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; const newZoom = Math.max(0.2, Math.min(3, this._zoom * zoomFactor)); this._offsetX = mx - (mx - this._offsetX) * (newZoom / this._zoom); this._offsetY = my - (my - this._offsetY) * (newZoom / this._zoom); this._zoom = newZoom; }, _onResize() { if (!this._canvas || !this._nodes.length) return; const w = this._canvas.parentElement.clientWidth; const h = this._canvas.parentElement.clientHeight; this._canvas.width = w * devicePixelRatio; this._canvas.height = h * devicePixelRatio; this._canvas.style.width = w + "px"; this._canvas.style.height = h + "px"; this._width = w; this._height = h; this._ctx.setTransform(1, 0, 0, 1, 0, 0); this._ctx.scale(devicePixelRatio, devicePixelRatio); }, }; // Init graph view event listeners after DOM ready function initGraphView() { const closeBtn = document.getElementById("graph-close"); const zoomIn = document.getElementById("graph-zoom-in"); const zoomOut = document.getElementById("graph-zoom-out"); const reset = document.getElementById("graph-reset"); const modal = document.getElementById("graph-modal"); const canvas = document.getElementById("graph-canvas"); if (closeBtn) closeBtn.addEventListener("click", () => GraphViewManager.close()); if (modal) modal.addEventListener("click", (e) => { if (e.target === modal) GraphViewManager.close(); }); if (zoomIn) zoomIn.addEventListener("click", () => { GraphViewManager._zoom = Math.min(3, GraphViewManager._zoom * 1.2); }); if (zoomOut) zoomOut.addEventListener("click", () => { GraphViewManager._zoom = Math.max(0.2, GraphViewManager._zoom * 0.8); }); if (reset) reset.addEventListener("click", () => { GraphViewManager._offsetX = 0; GraphViewManager._offsetY = 0; GraphViewManager._zoom = 1; }); if (canvas) { canvas.addEventListener("mousedown", (e) => GraphViewManager._onMouseDown(e)); canvas.addEventListener("mousemove", (e) => GraphViewManager._onMouseMove(e)); canvas.addEventListener("mouseup", (e) => GraphViewManager._onMouseUp(e)); canvas.addEventListener("mouseleave", () => { GraphViewManager._dragging = false; GraphViewManager._dragNode = null; GraphViewManager._panning = false; canvas.style.cursor = "grab"; }); canvas.addEventListener("wheel", (e) => GraphViewManager._onWheel(e), { passive: false }); window.addEventListener("resize", () => GraphViewManager._onResize()); } document.addEventListener("keydown", (e) => { if (modal && modal.classList.contains("active") && e.key === "Escape") { GraphViewManager.close(); } }); } // Add to init const _origInit = init; init = function() { _origInit(); initGraphView(); }; })();