3993 lines
142 KiB
JavaScript
3993 lines
142 KiB
JavaScript
/* ObsiGate — Vanilla JS SPA */
|
||
|
||
(function () {
|
||
"use strict";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// State
|
||
// ---------------------------------------------------------------------------
|
||
let currentVault = null;
|
||
let currentPath = null;
|
||
let searchTimeout = null;
|
||
let searchAbortController = null;
|
||
let showingSource = false;
|
||
let cachedRawSource = null;
|
||
let allVaults = [];
|
||
let selectedContextVault = "all";
|
||
let selectedTags = [];
|
||
let editorView = null;
|
||
let editorVault = null;
|
||
let editorPath = null;
|
||
let fallbackEditorEl = null;
|
||
let sidebarFilterCaseSensitive = false;
|
||
let searchCaseSensitive = false;
|
||
let _iconDebounceTimer = null;
|
||
let activeSidebarTab = "vaults";
|
||
let filterDebounce = null;
|
||
|
||
// 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;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// File extension → Lucide icon mapping
|
||
// ---------------------------------------------------------------------------
|
||
const EXT_ICONS = {
|
||
".md": "file-text",
|
||
".txt": "file-text",
|
||
".log": "file-text",
|
||
".py": "file-code",
|
||
".js": "file-code",
|
||
".ts": "file-code",
|
||
".jsx": "file-code",
|
||
".tsx": "file-code",
|
||
".html": "file-code",
|
||
".css": "file-code",
|
||
".scss": "file-code",
|
||
".less": "file-code",
|
||
".json": "file-json",
|
||
".yaml": "file-cog",
|
||
".yml": "file-cog",
|
||
".toml": "file-cog",
|
||
".xml": "file-code",
|
||
".sh": "terminal",
|
||
".bash": "terminal",
|
||
".zsh": "terminal",
|
||
".bat": "terminal",
|
||
".cmd": "terminal",
|
||
".ps1": "terminal",
|
||
".java": "file-code",
|
||
".c": "file-code",
|
||
".cpp": "file-code",
|
||
".h": "file-code",
|
||
".hpp": "file-code",
|
||
".cs": "file-code",
|
||
".go": "file-code",
|
||
".rs": "file-code",
|
||
".rb": "file-code",
|
||
".php": "file-code",
|
||
".sql": "database",
|
||
".csv": "table",
|
||
".ini": "file-cog",
|
||
".cfg": "file-cog",
|
||
".conf": "file-cog",
|
||
".env": "file-cog",
|
||
};
|
||
|
||
function getFileIcon(name) {
|
||
const ext = "." + name.split(".").pop().toLowerCase();
|
||
return EXT_ICONS[ext] || "file";
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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:)
|
||
// ---------------------------------------------------------------------------
|
||
const QueryParser = {
|
||
parse(raw) {
|
||
const result = { tags: [], vault: null, title: null, path: 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 {
|
||
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) {
|
||
// 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);
|
||
this._renderHistory(historyItems, inputValue);
|
||
|
||
// Title and tag suggestions from API (debounced)
|
||
clearTimeout(this._suggestTimer);
|
||
if (ctx.prefix && ctx.prefix.length >= 2) {
|
||
this._suggestTimer = setTimeout(() => this._fetchSuggestions(ctx, vault, inputValue), SUGGEST_DEBOUNCE_MS);
|
||
} else {
|
||
this._renderTitles([], "");
|
||
this._renderTags([], "");
|
||
}
|
||
|
||
// Show/hide sections
|
||
const hasContent = historyItems.length > 0;
|
||
this._historySection.hidden = historyItems.length === 0;
|
||
this._emptyEl.hidden = hasContent;
|
||
|
||
if (hasContent || (ctx.prefix && ctx.prefix.length >= 2)) {
|
||
this.show();
|
||
} else if (!hasContent) {
|
||
this.hide();
|
||
}
|
||
|
||
this._collectItems();
|
||
},
|
||
|
||
async _fetchSuggestions(ctx, vault, inputValue) {
|
||
suggestAbortController = new AbortController();
|
||
try {
|
||
const [titlesRes, tagsRes] = await Promise.all([
|
||
ctx.type !== "tag" ? api(`/api/suggest?q=${encodeURIComponent(ctx.prefix)}&vault=${encodeURIComponent(vault)}&limit=8`, { signal: suggestAbortController.signal }) : Promise.resolve({ suggestions: [] }),
|
||
(ctx.type === "tag" || ctx.type === "text") ? api(`/api/tags/suggest?q=${encodeURIComponent(ctx.prefix)}&vault=${encodeURIComponent(vault)}&limit=6`, { signal: suggestAbortController.signal }) : Promise.resolve({ suggestions: [] }),
|
||
]);
|
||
|
||
this._renderTitles(titlesRes.suggestions || [], ctx.prefix);
|
||
this._renderTags(tagsRes.suggestions || [], ctx.prefix);
|
||
|
||
// Update visibility
|
||
const hasTitles = (titlesRes.suggestions || []).length > 0;
|
||
const hasTags = (tagsRes.suggestions || []).length > 0;
|
||
this._titlesSection.hidden = !hasTitles;
|
||
this._tagsSection.hidden = !hasTags;
|
||
|
||
const historyVisible = !this._historySection.hidden;
|
||
const hasAny = historyVisible || hasTitles || hasTags;
|
||
this._emptyEl.hidden = hasAny;
|
||
if (hasAny) this.show(); else if (!historyVisible) this.hide();
|
||
|
||
this._collectItems();
|
||
} catch (err) {
|
||
if (err.name !== "AbortError") console.error("Suggestion fetch error:", err);
|
||
}
|
||
},
|
||
|
||
_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 = '<i data-lucide="clock" style="width:14px;height:14px"></i>';
|
||
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 = '<i data-lucide="file-text" style="width:14px;height:14px"></i>';
|
||
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();
|
||
openFile(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 = '<i data-lucide="hash" style="width:14px;height:14px"></i>';
|
||
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");
|
||
// Append tag: operator if not already typing one
|
||
const current = input.value;
|
||
const ctx = QueryParser.getContext(current, input.selectionStart);
|
||
if (ctx.type === "tag") {
|
||
// Replace the partial tag prefix
|
||
const before = current.slice(0, input.selectionStart - ctx.prefix.length);
|
||
input.value = before + item.tag + " ";
|
||
} else {
|
||
input.value = (current ? current + " " : "") + "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; }
|
||
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 = '<i data-lucide="x" style="width:10px;height:10px"></i>';
|
||
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 */ }
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Theme
|
||
// ---------------------------------------------------------------------------
|
||
function initTheme() {
|
||
const saved = localStorage.getItem("obsigate-theme") || "dark";
|
||
applyTheme(saved);
|
||
}
|
||
|
||
function applyTheme(theme) {
|
||
document.documentElement.setAttribute("data-theme", theme);
|
||
localStorage.setItem("obsigate-theme", theme);
|
||
|
||
// Update theme button icon and label
|
||
const themeBtn = document.getElementById("theme-toggle");
|
||
const themeLabel = document.getElementById("theme-label");
|
||
if (themeBtn && themeLabel) {
|
||
const icon = themeBtn.querySelector("i");
|
||
if (icon) {
|
||
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
|
||
}
|
||
themeLabel.textContent = theme === "dark" ? "Sombre" : "Clair";
|
||
safeCreateIcons();
|
||
}
|
||
|
||
// Swap highlight.js theme
|
||
const darkSheet = document.getElementById("hljs-theme-dark");
|
||
const lightSheet = document.getElementById("hljs-theme-light");
|
||
if (darkSheet && lightSheet) {
|
||
darkSheet.disabled = theme !== "dark";
|
||
lightSheet.disabled = theme !== "light";
|
||
}
|
||
}
|
||
|
||
function toggleTheme() {
|
||
const current = document.documentElement.getAttribute("data-theme");
|
||
applyTheme(current === "dark" ? "light" : "dark");
|
||
}
|
||
|
||
function initHeaderMenu() {
|
||
const menuBtn = document.getElementById("header-menu-btn");
|
||
const menuDropdown = document.getElementById("header-menu-dropdown");
|
||
|
||
if (!menuBtn || !menuDropdown) return;
|
||
|
||
menuBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
menuBtn.classList.toggle("active");
|
||
menuDropdown.classList.toggle("active");
|
||
});
|
||
|
||
// Close menu when clicking outside
|
||
document.addEventListener("click", (e) => {
|
||
if (!menuDropdown.contains(e.target) && e.target !== menuBtn) {
|
||
menuBtn.classList.remove("active");
|
||
menuDropdown.classList.remove("active");
|
||
}
|
||
});
|
||
|
||
// Prevent menu from closing when clicking inside
|
||
menuDropdown.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
});
|
||
}
|
||
|
||
function closeHeaderMenu() {
|
||
const menuBtn = document.getElementById("header-menu-btn");
|
||
const menuDropdown = document.getElementById("header-menu-dropdown");
|
||
if (!menuBtn || !menuDropdown) return;
|
||
menuBtn.classList.remove("active");
|
||
menuDropdown.classList.remove("active");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Custom Dropdowns
|
||
// ---------------------------------------------------------------------------
|
||
function initCustomDropdowns() {
|
||
document.querySelectorAll('.custom-dropdown').forEach(dropdown => {
|
||
const trigger = dropdown.querySelector('.custom-dropdown-trigger');
|
||
const options = dropdown.querySelectorAll('.custom-dropdown-option');
|
||
const hiddenInput = dropdown.querySelector('input[type="hidden"]');
|
||
const selectedText = dropdown.querySelector('.custom-dropdown-selected');
|
||
const menu = dropdown.querySelector('.custom-dropdown-menu');
|
||
|
||
if (!trigger) return;
|
||
|
||
// Toggle dropdown
|
||
trigger.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const isOpen = dropdown.classList.contains('open');
|
||
|
||
// Close all other dropdowns
|
||
document.querySelectorAll('.custom-dropdown.open').forEach(d => {
|
||
if (d !== dropdown) d.classList.remove('open');
|
||
});
|
||
|
||
dropdown.classList.toggle('open', !isOpen);
|
||
trigger.setAttribute('aria-expanded', !isOpen);
|
||
|
||
// Position fixed menu for sidebar dropdowns
|
||
if (!isOpen && dropdown.classList.contains('sidebar-dropdown') && menu) {
|
||
const rect = trigger.getBoundingClientRect();
|
||
menu.style.top = `${rect.bottom + 4}px`;
|
||
menu.style.left = `${rect.left}px`;
|
||
menu.style.width = `${rect.width}px`;
|
||
}
|
||
});
|
||
|
||
// Handle option selection
|
||
options.forEach(option => {
|
||
option.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const value = option.getAttribute('data-value');
|
||
const text = option.textContent;
|
||
|
||
// Update hidden input
|
||
if (hiddenInput) {
|
||
hiddenInput.value = value;
|
||
// Trigger change event
|
||
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
|
||
// Update selected text
|
||
if (selectedText) {
|
||
selectedText.textContent = text;
|
||
}
|
||
|
||
// Update visual selection
|
||
options.forEach(opt => opt.classList.remove('selected'));
|
||
option.classList.add('selected');
|
||
|
||
// Close dropdown
|
||
dropdown.classList.remove('open');
|
||
trigger.setAttribute('aria-expanded', 'false');
|
||
});
|
||
});
|
||
});
|
||
|
||
// Close dropdowns when clicking outside
|
||
document.addEventListener('click', () => {
|
||
document.querySelectorAll('.custom-dropdown.open').forEach(dropdown => {
|
||
dropdown.classList.remove('open');
|
||
const trigger = dropdown.querySelector('.custom-dropdown-trigger');
|
||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||
});
|
||
});
|
||
}
|
||
|
||
// Helper to populate custom dropdown options
|
||
function populateCustomDropdown(dropdownId, optionsList, defaultValue) {
|
||
const dropdown = document.getElementById(dropdownId);
|
||
if (!dropdown) return;
|
||
|
||
const optionsContainer = dropdown.querySelector('.custom-dropdown-menu');
|
||
const hiddenInput = dropdown.querySelector('input[type="hidden"]');
|
||
const selectedText = dropdown.querySelector('.custom-dropdown-selected');
|
||
|
||
if (!optionsContainer) return;
|
||
|
||
// Clear existing options (keep the first one if it's the default)
|
||
optionsContainer.innerHTML = '';
|
||
|
||
// Add new options
|
||
optionsList.forEach(opt => {
|
||
const li = document.createElement('li');
|
||
li.className = 'custom-dropdown-option';
|
||
li.setAttribute('role', 'option');
|
||
li.setAttribute('data-value', opt.value);
|
||
li.textContent = opt.text;
|
||
if (opt.value === defaultValue) {
|
||
li.classList.add('selected');
|
||
if (selectedText) selectedText.textContent = opt.text;
|
||
if (hiddenInput) hiddenInput.value = opt.value;
|
||
}
|
||
optionsContainer.appendChild(li);
|
||
});
|
||
|
||
// Re-initialize click handlers
|
||
optionsContainer.querySelectorAll('.custom-dropdown-option').forEach(option => {
|
||
option.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const value = option.getAttribute('data-value');
|
||
const text = option.textContent;
|
||
|
||
if (hiddenInput) {
|
||
hiddenInput.value = value;
|
||
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
|
||
if (selectedText) {
|
||
selectedText.textContent = text;
|
||
}
|
||
|
||
optionsContainer.querySelectorAll('.custom-dropdown-option').forEach(opt => opt.classList.remove('selected'));
|
||
option.classList.add('selected');
|
||
|
||
dropdown.classList.remove('open');
|
||
const trigger = dropdown.querySelector('.custom-dropdown-trigger');
|
||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Toast notifications
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Display a brief toast message at the bottom of the viewport. */
|
||
function showToast(message, type) {
|
||
type = type || "error";
|
||
let container = document.getElementById("toast-container");
|
||
if (!container) {
|
||
container = document.createElement("div");
|
||
container.id = "toast-container";
|
||
container.className = "toast-container";
|
||
container.setAttribute("aria-live", "polite");
|
||
document.body.appendChild(container);
|
||
}
|
||
var toast = document.createElement("div");
|
||
toast.className = "toast toast-" + type;
|
||
toast.textContent = message;
|
||
container.appendChild(toast);
|
||
// Trigger entrance animation
|
||
requestAnimationFrame(function () { toast.classList.add("show"); });
|
||
setTimeout(function () {
|
||
toast.classList.remove("show");
|
||
toast.addEventListener("transitionend", function () { toast.remove(); });
|
||
}, 3500);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// API helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Fetch JSON from an API endpoint with optional AbortSignal support.
|
||
* Surfaces errors to the user via toast instead of silently failing.
|
||
*
|
||
* @param {string} path - API URL path.
|
||
* @param {object} [opts] - Fetch options (may include signal).
|
||
* @returns {Promise<any>} Parsed JSON response.
|
||
*/
|
||
async function api(path, opts) {
|
||
var res;
|
||
try {
|
||
// Inject auth header if authenticated
|
||
const authHeaders = AuthManager.getAuthHeaders();
|
||
const mergedOpts = opts || {};
|
||
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");
|
||
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);
|
||
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 =
|
||
'<span class="user-display-name">' + (user.display_name || user.username) + '</span>' +
|
||
'<button class="btn-logout" id="logout-btn" title="Déconnexion"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
|
||
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()]);
|
||
} 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 = `
|
||
<div class="editor-container">
|
||
<div class="editor-header">
|
||
<div class="editor-title">⚙️ Administration — Utilisateurs</div>
|
||
<div class="editor-actions">
|
||
<button class="editor-btn" id="admin-close" title="Fermer">
|
||
<i data-lucide="x" style="width:16px;height:16px"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="editor-body" id="admin-body">
|
||
<div class="admin-toolbar">
|
||
<button class="btn-login" id="admin-add-user" style="font-size:0.85rem;padding:6px 16px;">+ Nouvel utilisateur</button>
|
||
</div>
|
||
<div id="admin-users-list" class="admin-users-list"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 =
|
||
'<p style="color:var(--danger);padding:16px;">Erreur : ' + err.message + '</p>';
|
||
}
|
||
},
|
||
|
||
_renderUsers(users) {
|
||
const container = document.getElementById('admin-users-list');
|
||
if (!users.length) {
|
||
container.innerHTML = '<p style="padding:16px;color:var(--text-muted);">Aucun utilisateur.</p>';
|
||
return;
|
||
}
|
||
let html = '<table class="admin-table"><thead><tr>' +
|
||
'<th>Utilisateur</th><th>Rôle</th><th>Vaults</th><th>Statut</th><th>Dernière connexion</th><th>Actions</th>' +
|
||
'</tr></thead><tbody>';
|
||
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 += '<tr>' +
|
||
'<td><strong>' + u.username + '</strong>' + (u.display_name && u.display_name !== u.username ? '<br><small>' + u.display_name + '</small>' : '') + '</td>' +
|
||
'<td><span class="admin-role-badge admin-role-' + u.role + '">' + u.role + '</span></td>' +
|
||
'<td><span class="admin-vaults-text">' + vaults + '</span></td>' +
|
||
'<td>' + status + '</td>' +
|
||
'<td><small>' + lastLogin + '</small></td>' +
|
||
'<td class="admin-actions">' +
|
||
'<button class="admin-action-btn" data-action="edit" data-username="' + u.username + '" title="Modifier">✏️</button>' +
|
||
'<button class="admin-action-btn danger" data-action="delete" data-username="' + u.username + '" title="Supprimer">🗑️</button>' +
|
||
'</td></tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
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 '<label class="checkbox-label"><input type="checkbox" name="vault" value="' + v + '" ' + checked + '><span>' + v + '</span></label>';
|
||
}).join('');
|
||
const allVaultsChecked = user && user.vaults.includes('*') ? 'checked' : '';
|
||
|
||
// Create form modal overlay
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'admin-form-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="admin-form-card">
|
||
<h3>${title}</h3>
|
||
<form id="admin-user-form">
|
||
${!isEdit ? '<div class="form-group"><label>Nom d\'utilisateur</label><input type="text" name="username" required pattern="[a-zA-Z0-9_-]{2,32}" placeholder="username"></div>' : ''}
|
||
<div class="form-group"><label>Nom affiché</label><input type="text" name="display_name" value="${isEdit ? (user.display_name || '') : ''}"></div>
|
||
<div class="form-group"><label>${isEdit ? 'Nouveau mot de passe (vide = inchangé)' : 'Mot de passe'}</label><input type="password" name="password" ${!isEdit ? 'required minlength="8"' : ''} placeholder="${isEdit ? 'Laisser vide pour ne pas changer' : 'Min. 8 caractères'}"></div>
|
||
<div class="form-group"><label>Rôle</label><select name="role"><option value="user" ${isEdit && user.role === 'user' ? 'selected' : ''}>Utilisateur</option><option value="admin" ${isEdit && user.role === 'admin' ? 'selected' : ''}>Admin</option></select></div>
|
||
<div class="form-group">
|
||
<label>Vaults autorisées</label>
|
||
<div class="admin-vault-list">${vaultCheckboxes}</div>
|
||
<label class="checkbox-label" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;"><input type="checkbox" id="admin-all-vaults" ${allVaultsChecked}><span><strong>Accès total</strong> (toutes les vaults, y compris futures)</span></label>
|
||
</div>
|
||
${isEdit ? '<div class="form-group"><label>Compte actif</label><label class="checkbox-label"><input type="checkbox" name="active" ' + (user.active ? 'checked' : '') + '><span>Actif</span></label></div>' : ''}
|
||
<div class="admin-form-actions">
|
||
<button type="button" class="config-btn-secondary" id="admin-form-cancel">Annuler</button>
|
||
<button type="submit" class="btn-login" style="font-size:0.85rem;padding:6px 20px;">Enregistrer</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
`;
|
||
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);
|
||
}
|
||
});
|
||
},
|
||
|
||
async _deleteUser(username) {
|
||
const currentUser = AuthManager.getUser();
|
||
if (currentUser && currentUser.username === username) {
|
||
showToast('Impossible de supprimer son propre compte');
|
||
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);
|
||
}
|
||
},
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sidebar toggle (desktop)
|
||
// ---------------------------------------------------------------------------
|
||
function initSidebarToggle() {
|
||
const toggleBtn = document.getElementById("sidebar-toggle-btn");
|
||
const sidebar = document.getElementById("sidebar");
|
||
const resizeHandle = document.getElementById("sidebar-resize-handle");
|
||
|
||
if (!toggleBtn || !sidebar || !resizeHandle) return;
|
||
|
||
// Restore saved state
|
||
const savedState = localStorage.getItem("obsigate-sidebar-hidden");
|
||
if (savedState === "true") {
|
||
sidebar.classList.add("hidden");
|
||
resizeHandle.classList.add("hidden");
|
||
toggleBtn.classList.add("active");
|
||
}
|
||
|
||
toggleBtn.addEventListener("click", () => {
|
||
const isHidden = sidebar.classList.toggle("hidden");
|
||
resizeHandle.classList.toggle("hidden", isHidden);
|
||
toggleBtn.classList.toggle("active", isHidden);
|
||
localStorage.setItem("obsigate-sidebar-hidden", isHidden ? "true" : "false");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mobile sidebar
|
||
// ---------------------------------------------------------------------------
|
||
function initMobile() {
|
||
const hamburger = document.getElementById("hamburger-btn");
|
||
const overlay = document.getElementById("sidebar-overlay");
|
||
const sidebar = document.getElementById("sidebar");
|
||
|
||
hamburger.addEventListener("click", () => {
|
||
sidebar.classList.toggle("mobile-open");
|
||
overlay.classList.toggle("active");
|
||
});
|
||
|
||
overlay.addEventListener("click", () => {
|
||
sidebar.classList.remove("mobile-open");
|
||
overlay.classList.remove("active");
|
||
});
|
||
}
|
||
|
||
function closeMobileSidebar() {
|
||
const sidebar = document.getElementById("sidebar");
|
||
const overlay = document.getElementById("sidebar-overlay");
|
||
if (sidebar) sidebar.classList.remove("mobile-open");
|
||
if (overlay) overlay.classList.remove("active");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Vault context switching
|
||
// ---------------------------------------------------------------------------
|
||
function initVaultContext() {
|
||
const filter = document.getElementById("vault-filter");
|
||
const quickSelect = document.getElementById("vault-quick-select");
|
||
if (!filter || !quickSelect) return;
|
||
|
||
filter.addEventListener("change", async () => {
|
||
await setSelectedVaultContext(filter.value, { focusVault: filter.value !== "all" });
|
||
});
|
||
|
||
quickSelect.addEventListener("change", async () => {
|
||
await setSelectedVaultContext(quickSelect.value, { focusVault: quickSelect.value !== "all" });
|
||
});
|
||
}
|
||
|
||
async function setSelectedVaultContext(vaultName, options) {
|
||
selectedContextVault = vaultName;
|
||
showingSource = false;
|
||
cachedRawSource = null;
|
||
syncVaultSelectors();
|
||
await refreshSidebarForContext();
|
||
await refreshTagsForContext();
|
||
showWelcome();
|
||
if (options && options.focusVault && vaultName !== "all") {
|
||
await focusVaultInSidebar(vaultName);
|
||
}
|
||
}
|
||
|
||
function syncVaultSelectors() {
|
||
const filter = document.getElementById("vault-filter");
|
||
const quickSelect = document.getElementById("vault-quick-select");
|
||
const contextText = document.getElementById("vault-context-text");
|
||
|
||
if (filter) filter.value = selectedContextVault;
|
||
if (quickSelect) quickSelect.value = selectedContextVault;
|
||
|
||
// Update vault context indicator
|
||
if (contextText) {
|
||
contextText.textContent = selectedContextVault === "all" ? "All" : selectedContextVault;
|
||
}
|
||
}
|
||
|
||
function scrollTreeItemIntoView(element, alignToTop) {
|
||
if (!element) return;
|
||
const scrollContainer = document.getElementById("sidebar-panel-vaults");
|
||
if (!scrollContainer) return;
|
||
|
||
const containerRect = scrollContainer.getBoundingClientRect();
|
||
const elementRect = element.getBoundingClientRect();
|
||
const isAbove = elementRect.top < containerRect.top;
|
||
const isBelow = elementRect.bottom > containerRect.bottom;
|
||
|
||
if (!isAbove && !isBelow && !alignToTop) return;
|
||
|
||
const currentTop = scrollContainer.scrollTop;
|
||
const offsetTop = element.offsetTop;
|
||
const targetTop = alignToTop
|
||
? Math.max(0, offsetTop - 60)
|
||
: Math.max(0, currentTop + (elementRect.top - containerRect.top) - (containerRect.height * 0.35));
|
||
|
||
scrollContainer.scrollTo({
|
||
top: targetTop,
|
||
behavior: "smooth",
|
||
});
|
||
}
|
||
|
||
async function refreshSidebarForContext() {
|
||
const container = document.getElementById("vault-tree");
|
||
container.innerHTML = "";
|
||
|
||
const vaultsToShow = selectedContextVault === "all"
|
||
? allVaults
|
||
: allVaults.filter((v) => v.name === selectedContextVault);
|
||
|
||
vaultsToShow.forEach((v) => {
|
||
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [
|
||
icon("chevron-right", 14),
|
||
icon("database", 16),
|
||
document.createTextNode(` ${v.name} `),
|
||
smallBadge(v.file_count),
|
||
]);
|
||
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
|
||
container.appendChild(vaultItem);
|
||
|
||
const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
|
||
container.appendChild(childContainer);
|
||
});
|
||
|
||
safeCreateIcons();
|
||
}
|
||
|
||
async function focusVaultInSidebar(vaultName) {
|
||
switchSidebarTab("vaults");
|
||
const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`);
|
||
if (!vaultItem) return;
|
||
document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused"));
|
||
vaultItem.classList.add("focused");
|
||
const childContainer = document.getElementById(`vault-children-${vaultName}`);
|
||
if (childContainer && childContainer.classList.contains("collapsed")) {
|
||
await toggleVault(vaultItem, vaultName, true);
|
||
}
|
||
scrollTreeItemIntoView(vaultItem, false);
|
||
}
|
||
|
||
async function refreshTagsForContext() {
|
||
const vaultParam = selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(selectedContextVault)}`;
|
||
const data = await api(`/api/tags${vaultParam}`);
|
||
const filteredTags = TagFilterService.filterTags(data.tags);
|
||
renderTagCloud(filteredTags);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sidebar — Vault tree
|
||
// ---------------------------------------------------------------------------
|
||
async function loadVaults() {
|
||
const vaults = await api("/api/vaults");
|
||
allVaults = vaults;
|
||
const container = document.getElementById("vault-tree");
|
||
container.innerHTML = "";
|
||
|
||
// Prepare dropdown options
|
||
const dropdownOptions = [
|
||
{ value: "all", text: "Tous les vaults" },
|
||
...vaults.map(v => ({ value: v.name, text: v.name }))
|
||
];
|
||
|
||
// Populate custom dropdowns
|
||
populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all");
|
||
populateCustomDropdown("vault-quick-select-dropdown", dropdownOptions, "all");
|
||
|
||
vaults.forEach((v) => {
|
||
// Sidebar tree entry
|
||
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [
|
||
icon("chevron-right", 14),
|
||
icon("database", 16),
|
||
document.createTextNode(` ${v.name} `),
|
||
smallBadge(v.file_count),
|
||
]);
|
||
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
|
||
container.appendChild(vaultItem);
|
||
|
||
const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
|
||
container.appendChild(childContainer);
|
||
});
|
||
|
||
syncVaultSelectors();
|
||
safeCreateIcons();
|
||
}
|
||
|
||
async function toggleVault(itemEl, vaultName, forceExpand) {
|
||
const childContainer = document.getElementById(`vault-children-${vaultName}`);
|
||
if (!childContainer) return;
|
||
|
||
scrollTreeItemIntoView(itemEl, false);
|
||
|
||
const shouldExpand = forceExpand || childContainer.classList.contains("collapsed");
|
||
|
||
if (shouldExpand) {
|
||
// Expand — load children if empty
|
||
if (childContainer.children.length === 0) {
|
||
await loadDirectory(vaultName, "", childContainer);
|
||
}
|
||
childContainer.classList.remove("collapsed");
|
||
// Swap chevron
|
||
const chevron = itemEl.querySelector("[data-lucide]");
|
||
if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
|
||
safeCreateIcons();
|
||
} else {
|
||
childContainer.classList.add("collapsed");
|
||
const chevron = itemEl.querySelector("[data-lucide]");
|
||
if (chevron) chevron.setAttribute("data-lucide", "chevron-right");
|
||
safeCreateIcons();
|
||
}
|
||
}
|
||
|
||
async function focusPathInSidebar(vaultName, targetPath, options) {
|
||
switchSidebarTab("vaults");
|
||
|
||
const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`);
|
||
if (!vaultItem) return;
|
||
|
||
document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused"));
|
||
vaultItem.classList.add("focused");
|
||
|
||
const vaultContainer = document.getElementById(`vault-children-${vaultName}`);
|
||
if (!vaultContainer) return;
|
||
|
||
if (vaultContainer.classList.contains("collapsed")) {
|
||
await toggleVault(vaultItem, vaultName, true);
|
||
}
|
||
|
||
if (!targetPath) {
|
||
// Clear any previous path selection
|
||
document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected"));
|
||
scrollTreeItemIntoView(vaultItem, options && options.alignToTop);
|
||
return;
|
||
}
|
||
|
||
const segments = targetPath.split("/").filter(Boolean);
|
||
let currentContainer = vaultContainer;
|
||
let cumulativePath = "";
|
||
let lastTargetItem = null;
|
||
|
||
for (let index = 0; index < segments.length; index++) {
|
||
cumulativePath += (cumulativePath ? "/" : "") + segments[index];
|
||
|
||
let targetItem = null;
|
||
try {
|
||
targetItem = currentContainer.querySelector(`.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(cumulativePath)}"]`);
|
||
} catch (e) {
|
||
targetItem = null;
|
||
}
|
||
|
||
if (!targetItem) {
|
||
return;
|
||
}
|
||
|
||
lastTargetItem = targetItem;
|
||
|
||
const isLastSegment = index === segments.length - 1;
|
||
if (!isLastSegment) {
|
||
const nextContainer = document.getElementById(`dir-${vaultName}-${cumulativePath}`);
|
||
if (nextContainer && nextContainer.classList.contains("collapsed")) {
|
||
targetItem.click();
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
}
|
||
if (nextContainer) {
|
||
currentContainer = nextContainer;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clear previous path selections and highlight the final target
|
||
document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected"));
|
||
if (lastTargetItem) {
|
||
lastTargetItem.classList.add("path-selected");
|
||
}
|
||
|
||
scrollTreeItemIntoView(lastTargetItem, options && options.alignToTop);
|
||
}
|
||
|
||
async function loadDirectory(vaultName, dirPath, container) {
|
||
// Show inline loading indicator while fetching directory contents
|
||
container.innerHTML = '<div class="tree-loading"><div class="loading-spinner" style="width:16px;height:16px;border-width:2px"></div></div>';
|
||
|
||
var data;
|
||
try {
|
||
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
|
||
data = await api(url);
|
||
} catch (err) {
|
||
container.innerHTML = '<div class="tree-loading" style="color:var(--text-muted);font-size:0.75rem;padding:4px 16px">Erreur de chargement</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = "";
|
||
|
||
const fragment = document.createDocumentFragment();
|
||
|
||
data.items.forEach((item) => {
|
||
if (item.type === "directory") {
|
||
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [
|
||
icon("chevron-right", 14),
|
||
icon("folder", 16),
|
||
document.createTextNode(` ${item.name} `),
|
||
smallBadge(item.children_count),
|
||
]);
|
||
fragment.appendChild(dirItem);
|
||
|
||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
||
fragment.appendChild(subContainer);
|
||
|
||
dirItem.addEventListener("click", async () => {
|
||
scrollTreeItemIntoView(dirItem, false);
|
||
if (subContainer.classList.contains("collapsed")) {
|
||
if (subContainer.children.length === 0) {
|
||
await loadDirectory(vaultName, item.path, subContainer);
|
||
}
|
||
subContainer.classList.remove("collapsed");
|
||
const chev = dirItem.querySelector("[data-lucide]");
|
||
if (chev) chev.setAttribute("data-lucide", "chevron-down");
|
||
safeCreateIcons();
|
||
} else {
|
||
subContainer.classList.add("collapsed");
|
||
const chev = dirItem.querySelector("[data-lucide]");
|
||
if (chev) chev.setAttribute("data-lucide", "chevron-right");
|
||
safeCreateIcons();
|
||
}
|
||
});
|
||
} else {
|
||
const fileIconName = getFileIcon(item.name);
|
||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [
|
||
icon(fileIconName, 16),
|
||
document.createTextNode(` ${displayName}`),
|
||
]);
|
||
fileItem.addEventListener("click", () => {
|
||
scrollTreeItemIntoView(fileItem, false);
|
||
openFile(vaultName, item.path);
|
||
closeMobileSidebar();
|
||
});
|
||
fragment.appendChild(fileItem);
|
||
}
|
||
});
|
||
|
||
container.appendChild(fragment);
|
||
safeCreateIcons();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sidebar filter
|
||
// ---------------------------------------------------------------------------
|
||
function initSidebarFilter() {
|
||
const input = document.getElementById("sidebar-filter-input");
|
||
const caseBtn = document.getElementById("sidebar-filter-case-btn");
|
||
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
|
||
|
||
input.addEventListener("input", () => {
|
||
const hasText = input.value.length > 0;
|
||
clearBtn.style.display = hasText ? "flex" : "none";
|
||
clearTimeout(filterDebounce);
|
||
filterDebounce = setTimeout(async () => {
|
||
const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||
if (hasText) {
|
||
if (activeSidebarTab === "vaults") {
|
||
await performTreeSearch(q);
|
||
} else {
|
||
filterTagCloud(q);
|
||
}
|
||
} else {
|
||
if (activeSidebarTab === "vaults") {
|
||
await restoreSidebarTree();
|
||
} else {
|
||
filterTagCloud("");
|
||
}
|
||
}
|
||
}, 220);
|
||
});
|
||
|
||
caseBtn.addEventListener("click", async () => {
|
||
sidebarFilterCaseSensitive = !sidebarFilterCaseSensitive;
|
||
caseBtn.classList.toggle("active");
|
||
const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||
if (input.value.trim()) {
|
||
if (activeSidebarTab === "vaults") {
|
||
await performTreeSearch(q);
|
||
} else {
|
||
filterTagCloud(q);
|
||
}
|
||
}
|
||
});
|
||
|
||
clearBtn.addEventListener("click", async () => {
|
||
input.value = "";
|
||
clearBtn.style.display = "none";
|
||
sidebarFilterCaseSensitive = false;
|
||
caseBtn.classList.remove("active");
|
||
clearTimeout(filterDebounce);
|
||
if (activeSidebarTab === "vaults") {
|
||
await restoreSidebarTree();
|
||
} else {
|
||
filterTagCloud("");
|
||
}
|
||
});
|
||
|
||
clearBtn.style.display = "none";
|
||
}
|
||
|
||
async function performTreeSearch(query) {
|
||
if (!query) {
|
||
await restoreSidebarTree();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const vaultParam = selectedContextVault === "all" ? "all" : selectedContextVault;
|
||
const url = `/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultParam)}`;
|
||
const data = await api(url);
|
||
renderFilteredSidebarResults(query, data.results);
|
||
} catch (err) {
|
||
console.error("Tree search error:", err);
|
||
renderFilteredSidebarResults(query, []);
|
||
}
|
||
}
|
||
|
||
async function restoreSidebarTree() {
|
||
await refreshSidebarForContext();
|
||
if (currentVault) {
|
||
focusPathInSidebar(currentVault, currentPath || "", { alignToTop: false }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
function renderFilteredSidebarResults(query, results) {
|
||
const container = document.getElementById("vault-tree");
|
||
container.innerHTML = "";
|
||
|
||
const grouped = new Map();
|
||
results.forEach((result) => {
|
||
if (!grouped.has(result.vault)) {
|
||
grouped.set(result.vault, []);
|
||
}
|
||
grouped.get(result.vault).push(result);
|
||
});
|
||
|
||
if (grouped.size === 0) {
|
||
container.appendChild(el("div", { class: "sidebar-filter-empty" }, [
|
||
document.createTextNode("Aucun répertoire ou fichier correspondant."),
|
||
]));
|
||
return;
|
||
}
|
||
|
||
grouped.forEach((entries, vaultName) => {
|
||
entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: "base" }));
|
||
|
||
const vaultHeader = el("div", { class: "tree-item vault-item filter-results-header", "data-vault": vaultName }, [
|
||
icon("database", 16),
|
||
document.createTextNode(` ${vaultName} `),
|
||
smallBadge(entries.length),
|
||
]);
|
||
container.appendChild(vaultHeader);
|
||
|
||
const resultsWrapper = el("div", { class: "filter-results-group" });
|
||
entries.forEach((entry) => {
|
||
const resultItem = el("div", {
|
||
class: `tree-item filter-result-item filter-result-${entry.type}`,
|
||
"data-vault": entry.vault,
|
||
"data-path": entry.path,
|
||
"data-type": entry.type,
|
||
}, [
|
||
icon(entry.type === "directory" ? "folder" : getFileIcon(entry.name), 16),
|
||
]);
|
||
|
||
const textWrap = el("div", { class: "filter-result-text" });
|
||
const primary = el("div", { class: "filter-result-primary" });
|
||
appendHighlightedText(primary, entry.name, query, sidebarFilterCaseSensitive);
|
||
const secondary = el("div", { class: "filter-result-secondary" });
|
||
appendHighlightedText(secondary, entry.path, query, sidebarFilterCaseSensitive);
|
||
textWrap.appendChild(primary);
|
||
textWrap.appendChild(secondary);
|
||
resultItem.appendChild(textWrap);
|
||
|
||
resultItem.addEventListener("click", async () => {
|
||
const input = document.getElementById("sidebar-filter-input");
|
||
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
|
||
if (input) input.value = "";
|
||
if (clearBtn) clearBtn.style.display = "none";
|
||
await restoreSidebarTree();
|
||
if (entry.type === "directory") {
|
||
await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true });
|
||
} else {
|
||
await openFile(entry.vault, entry.path);
|
||
await focusPathInSidebar(entry.vault, entry.path, { alignToTop: false });
|
||
}
|
||
closeMobileSidebar();
|
||
});
|
||
|
||
resultsWrapper.appendChild(resultItem);
|
||
});
|
||
|
||
container.appendChild(resultsWrapper);
|
||
});
|
||
|
||
flushIcons();
|
||
}
|
||
|
||
function filterSidebarTree(query) {
|
||
const tree = document.getElementById("vault-tree");
|
||
const items = tree.querySelectorAll(".tree-item");
|
||
const containers = tree.querySelectorAll(".tree-children");
|
||
|
||
if (!query) {
|
||
items.forEach((item) => item.classList.remove("filtered-out"));
|
||
containers.forEach((c) => {
|
||
c.classList.remove("filtered-out");
|
||
// Keep current collapsed state when clearing filter
|
||
});
|
||
return;
|
||
}
|
||
|
||
// First pass: mark all as filtered out
|
||
items.forEach((item) => item.classList.add("filtered-out"));
|
||
containers.forEach((c) => c.classList.add("filtered-out"));
|
||
|
||
// Second pass: find matching items and mark them + ancestors + descendants
|
||
const matchingItems = new Set();
|
||
|
||
items.forEach((item) => {
|
||
const text = sidebarFilterCaseSensitive ? item.textContent : item.textContent.toLowerCase();
|
||
const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase();
|
||
if (text.includes(searchQuery)) {
|
||
matchingItems.add(item);
|
||
item.classList.remove("filtered-out");
|
||
|
||
// Show all ancestor containers
|
||
let parent = item.parentElement;
|
||
while (parent && parent !== tree) {
|
||
parent.classList.remove("filtered-out");
|
||
if (parent.classList.contains("tree-children")) {
|
||
parent.classList.remove("collapsed");
|
||
}
|
||
parent = parent.parentElement;
|
||
}
|
||
|
||
// If this is a directory (has a children container after it), show all descendants
|
||
const nextEl = item.nextElementSibling;
|
||
if (nextEl && nextEl.classList.contains("tree-children")) {
|
||
nextEl.classList.remove("filtered-out");
|
||
nextEl.classList.remove("collapsed");
|
||
// Recursively show all children in this container
|
||
showAllDescendants(nextEl);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Third pass: show items that are descendants of matching directories
|
||
// and ensure their containers are visible
|
||
matchingItems.forEach((item) => {
|
||
const nextEl = item.nextElementSibling;
|
||
if (nextEl && nextEl.classList.contains("tree-children")) {
|
||
const children = nextEl.querySelectorAll(".tree-item");
|
||
children.forEach((child) => child.classList.remove("filtered-out"));
|
||
}
|
||
});
|
||
}
|
||
|
||
function showAllDescendants(container) {
|
||
const items = container.querySelectorAll(".tree-item");
|
||
items.forEach((item) => {
|
||
item.classList.remove("filtered-out");
|
||
// If this item has children, also show them
|
||
const nextEl = item.nextElementSibling;
|
||
if (nextEl && nextEl.classList.contains("tree-children")) {
|
||
nextEl.classList.remove("filtered-out");
|
||
nextEl.classList.remove("collapsed");
|
||
}
|
||
});
|
||
// Also ensure all nested containers are visible
|
||
const nestedContainers = container.querySelectorAll(".tree-children");
|
||
nestedContainers.forEach((c) => {
|
||
c.classList.remove("filtered-out");
|
||
c.classList.remove("collapsed");
|
||
});
|
||
}
|
||
|
||
function filterTagCloud(query) {
|
||
const tags = document.querySelectorAll("#tag-cloud .tag-item");
|
||
tags.forEach((tag) => {
|
||
const text = sidebarFilterCaseSensitive ? tag.textContent : tag.textContent.toLowerCase();
|
||
const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase();
|
||
if (!query || text.includes(searchQuery)) {
|
||
tag.classList.remove("filtered-out");
|
||
} else {
|
||
tag.classList.add("filtered-out");
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tag Filter Service
|
||
// ---------------------------------------------------------------------------
|
||
const TagFilterService = {
|
||
defaultFilters: [
|
||
{ pattern: "#<% ... %>", regex: "#<%.*%>", enabled: true },
|
||
{ pattern: "#{{ ... }}", regex: "#\\{\\{.*\\}\\}", enabled: true },
|
||
{ pattern: "#{ ... }", regex: "#\\{.*\\}", enabled: true }
|
||
],
|
||
|
||
getConfig() {
|
||
const stored = localStorage.getItem("obsigate-tag-filters");
|
||
if (stored) {
|
||
try {
|
||
return JSON.parse(stored);
|
||
} catch (e) {
|
||
return { tagFilters: this.defaultFilters };
|
||
}
|
||
}
|
||
return { tagFilters: this.defaultFilters };
|
||
},
|
||
|
||
saveConfig(config) {
|
||
localStorage.setItem("obsigate-tag-filters", JSON.stringify(config));
|
||
},
|
||
|
||
patternToRegex(pattern) {
|
||
// 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
|
||
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
|
||
const selector = `.tree-item[data-vault="${vaultName}"][data-path="${CSS.escape(filePath)}"]`;
|
||
try {
|
||
const active = document.querySelector(selector);
|
||
if (active) active.classList.add("active");
|
||
} catch (e) { /* selector might fail on special chars */ }
|
||
|
||
// Show loading state while fetching
|
||
const area = document.getElementById("content-area");
|
||
area.innerHTML = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
|
||
|
||
try {
|
||
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
|
||
const data = await api(url);
|
||
renderFile(data);
|
||
} catch (err) {
|
||
area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier.</p></div>';
|
||
}
|
||
}
|
||
|
||
function renderFile(data) {
|
||
const area = document.getElementById("content-area");
|
||
|
||
// Breadcrumb
|
||
const parts = data.path.split("/");
|
||
const breadcrumbEls = [];
|
||
breadcrumbEls.push(makeBreadcrumbSpan(data.vault, () => {
|
||
focusPathInSidebar(data.vault, "", { alignToTop: true });
|
||
}));
|
||
let accumulated = "";
|
||
parts.forEach((part, i) => {
|
||
breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")]));
|
||
accumulated += (accumulated ? "/" : "") + part;
|
||
const p = accumulated;
|
||
if (i < parts.length - 1) {
|
||
breadcrumbEls.push(makeBreadcrumbSpan(part, () => {
|
||
focusPathInSidebar(data.vault, p, { alignToTop: true });
|
||
}));
|
||
} else {
|
||
breadcrumbEls.push(makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => {
|
||
focusPathInSidebar(data.vault, data.path, { alignToTop: false });
|
||
}));
|
||
}
|
||
});
|
||
|
||
const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls);
|
||
|
||
// Tags
|
||
const tagsDiv = el("div", { class: "file-tags" });
|
||
(data.tags || []).forEach((tag) => {
|
||
if (!TagFilterService.isTagFiltered(tag)) {
|
||
const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
|
||
t.addEventListener("click", () => searchByTag(tag));
|
||
tagsDiv.appendChild(t);
|
||
}
|
||
});
|
||
|
||
// Action buttons
|
||
const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [
|
||
icon("copy", 14),
|
||
document.createTextNode("Copier"),
|
||
]);
|
||
copyBtn.addEventListener("click", async () => {
|
||
try {
|
||
// Fetch raw content if not already cached
|
||
if (!cachedRawSource) {
|
||
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||
const rawData = await api(rawUrl);
|
||
cachedRawSource = rawData.raw;
|
||
}
|
||
await navigator.clipboard.writeText(cachedRawSource);
|
||
copyBtn.lastChild.textContent = "Copié !";
|
||
setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500);
|
||
} catch (err) {
|
||
console.error("Copy error:", err);
|
||
showToast("Erreur lors de la copie");
|
||
}
|
||
});
|
||
|
||
const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [
|
||
icon("code", 14),
|
||
document.createTextNode("Source"),
|
||
]);
|
||
|
||
const downloadBtn = el("button", { class: "btn-action", title: "Télécharger" }, [
|
||
icon("download", 14),
|
||
document.createTextNode("Télécharger"),
|
||
]);
|
||
downloadBtn.addEventListener("click", () => {
|
||
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
|
||
const a = document.createElement("a");
|
||
a.href = dlUrl;
|
||
a.download = data.path.split("/").pop();
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
});
|
||
|
||
const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [
|
||
icon("edit", 14),
|
||
document.createTextNode("Éditer"),
|
||
]);
|
||
editBtn.addEventListener("click", () => {
|
||
openEditor(data.vault, data.path);
|
||
});
|
||
|
||
const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenêtre" }, [
|
||
icon("external-link", 14),
|
||
document.createTextNode("Nouvelle fenêtre"),
|
||
]);
|
||
openNewWindowBtn.addEventListener("click", () => {
|
||
const currentUrl = window.location.origin + window.location.pathname;
|
||
const fileUrl = `${currentUrl}?popup=true#file=${encodeURIComponent(data.vault)}:${encodeURIComponent(data.path)}`;
|
||
window.open(fileUrl, '_blank', 'menubar=no,toolbar=no,location=no,status=no,width=1000,height=800');
|
||
});
|
||
|
||
// Frontmatter
|
||
let fmSection = null;
|
||
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
||
const fmToggle = el("div", { class: "frontmatter-toggle" }, [
|
||
document.createTextNode("▶ Frontmatter"),
|
||
]);
|
||
const fmContent = el("div", { class: "frontmatter-content" }, [
|
||
document.createTextNode(JSON.stringify(data.frontmatter, null, 2)),
|
||
]);
|
||
fmToggle.addEventListener("click", () => {
|
||
fmContent.classList.toggle("open");
|
||
fmToggle.textContent = fmContent.classList.contains("open") ? "▼ Frontmatter" : "▶ Frontmatter";
|
||
});
|
||
fmSection = el("div", {}, [fmToggle, fmContent]);
|
||
}
|
||
|
||
// Content container (rendered HTML)
|
||
const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" });
|
||
mdDiv.innerHTML = data.html;
|
||
|
||
// Raw source container (hidden initially)
|
||
const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" });
|
||
|
||
// Source button toggle logic
|
||
sourceBtn.addEventListener("click", async () => {
|
||
const rendered = document.getElementById("file-rendered-content");
|
||
const raw = document.getElementById("file-raw-content");
|
||
if (!rendered || !raw) return;
|
||
|
||
showingSource = !showingSource;
|
||
if (showingSource) {
|
||
sourceBtn.classList.add("active");
|
||
if (!cachedRawSource) {
|
||
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||
const rawData = await api(rawUrl);
|
||
cachedRawSource = rawData.raw;
|
||
}
|
||
raw.textContent = cachedRawSource;
|
||
rendered.style.display = "none";
|
||
raw.style.display = "block";
|
||
} else {
|
||
sourceBtn.classList.remove("active");
|
||
rendered.style.display = "block";
|
||
raw.style.display = "none";
|
||
}
|
||
});
|
||
|
||
// Assemble
|
||
area.innerHTML = "";
|
||
area.appendChild(breadcrumb);
|
||
area.appendChild(el("div", { class: "file-header" }, [
|
||
el("div", { class: "file-title" }, [document.createTextNode(data.title)]),
|
||
tagsDiv,
|
||
el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, editBtn, openNewWindowBtn]),
|
||
]));
|
||
if (fmSection) area.appendChild(fmSection);
|
||
area.appendChild(mdDiv);
|
||
area.appendChild(rawDiv);
|
||
|
||
// Highlight code blocks
|
||
area.querySelectorAll("pre code").forEach((block) => {
|
||
safeHighlight(block);
|
||
});
|
||
|
||
// Wire up wikilinks
|
||
area.querySelectorAll(".wikilink").forEach((link) => {
|
||
link.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
const v = link.getAttribute("data-vault");
|
||
const p = link.getAttribute("data-path");
|
||
if (v && p) openFile(v, p);
|
||
});
|
||
});
|
||
|
||
safeCreateIcons();
|
||
area.scrollTop = 0;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sidebar tabs
|
||
// ---------------------------------------------------------------------------
|
||
function initSidebarTabs() {
|
||
document.querySelectorAll(".sidebar-tab").forEach((tab) => {
|
||
tab.addEventListener("click", () => switchSidebarTab(tab.dataset.tab));
|
||
});
|
||
}
|
||
|
||
function switchSidebarTab(tab) {
|
||
activeSidebarTab = tab;
|
||
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
|
||
const isActive = btn.dataset.tab === tab;
|
||
btn.classList.toggle("active", isActive);
|
||
btn.setAttribute("aria-selected", isActive ? "true" : "false");
|
||
});
|
||
document.querySelectorAll(".sidebar-tab-panel").forEach((panel) => {
|
||
const isActive = panel.id === `sidebar-panel-${tab}`;
|
||
panel.classList.toggle("active", isActive);
|
||
});
|
||
const filterInput = document.getElementById("sidebar-filter-input");
|
||
if (filterInput) {
|
||
filterInput.placeholder = tab === "vaults" ? "Filtrer fichiers..." : "Filtrer tags...";
|
||
}
|
||
const query = filterInput
|
||
? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase())
|
||
: "";
|
||
if (query) {
|
||
if (tab === "vaults") performTreeSearch(query);
|
||
else filterTagCloud(query);
|
||
}
|
||
}
|
||
|
||
function initHelpModal() {
|
||
const openBtn = document.getElementById("help-open-btn");
|
||
const closeBtn = document.getElementById("help-close");
|
||
const modal = document.getElementById("help-modal");
|
||
if (!openBtn || !closeBtn || !modal) return;
|
||
|
||
openBtn.addEventListener("click", () => {
|
||
modal.classList.add("active");
|
||
closeHeaderMenu();
|
||
safeCreateIcons();
|
||
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 navLinks = document.querySelectorAll(".help-nav-link");
|
||
|
||
if (!helpContent || !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: helpContent,
|
||
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();
|
||
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);
|
||
|
||
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);
|
||
// 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");
|
||
}
|
||
|
||
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),
|
||
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 {
|
||
await fetch("/api/config", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
showToast("Configuration backend sauvegardée");
|
||
} catch (err) {
|
||
console.error("Failed to save backend config:", err);
|
||
showToast("Erreur de sauvegarde");
|
||
}
|
||
}
|
||
|
||
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");
|
||
loadDiagnostics();
|
||
await Promise.all([loadVaults(), loadTags()]);
|
||
} catch (err) {
|
||
console.error("Reindex error:", err);
|
||
showToast("Erreur de réindexation");
|
||
} 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");
|
||
}
|
||
|
||
async function loadDiagnostics() {
|
||
const container = document.getElementById("config-diagnostics");
|
||
if (!container) return;
|
||
container.innerHTML = '<div class="config-diag-loading">Chargement...</div>';
|
||
try {
|
||
const data = await api("/api/diagnostics");
|
||
renderDiagnostics(container, data);
|
||
} catch (err) {
|
||
container.innerHTML = '<div class="config-diag-loading">Erreur de chargement</div>';
|
||
}
|
||
}
|
||
|
||
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 = `<span class="diag-label">${label}</span><span class="diag-value">${value}</span>`;
|
||
div.appendChild(row);
|
||
});
|
||
container.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)
|
||
// ---------------------------------------------------------------------------
|
||
function initSearch() {
|
||
const input = document.getElementById("search-input");
|
||
const caseBtn = document.getElementById("search-case-btn");
|
||
const clearBtn = document.getElementById("search-clear-btn");
|
||
|
||
// Initialize sub-controllers
|
||
AutocompleteDropdown.init();
|
||
SearchChips.init();
|
||
|
||
// Initially hide clear button
|
||
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("");
|
||
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") {
|
||
if (AutocompleteDropdown.selectActive()) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
// No active item — execute search normally
|
||
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 === "Escape") {
|
||
AutocompleteDropdown.hide();
|
||
e.stopPropagation();
|
||
}
|
||
} else if (e.key === "Enter") {
|
||
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();
|
||
}
|
||
});
|
||
|
||
caseBtn.addEventListener("click", () => {
|
||
searchCaseSensitive = !searchCaseSensitive;
|
||
caseBtn.classList.toggle("active");
|
||
});
|
||
|
||
clearBtn.addEventListener("click", () => {
|
||
input.value = "";
|
||
clearBtn.style.display = "none";
|
||
searchCaseSensitive = false;
|
||
caseBtn.classList.remove("active");
|
||
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)}`;
|
||
|
||
// 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) => {
|
||
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 (query && query.trim() && r.snippet) {
|
||
highlightSearchText(snippetDiv, r.snippet, query, searchCaseSensitive);
|
||
} else {
|
||
snippetDiv.textContent = r.snippet || "";
|
||
}
|
||
const item = el("div", { class: "search-result-item" }, [
|
||
titleDiv,
|
||
el("div", { class: "search-result-vault" }, [document.createTextNode(r.vault + " / " + r.path)]),
|
||
snippetDiv,
|
||
]);
|
||
if (r.tags && r.tags.length > 0) {
|
||
const tagsDiv = el("div", { class: "search-result-tags" });
|
||
r.tags.forEach((tag) => {
|
||
if (!TagFilterService.isTagFiltered(tag)) {
|
||
const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
|
||
tagEl.addEventListener("click", (e) => { e.stopPropagation(); addTagFilter(tag); });
|
||
tagsDiv.appendChild(tagEl);
|
||
}
|
||
});
|
||
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
|
||
}
|
||
item.addEventListener("click", () => openFile(r.vault, r.path));
|
||
container.appendChild(item);
|
||
});
|
||
area.appendChild(container);
|
||
}
|
||
|
||
// --- Advanced search results renderer (facets, highlighted snippets, pagination, sort) ---
|
||
function renderAdvancedSearchResults(data, query, tagFilter) {
|
||
const area = document.getElementById("content-area");
|
||
area.innerHTML = "";
|
||
|
||
// 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);
|
||
|
||
// 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);
|
||
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} <span class="facet-count">${count}</span>`;
|
||
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} <span class="facet-count">${count}</span>`;
|
||
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) => {
|
||
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 <mark> tags)
|
||
const snippetDiv = el("div", { class: "search-result-snippet search-result__snippet" });
|
||
if (r.snippet && r.snippet.includes("<mark>")) {
|
||
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" }, [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", () => openFile(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();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Resizable sidebar (horizontal)
|
||
// ---------------------------------------------------------------------------
|
||
function initSidebarResize() {
|
||
const handle = document.getElementById("sidebar-resize-handle");
|
||
const sidebar = document.getElementById("sidebar");
|
||
if (!handle || !sidebar) return;
|
||
|
||
// Restore saved width
|
||
const savedWidth = localStorage.getItem("obsigate-sidebar-width");
|
||
if (savedWidth) {
|
||
sidebar.style.width = savedWidth + "px";
|
||
}
|
||
|
||
let startX = 0;
|
||
let startWidth = 0;
|
||
|
||
function onMouseMove(e) {
|
||
const newWidth = Math.min(500, Math.max(200, startWidth + (e.clientX - startX)));
|
||
sidebar.style.width = newWidth + "px";
|
||
}
|
||
|
||
function onMouseUp() {
|
||
document.body.classList.remove("resizing");
|
||
handle.classList.remove("active");
|
||
document.removeEventListener("mousemove", onMouseMove);
|
||
document.removeEventListener("mouseup", onMouseUp);
|
||
localStorage.setItem("obsigate-sidebar-width", parseInt(sidebar.style.width));
|
||
}
|
||
|
||
handle.addEventListener("mousedown", (e) => {
|
||
e.preventDefault();
|
||
startX = e.clientX;
|
||
startWidth = sidebar.getBoundingClientRect().width;
|
||
document.body.classList.add("resizing");
|
||
handle.classList.add("active");
|
||
document.addEventListener("mousemove", onMouseMove);
|
||
document.addEventListener("mouseup", onMouseUp);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Resizable tag section (vertical)
|
||
// ---------------------------------------------------------------------------
|
||
function initTagResize() {
|
||
const handle = document.getElementById("tag-resize-handle");
|
||
const tagSection = document.getElementById("tag-cloud-section");
|
||
if (!handle || !tagSection) return;
|
||
|
||
// Restore saved height
|
||
const savedHeight = localStorage.getItem("obsigate-tag-height");
|
||
if (savedHeight) {
|
||
tagSection.style.height = savedHeight + "px";
|
||
}
|
||
|
||
let startY = 0;
|
||
let startHeight = 0;
|
||
|
||
function onMouseMove(e) {
|
||
// Dragging up increases height, dragging down decreases
|
||
const newHeight = Math.min(400, Math.max(60, startHeight - (e.clientY - startY)));
|
||
tagSection.style.height = newHeight + "px";
|
||
}
|
||
|
||
function onMouseUp() {
|
||
document.body.classList.remove("resizing-v");
|
||
handle.classList.remove("active");
|
||
document.removeEventListener("mousemove", onMouseMove);
|
||
document.removeEventListener("mouseup", onMouseUp);
|
||
localStorage.setItem("obsigate-tag-height", parseInt(tagSection.style.height));
|
||
}
|
||
|
||
handle.addEventListener("mousedown", (e) => {
|
||
e.preventDefault();
|
||
startY = e.clientY;
|
||
startHeight = tagSection.getBoundingClientRect().height;
|
||
document.body.classList.add("resizing-v");
|
||
handle.classList.add("active");
|
||
document.addEventListener("mousemove", onMouseMove);
|
||
document.addEventListener("mouseup", onMouseUp);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
function el(tag, attrs, children) {
|
||
const e = document.createElement(tag);
|
||
if (attrs) {
|
||
Object.entries(attrs).forEach(([k, v]) => e.setAttribute(k, v));
|
||
}
|
||
if (children) {
|
||
children.forEach((c) => { if (c) e.appendChild(c); });
|
||
}
|
||
return e;
|
||
}
|
||
|
||
function icon(name, size) {
|
||
const i = document.createElement("i");
|
||
i.setAttribute("data-lucide", name);
|
||
i.style.width = size + "px";
|
||
i.style.height = size + "px";
|
||
i.classList.add("icon");
|
||
return i;
|
||
}
|
||
|
||
function smallBadge(count) {
|
||
const s = document.createElement("span");
|
||
s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px";
|
||
s.textContent = `(${count})`;
|
||
return s;
|
||
}
|
||
|
||
function makeBreadcrumbSpan(text, onClick) {
|
||
const s = document.createElement("span");
|
||
s.textContent = text;
|
||
if (onClick) s.addEventListener("click", onClick);
|
||
return s;
|
||
}
|
||
|
||
function appendHighlightedText(container, text, query, caseSensitive) {
|
||
container.textContent = "";
|
||
if (!query) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
const source = caseSensitive ? text : text.toLowerCase();
|
||
const needle = caseSensitive ? query : query.toLowerCase();
|
||
let start = 0;
|
||
let index = source.indexOf(needle, start);
|
||
|
||
if (index === -1) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
while (index !== -1) {
|
||
if (index > start) {
|
||
container.appendChild(document.createTextNode(text.slice(start, index)));
|
||
}
|
||
const mark = el("mark", { class: "filter-highlight" }, [
|
||
document.createTextNode(text.slice(index, index + query.length)),
|
||
]);
|
||
container.appendChild(mark);
|
||
start = index + query.length;
|
||
index = source.indexOf(needle, start);
|
||
}
|
||
|
||
if (start < text.length) {
|
||
container.appendChild(document.createTextNode(text.slice(start)));
|
||
}
|
||
}
|
||
|
||
function highlightSearchText(container, text, query, caseSensitive) {
|
||
container.textContent = "";
|
||
if (!query || !text) {
|
||
container.appendChild(document.createTextNode(text || ""));
|
||
return;
|
||
}
|
||
|
||
const source = caseSensitive ? text : text.toLowerCase();
|
||
const needle = caseSensitive ? query : query.toLowerCase();
|
||
let start = 0;
|
||
let index = source.indexOf(needle, start);
|
||
|
||
if (index === -1) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
while (index !== -1) {
|
||
if (index > start) {
|
||
container.appendChild(document.createTextNode(text.slice(start, index)));
|
||
}
|
||
const mark = el("mark", { class: "search-highlight" }, [
|
||
document.createTextNode(text.slice(index, index + query.length)),
|
||
]);
|
||
container.appendChild(mark);
|
||
start = index + query.length;
|
||
index = source.indexOf(needle, start);
|
||
}
|
||
|
||
if (start < text.length) {
|
||
container.appendChild(document.createTextNode(text.slice(start)));
|
||
}
|
||
}
|
||
|
||
function showWelcome() {
|
||
hideProgressBar();
|
||
const area = document.getElementById("content-area");
|
||
area.innerHTML = `
|
||
<div class="welcome">
|
||
<i data-lucide="library" style="width:48px;height:48px;color:var(--text-muted)"></i>
|
||
<h2>ObsiGate</h2>
|
||
<p>Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.</p>
|
||
</div>`;
|
||
safeCreateIcons();
|
||
}
|
||
|
||
function showLoading() {
|
||
const area = document.getElementById("content-area");
|
||
area.innerHTML = `
|
||
<div class="loading-indicator">
|
||
<div class="loading-spinner"></div>
|
||
<div>Recherche en cours...</div>
|
||
</div>`;
|
||
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 = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
|
||
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 = '<i data-lucide="check" style="width:16px;height:16px"></i>';
|
||
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 = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
|
||
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.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(", ");
|
||
showToast(`${n} fichier(s) mis à jour (${vaults})`, "info");
|
||
|
||
// Refresh sidebar and tags if affected vault matches current context
|
||
const affectsCurrentVault = selectedContextVault === "all" ||
|
||
(data.vaults || []).includes(selectedContextVault);
|
||
if (affectsCurrentVault) {
|
||
try {
|
||
await Promise.all([loadVaults(), loadTags()]);
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
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 = `<div class="sync-panel__header">
|
||
<span class="sync-panel__title">Synchronisation</span>
|
||
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
|
||
</div>`;
|
||
|
||
if (events.length === 0) {
|
||
html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
|
||
} else {
|
||
html += `<div class="sync-panel__events">`;
|
||
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é",
|
||
};
|
||
const label = typeLabels[ev.type] || ev.type;
|
||
const detail = ev.data.vaults ? ev.data.vaults.join(", ") : (ev.data.vault || "");
|
||
html += `<div class="sync-panel__event">
|
||
<span class="sync-panel__event-type">${label}</span>
|
||
<span class="sync-panel__event-detail">${detail}</span>
|
||
<span class="sync-panel__event-time">${time}</span>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
}
|
||
|
||
panel.innerHTML = html;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Init
|
||
// ---------------------------------------------------------------------------
|
||
async function init() {
|
||
initTheme();
|
||
initHeaderMenu();
|
||
initCustomDropdowns();
|
||
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
|
||
document.getElementById("header-logo").addEventListener("click", goHome);
|
||
initSearch();
|
||
initSidebarToggle();
|
||
initMobile();
|
||
initVaultContext();
|
||
initSidebarTabs();
|
||
initHelpModal();
|
||
initConfigModal();
|
||
initSidebarFilter();
|
||
initSidebarResize();
|
||
initEditor();
|
||
initSyncStatus();
|
||
initLoginForm();
|
||
|
||
// Check auth status first
|
||
const authOk = await AuthManager.initAuth();
|
||
|
||
if (authOk) {
|
||
try {
|
||
await Promise.all([loadVaults(), loadTags()]);
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to initialize ObsiGate:", err);
|
||
showToast("Erreur lors de l'initialisation");
|
||
}
|
||
}
|
||
|
||
safeCreateIcons();
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
})();
|