frontend/js/ structure: state.js (55 lines) — Shared mutable state, constants utils.js (510 lines) — EXT_ICONS, getFileIcon, escapeHtml, safeCreateIcons auth.js (547 lines) — api(), AuthManager, initLoginForm, AdminPanel search.js (1106 lines)— SearchHistory, QueryParser, Autocomplete, performSearch sidebar.js (1091 lines)— Vault tree, sidebar filter, TagFilterService, loadTags viewer.js (1554 lines)— openFile, Outline, ScrollSpy, Frontmatter, Editor ui.js (2250 lines)— Theme, Toast, Sidebar, Dropdowns, Tabs, ContextMenu dashboard.js (461 lines) — Dashboard widgets (Recent, Stats, Bookmarks) config.js (999 lines) — Config panel, Hidden files, About, Sidebar tabs sync.js (436 lines) — SSE/IndexUpdateManager, PWA registration graph.js (401 lines) — GraphViewManager (force-directed canvas graph) legacy.js (550 lines) — Remaining bridge functions (goHome, showWelcome, initSearch) app.js (80 lines) — Thin orchestrator: imports all modules, calls init() index.html: switched from <script src="app.js"> to <script type="module" src="js/app.js"> Original app.js preserved for backward compatibility. All 14 modules pass node --check syntax validation.
1088 lines
40 KiB
JavaScript
1088 lines
40 KiB
JavaScript
// ---------------------------------------------------------------------------
|
||
// Vault context switching
|
||
// ---------------------------------------------------------------------------
|
||
function initVaultContext() {
|
||
const filter = document.getElementById("vault-filter");
|
||
const quickSelect = document.getElementById("vault-quick-select");
|
||
if (!filter || !quickSelect) return;
|
||
|
||
filter.addEventListener("change", async () => {
|
||
await setSelectedVaultContext(filter.value, { focusVault: filter.value !== "all" });
|
||
});
|
||
|
||
quickSelect.addEventListener("change", async () => {
|
||
await setSelectedVaultContext(quickSelect.value, { focusVault: quickSelect.value !== "all" });
|
||
});
|
||
}
|
||
|
||
async function setSelectedVaultContext(vaultName, options) {
|
||
selectedContextVault = vaultName;
|
||
showingSource = false;
|
||
cachedRawSource = null;
|
||
syncVaultSelectors();
|
||
await refreshSidebarForContext();
|
||
await refreshTagsForContext();
|
||
|
||
// Synchroniser le dashboard et les fichiers récents
|
||
if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.load) {
|
||
DashboardRecentWidget.load(vaultName);
|
||
}
|
||
if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) {
|
||
DashboardBookmarkWidget.load(vaultName);
|
||
}
|
||
if (activeSidebarTab === "recent") {
|
||
loadRecentFiles(vaultName === "all" ? null : vaultName);
|
||
}
|
||
|
||
showWelcome();
|
||
if (options && options.focusVault && vaultName !== "all") {
|
||
await focusVaultInSidebar(vaultName);
|
||
}
|
||
}
|
||
|
||
function syncVaultSelectors() {
|
||
const filter = document.getElementById("vault-filter");
|
||
const quickSelect = document.getElementById("vault-quick-select");
|
||
const recentFilter = document.getElementById("recent-vault-filter");
|
||
const dashboardFilter = document.getElementById("dashboard-vault-filter");
|
||
const contextText = document.getElementById("vault-context-text");
|
||
|
||
if (filter) filter.value = selectedContextVault;
|
||
if (quickSelect) quickSelect.value = selectedContextVault;
|
||
if (recentFilter) recentFilter.value = selectedContextVault === "all" ? "" : selectedContextVault;
|
||
if (dashboardFilter) dashboardFilter.value = selectedContextVault;
|
||
|
||
// Mise à jour visuelle des dropdowns personnalisés
|
||
updateCustomDropdownVisual("vault-filter-dropdown", selectedContextVault);
|
||
updateCustomDropdownVisual("vault-quick-select-dropdown", selectedContextVault);
|
||
|
||
// Update vault context indicator
|
||
if (contextText) {
|
||
contextText.textContent = selectedContextVault === "all" ? "All" : selectedContextVault;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Updates the visual state of a custom dropdown based on its current value.
|
||
*/
|
||
function updateCustomDropdownVisual(dropdownId, value) {
|
||
const dropdown = document.getElementById(dropdownId);
|
||
if (!dropdown) return;
|
||
|
||
const selectedText = dropdown.querySelector(".custom-dropdown-selected");
|
||
const options = dropdown.querySelectorAll(".custom-dropdown-option");
|
||
|
||
options.forEach((opt) => {
|
||
const optValue = opt.getAttribute("data-value");
|
||
if (optValue === value) {
|
||
opt.classList.add("selected");
|
||
if (selectedText) selectedText.textContent = opt.textContent;
|
||
} else {
|
||
opt.classList.remove("selected");
|
||
}
|
||
});
|
||
}
|
||
|
||
function scrollTreeItemIntoView(element, alignToTop) {
|
||
if (!element) return;
|
||
const scrollContainer = document.getElementById("sidebar-panel-vaults");
|
||
if (!scrollContainer) return;
|
||
|
||
const containerRect = scrollContainer.getBoundingClientRect();
|
||
const elementRect = element.getBoundingClientRect();
|
||
const isAbove = elementRect.top < containerRect.top;
|
||
const isBelow = elementRect.bottom > containerRect.bottom;
|
||
|
||
if (!isAbove && !isBelow && !alignToTop) return;
|
||
|
||
const currentTop = scrollContainer.scrollTop;
|
||
const offsetTop = element.offsetTop;
|
||
const shouldCenter = alignToTop === "center";
|
||
const centeredTop = Math.max(0, currentTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2));
|
||
const targetTop = shouldCenter
|
||
? centeredTop
|
||
: alignToTop
|
||
? Math.max(0, offsetTop - 60)
|
||
: Math.max(0, currentTop + (elementRect.top - containerRect.top) - containerRect.height * 0.35);
|
||
|
||
scrollContainer.scrollTo({
|
||
top: targetTop,
|
||
behavior: "smooth",
|
||
});
|
||
}
|
||
|
||
async function refreshSidebarForContext() {
|
||
const container = document.getElementById("vault-tree");
|
||
container.innerHTML = "";
|
||
|
||
const vaultsToShow = selectedContextVault === "all" ? allVaults : allVaults.filter((v) => v.name === selectedContextVault);
|
||
|
||
vaultsToShow.forEach((v) => {
|
||
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
|
||
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
|
||
|
||
vaultItem.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
const isReadonly = false;
|
||
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
||
});
|
||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
|
||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
|
||
|
||
container.appendChild(vaultItem);
|
||
|
||
const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
|
||
container.appendChild(childContainer);
|
||
});
|
||
|
||
safeCreateIcons();
|
||
}
|
||
|
||
async function focusVaultInSidebar(vaultName) {
|
||
switchSidebarTab("vaults");
|
||
const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`);
|
||
if (!vaultItem) return;
|
||
document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused"));
|
||
vaultItem.classList.add("focused");
|
||
const childContainer = document.getElementById(`vault-children-${vaultName}`);
|
||
if (childContainer && childContainer.classList.contains("collapsed")) {
|
||
await toggleVault(vaultItem, vaultName, true);
|
||
}
|
||
scrollTreeItemIntoView(vaultItem, false);
|
||
}
|
||
|
||
async function refreshTagsForContext() {
|
||
const vaultParam = selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(selectedContextVault)}`;
|
||
const data = await api(`/api/tags${vaultParam}`);
|
||
const filteredTags = TagFilterService.filterTags(data.tags);
|
||
renderTagCloud(filteredTags);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helper: Check if path should be displayed based on hideHiddenFiles setting
|
||
// ---------------------------------------------------------------------------
|
||
function shouldDisplayPath(path, vaultName) {
|
||
// Get hideHiddenFiles setting for this vault (default: false = show all)
|
||
const settings = vaultSettings[vaultName] || { hideHiddenFiles: false };
|
||
|
||
if (!settings.hideHiddenFiles) {
|
||
// Show all files
|
||
return true;
|
||
}
|
||
|
||
// Check if any segment of the path starts with a dot (hidden)
|
||
const segments = path.split("/").filter(Boolean);
|
||
for (const segment of segments) {
|
||
if (segment.startsWith(".")) {
|
||
return false; // Hide this path
|
||
}
|
||
}
|
||
|
||
return true; // Show this path
|
||
}
|
||
|
||
async function loadVaultSettings() {
|
||
try {
|
||
const settings = await api("/api/vaults/settings/all");
|
||
vaultSettings = settings;
|
||
} catch (err) {
|
||
console.error("Failed to load vault settings:", err);
|
||
vaultSettings = {};
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sidebar — Vault tree
|
||
// ---------------------------------------------------------------------------
|
||
async function loadVaults() {
|
||
const vaults = await api("/api/vaults");
|
||
allVaults = vaults;
|
||
const container = document.getElementById("vault-tree");
|
||
container.innerHTML = "";
|
||
|
||
// Prepare dropdown options
|
||
const dropdownOptions = [{ value: "all", text: "Tous les vaults" }, ...vaults.map((v) => ({ value: v.name, text: v.name }))];
|
||
|
||
// Populate custom dropdowns
|
||
populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all");
|
||
populateCustomDropdown("vault-quick-select-dropdown", dropdownOptions, "all");
|
||
|
||
// Populate standard selects
|
||
_populateRecentVaultFilter();
|
||
if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.populateVaultFilter) {
|
||
DashboardRecentWidget.populateVaultFilter();
|
||
}
|
||
|
||
vaults.forEach((v) => {
|
||
// Sidebar tree entry
|
||
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
|
||
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
|
||
|
||
vaultItem.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
const isReadonly = false;
|
||
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
||
});
|
||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
|
||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
|
||
|
||
container.appendChild(vaultItem);
|
||
|
||
const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
|
||
container.appendChild(childContainer);
|
||
});
|
||
|
||
syncVaultSelectors();
|
||
safeCreateIcons();
|
||
}
|
||
|
||
/**
|
||
* Refreshes the sidebar tree while preserving the expanded state of vaults and folders.
|
||
* Optimized to avoid a full sidebar wipe and minimize visible loading states.
|
||
*/
|
||
/**
|
||
* Incrementally update a directory container without wiping existing DOM.
|
||
* Only adds new items, removes deleted ones, and updates changed ones.
|
||
*/
|
||
async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||
let data;
|
||
try {
|
||
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
|
||
data = await api(url);
|
||
} catch (err) {
|
||
// Server unavailable — keep existing content
|
||
return;
|
||
}
|
||
|
||
// Build a map of existing DOM elements by path
|
||
const existingItems = {};
|
||
const existingChildren = {}; // path -> child container (for directories)
|
||
for (let i = 0; i < container.children.length; i++) {
|
||
const child = container.children[i];
|
||
if (child.classList.contains("tree-item") && child.dataset.path) {
|
||
existingItems[child.dataset.path] = child;
|
||
// The next sibling should be the tree-children container for this directory
|
||
if (i + 1 < container.children.length) {
|
||
const next = container.children[i + 1];
|
||
if (next.classList.contains("tree-children")) {
|
||
existingChildren[child.dataset.path] = next;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const fragment = document.createDocumentFragment();
|
||
|
||
data.items.forEach((item) => {
|
||
if (!shouldDisplayPath(item.path, vaultName)) return;
|
||
|
||
const existing = existingItems[item.path];
|
||
|
||
if (existing) {
|
||
// Item already exists — reuse it, but update text/badge if needed
|
||
const textEl = existing.querySelector(".tree-item-text");
|
||
const displayName = item.type === "file" && item.name.match(/\.md$/i)
|
||
? item.name.replace(/\.md$/i, "")
|
||
: item.name;
|
||
if (textEl && textEl.textContent !== displayName) {
|
||
textEl.textContent = displayName;
|
||
}
|
||
// Update badge for directories
|
||
if (item.type === "directory") {
|
||
const badge = existing.querySelector(".badge-small");
|
||
const newBadge = `(${item.children_count})`;
|
||
if (badge && badge.textContent !== newBadge) {
|
||
badge.textContent = newBadge;
|
||
} else if (!badge) {
|
||
existing.appendChild(smallBadge(item.children_count));
|
||
}
|
||
}
|
||
fragment.appendChild(existing);
|
||
// Also re-add the child container for directories
|
||
if (item.type === "directory" && existingChildren[item.path]) {
|
||
fragment.appendChild(existingChildren[item.path]);
|
||
} else if (item.type === "directory") {
|
||
// Directory existed but no child container — create one
|
||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
||
fragment.appendChild(subContainer);
|
||
}
|
||
} else {
|
||
// New item — create it
|
||
if (item.type === "directory") {
|
||
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
|
||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
|
||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
||
fragment.appendChild(dirItem);
|
||
|
||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
||
fragment.appendChild(subContainer);
|
||
|
||
dirItem.addEventListener("click", async () => {
|
||
scrollTreeItemIntoView(dirItem, false);
|
||
if (subContainer.classList.contains("collapsed")) {
|
||
if (subContainer.children.length === 0) {
|
||
await loadDirectory(vaultName, item.path, subContainer);
|
||
}
|
||
subContainer.classList.remove("collapsed");
|
||
const chev = dirItem.querySelector("[data-lucide]");
|
||
if (chev) chev.setAttribute("data-lucide", "chevron-down");
|
||
safeCreateIcons();
|
||
} else {
|
||
subContainer.classList.add("collapsed");
|
||
const chev = dirItem.querySelector("[data-lucide]");
|
||
if (chev) chev.setAttribute("data-lucide", "chevron-right");
|
||
safeCreateIcons();
|
||
}
|
||
});
|
||
|
||
dirItem.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
|
||
});
|
||
} else {
|
||
const fileIconName = getFileIcon(item.name);
|
||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||
fileItem.addEventListener("click", () => {
|
||
scrollTreeItemIntoView(fileItem, false);
|
||
TabManager.openPreview(vaultName, item.path);
|
||
closeMobileSidebar();
|
||
});
|
||
|
||
fileItem.addEventListener("dblclick", (e) => {
|
||
e.preventDefault();
|
||
TabManager.openPersistent(vaultName, item.path);
|
||
});
|
||
|
||
fileItem.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false);
|
||
});
|
||
|
||
fragment.appendChild(fileItem);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Replace container content in a single batch operation to avoid flash
|
||
container.textContent = "";
|
||
container.appendChild(fragment);
|
||
}
|
||
|
||
async function refreshSidebarTreePreservingState() {
|
||
// 1. Capture expanded states
|
||
const expandedVaults = Array.from(document.querySelectorAll(".vault-item"))
|
||
.filter((v) => {
|
||
const children = document.getElementById(`vault-children-${v.dataset.vault}`);
|
||
return children && !children.classList.contains("collapsed");
|
||
})
|
||
.map((v) => v.dataset.vault);
|
||
|
||
const expandedDirs = Array.from(document.querySelectorAll(".tree-item[data-path]"))
|
||
.filter((item) => {
|
||
const vault = item.dataset.vault;
|
||
const path = item.dataset.path;
|
||
const children = document.getElementById(`dir-${vault}-${path}`);
|
||
return children && !children.classList.contains("collapsed");
|
||
})
|
||
.map((item) => ({ vault: item.dataset.vault, path: item.dataset.path }));
|
||
|
||
const selectedItem = document.querySelector(".tree-item.path-selected");
|
||
const selectedState = selectedItem ? { vault: selectedItem.dataset.vault, path: selectedItem.dataset.path } : null;
|
||
|
||
// 2. Soft update: vault names/counts without wiping the tree
|
||
try {
|
||
const vaults = await api("/api/vaults");
|
||
allVaults = vaults;
|
||
vaults.forEach((v) => {
|
||
const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`);
|
||
if (vItem) {
|
||
const badge = vItem.querySelector(".badge-small");
|
||
if (badge) badge.textContent = `(${v.file_count})`;
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.warn("Soft vault refresh failed, falling back to full reload", e);
|
||
await loadVaults();
|
||
return;
|
||
}
|
||
|
||
// 3. Incrementally update expanded vaults (no DOM wipe)
|
||
for (const vName of expandedVaults) {
|
||
const container = document.getElementById(`vault-children-${vName}`);
|
||
if (container) {
|
||
await incrementalLoadDirectory(vName, "", container);
|
||
}
|
||
}
|
||
|
||
// 4. Incrementally update expanded directories (parents first, no DOM wipe)
|
||
expandedDirs.sort((a, b) => a.path.split("/").length - b.path.split("/").length);
|
||
for (const dir of expandedDirs) {
|
||
const container = document.getElementById(`dir-${dir.vault}-${dir.path}`);
|
||
if (container) {
|
||
try {
|
||
await incrementalLoadDirectory(dir.vault, dir.path, container);
|
||
container.classList.remove("collapsed");
|
||
const dItem = document.querySelector(`.tree-item[data-vault="${CSS.escape(dir.vault)}"][data-path="${CSS.escape(dir.path)}"]`);
|
||
if (dItem) {
|
||
const chev = dItem.querySelector("[data-lucide]");
|
||
if (chev) chev.setAttribute("data-lucide", "chevron-down");
|
||
}
|
||
} catch (e) {
|
||
console.error(`Failed to refresh directory ${dir.vault}/${dir.path}`, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. Restore selection
|
||
if (selectedState) {
|
||
await focusPathInSidebar(selectedState.vault, selectedState.path, { alignToTop: false });
|
||
}
|
||
|
||
safeCreateIcons();
|
||
}
|
||
|
||
async function toggleVault(itemEl, vaultName, forceExpand) {
|
||
const childContainer = document.getElementById(`vault-children-${vaultName}`);
|
||
if (!childContainer) return;
|
||
|
||
scrollTreeItemIntoView(itemEl, false);
|
||
|
||
const shouldExpand = forceExpand || childContainer.classList.contains("collapsed");
|
||
|
||
if (shouldExpand) {
|
||
// Expand — load children if empty
|
||
if (childContainer.children.length === 0) {
|
||
await loadDirectory(vaultName, "", childContainer);
|
||
}
|
||
childContainer.classList.remove("collapsed");
|
||
// Swap chevron
|
||
const chevron = itemEl.querySelector("[data-lucide]");
|
||
if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
|
||
safeCreateIcons();
|
||
} else {
|
||
childContainer.classList.add("collapsed");
|
||
const chevron = itemEl.querySelector("[data-lucide]");
|
||
if (chevron) chevron.setAttribute("data-lucide", "chevron-right");
|
||
safeCreateIcons();
|
||
}
|
||
}
|
||
|
||
async function expandDirectoryInSidebar(vaultName, dirPath, dirItem) {
|
||
const subContainer = document.getElementById(`dir-${vaultName}-${dirPath}`);
|
||
if (!subContainer) return null;
|
||
|
||
if (subContainer.children.length === 0) {
|
||
await loadDirectory(vaultName, dirPath, subContainer);
|
||
}
|
||
|
||
subContainer.classList.remove("collapsed");
|
||
if (dirItem) {
|
||
const chevron = dirItem.querySelector("[data-lucide]");
|
||
if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
|
||
}
|
||
safeCreateIcons();
|
||
return subContainer;
|
||
}
|
||
|
||
async function focusPathInSidebar(vaultName, targetPath, options) {
|
||
switchSidebarTab("vaults");
|
||
|
||
const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`);
|
||
if (!vaultItem) return;
|
||
|
||
document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused"));
|
||
vaultItem.classList.add("focused");
|
||
|
||
const vaultContainer = document.getElementById(`vault-children-${vaultName}`);
|
||
if (!vaultContainer) return;
|
||
|
||
if (vaultContainer.classList.contains("collapsed")) {
|
||
await toggleVault(vaultItem, vaultName, true);
|
||
}
|
||
|
||
if (!targetPath) {
|
||
// Clear any previous path selection
|
||
document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected"));
|
||
scrollTreeItemIntoView(vaultItem, options && options.alignToTop);
|
||
return;
|
||
}
|
||
|
||
const segments = targetPath.split("/").filter(Boolean);
|
||
let currentContainer = vaultContainer;
|
||
let cumulativePath = "";
|
||
let lastTargetItem = null;
|
||
|
||
for (let index = 0; index < segments.length; index++) {
|
||
cumulativePath += (cumulativePath ? "/" : "") + segments[index];
|
||
|
||
let targetItem = null;
|
||
try {
|
||
targetItem = currentContainer.querySelector(`.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(cumulativePath)}"]`);
|
||
} catch (e) {
|
||
targetItem = null;
|
||
}
|
||
|
||
if (!targetItem) {
|
||
return;
|
||
}
|
||
|
||
lastTargetItem = targetItem;
|
||
|
||
const isLastSegment = index === segments.length - 1;
|
||
if (!isLastSegment) {
|
||
const nextContainer = await expandDirectoryInSidebar(vaultName, cumulativePath, targetItem);
|
||
if (nextContainer) {
|
||
currentContainer = nextContainer;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (lastTargetItem && options && options.expandTarget) {
|
||
await expandDirectoryInSidebar(vaultName, targetPath, lastTargetItem);
|
||
}
|
||
|
||
// Clear previous path selections and highlight the final target
|
||
document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected"));
|
||
if (lastTargetItem) {
|
||
lastTargetItem.classList.add("path-selected");
|
||
}
|
||
|
||
scrollTreeItemIntoView(lastTargetItem, options && options.alignToTop);
|
||
}
|
||
|
||
function getParentDirectoryPath(filePath) {
|
||
if (!filePath) return "";
|
||
const segments = filePath.split("/").filter(Boolean);
|
||
if (segments.length <= 1) return "";
|
||
segments.pop();
|
||
return segments.join("/");
|
||
}
|
||
|
||
function syncActiveFileTreeItem(vaultName, filePath) {
|
||
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
|
||
if (!vaultName || !filePath) return;
|
||
const selector = `.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(filePath)}"]`;
|
||
try {
|
||
const active = document.querySelector(selector);
|
||
if (active) active.classList.add("active");
|
||
} catch (e) {
|
||
/* selector might fail on special chars */
|
||
}
|
||
}
|
||
|
||
async function loadDirectory(vaultName, dirPath, container) {
|
||
// Only show the loading spinner if the container is currently empty
|
||
const isEmpty = container.children.length === 0;
|
||
if (isEmpty) {
|
||
container.innerHTML = '<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(filterDebounce);
|
||
filterDebounce = setTimeout(async () => {
|
||
const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||
if (hasText) {
|
||
if (activeSidebarTab === "vaults") {
|
||
await performTreeSearch(q);
|
||
} else {
|
||
filterTagCloud(q);
|
||
}
|
||
} else {
|
||
if (activeSidebarTab === "vaults") {
|
||
await restoreSidebarTree();
|
||
} else {
|
||
filterTagCloud("");
|
||
}
|
||
}
|
||
}, 220);
|
||
});
|
||
|
||
caseBtn.addEventListener("click", async () => {
|
||
sidebarFilterCaseSensitive = !sidebarFilterCaseSensitive;
|
||
caseBtn.classList.toggle("active");
|
||
const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||
if (input.value.trim()) {
|
||
if (activeSidebarTab === "vaults") {
|
||
await performTreeSearch(q);
|
||
} else {
|
||
filterTagCloud(q);
|
||
}
|
||
}
|
||
});
|
||
|
||
clearBtn.addEventListener("click", async () => {
|
||
input.value = "";
|
||
clearBtn.style.display = "none";
|
||
sidebarFilterCaseSensitive = false;
|
||
caseBtn.classList.remove("active");
|
||
clearTimeout(filterDebounce);
|
||
if (activeSidebarTab === "vaults") {
|
||
await restoreSidebarTree();
|
||
} else {
|
||
filterTagCloud("");
|
||
}
|
||
});
|
||
|
||
clearBtn.style.display = "none";
|
||
}
|
||
|
||
async function performTreeSearch(query) {
|
||
if (!query) {
|
||
await restoreSidebarTree();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const vaultParam = selectedContextVault === "all" ? "all" : selectedContextVault;
|
||
const url = `/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultParam)}`;
|
||
const data = await api(url);
|
||
renderFilteredSidebarResults(query, data.results);
|
||
} catch (err) {
|
||
console.error("Tree search error:", err);
|
||
renderFilteredSidebarResults(query, []);
|
||
}
|
||
}
|
||
|
||
async function restoreSidebarTree() {
|
||
await refreshSidebarForContext();
|
||
if (currentVault) {
|
||
focusPathInSidebar(currentVault, currentPath || "", { alignToTop: false }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
function renderFilteredSidebarResults(query, results) {
|
||
const container = document.getElementById("vault-tree");
|
||
container.innerHTML = "";
|
||
|
||
const grouped = new Map();
|
||
results.forEach((result) => {
|
||
if (!grouped.has(result.vault)) {
|
||
grouped.set(result.vault, []);
|
||
}
|
||
grouped.get(result.vault).push(result);
|
||
});
|
||
|
||
if (grouped.size === 0) {
|
||
container.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode("Aucun répertoire ou fichier correspondant.")]));
|
||
return;
|
||
}
|
||
|
||
grouped.forEach((entries, vaultName) => {
|
||
entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: "base" }));
|
||
|
||
const vaultHeader = el("div", { class: "tree-item vault-item filter-results-header", "data-vault": vaultName }, [getVaultIcon(vaultName, 16), document.createTextNode(` ${vaultName} `), smallBadge(entries.length)]);
|
||
container.appendChild(vaultHeader);
|
||
|
||
const resultsWrapper = el("div", { class: "filter-results-group" });
|
||
entries.forEach((entry) => {
|
||
const resultItem = el(
|
||
"div",
|
||
{
|
||
class: `tree-item filter-result-item filter-result-${entry.type}`,
|
||
"data-vault": entry.vault,
|
||
"data-path": entry.path,
|
||
"data-type": entry.type,
|
||
},
|
||
[icon(entry.type === "directory" ? "folder" : getFileIcon(entry.name), 16)],
|
||
);
|
||
|
||
const textWrap = el("div", { class: "filter-result-text" });
|
||
const primary = el("div", { class: "filter-result-primary" });
|
||
appendHighlightedText(primary, entry.name, query, sidebarFilterCaseSensitive);
|
||
const secondary = el("div", { class: "filter-result-secondary" });
|
||
appendHighlightedText(secondary, entry.path, query, sidebarFilterCaseSensitive);
|
||
textWrap.appendChild(primary);
|
||
textWrap.appendChild(secondary);
|
||
resultItem.appendChild(textWrap);
|
||
|
||
resultItem.addEventListener("click", async () => {
|
||
const input = document.getElementById("sidebar-filter-input");
|
||
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
|
||
if (input) input.value = "";
|
||
if (clearBtn) clearBtn.style.display = "none";
|
||
await restoreSidebarTree();
|
||
if (entry.type === "directory") {
|
||
await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true, expandTarget: true });
|
||
} else {
|
||
await TabManager.openPreview(entry.vault, entry.path);
|
||
await focusPathInSidebar(entry.vault, getParentDirectoryPath(entry.path), { alignToTop: true, expandTarget: true });
|
||
syncActiveFileTreeItem(entry.vault, entry.path);
|
||
}
|
||
closeMobileSidebar();
|
||
});
|
||
|
||
resultsWrapper.appendChild(resultItem);
|
||
});
|
||
|
||
container.appendChild(resultsWrapper);
|
||
});
|
||
|
||
flushIcons();
|
||
}
|
||
|
||
function filterSidebarTree(query) {
|
||
const tree = document.getElementById("vault-tree");
|
||
const items = tree.querySelectorAll(".tree-item");
|
||
const containers = tree.querySelectorAll(".tree-children");
|
||
|
||
if (!query) {
|
||
items.forEach((item) => item.classList.remove("filtered-out"));
|
||
containers.forEach((c) => {
|
||
c.classList.remove("filtered-out");
|
||
// Keep current collapsed state when clearing filter
|
||
});
|
||
return;
|
||
}
|
||
|
||
// First pass: mark all as filtered out
|
||
items.forEach((item) => item.classList.add("filtered-out"));
|
||
containers.forEach((c) => c.classList.add("filtered-out"));
|
||
|
||
// Second pass: find matching items and mark them + ancestors + descendants
|
||
const matchingItems = new Set();
|
||
|
||
items.forEach((item) => {
|
||
const text = sidebarFilterCaseSensitive ? item.textContent : item.textContent.toLowerCase();
|
||
const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase();
|
||
if (text.includes(searchQuery)) {
|
||
matchingItems.add(item);
|
||
item.classList.remove("filtered-out");
|
||
|
||
// Show all ancestor containers
|
||
let parent = item.parentElement;
|
||
while (parent && parent !== tree) {
|
||
parent.classList.remove("filtered-out");
|
||
if (parent.classList.contains("tree-children")) {
|
||
parent.classList.remove("collapsed");
|
||
}
|
||
parent = parent.parentElement;
|
||
}
|
||
|
||
// If this is a directory (has a children container after it), show all descendants
|
||
const nextEl = item.nextElementSibling;
|
||
if (nextEl && nextEl.classList.contains("tree-children")) {
|
||
nextEl.classList.remove("filtered-out");
|
||
nextEl.classList.remove("collapsed");
|
||
// Recursively show all children in this container
|
||
showAllDescendants(nextEl);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Third pass: show items that are descendants of matching directories
|
||
// and ensure their containers are visible
|
||
matchingItems.forEach((item) => {
|
||
const nextEl = item.nextElementSibling;
|
||
if (nextEl && nextEl.classList.contains("tree-children")) {
|
||
const children = nextEl.querySelectorAll(".tree-item");
|
||
children.forEach((child) => child.classList.remove("filtered-out"));
|
||
}
|
||
});
|
||
}
|
||
|
||
function showAllDescendants(container) {
|
||
const items = container.querySelectorAll(".tree-item");
|
||
items.forEach((item) => {
|
||
item.classList.remove("filtered-out");
|
||
// If this item has children, also show them
|
||
const nextEl = item.nextElementSibling;
|
||
if (nextEl && nextEl.classList.contains("tree-children")) {
|
||
nextEl.classList.remove("filtered-out");
|
||
nextEl.classList.remove("collapsed");
|
||
}
|
||
});
|
||
// Also ensure all nested containers are visible
|
||
const nestedContainers = container.querySelectorAll(".tree-children");
|
||
nestedContainers.forEach((c) => {
|
||
c.classList.remove("filtered-out");
|
||
c.classList.remove("collapsed");
|
||
});
|
||
}
|
||
|
||
function filterTagCloud(query) {
|
||
const tags = document.querySelectorAll("#tag-cloud .tag-item");
|
||
tags.forEach((tag) => {
|
||
const text = sidebarFilterCaseSensitive ? tag.textContent : tag.textContent.toLowerCase();
|
||
const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase();
|
||
if (!query || text.includes(searchQuery)) {
|
||
tag.classList.remove("filtered-out");
|
||
} else {
|
||
tag.classList.add("filtered-out");
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tag Filter Service
|
||
// ---------------------------------------------------------------------------
|
||
const TagFilterService = {
|
||
defaultFilters: [
|
||
{ pattern: "#<% ... %>", regex: "#<%.*%>", enabled: true },
|
||
{ pattern: "#{{ ... }}", regex: "#\\{\\{.*\\}\\}", enabled: true },
|
||
{ pattern: "#{ ... }", regex: "#\\{.*\\}", enabled: true },
|
||
],
|
||
|
||
getConfig() {
|
||
const stored = localStorage.getItem("obsigate-tag-filters");
|
||
if (stored) {
|
||
try {
|
||
return JSON.parse(stored);
|
||
} catch (e) {
|
||
return { tagFilters: this.defaultFilters };
|
||
}
|
||
}
|
||
return { tagFilters: this.defaultFilters };
|
||
},
|
||
|
||
saveConfig(config) {
|
||
localStorage.setItem("obsigate-tag-filters", JSON.stringify(config));
|
||
},
|
||
|
||
patternToRegex(pattern) {
|
||
// 1. Escape ALL special regex characters
|
||
// We use a broader set including * and .
|
||
let regex = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
|
||
// 2. Convert escaped '*' to '.*' (wildcard)
|
||
regex = regex.replace(/\\\*/g, ".*");
|
||
|
||
// 3. Convert escaped '...' (or any sequence of 2+ dots like ..) to '.*'
|
||
// We also handle optional whitespace around it to make it more user-friendly
|
||
regex = regex.replace(/\s*\\\.{2,}\s*/g, ".*");
|
||
|
||
return regex;
|
||
},
|
||
|
||
isTagFiltered(tag) {
|
||
const config = this.getConfig();
|
||
const filters = config.tagFilters || this.defaultFilters;
|
||
const tagWithHash = `#${tag}`;
|
||
|
||
for (const filter of filters) {
|
||
if (!filter.enabled) continue;
|
||
try {
|
||
// Robustly handle regex with or without ^/$
|
||
let patternStr = filter.regex;
|
||
if (!patternStr.startsWith("^")) patternStr = "^" + patternStr;
|
||
if (!patternStr.endsWith("$")) patternStr = patternStr + "$";
|
||
|
||
const regex = new RegExp(patternStr);
|
||
if (regex.test(tagWithHash)) {
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
console.warn("Invalid regex:", filter.regex, e);
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
|
||
filterTags(tags) {
|
||
const filtered = {};
|
||
Object.entries(tags).forEach(([tag, count]) => {
|
||
if (!this.isTagFiltered(tag)) {
|
||
filtered[tag] = count;
|
||
}
|
||
});
|
||
return filtered;
|
||
},
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tags
|
||
// ---------------------------------------------------------------------------
|
||
async function loadTags() {
|
||
const data = await api("/api/tags");
|
||
const filteredTags = TagFilterService.filterTags(data.tags);
|
||
renderTagCloud(filteredTags);
|
||
}
|
||
|
||
function renderTagCloud(tags) {
|
||
const cloud = document.getElementById("tag-cloud");
|
||
cloud.innerHTML = "";
|
||
|
||
const counts = Object.values(tags);
|
||
if (counts.length === 0) return;
|
||
|
||
const maxCount = Math.max(...counts);
|
||
const minSize = 0.7;
|
||
const maxSize = 1.25;
|
||
|
||
Object.entries(tags).forEach(([tag, count]) => {
|
||
const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0;
|
||
const size = minSize + ratio * (maxSize - minSize);
|
||
const tagEl = el("span", { class: "tag-item", style: `font-size:${size}rem` }, [document.createTextNode(`#${tag}`)]);
|
||
tagEl.addEventListener("click", () => searchByTag(tag));
|
||
cloud.appendChild(tagEl);
|
||
});
|
||
}
|
||
|
||
function addTagFilter(tag) {
|
||
if (!selectedTags.includes(tag)) {
|
||
selectedTags.push(tag);
|
||
performTagSearch();
|
||
}
|
||
}
|
||
|
||
function removeTagFilter(tag) {
|
||
selectedTags = selectedTags.filter((t) => t !== tag);
|
||
if (selectedTags.length > 0) {
|
||
performTagSearch();
|
||
} else {
|
||
const input = document.getElementById("search-input");
|
||
if (input.value.trim()) {
|
||
performAdvancedSearch(input.value.trim(), document.getElementById("vault-filter").value, null);
|
||
} else {
|
||
showWelcome();
|
||
}
|
||
}
|
||
}
|
||
|
||
function performTagSearch() {
|
||
const input = document.getElementById("search-input");
|
||
const query = input.value.trim();
|
||
const vault = document.getElementById("vault-filter").value;
|
||
performAdvancedSearch(query, vault, selectedTags.length > 0 ? selectedTags.join(",") : null);
|
||
}
|
||
|
||
function buildSearchResultsHeader(data, query, tagFilter) {
|
||
const header = el("div", { class: "search-results-header" });
|
||
const summaryText = el("span", { class: "search-results-summary-text" });
|
||
|
||
if (query && tagFilter) {
|
||
summaryText.textContent = `${data.count} résultat(s) pour "${query}" avec les tags`;
|
||
} else if (query) {
|
||
summaryText.textContent = `${data.count} résultat(s) pour "${query}"`;
|
||
} else if (tagFilter) {
|
||
summaryText.textContent = `${data.count} fichier(s) avec les tags`;
|
||
} else {
|
||
summaryText.textContent = `${data.count} résultat(s)`;
|
||
}
|
||
|
||
header.appendChild(summaryText);
|
||
|
||
if (selectedTags.length > 0) {
|
||
const activeTags = el("div", { class: "search-results-active-tags" });
|
||
selectedTags.forEach((tag) => {
|
||
const removeBtn = el(
|
||
"button",
|
||
{
|
||
class: "search-results-active-tag-remove",
|
||
title: `Retirer ${tag} du filtre`,
|
||
"aria-label": `Retirer ${tag} du filtre`,
|
||
},
|
||
[document.createTextNode("×")],
|
||
);
|
||
removeBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
removeTagFilter(tag);
|
||
});
|
||
|
||
const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]);
|
||
activeTags.appendChild(chip);
|
||
});
|
||
header.appendChild(activeTags);
|
||
}
|
||
|
||
return header;
|
||
}
|
||
|
||
function searchByTag(tag) {
|
||
addTagFilter(tag);
|
||
}
|
||
|