6064 lines
210 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ObsiGate — Vanilla JS SPA */
(function () {
"use strict";
const APP_VERSION = "1.5.0";
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let currentVault = null;
let currentPath = null;
let searchTimeout = null;
let searchAbortController = null;
let showingSource = false;
let cachedRawSource = null;
let allVaults = [];
let selectedContextVault = "all";
let selectedTags = [];
let editorView = null;
let editorVault = null;
let editorPath = null;
let fallbackEditorEl = null;
let sidebarFilterCaseSensitive = false;
let searchCaseSensitive = false;
let _iconDebounceTimer = null;
let activeSidebarTab = "vaults";
let filterDebounce = null;
// Vault settings cache for hideHiddenFiles
let vaultSettings = {};
// Advanced search state
let advancedSearchOffset = 0;
let advancedSearchTotal = 0;
let advancedSearchSort = "relevance";
let advancedSearchLastQuery = "";
let suggestAbortController = null;
let dropdownActiveIndex = -1;
let dropdownItems = [];
let currentSearchId = 0;
// Advanced search constants
const SEARCH_HISTORY_KEY = "obsigate_search_history";
const MAX_HISTORY_ENTRIES = 50;
const SUGGEST_DEBOUNCE_MS = 150;
const ADVANCED_SEARCH_LIMIT = 50;
const MIN_SEARCH_LENGTH = 2;
const SEARCH_TIMEOUT_MS = 30000;
// Outline/TOC state
let outlineObserver = null;
let activeHeadingId = null;
let headingsCache = [];
let rightSidebarVisible = true;
let rightSidebarWidth = 280;
// ---------------------------------------------------------------------------
// File extension → Lucide icon mapping
// ---------------------------------------------------------------------------
const EXT_ICONS = {
".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 */
}
}
}
// ---------------------------------------------------------------------------
// Outline/TOC Manager
// ---------------------------------------------------------------------------
const OutlineManager = {
/**
* Slugify text to create valid IDs
*/
slugify(text) {
return (
text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim() || "heading"
);
},
/**
* Parse headings from markdown content
*/
parseHeadings() {
const contentArea = document.querySelector(".md-content");
if (!contentArea) return [];
const headings = [];
const h2s = contentArea.querySelectorAll("h2");
const h3s = contentArea.querySelectorAll("h3");
const allHeadings = [...h2s, ...h3s].sort((a, b) => {
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
});
const usedIds = new Map();
allHeadings.forEach((heading) => {
const text = heading.textContent.trim();
if (!text) return;
const level = parseInt(heading.tagName[1]);
let id = this.slugify(text);
// Handle duplicate IDs
if (usedIds.has(id)) {
const count = usedIds.get(id) + 1;
usedIds.set(id, count);
id = `${id}-${count}`;
} else {
usedIds.set(id, 1);
}
// Inject ID into heading if not present
if (!heading.id) {
heading.id = id;
} else {
id = heading.id;
}
headings.push({
id,
level,
text,
element: heading,
});
});
return headings;
},
/**
* Render outline list
*/
renderOutline(headings) {
const outlineList = document.getElementById("outline-list");
const outlineEmpty = document.getElementById("outline-empty");
if (!outlineList) return;
outlineList.innerHTML = "";
if (!headings || headings.length === 0) {
outlineList.hidden = true;
if (outlineEmpty) {
outlineEmpty.hidden = false;
safeCreateIcons();
}
return;
}
outlineList.hidden = false;
if (outlineEmpty) outlineEmpty.hidden = true;
headings.forEach((heading) => {
const item = el(
"a",
{
class: `outline-item level-${heading.level}`,
href: `#${heading.id}`,
"data-heading-id": heading.id,
role: "link",
},
[document.createTextNode(heading.text)],
);
item.addEventListener("click", (e) => {
e.preventDefault();
this.scrollToHeading(heading.id);
});
outlineList.appendChild(item);
});
headingsCache = headings;
},
/**
* Scroll to heading with smooth behavior
*/
scrollToHeading(headingId) {
const heading = document.getElementById(headingId);
if (!heading) return;
const contentArea = document.getElementById("content-area");
if (!contentArea) return;
// Calculate offset for fixed header (if any)
const headerHeight = 80;
const headingTop = heading.offsetTop;
contentArea.scrollTo({
top: headingTop - headerHeight,
behavior: "smooth",
});
// Update active state immediately
this.setActiveHeading(headingId);
},
/**
* Set active heading in outline
*/
setActiveHeading(headingId) {
if (activeHeadingId === headingId) return;
activeHeadingId = headingId;
const items = document.querySelectorAll(".outline-item");
items.forEach((item) => {
if (item.getAttribute("data-heading-id") === headingId) {
item.classList.add("active");
item.setAttribute("aria-current", "location");
// Scroll outline item into view
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
} else {
item.classList.remove("active");
item.removeAttribute("aria-current");
}
});
},
/**
* Initialize outline for current document
*/
init() {
const headings = this.parseHeadings();
this.renderOutline(headings);
ScrollSpyManager.init(headings);
ReadingProgressManager.init();
},
/**
* Cleanup
*/
destroy() {
ScrollSpyManager.destroy();
ReadingProgressManager.destroy();
headingsCache = [];
activeHeadingId = null;
},
};
// ---------------------------------------------------------------------------
// Scroll Spy Manager
// ---------------------------------------------------------------------------
const ScrollSpyManager = {
observer: null,
headings: [],
init(headings) {
this.destroy();
this.headings = headings;
if (!headings || headings.length === 0) return;
const contentArea = document.getElementById("content-area");
if (!contentArea) return;
const options = {
root: contentArea,
rootMargin: "-20% 0px -70% 0px",
threshold: [0, 0.3, 0.5, 1.0],
};
this.observer = new IntersectionObserver((entries) => {
// Find the most visible heading
let mostVisible = null;
let maxRatio = 0;
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
maxRatio = entry.intersectionRatio;
mostVisible = entry.target;
}
});
if (mostVisible && mostVisible.id) {
OutlineManager.setActiveHeading(mostVisible.id);
}
}, options);
// Observe all headings
headings.forEach((heading) => {
if (heading.element) {
this.observer.observe(heading.element);
}
});
},
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
this.headings = [];
},
};
// ---------------------------------------------------------------------------
// Reading Progress Manager
// ---------------------------------------------------------------------------
const ReadingProgressManager = {
scrollHandler: null,
init() {
this.destroy();
const contentArea = document.getElementById("content-area");
if (!contentArea) return;
this.scrollHandler = this.throttle(() => {
this.updateProgress();
}, 100);
contentArea.addEventListener("scroll", this.scrollHandler);
this.updateProgress();
},
updateProgress() {
const contentArea = document.getElementById("content-area");
const progressFill = document.getElementById("reading-progress-fill");
const progressText = document.getElementById("reading-progress-text");
if (!contentArea || !progressFill || !progressText) return;
const scrollTop = contentArea.scrollTop;
const scrollHeight = contentArea.scrollHeight;
const clientHeight = contentArea.clientHeight;
const maxScroll = scrollHeight - clientHeight;
const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0;
progressFill.style.width = `${percentage}%`;
progressText.textContent = `${percentage}%`;
},
throttle(func, delay) {
let lastCall = 0;
return function (...args) {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
func.apply(this, args);
}
};
},
destroy() {
const contentArea = document.getElementById("content-area");
if (contentArea && this.scrollHandler) {
contentArea.removeEventListener("scroll", this.scrollHandler);
}
this.scrollHandler = null;
// Reset progress
const progressFill = document.getElementById("reading-progress-fill");
const progressText = document.getElementById("reading-progress-text");
if (progressFill) progressFill.style.width = "0%";
if (progressText) progressText.textContent = "0%";
},
};
// ---------------------------------------------------------------------------
// Right Sidebar Manager
// ---------------------------------------------------------------------------
const RightSidebarManager = {
init() {
this.loadState();
this.initToggle();
this.initResize();
},
loadState() {
const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible");
const savedWidth = localStorage.getItem("obsigate-right-sidebar-width");
if (savedVisible !== null) {
rightSidebarVisible = savedVisible === "true";
}
if (savedWidth) {
rightSidebarWidth = parseInt(savedWidth) || 280;
}
this.applyState();
},
applyState() {
const sidebar = document.getElementById("right-sidebar");
const handle = document.getElementById("right-sidebar-resize-handle");
const tocBtn = document.getElementById("toc-toggle-btn");
const headerToggleBtn = document.getElementById("right-sidebar-toggle-btn");
if (!sidebar) return;
if (rightSidebarVisible) {
sidebar.classList.remove("hidden");
sidebar.style.width = `${rightSidebarWidth}px`;
if (handle) handle.classList.remove("hidden");
if (tocBtn) {
tocBtn.classList.add("active");
tocBtn.title = "Masquer le sommaire";
}
if (headerToggleBtn) {
headerToggleBtn.title = "Masquer le panneau";
headerToggleBtn.setAttribute("aria-label", "Masquer le panneau");
}
} else {
sidebar.classList.add("hidden");
if (handle) handle.classList.add("hidden");
if (tocBtn) {
tocBtn.classList.remove("active");
tocBtn.title = "Afficher le sommaire";
}
if (headerToggleBtn) {
headerToggleBtn.title = "Afficher le panneau";
headerToggleBtn.setAttribute("aria-label", "Afficher le panneau");
}
}
// Update icons
safeCreateIcons();
},
toggle() {
rightSidebarVisible = !rightSidebarVisible;
localStorage.setItem("obsigate-right-sidebar-visible", rightSidebarVisible);
this.applyState();
},
initToggle() {
const toggleBtn = document.getElementById("right-sidebar-toggle-btn");
if (toggleBtn) {
toggleBtn.addEventListener("click", () => this.toggle());
}
},
initResize() {
const handle = document.getElementById("right-sidebar-resize-handle");
const sidebar = document.getElementById("right-sidebar");
if (!handle || !sidebar) return;
let isResizing = false;
let startX = 0;
let startWidth = 0;
const onMouseDown = (e) => {
isResizing = true;
startX = e.clientX;
startWidth = sidebar.offsetWidth;
handle.classList.add("active");
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
};
const onMouseMove = (e) => {
if (!isResizing) return;
const delta = startX - e.clientX;
let newWidth = startWidth + delta;
// Constrain width
newWidth = Math.max(200, Math.min(400, newWidth));
sidebar.style.width = `${newWidth}px`;
rightSidebarWidth = newWidth;
};
const onMouseUp = () => {
if (!isResizing) return;
isResizing = false;
handle.classList.remove("active");
document.body.style.cursor = "";
document.body.style.userSelect = "";
localStorage.setItem("obsigate-right-sidebar-width", rightSidebarWidth);
};
handle.addEventListener("mousedown", onMouseDown);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
},
};
// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------
function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("obsigate-theme", theme);
// Update theme button icon and label
const themeBtn = document.getElementById("theme-toggle");
const themeLabel = document.getElementById("theme-label");
if (themeBtn && themeLabel) {
const icon = themeBtn.querySelector("i");
if (icon) {
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
}
themeLabel.textContent = theme === "dark" ? "Sombre" : "Clair";
safeCreateIcons();
}
// Swap highlight.js theme
const darkSheet = document.getElementById("hljs-theme-dark");
const lightSheet = document.getElementById("hljs-theme-light");
if (darkSheet && lightSheet) {
darkSheet.disabled = theme !== "dark";
lightSheet.disabled = theme !== "light";
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme");
applyTheme(current === "dark" ? "light" : "dark");
}
function initHeaderMenu() {
const menuBtn = document.getElementById("header-menu-btn");
const menuDropdown = document.getElementById("header-menu-dropdown");
if (!menuBtn || !menuDropdown) return;
menuBtn.addEventListener("click", (e) => {
e.stopPropagation();
menuBtn.classList.toggle("active");
menuDropdown.classList.toggle("active");
});
// Close menu when clicking outside
document.addEventListener("click", (e) => {
if (!menuDropdown.contains(e.target) && e.target !== menuBtn) {
menuBtn.classList.remove("active");
menuDropdown.classList.remove("active");
}
});
// Prevent menu from closing when clicking inside
menuDropdown.addEventListener("click", (e) => {
e.stopPropagation();
});
}
function closeHeaderMenu() {
const menuBtn = document.getElementById("header-menu-btn");
const menuDropdown = document.getElementById("header-menu-dropdown");
if (!menuBtn || !menuDropdown) return;
menuBtn.classList.remove("active");
menuDropdown.classList.remove("active");
}
// ---------------------------------------------------------------------------
// Custom Dropdowns
// ---------------------------------------------------------------------------
function initCustomDropdowns() {
document.querySelectorAll(".custom-dropdown").forEach((dropdown) => {
const trigger = dropdown.querySelector(".custom-dropdown-trigger");
const options = dropdown.querySelectorAll(".custom-dropdown-option");
const hiddenInput = dropdown.querySelector('input[type="hidden"]');
const selectedText = dropdown.querySelector(".custom-dropdown-selected");
const menu = dropdown.querySelector(".custom-dropdown-menu");
if (!trigger) return;
// Toggle dropdown
trigger.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.contains("open");
// Close all other dropdowns
document.querySelectorAll(".custom-dropdown.open").forEach((d) => {
if (d !== dropdown) d.classList.remove("open");
});
dropdown.classList.toggle("open", !isOpen);
trigger.setAttribute("aria-expanded", !isOpen);
// Position fixed menu for sidebar dropdowns
if (!isOpen && dropdown.classList.contains("sidebar-dropdown") && menu) {
const rect = trigger.getBoundingClientRect();
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${rect.left}px`;
menu.style.width = `${rect.width}px`;
}
});
// Handle option selection
options.forEach((option) => {
option.addEventListener("click", (e) => {
e.stopPropagation();
const value = option.getAttribute("data-value");
const text = option.textContent;
// Update hidden input
if (hiddenInput) {
hiddenInput.value = value;
// Trigger change event
hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
}
// Update selected text
if (selectedText) {
selectedText.textContent = text;
}
// Update visual selection
options.forEach((opt) => opt.classList.remove("selected"));
option.classList.add("selected");
// Close dropdown
dropdown.classList.remove("open");
trigger.setAttribute("aria-expanded", "false");
});
});
});
// Close dropdowns when clicking outside
document.addEventListener("click", () => {
document.querySelectorAll(".custom-dropdown.open").forEach((dropdown) => {
dropdown.classList.remove("open");
const trigger = dropdown.querySelector(".custom-dropdown-trigger");
if (trigger) trigger.setAttribute("aria-expanded", "false");
});
});
}
// Helper to populate custom dropdown options
function populateCustomDropdown(dropdownId, optionsList, defaultValue) {
const dropdown = document.getElementById(dropdownId);
if (!dropdown) return;
const optionsContainer = dropdown.querySelector(".custom-dropdown-menu");
const hiddenInput = dropdown.querySelector('input[type="hidden"]');
const selectedText = dropdown.querySelector(".custom-dropdown-selected");
if (!optionsContainer) return;
// Clear existing options (keep the first one if it's the default)
optionsContainer.innerHTML = "";
// Add new options
optionsList.forEach((opt) => {
const li = document.createElement("li");
li.className = "custom-dropdown-option";
li.setAttribute("role", "option");
li.setAttribute("data-value", opt.value);
li.textContent = opt.text;
if (opt.value === defaultValue) {
li.classList.add("selected");
if (selectedText) selectedText.textContent = opt.text;
if (hiddenInput) hiddenInput.value = opt.value;
}
optionsContainer.appendChild(li);
});
// Re-initialize click handlers
optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((option) => {
option.addEventListener("click", (e) => {
e.stopPropagation();
const value = option.getAttribute("data-value");
const text = option.textContent;
if (hiddenInput) {
hiddenInput.value = value;
hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
}
if (selectedText) {
selectedText.textContent = text;
}
optionsContainer.querySelectorAll(".custom-dropdown-option").forEach((opt) => opt.classList.remove("selected"));
option.classList.add("selected");
dropdown.classList.remove("open");
const trigger = dropdown.querySelector(".custom-dropdown-trigger");
if (trigger) trigger.setAttribute("aria-expanded", "false");
});
});
}
// ---------------------------------------------------------------------------
// Toast notifications
// ---------------------------------------------------------------------------
/** Display a brief toast message at the bottom of the viewport. */
function showToast(message, type) {
console.log("showToast called with:", message, type);
type = type || "info";
let container = document.getElementById("toast-container");
if (!container) {
container = document.createElement("div");
container.id = "toast-container";
container.className = "toast-container";
container.setAttribute("aria-live", "polite");
document.body.appendChild(container);
}
var toast = document.createElement("div");
toast.className = "toast toast-" + type;
toast.textContent = message;
container.appendChild(toast);
// Trigger entrance animation
requestAnimationFrame(function () {
toast.classList.add("show");
});
setTimeout(function () {
toast.classList.remove("show");
toast.addEventListener("transitionend", function () {
toast.remove();
});
}, 3500);
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
/**
* Fetch JSON from an API endpoint with optional AbortSignal support.
* Surfaces errors to the user via toast instead of silently failing.
*
* @param {string} path - API URL path.
* @param {object} [opts] - Fetch options (may include signal).
* @returns {Promise<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", "error");
throw err;
}
if (res.status === 401 && AuthManager._authEnabled) {
// Token expired — try refresh
try {
await AuthManager.refreshAccessToken();
// Retry the request with new token
const retryHeaders = AuthManager.getAuthHeaders();
const retryOpts = opts || {};
retryOpts.headers = { ...retryOpts.headers, ...retryHeaders };
retryOpts.credentials = "include";
res = await fetch(path, retryOpts);
} catch (refreshErr) {
AuthManager.clearSession();
AuthManager.showLoginScreen();
throw new Error("Session expirée");
}
}
if (!res.ok) {
var detail = "";
try {
var body = await res.json();
detail = body.detail || "";
} catch (_) {
/* no json body */
}
showToast(detail || "Erreur API : " + res.status, "error");
throw new Error(detail || "API error: " + res.status);
}
return res.json();
}
// ---------------------------------------------------------------------------
// AuthManager — Authentication state & token management
// ---------------------------------------------------------------------------
const AuthManager = {
ACCESS_TOKEN_KEY: "obsigate_access_token",
TOKEN_EXPIRY_KEY: "obsigate_token_expiry",
USER_KEY: "obsigate_user",
_authEnabled: false,
// ── Token storage (sessionStorage) ─────────────────────────────
saveToken(tokenData) {
const expiresAt = Date.now() + tokenData.expires_in * 1000;
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, tokenData.access_token);
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString());
if (tokenData.user) {
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
}
},
getToken() {
return sessionStorage.getItem(this.ACCESS_TOKEN_KEY);
},
getUser() {
const raw = sessionStorage.getItem(this.USER_KEY);
return raw ? JSON.parse(raw) : null;
},
isTokenExpired() {
const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY);
if (!expiry) return true;
// Renew 60s before expiration
return Date.now() > parseInt(expiry) - 60000;
},
clearSession() {
sessionStorage.removeItem(this.ACCESS_TOKEN_KEY);
sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY);
sessionStorage.removeItem(this.USER_KEY);
},
getAuthHeaders() {
const token = this.getToken();
if (!token || !this._authEnabled) return null;
return { Authorization: "Bearer " + token };
},
// ── API calls ──────────────────────────────────────────────────
async login(username, password, rememberMe) {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password, remember_me: rememberMe || false }),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "Erreur de connexion");
}
const data = await response.json();
this.saveToken(data);
return data.user;
},
async logout() {
try {
const token = this.getToken();
await fetch("/api/auth/logout", {
method: "POST",
headers: token ? { Authorization: "Bearer " + token } : {},
credentials: "include",
});
} catch (e) {
/* continue even if API fails */
}
this.clearSession();
this.showLoginScreen();
},
async refreshAccessToken() {
const response = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!response.ok) {
this.clearSession();
throw new Error("Session expirée");
}
const data = await response.json();
const expiry = Date.now() + data.expires_in * 1000;
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, data.access_token);
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiry.toString());
return data.access_token;
},
// ── UI controls ────────────────────────────────────────────────
showLoginScreen() {
const app = document.getElementById("app");
const login = document.getElementById("login-screen");
if (app) app.classList.add("hidden");
if (login) {
login.classList.remove("hidden");
const usernameInput = document.getElementById("login-username");
if (usernameInput) usernameInput.focus();
}
},
showApp() {
const login = document.getElementById("login-screen");
const app = document.getElementById("app");
if (login) login.classList.add("hidden");
if (app) app.classList.remove("hidden");
this.renderUserMenu();
},
renderUserMenu() {
const user = this.getUser();
const userMenu = document.getElementById("user-menu");
if (!userMenu) return;
if (!user || !this._authEnabled) {
userMenu.innerHTML = "";
return;
}
userMenu.innerHTML = '<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, "error");
}
});
},
async _deleteUser(username) {
const currentUser = AuthManager.getUser();
if (currentUser && currentUser.username === username) {
showToast("Impossible de supprimer son propre compte", "error");
return;
}
if (!confirm("Supprimer l'utilisateur \"" + username + '" ?')) return;
try {
await api("/api/auth/admin/users/" + username, { method: "DELETE" });
this._loadUsers();
showToast("Utilisateur supprimé", "success");
} catch (err) {
showToast(err.message, "error");
}
},
};
// ---------------------------------------------------------------------------
// Sidebar toggle (desktop)
// ---------------------------------------------------------------------------
function initSidebarToggle() {
const toggleBtn = document.getElementById("sidebar-toggle-btn");
const sidebar = document.getElementById("sidebar");
const resizeHandle = document.getElementById("sidebar-resize-handle");
if (!toggleBtn || !sidebar || !resizeHandle) return;
// Restore saved state
const savedState = localStorage.getItem("obsigate-sidebar-hidden");
if (savedState === "true") {
sidebar.classList.add("hidden");
resizeHandle.classList.add("hidden");
toggleBtn.classList.add("active");
}
toggleBtn.addEventListener("click", () => {
const isHidden = sidebar.classList.toggle("hidden");
resizeHandle.classList.toggle("hidden", isHidden);
toggleBtn.classList.toggle("active", isHidden);
localStorage.setItem("obsigate-sidebar-hidden", isHidden ? "true" : "false");
});
}
// ---------------------------------------------------------------------------
// Mobile sidebar
// ---------------------------------------------------------------------------
function initMobile() {
const hamburger = document.getElementById("hamburger-btn");
const overlay = document.getElementById("sidebar-overlay");
const sidebar = document.getElementById("sidebar");
hamburger.addEventListener("click", () => {
sidebar.classList.toggle("mobile-open");
overlay.classList.toggle("active");
});
overlay.addEventListener("click", () => {
sidebar.classList.remove("mobile-open");
overlay.classList.remove("active");
});
}
function closeMobileSidebar() {
const sidebar = document.getElementById("sidebar");
const overlay = document.getElementById("sidebar-overlay");
if (sidebar) sidebar.classList.remove("mobile-open");
if (overlay) overlay.classList.remove("active");
}
// ---------------------------------------------------------------------------
// Vault context switching
// ---------------------------------------------------------------------------
function initVaultContext() {
const filter = document.getElementById("vault-filter");
const quickSelect = document.getElementById("vault-quick-select");
if (!filter || !quickSelect) return;
filter.addEventListener("change", async () => {
await setSelectedVaultContext(filter.value, { focusVault: filter.value !== "all" });
});
quickSelect.addEventListener("change", async () => {
await setSelectedVaultContext(quickSelect.value, { focusVault: quickSelect.value !== "all" });
});
}
async function setSelectedVaultContext(vaultName, options) {
selectedContextVault = vaultName;
showingSource = false;
cachedRawSource = null;
syncVaultSelectors();
await refreshSidebarForContext();
await refreshTagsForContext();
// Synchroniser le dashboard et les fichiers récents
if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.load) {
DashboardRecentWidget.load(vaultName);
}
if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) {
DashboardBookmarkWidget.load(vaultName);
}
if (activeSidebarTab === "recent") {
loadRecentFiles(vaultName === "all" ? null : vaultName);
}
showWelcome();
if (options && options.focusVault && vaultName !== "all") {
await focusVaultInSidebar(vaultName);
}
}
function syncVaultSelectors() {
const filter = document.getElementById("vault-filter");
const quickSelect = document.getElementById("vault-quick-select");
const recentFilter = document.getElementById("recent-vault-filter");
const dashboardFilter = document.getElementById("dashboard-vault-filter");
const contextText = document.getElementById("vault-context-text");
if (filter) filter.value = selectedContextVault;
if (quickSelect) quickSelect.value = selectedContextVault;
if (recentFilter) recentFilter.value = selectedContextVault === "all" ? "" : selectedContextVault;
if (dashboardFilter) dashboardFilter.value = selectedContextVault;
// Mise à jour visuelle des dropdowns personnalisés
updateCustomDropdownVisual("vault-filter-dropdown", selectedContextVault);
updateCustomDropdownVisual("vault-quick-select-dropdown", selectedContextVault);
// Update vault context indicator
if (contextText) {
contextText.textContent = selectedContextVault === "all" ? "All" : selectedContextVault;
}
}
/**
* Updates the visual state of a custom dropdown based on its current value.
*/
function updateCustomDropdownVisual(dropdownId, value) {
const dropdown = document.getElementById(dropdownId);
if (!dropdown) return;
const selectedText = dropdown.querySelector(".custom-dropdown-selected");
const options = dropdown.querySelectorAll(".custom-dropdown-option");
options.forEach((opt) => {
const optValue = opt.getAttribute("data-value");
if (optValue === value) {
opt.classList.add("selected");
if (selectedText) selectedText.textContent = opt.textContent;
} else {
opt.classList.remove("selected");
}
});
}
function scrollTreeItemIntoView(element, alignToTop) {
if (!element) return;
const scrollContainer = document.getElementById("sidebar-panel-vaults");
if (!scrollContainer) return;
const containerRect = scrollContainer.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const isAbove = elementRect.top < containerRect.top;
const isBelow = elementRect.bottom > containerRect.bottom;
if (!isAbove && !isBelow && !alignToTop) return;
const currentTop = scrollContainer.scrollTop;
const offsetTop = element.offsetTop;
const 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), getVaultIcon(v.name, 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);
}
// ---------------------------------------------------------------------------
// Helper: Check if path should be displayed based on hideHiddenFiles setting
// ---------------------------------------------------------------------------
function shouldDisplayPath(path, vaultName) {
// Get hideHiddenFiles setting for this vault (default: false = show all)
const settings = vaultSettings[vaultName] || { hideHiddenFiles: false };
if (!settings.hideHiddenFiles) {
// Show all files
return true;
}
// Check if any segment of the path starts with a dot (hidden)
const segments = path.split("/").filter(Boolean);
for (const segment of segments) {
if (segment.startsWith(".")) {
return false; // Hide this path
}
}
return true; // Show this path
}
async function loadVaultSettings() {
try {
const settings = await api("/api/vaults/settings/all");
vaultSettings = settings;
} catch (err) {
console.error("Failed to load vault settings:", err);
vaultSettings = {};
}
}
// ---------------------------------------------------------------------------
// Sidebar — Vault tree
// ---------------------------------------------------------------------------
async function loadVaults() {
const vaults = await api("/api/vaults");
allVaults = vaults;
const container = document.getElementById("vault-tree");
container.innerHTML = "";
// Prepare dropdown options
const dropdownOptions = [{ value: "all", text: "Tous les vaults" }, ...vaults.map((v) => ({ value: v.name, text: v.name }))];
// Populate custom dropdowns
populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all");
populateCustomDropdown("vault-quick-select-dropdown", dropdownOptions, "all");
// Populate standard selects
_populateRecentVaultFilter();
if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.populateVaultFilter) {
DashboardRecentWidget.populateVaultFilter();
}
vaults.forEach((v) => {
// Sidebar tree entry
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), 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();
}
/**
* Refreshes the sidebar tree while preserving the expanded state of vaults and folders.
* Optimized to avoid a full sidebar wipe and minimize visible loading states.
*/
async function refreshSidebarTreePreservingState() {
// 1. Capture expanded states
const expandedVaults = Array.from(document.querySelectorAll(".vault-item"))
.filter((v) => {
const children = document.getElementById(`vault-children-${v.dataset.vault}`);
return children && !children.classList.contains("collapsed");
})
.map((v) => v.dataset.vault);
const expandedDirs = Array.from(document.querySelectorAll(".tree-item[data-path]"))
.filter((item) => {
const vault = item.dataset.vault;
const path = item.dataset.path;
const children = document.getElementById(`dir-${vault}-${path}`);
return children && !children.classList.contains("collapsed");
})
.map((item) => ({ vault: item.dataset.vault, path: item.dataset.path }));
const selectedItem = document.querySelector(".tree-item.path-selected");
const selectedState = selectedItem ? { vault: selectedItem.dataset.vault, path: selectedItem.dataset.path } : null;
// 2. Soft update: load vaults to update names/counts without wiping the tree
try {
const vaults = await api("/api/vaults");
allVaults = vaults;
vaults.forEach((v) => {
const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`);
if (vItem) {
const badge = vItem.querySelector(".badge-small");
if (badge) badge.textContent = `(${v.file_count})`;
}
});
} catch (e) {
console.warn("Soft vault refresh failed, falling back to full reload", e);
await loadVaults();
}
// 3. Refresh expanded vaults
// If we didn't wipe the tree, we only need to call loadDirectory to update the children
for (const vName of expandedVaults) {
const container = document.getElementById(`vault-children-${vName}`);
if (container) {
await loadDirectory(vName, "", container);
}
}
// 4. Re-expand directories (parents first)
expandedDirs.sort((a, b) => a.path.split("/").length - b.path.split("/").length);
for (const dir of expandedDirs) {
const dItem = document.querySelector(`.tree-item[data-vault="${CSS.escape(dir.vault)}"][data-path="${CSS.escape(dir.path)}"]`);
const container = document.getElementById(`dir-${dir.vault}-${dir.path}`);
if (dItem && container) {
// If it was already expanded but currently has its old content, loadDirectory will update it
try {
await loadDirectory(dir.vault, dir.path, container);
container.classList.remove("collapsed");
const chev = dItem.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-down");
} catch (e) {
console.error(`Failed to refresh directory ${dir.vault}/${dir.path}`, e);
}
}
}
// 5. Restore selection
if (selectedState) {
await focusPathInSidebar(selectedState.vault, selectedState.path, { alignToTop: false });
}
safeCreateIcons();
}
async function toggleVault(itemEl, vaultName, forceExpand) {
const childContainer = document.getElementById(`vault-children-${vaultName}`);
if (!childContainer) return;
scrollTreeItemIntoView(itemEl, false);
const shouldExpand = forceExpand || childContainer.classList.contains("collapsed");
if (shouldExpand) {
// Expand — load children if empty
if (childContainer.children.length === 0) {
await loadDirectory(vaultName, "", childContainer);
}
childContainer.classList.remove("collapsed");
// Swap chevron
const chevron = itemEl.querySelector("[data-lucide]");
if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
childContainer.classList.add("collapsed");
const chevron = itemEl.querySelector("[data-lucide]");
if (chevron) chevron.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
}
async function 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) {
// Only show the loading spinner if the container is currently empty
const isEmpty = container.children.length === 0;
if (isEmpty) {
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) => {
// Apply client-side filtering for hidden files
if (!shouldDisplayPath(item.path, vaultName)) {
return; // Skip this item
}
if (item.type === "directory") {
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), 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 }, [getVaultIcon(vaultName, 16), document.createTextNode(` ${vaultName} `), smallBadge(entries.length)]);
container.appendChild(vaultHeader);
const resultsWrapper = el("div", { class: "filter-results-group" });
entries.forEach((entry) => {
const resultItem = el(
"div",
{
class: `tree-item filter-result-item filter-result-${entry.type}`,
"data-vault": entry.vault,
"data-path": entry.path,
"data-type": entry.type,
},
[icon(entry.type === "directory" ? "folder" : getFileIcon(entry.name), 16)],
);
const textWrap = el("div", { class: "filter-result-text" });
const primary = el("div", { class: "filter-result-primary" });
appendHighlightedText(primary, entry.name, query, sidebarFilterCaseSensitive);
const secondary = el("div", { class: "filter-result-secondary" });
appendHighlightedText(secondary, entry.path, query, sidebarFilterCaseSensitive);
textWrap.appendChild(primary);
textWrap.appendChild(secondary);
resultItem.appendChild(textWrap);
resultItem.addEventListener("click", async () => {
const input = document.getElementById("sidebar-filter-input");
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
if (input) input.value = "";
if (clearBtn) clearBtn.style.display = "none";
await restoreSidebarTree();
if (entry.type === "directory") {
await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true });
} 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", "error");
}
});
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("pop-out")]);
openNewWindowBtn.addEventListener("click", () => {
const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}`;
window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no");
});
const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]);
tocBtn.addEventListener("click", () => {
RightSidebarManager.toggle();
});
// Frontmatter — Accent Card
let fmSection = null;
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
fmSection = buildFrontmatterCard(data.frontmatter);
}
// Content container (rendered HTML)
const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" });
mdDiv.innerHTML = data.html;
// Raw source container (hidden initially)
const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" });
// Source button toggle logic
sourceBtn.addEventListener("click", async () => {
const rendered = document.getElementById("file-rendered-content");
const raw = document.getElementById("file-raw-content");
if (!rendered || !raw) return;
showingSource = !showingSource;
if (showingSource) {
sourceBtn.classList.add("active");
if (!cachedRawSource) {
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
const rawData = await api(rawUrl);
cachedRawSource = rawData.raw;
}
raw.textContent = cachedRawSource;
rendered.style.display = "none";
raw.style.display = "block";
} else {
sourceBtn.classList.remove("active");
rendered.style.display = "block";
raw.style.display = "none";
}
});
// Assemble
area.innerHTML = "";
area.appendChild(breadcrumb);
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, downloadBtn, editBtn, openNewWindowBtn, tocBtn])]));
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;
// Initialize outline/TOC for this document
OutlineManager.init();
}
// ---------------------------------------------------------------------------
// Recent files
// ---------------------------------------------------------------------------
let _recentRefreshTimer = null;
let _recentTimestampTimer = null;
let _recentFilesCache = [];
// ---------------------------------------------------------------------------
// Dashboard Recent Files Widget
// ---------------------------------------------------------------------------
const DashboardRecentWidget = {
_cache: [],
_currentFilter: "",
async load(vaultFilter = "") {
const v = vaultFilter || selectedContextVault || "all";
this._currentFilter = v;
this.showLoading();
let url = "/api/recent?mode=opened";
if (v !== "all") url += `&vault=${encodeURIComponent(v)}`;
try {
const data = await api(url);
this._cache = data.files || [];
this.render();
} catch (err) {
console.error("Dashboard: Failed to load recent files:", err);
this.showError();
}
},
async toggleBookmark(vault, path, title, card) {
try {
const data = await api("/api/bookmarks/toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vault, path, title }),
});
// Refresh both widgets to keep sync
DashboardBookmarkWidget.load();
// Update current card icon if it exists
if (card) {
const btn = card.querySelector(".dashboard-card-bookmark-btn");
if (btn) {
btn.classList.toggle("active", data.bookmarked);
const icon = btn.querySelector("i");
if (icon) icon.setAttribute("data-lucide", data.bookmarked ? "bookmark" : "bookmark-plus");
safeCreateIcons();
}
}
// Check if we need to refresh the current list to reflect bookmark status across all cards
// To avoid flickering, just update the cache and re-render if needed or do a silent refresh
this._cache.forEach(f => {
if (f.vault === vault && f.path === path) f.bookmarked = data.bookmarked;
});
} catch (err) {
console.error("Failed to toggle bookmark:", err);
showToast("Erreur lors de l'épinglage", "error");
}
},
showLoading() {
const grid = document.getElementById("dashboard-recent-grid");
const loading = document.getElementById("dashboard-loading");
const empty = document.getElementById("dashboard-recent-empty");
const count = document.getElementById("dashboard-count");
if (grid) grid.innerHTML = "";
if (loading) loading.classList.add("active");
if (empty) empty.classList.add("hidden");
if (count) count.textContent = "";
},
render() {
const grid = document.getElementById("dashboard-recent-grid");
const loading = document.getElementById("dashboard-loading");
const empty = document.getElementById("dashboard-recent-empty");
const count = document.getElementById("dashboard-count");
if (loading) loading.classList.remove("active");
if (!this._cache || this._cache.length === 0) {
this.showEmpty();
return;
}
if (empty) empty.classList.add("hidden");
if (count) count.textContent = `${this._cache.length} fichier${this._cache.length > 1 ? "s" : ""}`;
if (!grid) return;
grid.innerHTML = "";
this._cache.forEach((f, index) => {
const card = this._createCard(f, index);
grid.appendChild(card);
});
safeCreateIcons();
},
_createCard(file, index) {
const card = document.createElement("div");
card.className = "dashboard-card";
card.setAttribute("data-vault", file.vault);
card.setAttribute("data-path", file.path);
card.style.animationDelay = `${Math.min(index * 50, 400)}ms`;
// Header with icon and vault badge
const header = document.createElement("div");
header.className = "dashboard-card-header";
const icon = document.createElement("div");
icon.className = "dashboard-card-icon";
icon.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>';
const badge = document.createElement("span");
badge.className = "dashboard-vault-badge";
badge.textContent = file.vault;
const bookmarkBtn = document.createElement("button");
bookmarkBtn.className = `dashboard-card-bookmark-btn ${file.bookmarked ? "active" : ""}`;
bookmarkBtn.title = file.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks";
bookmarkBtn.innerHTML = `<i data-lucide="${file.bookmarked ? "bookmark" : "bookmark-plus"}" style="width:14px;height:14px"></i>`;
bookmarkBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.toggleBookmark(file.vault, file.path, file.title, card);
});
header.appendChild(icon);
header.appendChild(badge);
header.appendChild(bookmarkBtn);
card.appendChild(header);
// Title
const title = document.createElement("h3");
title.className = "dashboard-card-title";
title.textContent = file.title || file.path.split("/").pop();
title.title = file.title || file.path;
card.appendChild(title);
// Path (compact)
const pathParts = file.path.split("/");
if (pathParts.length > 1) {
const path = document.createElement("div");
path.className = "dashboard-card-path";
path.textContent = pathParts.slice(0, -1).join(" / ");
path.title = file.path;
card.appendChild(path);
}
// Footer with time and tags
const footer = document.createElement("div");
footer.className = "dashboard-card-footer";
const time = document.createElement("span");
time.className = "dashboard-card-time";
time.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> ${file.mtime_human || this._humanizeDelta(file.mtime)}`;
footer.appendChild(time);
// Tags
if (file.tags && file.tags.length > 0) {
const tags = document.createElement("div");
tags.className = "dashboard-card-tags";
file.tags.slice(0, 3).forEach((tag) => {
const tagEl = document.createElement("span");
tagEl.className = "tag-pill";
tagEl.textContent = tag;
tags.appendChild(tagEl);
});
footer.appendChild(tags);
}
card.appendChild(footer);
// Click handler
card.addEventListener("click", () => {
openFile(file.vault, file.path);
});
return card;
},
showEmpty() {
const grid = document.getElementById("dashboard-recent-grid");
const loading = document.getElementById("dashboard-loading");
const empty = document.getElementById("dashboard-recent-empty");
const count = document.getElementById("dashboard-count");
if (grid) grid.innerHTML = "";
if (loading) loading.classList.remove("active");
if (empty) empty.classList.remove("hidden");
if (count) count.textContent = "0 fichiers";
safeCreateIcons();
},
showError() {
this.showEmpty();
const empty = document.getElementById("dashboard-recent-empty");
if (empty) {
const msg = empty.querySelector("span");
if (msg) msg.textContent = "Erreur de chargement";
}
},
_humanizeDelta(mtime) {
const delta = Date.now() / 1000 - mtime;
if (delta < 60) return "à l'instant";
if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`;
if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`;
if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`;
return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
},
populateVaultFilter() {
const select = document.getElementById("dashboard-vault-filter");
if (!select) return;
// Keep first option "Tous les vaults"
while (select.options.length > 1) select.remove(1);
if (typeof allVaults !== "undefined" && Array.isArray(allVaults)) {
allVaults.forEach((v) => {
const opt = document.createElement("option");
opt.value = v.name;
opt.textContent = v.name;
select.appendChild(opt);
});
}
syncVaultSelectors();
},
init() {
const select = document.getElementById("dashboard-vault-filter");
if (select) {
select.addEventListener("change", async () => {
await setSelectedVaultContext(select.value, { focusVault: select.value !== "all" });
});
}
this.populateVaultFilter();
},
};
// ---------------------------------------------------------------------------
// Dashboard Bookmarks Widget
// ---------------------------------------------------------------------------
const DashboardBookmarkWidget = {
_cache: [],
_currentFilter: "",
async load(vaultFilter = "") {
const v = vaultFilter || selectedContextVault || "all";
this._currentFilter = v;
this.showLoading();
let url = "/api/bookmarks";
if (v !== "all") url += `?vault=${encodeURIComponent(v)}`;
try {
const data = await api(url);
this._cache = data.files || [];
this.render();
} catch (err) {
console.error("Dashboard: Failed to load bookmarks:", err);
this.showEmpty();
}
},
showLoading() {
const grid = document.getElementById("dashboard-bookmarks-grid");
const empty = document.getElementById("dashboard-bookmarks-empty");
const section = document.getElementById("dashboard-bookmarks-section");
if (grid) grid.innerHTML = "";
if (empty) empty.classList.add("hidden");
},
render() {
const grid = document.getElementById("dashboard-bookmarks-grid");
const empty = document.getElementById("dashboard-bookmarks-empty");
const section = document.getElementById("dashboard-bookmarks-section");
if (!this._cache || this._cache.length === 0) {
if (grid) grid.innerHTML = "";
if (empty) empty.classList.remove("hidden");
return;
}
if (empty) empty.classList.add("hidden");
if (!grid) return;
grid.innerHTML = "";
this._cache.forEach((f, idx) => {
const card = DashboardRecentWidget._createCard(f, idx);
grid.appendChild(card);
});
safeCreateIcons();
},
showEmpty() {
const grid = document.getElementById("dashboard-bookmarks-grid");
const empty = document.getElementById("dashboard-bookmarks-empty");
if (grid) grid.innerHTML = "";
if (empty) empty.classList.remove("hidden");
}
};
async function loadRecentFiles(vaultFilter) {
const listEl = document.getElementById("recent-list");
const emptyEl = document.getElementById("recent-empty");
if (!listEl) return;
let url = "/api/recent?mode=modified";
if (vaultFilter) url += `&vault=${encodeURIComponent(vaultFilter)}`;
try {
const data = await api(url);
_recentFilesCache = data.files || [];
renderRecentList(_recentFilesCache);
} catch (err) {
console.error("Failed to load recent files:", err);
listEl.innerHTML = "";
if (emptyEl) {
emptyEl.classList.remove("hidden");
}
}
}
function renderRecentList(files) {
const listEl = document.getElementById("recent-list");
const emptyEl = document.getElementById("recent-empty");
if (!listEl) return;
listEl.innerHTML = "";
if (!files || files.length === 0) {
if (emptyEl) {
emptyEl.classList.remove("hidden");
safeCreateIcons();
}
return;
}
if (emptyEl) emptyEl.classList.add("hidden");
files.forEach((f) => {
const item = el("div", { class: "recent-item", "data-vault": f.vault, "data-path": f.path });
// Header row: time + vault badge
const header = el("div", { class: "recent-item-header" });
const timeSpan = el("span", { class: "recent-time" }, [icon("clock", 11), document.createTextNode(f.mtime_human)]);
const badge = el("span", { class: "recent-vault-badge" }, [document.createTextNode(f.vault)]);
header.appendChild(timeSpan);
header.appendChild(badge);
item.appendChild(header);
// Title
const titleEl = el("div", { class: "recent-item-title" }, [document.createTextNode(f.title || f.path.split("/").pop())]);
item.appendChild(titleEl);
// Path breadcrumb
const pathParts = f.path.split("/");
if (pathParts.length > 1) {
const pathEl = el("div", { class: "recent-item-path" }, [document.createTextNode(pathParts.slice(0, -1).join(" / "))]);
item.appendChild(pathEl);
}
// Preview
if (f.preview) {
const previewEl = el("div", { class: "recent-item-preview" }, [document.createTextNode(f.preview)]);
item.appendChild(previewEl);
}
// Tags
if (f.tags && f.tags.length > 0) {
const tagsEl = el("div", { class: "recent-item-tags" });
f.tags.forEach((t) => {
tagsEl.appendChild(el("span", { class: "tag-pill" }, [document.createTextNode(t)]));
});
item.appendChild(tagsEl);
}
// Click handler
item.addEventListener("click", () => {
openFile(f.vault, f.path);
closeMobileSidebar();
});
listEl.appendChild(item);
});
safeCreateIcons();
}
function _humanizeDelta(mtime) {
const delta = Date.now() / 1000 - mtime;
if (delta < 60) return "à l'instant";
if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`;
if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`;
if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`;
return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
}
function _refreshRecentTimestamps() {
if (activeSidebarTab !== "recent" || !_recentFilesCache.length) return;
const items = document.querySelectorAll(".recent-item");
items.forEach((item, i) => {
if (i < _recentFilesCache.length) {
const timeSpan = item.querySelector(".recent-time");
if (timeSpan) {
// keep the icon, update text
const textNode = timeSpan.lastChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
textNode.textContent = _humanizeDelta(_recentFilesCache[i].mtime);
}
}
}
});
}
function _populateRecentVaultFilter() {
const select = document.getElementById("recent-vault-filter");
if (!select) return;
// keep first option "Tous les vaults"
while (select.options.length > 1) select.remove(1);
allVaults.forEach((v) => {
const opt = document.createElement("option");
opt.value = v.name;
opt.textContent = v.name;
select.appendChild(opt);
});
syncVaultSelectors();
}
function initRecentTab() {
const select = document.getElementById("recent-vault-filter");
if (select) {
select.addEventListener("change", async () => {
const val = select.value || "all";
await setSelectedVaultContext(val, { focusVault: val !== "all" });
});
}
// Periodic timestamp refresh (every 60s)
_recentTimestampTimer = setInterval(_refreshRecentTimestamps, 60000);
}
// ---------------------------------------------------------------------------
// Sidebar tabs
// ---------------------------------------------------------------------------
function initSidebarTabs() {
document.querySelectorAll(".sidebar-tab").forEach((tab) => {
tab.addEventListener("click", () => switchSidebarTab(tab.dataset.tab));
});
}
function switchSidebarTab(tab) {
activeSidebarTab = tab;
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
const isActive = btn.dataset.tab === tab;
btn.classList.toggle("active", isActive);
btn.setAttribute("aria-selected", isActive ? "true" : "false");
});
document.querySelectorAll(".sidebar-tab-panel").forEach((panel) => {
const isActive = panel.id === `sidebar-panel-${tab}`;
panel.classList.toggle("active", isActive);
});
const filterInput = document.getElementById("sidebar-filter-input");
if (filterInput) {
const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "" };
filterInput.placeholder = placeholders[tab] || "";
}
const query = filterInput ? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : "";
if (query) {
if (tab === "vaults") performTreeSearch(query);
else if (tab === "tags") filterTagCloud(query);
}
// Auto-load recent files when switching to the recent tab
if (tab === "recent") {
_populateRecentVaultFilter();
const vaultFilter = document.getElementById("recent-vault-filter");
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
}
}
function initHelpModal() {
const openBtn = document.getElementById("help-open-btn");
const closeBtn = document.getElementById("help-close");
const modal = document.getElementById("help-modal");
if (!openBtn || !closeBtn || !modal) return;
openBtn.addEventListener("click", () => {
modal.classList.add("active");
closeHeaderMenu();
safeCreateIcons();
initHelpNavigation();
});
closeBtn.addEventListener("click", closeHelpModal);
modal.addEventListener("click", (e) => {
if (e.target === modal) {
closeHelpModal();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
closeHelpModal();
}
});
}
function initHelpNavigation() {
const helpContent = document.querySelector(".help-content");
const 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();
await loadHiddenFilesSettings();
safeCreateIcons();
});
closeBtn.addEventListener("click", closeConfigModal);
modal.addEventListener("click", (e) => {
if (e.target === modal) {
closeConfigModal();
}
});
addBtn.addEventListener("click", addConfigFilter);
patternInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
addConfigFilter();
}
});
patternInput.addEventListener("input", updateRegexPreview);
// Frontend config fields — save to localStorage on change
["cfg-debounce", "cfg-results-per-page", "cfg-min-query", "cfg-timeout"].forEach((id) => {
const input = document.getElementById(id);
if (input) input.addEventListener("change", saveFrontendConfig);
});
// Backend save button
const saveBtn = document.getElementById("cfg-save-backend");
if (saveBtn) saveBtn.addEventListener("click", saveBackendConfig);
// Force reindex
const reindexBtn = document.getElementById("cfg-reindex");
if (reindexBtn) reindexBtn.addEventListener("click", forceReindex);
// Reset defaults
const resetBtn = document.getElementById("cfg-reset-defaults");
if (resetBtn) resetBtn.addEventListener("click", resetConfigDefaults);
// Refresh diagnostics
const diagBtn = document.getElementById("cfg-refresh-diag");
if (diagBtn) diagBtn.addEventListener("click", loadDiagnostics);
// Hidden files configuration
const saveHiddenBtn = document.getElementById("cfg-save-hidden-files");
if (saveHiddenBtn) saveHiddenBtn.addEventListener("click", saveHiddenFilesSettings);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
closeConfigModal();
}
});
// Load saved frontend config on startup
applyFrontendConfig();
}
function closeConfigModal() {
const modal = document.getElementById("config-modal");
if (modal) modal.classList.remove("active");
}
// --- Config field helpers ---
const _FRONTEND_CONFIG_KEY = "obsigate-perf-config";
function _getFrontendConfig() {
try {
return JSON.parse(localStorage.getItem(_FRONTEND_CONFIG_KEY) || "{}");
} catch {
return {};
}
}
function applyFrontendConfig() {
const cfg = _getFrontendConfig();
if (cfg.debounce_ms) {
/* applied dynamically in debounce setTimeout */
}
if (cfg.results_per_page) {
/* used as ADVANCED_SEARCH_LIMIT override */
}
if (cfg.min_query_length) {
/* used as MIN_SEARCH_LENGTH override */
}
if (cfg.search_timeout_ms) {
/* used as SEARCH_TIMEOUT_MS override */
}
}
function _getEffective(key, fallback) {
const cfg = _getFrontendConfig();
return cfg[key] !== undefined ? cfg[key] : fallback;
}
async function loadConfigFields() {
// Frontend fields from localStorage
const cfg = _getFrontendConfig();
_setField("cfg-debounce", cfg.debounce_ms || 300);
_setField("cfg-results-per-page", cfg.results_per_page || 50);
_setField("cfg-min-query", cfg.min_query_length || 2);
_setField("cfg-timeout", cfg.search_timeout_ms || 30000);
// Backend fields from API
try {
const data = await api("/api/config");
_setField("cfg-workers", data.search_workers);
_setField("cfg-max-content", data.max_content_size);
_setField("cfg-title-boost", data.title_boost);
_setField("cfg-tag-boost", data.tag_boost);
_setField("cfg-prefix-exp", data.prefix_max_expansions);
_setField("cfg-recent-limit", data.recent_files_limit || 20);
// Watcher config
_setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false);
_setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true);
_setField("cfg-watcher-interval", data.watcher_polling_interval || 5);
_setField("cfg-watcher-debounce", data.watcher_debounce || 2);
} catch (err) {
console.error("Failed to load backend config:", err);
}
}
function _setField(id, value) {
const el = document.getElementById(id);
if (el && value !== undefined) el.value = value;
}
function _setCheckbox(id, checked) {
const el = document.getElementById(id);
if (el) el.checked = !!checked;
}
function _getCheckbox(id) {
const el = document.getElementById(id);
return el ? el.checked : false;
}
function _getFieldNum(id, fallback) {
const el = document.getElementById(id);
if (!el) return fallback;
const v = parseFloat(el.value);
return isNaN(v) ? fallback : v;
}
function saveFrontendConfig() {
const cfg = {
debounce_ms: _getFieldNum("cfg-debounce", 300),
results_per_page: _getFieldNum("cfg-results-per-page", 50),
min_query_length: _getFieldNum("cfg-min-query", 2),
search_timeout_ms: _getFieldNum("cfg-timeout", 30000),
};
localStorage.setItem(_FRONTEND_CONFIG_KEY, JSON.stringify(cfg));
showToast("Paramètres client sauvegardés", "success");
}
async function saveBackendConfig() {
const body = {
search_workers: _getFieldNum("cfg-workers", 2),
max_content_size: _getFieldNum("cfg-max-content", 100000),
title_boost: _getFieldNum("cfg-title-boost", 3.0),
tag_boost: _getFieldNum("cfg-tag-boost", 2.0),
prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50),
recent_files_limit: _getFieldNum("cfg-recent-limit", 20),
watcher_enabled: _getCheckbox("cfg-watcher-enabled"),
watcher_use_polling: _getCheckbox("cfg-watcher-polling"),
watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0),
watcher_debounce: _getFieldNum("cfg-watcher-debounce", 2.0),
};
try {
const res = await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
showToast("Configuration backend sauvegardée", "success");
} else {
const errorData = await res.json().catch(() => ({}));
showToast(errorData.detail || "Erreur de sauvegarde", "error");
}
} catch (err) {
console.error("Failed to save backend config:", err);
showToast("Erreur de sauvegarde", "error");
}
}
async function forceReindex() {
const btn = document.getElementById("cfg-reindex");
if (btn) {
btn.disabled = true;
btn.textContent = "Réindexation...";
}
try {
await api("/api/index/reload");
showToast("Réindexation terminée", "success");
loadDiagnostics();
await Promise.all([loadVaults(), loadTags()]);
} catch (err) {
console.error("Reindex error:", err);
showToast("Erreur de réindexation", "error");
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = "Forcer réindexation";
}
}
}
async function resetConfigDefaults() {
// Reset frontend
localStorage.removeItem(_FRONTEND_CONFIG_KEY);
// Reset backend
try {
await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
search_workers: 2,
debounce_ms: 300,
results_per_page: 50,
min_query_length: 2,
search_timeout_ms: 30000,
max_content_size: 100000,
title_boost: 3.0,
path_boost: 1.5,
tag_boost: 2.0,
prefix_max_expansions: 50,
snippet_context_chars: 120,
max_snippet_highlights: 5,
}),
});
} catch (err) {
console.error("Reset config error:", err);
}
loadConfigFields();
showToast("Configuration réinitialisée", "success");
}
async function loadDiagnostics() {
const container = document.getElementById("config-diagnostics");
if (!container) return;
container.innerHTML = '<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);
});
}
// --- Hidden Files Configuration ---
async function loadHiddenFilesSettings() {
const container = document.getElementById("hidden-files-vault-list");
if (!container) return;
container.innerHTML = '<div style="padding:12px;color:var(--text-muted)">Chargement...</div>';
try {
const settings = await api("/api/vaults/settings/all");
renderHiddenFilesSettings(container, settings);
} catch (err) {
console.error("Failed to load hidden files settings:", err);
container.innerHTML = '<div style="padding:12px;color:var(--error)">Erreur de chargement</div>';
}
}
function renderHiddenFilesSettings(container, allSettings) {
container.innerHTML = "";
if (!allVaults || allVaults.length === 0) {
container.innerHTML = '<div style="padding:12px;color:var(--text-muted)">Aucun vault configuré</div>';
return;
}
allVaults.forEach((vault) => {
const settings = allSettings[vault.name] || { hideHiddenFiles: false };
const vaultCard = el("div", { class: "hidden-files-vault-card", "data-vault": vault.name });
// Vault header
const header = el("div", { class: "hidden-files-vault-header" }, [el("h3", {}, [document.createTextNode(vault.name)]), el("span", { class: "hidden-files-vault-type" }, [document.createTextNode(vault.type || "VAULT")])]);
// Hide hidden files toggle
const toggleRow = el("div", { class: "config-row" }, [
el("label", { class: "config-label", for: `hide-hidden-${vault.name}` }, [document.createTextNode("Masquer les fichiers/dossiers cachés")]),
el("label", { class: "config-toggle" }, [
el("input", {
type: "checkbox",
id: `hide-hidden-${vault.name}`,
"data-vault": vault.name,
checked: settings.hideHiddenFiles ? "true" : false,
}),
el("span", { class: "config-toggle-slider" }),
]),
el("span", { class: "config-hint" }, [document.createTextNode("Masquer les fichiers/dossiers commençant par un point dans l'interface (ils restent indexés et cherchables)")]),
]);
vaultCard.appendChild(header);
vaultCard.appendChild(toggleRow);
container.appendChild(vaultCard);
});
}
async function saveHiddenFilesSettings() {
const btn = document.getElementById("cfg-save-hidden-files");
if (btn) {
btn.disabled = true;
btn.textContent = "Sauvegarde...";
}
try {
const vaultCards = document.querySelectorAll(".hidden-files-vault-card");
const promises = [];
vaultCards.forEach((card) => {
const vaultName = card.dataset.vault;
const hideHiddenFiles = document.getElementById(`hide-hidden-${vaultName}`)?.checked || false;
const settings = {
hideHiddenFiles,
};
promises.push(
api(`/api/vaults/${encodeURIComponent(vaultName)}/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
}),
);
});
await Promise.all(promises);
// Reload vault settings to update the cache
await loadVaultSettings();
showToast("✓ Paramètres sauvegardés", "success");
// Refresh the UI to apply the filter
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
} catch (err) {
console.error("Failed to save hidden files settings:", err);
const errorMsg = err.message || "Erreur inconnue";
showToast(`Erreur: ${errorMsg}`, "error");
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = "💾 Sauvegarder";
}
}
}
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) => {
// Apply client-side filtering for hidden files
if (!shouldDisplayPath(r.path, r.vault)) {
return; // Skip this result
}
const titleDiv = el("div", { class: "search-result-title" });
if (query && query.trim()) {
highlightSearchText(titleDiv, r.title, query, searchCaseSensitive);
} else {
titleDiv.textContent = r.title;
}
const snippetDiv = el("div", { class: "search-result-snippet" });
if (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) => {
// Apply client-side filtering for hidden files
if (!shouldDisplayPath(r.path, r.vault)) {
return; // Skip this result
}
const titleDiv = el("div", { class: "search-result-title" });
if (freeText) {
highlightSearchText(titleDiv, r.title, freeText, searchCaseSensitive);
} else {
titleDiv.textContent = r.title;
}
// Snippet — use HTML from backend (already has <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);
});
}
// ---------------------------------------------------------------------------
// Frontmatter Accent Card Builder
// ---------------------------------------------------------------------------
function buildFrontmatterCard(frontmatter) {
// Helper: format date
function formatDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
const date = d.toISOString().slice(0, 10);
const time = d.toTimeString().slice(0, 5);
return `${date} · ${time}`;
}
// Extract boolean flags
const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] }));
// Toggle state
let isOpen = true;
// Build header with chevron
const chevron = el("span", { class: "fm-chevron open" });
chevron.innerHTML = '<i data-lucide="chevron-down" style="width:14px;height:14px"></i>';
const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]);
// ZONE 1: Top strip
const topBadges = [];
// Title badge
const title = frontmatter.titre || frontmatter.title || "";
if (title) {
topBadges.push(el("span", { class: "ac-title" }, [document.createTextNode(`"${title}"`)]));
}
// Status badge
if (frontmatter.statut) {
const statusBadge = el("span", { class: "ac-badge green" }, [el("span", { class: "ac-dot" }), document.createTextNode(frontmatter.statut)]);
topBadges.push(statusBadge);
}
// Category badge
if (frontmatter.catégorie || frontmatter.categorie) {
const cat = frontmatter.catégorie || frontmatter.categorie;
const catBadge = el("span", { class: "ac-badge blue" }, [document.createTextNode(cat)]);
topBadges.push(catBadge);
}
// Publish badge
if (frontmatter.publish) {
topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("publié")]));
}
// Favoris badge
if (frontmatter.favoris) {
topBadges.push(el("span", { class: "ac-badge purple" }, [document.createTextNode("favori")]));
}
const acTop = el("div", { class: "ac-top" }, topBadges);
// ZONE 2: Body 2 columns
const leftCol = el("div", { class: "ac-col" }, [
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("auteur")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.auteur || "—")])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("catégorie")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.catégorie || frontmatter.categorie || "—")])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("statut")]), el("span", { class: "ac-v" }, [document.createTextNode(frontmatter.statut || "—")])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("aliases")]), el("span", { class: "ac-v muted" }, [document.createTextNode(frontmatter.aliases && frontmatter.aliases.length > 0 ? frontmatter.aliases.join(", ") : "[]")])]),
]);
const rightCol = el("div", { class: "ac-col" }, [
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("creation_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.creation_date))])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("modification_date")]), el("span", { class: "ac-v mono" }, [document.createTextNode(formatDate(frontmatter.modification_date))])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("publish")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.publish || false))])]),
el("div", { class: "ac-row" }, [el("span", { class: "ac-k" }, [document.createTextNode("favoris")]), el("span", { class: "ac-v" }, [document.createTextNode(String(frontmatter.favoris || false))])]),
]);
const acBody = el("div", { class: "ac-body" }, [leftCol, rightCol]);
// ZONE 3: Tags row
const tagPills = [];
if (frontmatter.tags && frontmatter.tags.length > 0) {
frontmatter.tags.forEach((tag) => {
tagPills.push(el("span", { class: "ac-tag" }, [document.createTextNode(tag)]));
});
}
const acTagsRow = el("div", { class: "ac-tags-row" }, [el("span", { class: "ac-tags-k" }, [document.createTextNode("tags")]), el("div", { class: "ac-tags-wrap" }, tagPills)]);
// ZONE 4: Flags row
const flagChips = [];
booleanFlags.forEach((flag) => {
const chipClass = flag.value ? "flag-chip on" : "flag-chip off";
flagChips.push(el("span", { class: chipClass }, [el("span", { class: "flag-dot" }), document.createTextNode(flag.key)]));
});
const acFlagsRow = el("div", { class: "ac-flags-row" }, [el("span", { class: "ac-flags-k" }, [document.createTextNode("flags")]), ...flagChips]);
// Assemble the card
const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]);
// Toggle functionality
fmHeader.addEventListener("click", () => {
isOpen = !isOpen;
if (isOpen) {
acCard.style.display = "block";
chevron.classList.remove("closed");
chevron.classList.add("open");
} else {
acCard.style.display = "none";
chevron.classList.remove("open");
chevron.classList.add("closed");
}
safeCreateIcons();
});
// Wrap in section
const fmSection = el("div", { class: "fm-section" }, [fmHeader, acCard]);
return fmSection;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function el(tag, attrs, children) {
const e = document.createElement(tag);
if (attrs) {
Object.entries(attrs).forEach(([k, v]) => {
// Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug
if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) {
return;
}
e.setAttribute(k, v);
});
}
if (children) {
children.forEach((c) => {
if (c) e.appendChild(c);
});
}
return e;
}
function icon(name, size) {
const i = document.createElement("i");
i.setAttribute("data-lucide", name);
i.style.width = size + "px";
i.style.height = size + "px";
i.classList.add("icon");
return i;
}
function smallBadge(count) {
const s = document.createElement("span");
s.className = "badge-small";
s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px";
s.textContent = `(${count})`;
return s;
}
function getVaultIcon(vaultName, size = 16) {
const v = allVaults.find((val) => val.name === vaultName);
const type = v ? v.type : "VAULT";
if (type === "DIR") {
const i = icon("folder", size);
i.style.color = "#eab308"; // yellow tint
return i;
} else {
const purple = "#8b5cf6";
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("xmlns", svgNS);
svg.setAttribute("width", size);
svg.setAttribute("height", size);
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", purple);
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.classList.add("icon");
const path1 = document.createElementNS(svgNS, "path");
path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z");
const path2 = document.createElementNS(svgNS, "path");
path2.setAttribute("d", "M11 3 8 9l4 12");
const path3 = document.createElementNS(svgNS, "path");
path3.setAttribute("d", "M12 21l4-12-3-6");
const path4 = document.createElementNS(svgNS, "path");
path4.setAttribute("d", "M2 9h20");
svg.appendChild(path1);
svg.appendChild(path2);
svg.appendChild(path3);
svg.appendChild(path4);
return svg;
}
}
function makeBreadcrumbSpan(text, onClick) {
const s = document.createElement("span");
s.textContent = text;
if (onClick) s.addEventListener("click", 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();
// Ensure the dashboard container exists and has the correct structure (it might have been wiped by renderFile or be an old version)
const area = document.getElementById("content-area");
const home = document.getElementById("dashboard-home");
const bookmarksSection = document.getElementById("dashboard-bookmarks-section");
if (area && (!home || !bookmarksSection)) {
area.innerHTML = `
<div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
<!-- Bookmarks Section -->
<div id="dashboard-bookmarks-section" class="dashboard-section">
<div class="dashboard-header">
<div class="dashboard-title-row">
<i data-lucide="bookmark" class="dashboard-icon" style="color:var(--accent-green)"></i>
<h2>Bookmarks</h2>
</div>
</div>
<div id="dashboard-bookmarks-grid" class="dashboard-recent-grid"></div>
<div id="dashboard-bookmarks-empty" class="dashboard-recent-empty">
<i data-lucide="pin"></i>
<span>Aucun bookmark</span>
<p>Épinglez des fichiers pour les retrouver ici.</p>
</div>
</div>
<!-- Recently Opened Section -->
<div id="dashboard-recent-section" class="dashboard-section">
<div class="dashboard-header">
<div class="dashboard-title-row">
<i data-lucide="clock" class="dashboard-icon"></i>
<h2>Derniers fichiers ouverts</h2>
<span id="dashboard-count" class="dashboard-badge"></span>
</div>
<div class="dashboard-actions">
<select id="dashboard-vault-filter" class="dashboard-filter" aria-label="Filtrer par vault">
<option value="all">Tous les vaults</option>
</select>
</div>
</div>
<div id="dashboard-recent-grid" class="dashboard-recent-grid"></div>
<div id="dashboard-loading" class="dashboard-loading">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
<div id="dashboard-recent-empty" class="dashboard-recent-empty hidden">
<i data-lucide="inbox"></i>
<span>Aucun fichier récent</span>
<p>Ouvrez un fichier pour le voir apparaître ici</p>
</div>
</div>
</div>`;
// Re-initialize widgets which might need to bind events to new elements
if (typeof DashboardRecentWidget !== "undefined") {
DashboardRecentWidget.init();
}
safeCreateIcons();
}
// Show the dashboard widgets
if (typeof DashboardRecentWidget !== "undefined") {
DashboardRecentWidget.load(selectedContextVault);
}
if (typeof DashboardBookmarkWidget !== "undefined") {
DashboardBookmarkWidget.load(selectedContextVault);
}
}
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.addEventListener("index_start", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_start", data);
connectionState = "syncing";
_updateBadge();
showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info");
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_progress", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_progress", data);
connectionState = "syncing";
_updateBadge();
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_complete", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_complete", data);
connectionState = "connected";
_updateBadge();
showToast(`Indexation terminée (${data.total_files} fichiers)`, "success");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.onerror = () => {
connectionState = "disconnected";
_updateBadge();
eventSource.close();
eventSource = null;
_scheduleReconnect();
};
}
function _scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
connect();
}, reconnectDelay);
}
function _addEvent(type, data) {
recentEvents.unshift({
type,
data,
timestamp: new Date().toISOString(),
});
if (recentEvents.length > MAX_RECENT_EVENTS) {
recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
}
}
async function _onIndexUpdated(data) {
// Brief syncing state
connectionState = "syncing";
_updateBadge();
const n = data.total_changes || 0;
const vaults = (data.vaults || []).join(", ");
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);
}
}
// Refresh recent tab if it is active
if (activeSidebarTab === "recent") {
const vaultFilter = document.getElementById("recent-vault-filter");
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
}
setTimeout(() => {
connectionState = "connected";
_updateBadge();
}, 1500);
}
async function _onIndexReloaded(data) {
connectionState = "syncing";
_updateBadge();
showToast("Index complet rechargé", "info");
try {
await Promise.all([loadVaults(), loadTags()]);
} catch (err) {
console.error("Error refreshing after full reload:", err);
}
setTimeout(() => {
connectionState = "connected";
_updateBadge();
}, 1500);
}
function _updateBadge() {
const badge = document.getElementById("sync-badge");
if (!badge) return;
badge.className = "sync-badge sync-badge--" + connectionState;
const labels = {
disconnected: "Déconnecté",
connecting: "Connexion...",
connected: "Synchronisé",
syncing: "Mise à jour...",
};
badge.title = labels[connectionState] || connectionState;
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
connectionState = "disconnected";
_updateBadge();
}
function getState() {
return connectionState;
}
function getRecentEvents() {
return recentEvents;
}
return { connect, disconnect, getState, getRecentEvents };
})();
function initSyncStatus() {
const badge = document.getElementById("sync-badge");
if (!badge) return;
badge.addEventListener("click", (e) => {
e.stopPropagation();
toggleSyncPanel();
});
IndexUpdateManager.connect();
}
function toggleSyncPanel() {
let panel = document.getElementById("sync-panel");
if (panel) {
panel.remove();
return;
}
// Auto reconnect if disconnected when user opens the panel
if (IndexUpdateManager.getState() === "disconnected") {
IndexUpdateManager.connect();
}
panel = document.createElement("div");
panel.id = "sync-panel";
panel.className = "sync-panel";
_renderSyncPanel(panel);
document.body.appendChild(panel);
// Close on outside click
setTimeout(() => {
document.addEventListener("click", _closeSyncPanelOutside, { once: true });
}, 0);
}
function _closeSyncPanelOutside(e) {
const panel = document.getElementById("sync-panel");
if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
panel.remove();
}
}
function _renderSyncPanel(panel) {
const state = IndexUpdateManager.getState();
const events = IndexUpdateManager.getRecentEvents();
const stateLabels = {
disconnected: "Déconnecté",
connecting: "Connexion...",
connected: "Connecté",
syncing: "Synchronisation...",
};
let html = `<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é",
index_start: "Démarrage index.",
index_progress: "Vault indexé",
index_complete: "Indexation tech.",
};
const label = typeLabels[ev.type] || ev.type;
let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`;
if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
html += `<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;
}
// ---------------------------------------------------------------------------
// Find in Page Manager
// ---------------------------------------------------------------------------
const FindInPageManager = {
isOpen: false,
searchTerm: "",
matches: [],
currentIndex: -1,
options: {
caseSensitive: false,
wholeWord: false,
useRegex: false,
},
debounceTimer: null,
previousFocus: null,
init() {
const bar = document.getElementById("find-in-page-bar");
const input = document.getElementById("find-input");
const prevBtn = document.getElementById("find-prev");
const nextBtn = document.getElementById("find-next");
const closeBtn = document.getElementById("find-close");
const caseSensitiveBtn = document.getElementById("find-case-sensitive");
const wholeWordBtn = document.getElementById("find-whole-word");
const regexBtn = document.getElementById("find-regex");
if (!bar || !input) return;
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
// Ctrl+F or Cmd+F to open
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
e.preventDefault();
this.open();
}
// Escape to close
if (e.key === "Escape" && this.isOpen) {
e.preventDefault();
this.close();
}
// Enter to go to next
if (e.key === "Enter" && this.isOpen && document.activeElement === input) {
e.preventDefault();
if (e.shiftKey) {
this.goToPrevious();
} else {
this.goToNext();
}
}
// F3 for next/previous
if (e.key === "F3" && this.isOpen) {
e.preventDefault();
if (e.shiftKey) {
this.goToPrevious();
} else {
this.goToNext();
}
}
});
// Input event with debounce
input.addEventListener("input", (e) => {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.search(e.target.value);
}, 250);
});
// Navigation buttons
prevBtn.addEventListener("click", () => this.goToPrevious());
nextBtn.addEventListener("click", () => this.goToNext());
// Close button
closeBtn.addEventListener("click", () => this.close());
// Option toggles
caseSensitiveBtn.addEventListener("click", () => {
this.options.caseSensitive = !this.options.caseSensitive;
caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive);
this.saveState();
if (this.searchTerm) this.search(this.searchTerm);
});
wholeWordBtn.addEventListener("click", () => {
this.options.wholeWord = !this.options.wholeWord;
wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord);
this.saveState();
if (this.searchTerm) this.search(this.searchTerm);
});
regexBtn.addEventListener("click", () => {
this.options.useRegex = !this.options.useRegex;
regexBtn.setAttribute("aria-pressed", this.options.useRegex);
this.saveState();
if (this.searchTerm) this.search(this.searchTerm);
});
// Load saved state
this.loadState();
},
open() {
const bar = document.getElementById("find-in-page-bar");
const input = document.getElementById("find-input");
if (!bar || !input) return;
this.previousFocus = document.activeElement;
this.isOpen = true;
bar.hidden = false;
input.focus();
input.select();
safeCreateIcons();
},
close() {
const bar = document.getElementById("find-in-page-bar");
if (!bar) return;
this.isOpen = false;
bar.hidden = true;
this.clearHighlights();
this.matches = [];
this.currentIndex = -1;
this.searchTerm = "";
// Restore previous focus
if (this.previousFocus && this.previousFocus.focus) {
this.previousFocus.focus();
}
},
search(term) {
this.searchTerm = term;
this.clearHighlights();
this.hideError();
if (!term || term.trim().length === 0) {
this.updateCounter();
this.updateNavButtons();
return;
}
const contentArea = document.querySelector(".md-content");
if (!contentArea) {
this.updateCounter();
this.updateNavButtons();
return;
}
try {
const regex = this.createRegex(term);
this.matches = [];
this.findMatches(contentArea, regex);
this.currentIndex = this.matches.length > 0 ? 0 : -1;
this.highlightMatches();
this.updateCounter();
this.updateNavButtons();
if (this.matches.length > 0) {
this.scrollToMatch(0);
}
} catch (err) {
this.showError(err.message);
this.matches = [];
this.currentIndex = -1;
this.updateCounter();
this.updateNavButtons();
}
},
createRegex(term) {
let pattern = term;
if (!this.options.useRegex) {
// Escape special regex characters
pattern = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
if (this.options.wholeWord) {
pattern = "\\b" + pattern + "\\b";
}
const flags = this.options.caseSensitive ? "g" : "gi";
return new RegExp(pattern, flags);
},
findMatches(container, regex) {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
// Skip code blocks, scripts, styles
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
const tagName = parent.tagName.toLowerCase();
if (["code", "pre", "script", "style"].includes(tagName)) {
return NodeFilter.FILTER_REJECT;
}
// Skip empty text nodes
if (!node.textContent || node.textContent.trim().length === 0) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
let node;
while ((node = walker.nextNode())) {
const text = node.textContent;
let match;
regex.lastIndex = 0; // Reset regex
while ((match = regex.exec(text)) !== null) {
this.matches.push({
node: node,
index: match.index,
length: match[0].length,
text: match[0],
});
// Prevent infinite loop with zero-width matches
if (match.index === regex.lastIndex) {
regex.lastIndex++;
}
}
}
},
highlightMatches() {
const matchesByNode = new Map();
this.matches.forEach((match, idx) => {
if (!matchesByNode.has(match.node)) {
matchesByNode.set(match.node, []);
}
matchesByNode.get(match.node).push({ match, idx });
});
matchesByNode.forEach((entries, node) => {
if (!node || !node.parentNode) return;
const text = node.textContent || "";
let cursor = 0;
const fragment = document.createDocumentFragment();
entries.sort((a, b) => a.match.index - b.match.index);
entries.forEach(({ match, idx }) => {
if (match.index > cursor) {
fragment.appendChild(document.createTextNode(text.substring(cursor, match.index)));
}
const matchText = text.substring(match.index, match.index + match.length);
const mark = document.createElement("mark");
mark.className = idx === this.currentIndex ? "find-highlight find-highlight-active" : "find-highlight";
mark.textContent = matchText;
mark.setAttribute("data-find-index", idx);
fragment.appendChild(mark);
match.element = mark;
cursor = match.index + match.length;
});
if (cursor < text.length) {
fragment.appendChild(document.createTextNode(text.substring(cursor)));
}
node.parentNode.replaceChild(fragment, node);
});
},
clearHighlights() {
const contentArea = document.querySelector(".md-content");
if (!contentArea) return;
const marks = contentArea.querySelectorAll("mark.find-highlight");
marks.forEach((mark) => {
if (!mark.parentNode) return;
const text = mark.textContent;
const textNode = document.createTextNode(text);
mark.parentNode.replaceChild(textNode, mark);
});
// Normalize text nodes to merge adjacent text nodes
contentArea.normalize();
},
goToNext() {
if (this.matches.length === 0) return;
// Remove active class from current
if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) {
this.matches[this.currentIndex].element.classList.remove("find-highlight-active");
}
// Move to next (with wrapping)
this.currentIndex = (this.currentIndex + 1) % this.matches.length;
// Add active class to new current
if (this.matches[this.currentIndex].element) {
this.matches[this.currentIndex].element.classList.add("find-highlight-active");
}
this.scrollToMatch(this.currentIndex);
this.updateCounter();
},
goToPrevious() {
if (this.matches.length === 0) return;
// Remove active class from current
if (this.currentIndex >= 0 && this.matches[this.currentIndex].element) {
this.matches[this.currentIndex].element.classList.remove("find-highlight-active");
}
// Move to previous (with wrapping)
this.currentIndex = this.currentIndex <= 0 ? this.matches.length - 1 : this.currentIndex - 1;
// Add active class to new current
if (this.matches[this.currentIndex].element) {
this.matches[this.currentIndex].element.classList.add("find-highlight-active");
}
this.scrollToMatch(this.currentIndex);
this.updateCounter();
},
scrollToMatch(index) {
if (index < 0 || index >= this.matches.length) return;
const match = this.matches[index];
if (!match.element) return;
const contentArea = document.getElementById("content-area");
if (!contentArea) {
match.element.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
// Calculate position with offset for header
const elementTop = match.element.offsetTop;
const offset = 100; // Offset for header
contentArea.scrollTo({
top: elementTop - offset,
behavior: "smooth",
});
},
updateCounter() {
const counter = document.getElementById("find-counter");
if (!counter) return;
const count = this.matches.length;
if (count === 0) {
counter.textContent = "0 occurrence";
} else if (count === 1) {
counter.textContent = "1 occurrence";
} else {
counter.textContent = `${count} occurrences`;
}
},
updateNavButtons() {
const prevBtn = document.getElementById("find-prev");
const nextBtn = document.getElementById("find-next");
if (!prevBtn || !nextBtn) return;
const hasMatches = this.matches.length > 0;
prevBtn.disabled = !hasMatches;
nextBtn.disabled = !hasMatches;
},
showError(message) {
const errorEl = document.getElementById("find-error");
if (!errorEl) return;
errorEl.textContent = message;
errorEl.hidden = false;
},
hideError() {
const errorEl = document.getElementById("find-error");
if (!errorEl) return;
errorEl.hidden = true;
},
saveState() {
try {
const state = {
options: this.options,
};
localStorage.setItem("obsigate-find-in-page-state", JSON.stringify(state));
} catch (e) {
// Ignore localStorage errors
}
},
loadState() {
try {
const saved = localStorage.getItem("obsigate-find-in-page-state");
if (saved) {
const state = JSON.parse(saved);
if (state.options) {
this.options = { ...this.options, ...state.options };
// Update button states
const caseSensitiveBtn = document.getElementById("find-case-sensitive");
const wholeWordBtn = document.getElementById("find-whole-word");
const regexBtn = document.getElementById("find-regex");
if (caseSensitiveBtn) caseSensitiveBtn.setAttribute("aria-pressed", this.options.caseSensitive);
if (wholeWordBtn) wholeWordBtn.setAttribute("aria-pressed", this.options.wholeWord);
if (regexBtn) regexBtn.setAttribute("aria-pressed", this.options.useRegex);
}
}
} catch (e) {
// Ignore localStorage errors
}
},
};
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
async function init() {
initTheme();
initHeaderMenu();
initCustomDropdowns();
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
document.getElementById("header-logo").addEventListener("click", goHome);
const refreshBtn = document.getElementById("header-refresh-btn");
if (refreshBtn) refreshBtn.addEventListener("click", goHome);
initSearch();
initSidebarToggle();
initMobile();
initVaultContext();
initSidebarTabs();
initHelpModal();
initConfigModal();
initSidebarFilter();
initSidebarResize();
initEditor();
initSyncStatus();
initLoginForm();
initRecentTab();
RightSidebarManager.init();
FindInPageManager.init();
// Check auth status first
const authOk = await AuthManager.initAuth();
if (authOk) {
try {
await Promise.all([loadVaultSettings(), loadVaults(), loadTags()]);
// Initialize dashboard widgets now that vaults are loaded
if (typeof DashboardRecentWidget !== "undefined") {
DashboardRecentWidget.init();
}
// Check for popup mode query parameter
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("popup") === "true") {
document.body.classList.add("popup-mode");
}
// Handle direct deep-link to file via #file=...
if (window.location.hash && window.location.hash.startsWith("#file=")) {
const hashVal = window.location.hash.substring(6);
const sepIndex = hashVal.indexOf(":");
if (sepIndex > -1) {
const vault = decodeURIComponent(hashVal.substring(0, sepIndex));
const path = decodeURIComponent(hashVal.substring(sepIndex + 1));
openFile(vault, path);
}
} else if (urlParams.get("popup") !== "true") {
// Default to dashboard if no deep link and not in popup mode
showWelcome();
}
} catch (err) {
console.error("Failed to initialize ObsiGate:", err);
showToast("Erreur lors de l'initialisation", "error");
}
}
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// PWA Service Worker Registration
// ---------------------------------------------------------------------------
function registerServiceWorker() {
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("[PWA] Service Worker registered successfully:", registration.scope);
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60000); // Check every minute
// Handle service worker updates
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
// New service worker available
showUpdateNotification();
}
});
});
})
.catch((error) => {
console.log("[PWA] Service Worker registration failed:", error);
});
});
}
}
function showUpdateNotification() {
const message = document.createElement("div");
message.className = "pwa-update-notification";
message.innerHTML = `
<div class="pwa-update-content">
<span>Une nouvelle version d'ObsiGate est disponible !</span>
<button class="pwa-update-btn" onclick="window.location.reload()">Mettre à jour</button>
<button class="pwa-update-dismiss" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
`;
document.body.appendChild(message);
// Auto-dismiss after 30 seconds
setTimeout(() => {
if (message.parentElement) {
message.remove();
}
}, 30000);
}
// Handle install prompt
let deferredPrompt;
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
deferredPrompt = e;
// Show install button if desired
const installBtn = document.getElementById("pwa-install-btn");
if (installBtn) {
installBtn.style.display = "block";
installBtn.addEventListener("click", async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`[PWA] User response to install prompt: ${outcome}`);
deferredPrompt = null;
installBtn.style.display = "none";
}
});
}
});
// Log when app is installed
window.addEventListener("appinstalled", () => {
console.log("[PWA] ObsiGate has been installed");
showToast("ObsiGate installé avec succès !", "success");
});
document.addEventListener("DOMContentLoaded", () => {
init();
registerServiceWorker();
});
})();