6064 lines
210 KiB
JavaScript
6064 lines
210 KiB
JavaScript
/* 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();
|
||
});
|
||
})();
|