ObsiGate/frontend/js/sidebar.js
Bruno Charest eeae538d86
All checks were successful
CI / lint (push) Successful in 15s
CI / security (push) Successful in 9s
CI / test (push) Successful in 17s
CI / build (push) Successful in 2s
fix: flushIcons non importé dans sidebar.js
2026-05-29 15:41:05 -04:00

1098 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { state } from './state.js';
import { safeCreateIcons, getFileIcon, flushIcons } from './utils.js';
import { api } from './auth.js';
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
import { _populateRecentVaultFilter, switchSidebarTab } from './config.js';
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText } from './viewer.js';
import { performAdvancedSearch } from './search.js';
// ---------------------------------------------------------------------------
// 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) {
state.selectedContextVault = vaultName;
state.showingSource = false;
state.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 (state.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 = state.selectedContextVault;
if (quickSelect) quickSelect.value = state.selectedContextVault;
if (recentFilter) recentFilter.value = state.selectedContextVault === "all" ? "" : state.selectedContextVault;
if (dashboardFilter) dashboardFilter.value = state.selectedContextVault;
// Mise à jour visuelle des dropdowns personnalisés
updateCustomDropdownVisual("vault-filter-dropdown", state.selectedContextVault);
updateCustomDropdownVisual("vault-quick-select-dropdown", state.selectedContextVault);
// Update vault context indicator
if (contextText) {
contextText.textContent = state.selectedContextVault === "all" ? "All" : state.selectedContextVault;
}
}
/**
* Updates the visual state of a custom dropdown based on its current value.
*/
function updateCustomDropdownVisual(dropdownId, value) {
const dropdown = document.getElementById(dropdownId);
if (!dropdown) return;
const selectedText = dropdown.querySelector(".custom-dropdown-selected");
const options = dropdown.querySelectorAll(".custom-dropdown-option");
options.forEach((opt) => {
const optValue = opt.getAttribute("data-value");
if (optValue === value) {
opt.classList.add("selected");
if (selectedText) selectedText.textContent = opt.textContent;
} else {
opt.classList.remove("selected");
}
});
}
function scrollTreeItemIntoView(element, alignToTop) {
if (!element) return;
const scrollContainer = document.getElementById("sidebar-panel-vaults");
if (!scrollContainer) return;
const containerRect = scrollContainer.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const isAbove = elementRect.top < containerRect.top;
const isBelow = elementRect.bottom > containerRect.bottom;
if (!isAbove && !isBelow && !alignToTop) return;
const currentTop = scrollContainer.scrollTop;
const offsetTop = element.offsetTop;
const shouldCenter = alignToTop === "center";
const centeredTop = Math.max(0, currentTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2));
const targetTop = shouldCenter
? centeredTop
: alignToTop
? Math.max(0, offsetTop - 60)
: Math.max(0, currentTop + (elementRect.top - containerRect.top) - containerRect.height * 0.35);
scrollContainer.scrollTo({
top: targetTop,
behavior: "smooth",
});
}
async function refreshSidebarForContext() {
const container = document.getElementById("vault-tree");
container.innerHTML = "";
const vaultsToShow = state.selectedContextVault === "all" ? state.allVaults : state.allVaults.filter((v) => v.name === state.selectedContextVault);
vaultsToShow.forEach((v) => {
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
vaultItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
});
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
container.appendChild(vaultItem);
const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
container.appendChild(childContainer);
});
safeCreateIcons();
}
async function focusVaultInSidebar(vaultName) {
switchSidebarTab("vaults");
const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`);
if (!vaultItem) return;
document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused"));
vaultItem.classList.add("focused");
const childContainer = document.getElementById(`vault-children-${vaultName}`);
if (childContainer && childContainer.classList.contains("collapsed")) {
await toggleVault(vaultItem, vaultName, true);
}
scrollTreeItemIntoView(vaultItem, false);
}
async function refreshTagsForContext() {
const vaultParam = state.selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(state.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 = state.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");
state.vaultSettings = settings;
} catch (err) {
console.error("Failed to load vault settings:", err);
state.vaultSettings = {};
}
}
// ---------------------------------------------------------------------------
// Sidebar — Vault tree
// ---------------------------------------------------------------------------
async function loadVaults() {
const vaults = await api("/api/vaults");
state.allVaults = vaults;
const container = document.getElementById("vault-tree");
container.innerHTML = "";
// Prepare dropdown options
const dropdownOptions = [{ value: "all", text: "Tous les vaults" }, ...vaults.map((v) => ({ value: v.name, text: v.name }))];
// Populate custom dropdowns
populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all");
populateCustomDropdown("vault-quick-select-dropdown", dropdownOptions, "all");
// Populate standard selects
_populateRecentVaultFilter();
if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.populateVaultFilter) {
DashboardRecentWidget.populateVaultFilter();
}
vaults.forEach((v) => {
// Sidebar tree entry
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
vaultItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
});
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
container.appendChild(vaultItem);
const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
container.appendChild(childContainer);
});
syncVaultSelectors();
safeCreateIcons();
}
/**
* Refreshes the sidebar tree while preserving the expanded state of vaults and folders.
* Optimized to avoid a full sidebar wipe and minimize visible loading states.
*/
/**
* Incrementally update a directory container without wiping existing DOM.
* Only adds new items, removes deleted ones, and updates changed ones.
*/
async function incrementalLoadDirectory(vaultName, dirPath, container) {
let data;
try {
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
data = await api(url);
} catch (err) {
// Server unavailable — keep existing content
return;
}
// Build a map of existing DOM elements by path
const existingItems = {};
const existingChildren = {}; // path -> child container (for directories)
for (let i = 0; i < container.children.length; i++) {
const child = container.children[i];
if (child.classList.contains("tree-item") && child.dataset.path) {
existingItems[child.dataset.path] = child;
// The next sibling should be the tree-children container for this directory
if (i + 1 < container.children.length) {
const next = container.children[i + 1];
if (next.classList.contains("tree-children")) {
existingChildren[child.dataset.path] = next;
}
}
}
}
const fragment = document.createDocumentFragment();
data.items.forEach((item) => {
if (!shouldDisplayPath(item.path, vaultName)) return;
const existing = existingItems[item.path];
if (existing) {
// Item already exists — reuse it, but update text/badge if needed
const textEl = existing.querySelector(".tree-item-text");
const displayName = item.type === "file" && item.name.match(/\.md$/i)
? item.name.replace(/\.md$/i, "")
: item.name;
if (textEl && textEl.textContent !== displayName) {
textEl.textContent = displayName;
}
// Update badge for directories
if (item.type === "directory") {
const badge = existing.querySelector(".badge-small");
const newBadge = `(${item.children_count})`;
if (badge && badge.textContent !== newBadge) {
badge.textContent = newBadge;
} else if (!badge) {
existing.appendChild(smallBadge(item.children_count));
}
}
fragment.appendChild(existing);
// Also re-add the child container for directories
if (item.type === "directory" && existingChildren[item.path]) {
fragment.appendChild(existingChildren[item.path]);
} else if (item.type === "directory") {
// Directory existed but no child container — create one
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
fragment.appendChild(subContainer);
}
} else {
// New item — create it
if (item.type === "directory") {
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
fragment.appendChild(dirItem);
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
fragment.appendChild(subContainer);
dirItem.addEventListener("click", async () => {
scrollTreeItemIntoView(dirItem, false);
if (subContainer.classList.contains("collapsed")) {
if (subContainer.children.length === 0) {
await loadDirectory(vaultName, item.path, subContainer);
}
subContainer.classList.remove("collapsed");
const chev = dirItem.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
subContainer.classList.add("collapsed");
const chev = dirItem.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
});
dirItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
});
} else {
const fileIconName = getFileIcon(item.name);
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
fileItem.addEventListener("click", () => {
scrollTreeItemIntoView(fileItem, false);
TabManager.openPreview(vaultName, item.path);
closeMobileSidebar();
});
fileItem.addEventListener("dblclick", (e) => {
e.preventDefault();
TabManager.openPersistent(vaultName, item.path);
});
fileItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false);
});
fragment.appendChild(fileItem);
}
}
});
// Replace container content in a single batch operation to avoid flash
container.textContent = "";
container.appendChild(fragment);
}
export async function refreshSidebarTreePreservingState() {
// 1. Capture expanded states
const expandedVaults = Array.from(document.querySelectorAll(".vault-item"))
.filter((v) => {
const children = document.getElementById(`vault-children-${v.dataset.vault}`);
return children && !children.classList.contains("collapsed");
})
.map((v) => v.dataset.vault);
const expandedDirs = Array.from(document.querySelectorAll(".tree-item[data-path]"))
.filter((item) => {
const vault = item.dataset.vault;
const path = item.dataset.path;
const children = document.getElementById(`dir-${vault}-${path}`);
return children && !children.classList.contains("collapsed");
})
.map((item) => ({ vault: item.dataset.vault, path: item.dataset.path }));
const selectedItem = document.querySelector(".tree-item.path-selected");
const selectedState = selectedItem ? { vault: selectedItem.dataset.vault, path: selectedItem.dataset.path } : null;
// 2. Soft update: vault names/counts without wiping the tree
try {
const vaults = await api("/api/vaults");
state.allVaults = vaults;
vaults.forEach((v) => {
const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`);
if (vItem) {
const badge = vItem.querySelector(".badge-small");
if (badge) badge.textContent = `(${v.file_count})`;
}
});
} catch (e) {
console.warn("Soft vault refresh failed, falling back to full reload", e);
await loadVaults();
return;
}
// 3. Incrementally update expanded vaults (no DOM wipe)
for (const vName of expandedVaults) {
const container = document.getElementById(`vault-children-${vName}`);
if (container) {
await incrementalLoadDirectory(vName, "", container);
}
}
// 4. Incrementally update expanded directories (parents first, no DOM wipe)
expandedDirs.sort((a, b) => a.path.split("/").length - b.path.split("/").length);
for (const dir of expandedDirs) {
const container = document.getElementById(`dir-${dir.vault}-${dir.path}`);
if (container) {
try {
await incrementalLoadDirectory(dir.vault, dir.path, container);
container.classList.remove("collapsed");
const dItem = document.querySelector(`.tree-item[data-vault="${CSS.escape(dir.vault)}"][data-path="${CSS.escape(dir.path)}"]`);
if (dItem) {
const chev = dItem.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-down");
}
} catch (e) {
console.error(`Failed to refresh directory ${dir.vault}/${dir.path}`, e);
}
}
}
// 5. Restore selection
if (selectedState) {
await focusPathInSidebar(selectedState.vault, selectedState.path, { alignToTop: false });
}
safeCreateIcons();
}
async function toggleVault(itemEl, vaultName, forceExpand) {
const childContainer = document.getElementById(`vault-children-${vaultName}`);
if (!childContainer) return;
scrollTreeItemIntoView(itemEl, false);
const shouldExpand = forceExpand || childContainer.classList.contains("collapsed");
if (shouldExpand) {
// Expand — load children if empty
if (childContainer.children.length === 0) {
await loadDirectory(vaultName, "", childContainer);
}
childContainer.classList.remove("collapsed");
// Swap chevron
const chevron = itemEl.querySelector("[data-lucide]");
if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
childContainer.classList.add("collapsed");
const chevron = itemEl.querySelector("[data-lucide]");
if (chevron) chevron.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
}
async function expandDirectoryInSidebar(vaultName, dirPath, dirItem) {
const subContainer = document.getElementById(`dir-${vaultName}-${dirPath}`);
if (!subContainer) return null;
if (subContainer.children.length === 0) {
await loadDirectory(vaultName, dirPath, subContainer);
}
subContainer.classList.remove("collapsed");
if (dirItem) {
const chevron = dirItem.querySelector("[data-lucide]");
if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
}
safeCreateIcons();
return subContainer;
}
async function focusPathInSidebar(vaultName, targetPath, options) {
switchSidebarTab("vaults");
const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`);
if (!vaultItem) return;
document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused"));
vaultItem.classList.add("focused");
const vaultContainer = document.getElementById(`vault-children-${vaultName}`);
if (!vaultContainer) return;
if (vaultContainer.classList.contains("collapsed")) {
await toggleVault(vaultItem, vaultName, true);
}
if (!targetPath) {
// Clear any previous path selection
document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected"));
scrollTreeItemIntoView(vaultItem, options && options.alignToTop);
return;
}
const segments = targetPath.split("/").filter(Boolean);
let currentContainer = vaultContainer;
let cumulativePath = "";
let lastTargetItem = null;
for (let index = 0; index < segments.length; index++) {
cumulativePath += (cumulativePath ? "/" : "") + segments[index];
let targetItem = null;
try {
targetItem = currentContainer.querySelector(`.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(cumulativePath)}"]`);
} catch (e) {
targetItem = null;
}
if (!targetItem) {
return;
}
lastTargetItem = targetItem;
const isLastSegment = index === segments.length - 1;
if (!isLastSegment) {
const nextContainer = await expandDirectoryInSidebar(vaultName, cumulativePath, targetItem);
if (nextContainer) {
currentContainer = nextContainer;
}
}
}
if (lastTargetItem && options && options.expandTarget) {
await expandDirectoryInSidebar(vaultName, targetPath, lastTargetItem);
}
// Clear previous path selections and highlight the final target
document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected"));
if (lastTargetItem) {
lastTargetItem.classList.add("path-selected");
}
scrollTreeItemIntoView(lastTargetItem, options && options.alignToTop);
}
function getParentDirectoryPath(filePath) {
if (!filePath) return "";
const segments = filePath.split("/").filter(Boolean);
if (segments.length <= 1) return "";
segments.pop();
return segments.join("/");
}
function syncActiveFileTreeItem(vaultName, filePath) {
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
if (!vaultName || !filePath) return;
const selector = `.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(filePath)}"]`;
try {
const active = document.querySelector(selector);
if (active) active.classList.add("active");
} catch (e) {
/* selector might fail on special chars */
}
}
async function loadDirectory(vaultName, dirPath, container) {
// Only show the loading spinner if the container is currently empty
const isEmpty = container.children.length === 0;
if (isEmpty) {
container.innerHTML = '<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), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
fragment.appendChild(dirItem);
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
fragment.appendChild(subContainer);
dirItem.addEventListener("click", async () => {
scrollTreeItemIntoView(dirItem, false);
if (subContainer.classList.contains("collapsed")) {
if (subContainer.children.length === 0) {
await loadDirectory(vaultName, item.path, subContainer);
}
subContainer.classList.remove("collapsed");
const chev = dirItem.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
subContainer.classList.add("collapsed");
const chev = dirItem.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
});
dirItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly);
});
} else {
const fileIconName = getFileIcon(item.name);
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
fileItem.addEventListener("click", () => {
scrollTreeItemIntoView(fileItem, false);
TabManager.openPreview(vaultName, item.path);
closeMobileSidebar();
});
fileItem.addEventListener("dblclick", (e) => {
e.preventDefault();
TabManager.openPersistent(vaultName, item.path);
});
fileItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly);
});
fragment.appendChild(fileItem);
}
});
container.appendChild(fragment);
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// Sidebar filter
// ---------------------------------------------------------------------------
function initSidebarFilter() {
const input = document.getElementById("sidebar-filter-input");
const caseBtn = document.getElementById("sidebar-filter-case-btn");
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
input.addEventListener("input", () => {
const hasText = input.value.length > 0;
clearBtn.style.display = hasText ? "flex" : "none";
clearTimeout(state.filterDebounce);
state.filterDebounce = setTimeout(async () => {
const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
if (hasText) {
if (state.activeSidebarTab === "vaults") {
await performTreeSearch(q);
} else {
filterTagCloud(q);
}
} else {
if (state.activeSidebarTab === "vaults") {
await restoreSidebarTree();
} else {
filterTagCloud("");
}
}
}, 220);
});
caseBtn.addEventListener("click", async () => {
state.sidebarFilterCaseSensitive = !state.sidebarFilterCaseSensitive;
caseBtn.classList.toggle("active");
const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
if (input.value.trim()) {
if (state.activeSidebarTab === "vaults") {
await performTreeSearch(q);
} else {
filterTagCloud(q);
}
}
});
clearBtn.addEventListener("click", async () => {
input.value = "";
clearBtn.style.display = "none";
state.sidebarFilterCaseSensitive = false;
caseBtn.classList.remove("active");
clearTimeout(state.filterDebounce);
if (state.activeSidebarTab === "vaults") {
await restoreSidebarTree();
} else {
filterTagCloud("");
}
});
clearBtn.style.display = "none";
}
async function performTreeSearch(query) {
if (!query) {
await restoreSidebarTree();
return;
}
try {
const vaultParam = state.selectedContextVault === "all" ? "all" : state.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 (state.currentVault) {
focusPathInSidebar(state.currentVault, state.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, state.sidebarFilterCaseSensitive);
const secondary = el("div", { class: "filter-result-secondary" });
appendHighlightedText(secondary, entry.path, query, state.sidebarFilterCaseSensitive);
textWrap.appendChild(primary);
textWrap.appendChild(secondary);
resultItem.appendChild(textWrap);
resultItem.addEventListener("click", async () => {
const input = document.getElementById("sidebar-filter-input");
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
if (input) input.value = "";
if (clearBtn) clearBtn.style.display = "none";
await restoreSidebarTree();
if (entry.type === "directory") {
await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true, expandTarget: true });
} else {
await TabManager.openPreview(entry.vault, entry.path);
await focusPathInSidebar(entry.vault, getParentDirectoryPath(entry.path), { alignToTop: true, expandTarget: true });
syncActiveFileTreeItem(entry.vault, entry.path);
}
closeMobileSidebar();
});
resultsWrapper.appendChild(resultItem);
});
container.appendChild(resultsWrapper);
});
flushIcons();
}
function filterSidebarTree(query) {
const tree = document.getElementById("vault-tree");
const items = tree.querySelectorAll(".tree-item");
const containers = tree.querySelectorAll(".tree-children");
if (!query) {
items.forEach((item) => item.classList.remove("filtered-out"));
containers.forEach((c) => {
c.classList.remove("filtered-out");
// Keep current collapsed state when clearing filter
});
return;
}
// First pass: mark all as filtered out
items.forEach((item) => item.classList.add("filtered-out"));
containers.forEach((c) => c.classList.add("filtered-out"));
// Second pass: find matching items and mark them + ancestors + descendants
const matchingItems = new Set();
items.forEach((item) => {
const text = state.sidebarFilterCaseSensitive ? item.textContent : item.textContent.toLowerCase();
const searchQuery = state.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 = state.sidebarFilterCaseSensitive ? tag.textContent : tag.textContent.toLowerCase();
const searchQuery = state.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 (!state.selectedTags.includes(tag)) {
state.selectedTags.push(tag);
performTagSearch();
}
}
function removeTagFilter(tag) {
state.selectedTags = state.selectedTags.filter((t) => t !== tag);
if (state.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, state.selectedTags.length > 0 ? state.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 (state.selectedTags.length > 0) {
const activeTags = el("div", { class: "search-results-active-tags" });
state.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);
}
export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, refreshTagsForContext, syncActiveFileTreeItem, searchByTag, addTagFilter, buildSearchResultsHeader, removeTagFilter };