ObsiGate/frontend/js/sidebar.js
Bruno Charest 7866f93778
All checks were successful
CI / lint (push) Successful in 13s
CI / security (push) Successful in 8s
CI / test (push) Successful in 16s
CI / build (push) Successful in 6s
refactor: state.js → mutable object to fix 'assignment to constant' errors
ES module imports are read-only live bindings — can't reassign
imported let/const variables. Replace individual 'export let' with
single 'export const state = {...}' mutable object.

All modules updated: import { state } from './state.js'
All state access changed to state.xxx pattern.

Fixes cascade of 'Assignment to constant variable' errors.
2026-05-28 16:34:39 -04:00

1092 lines
48 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';
2|
3|// ---------------------------------------------------------------------------
4|// Vault context switching
5|// ---------------------------------------------------------------------------
6|function initVaultContext() {
7| const filter = document.getElementById("vault-filter");
8| const quickSelect = document.getElementById("vault-quick-select");
9| if (!filter || !quickSelect) return;
10|
11| filter.addEventListener("change", async () => {
12| await setSelectedVaultContext(filter.value, { focusVault: filter.value !== "all" });
13| });
14|
15| quickSelect.addEventListener("change", async () => {
16| await setSelectedVaultContext(quickSelect.value, { focusVault: quickSelect.value !== "all" });
17| });
18|}
19|
20|async function setSelectedVaultContext(vaultName, options) {
21| state.selectedContextVault = vaultName;
22| state.showingSource = false;
23| state.cachedRawSource = null;
24| syncVaultSelectors();
25| await refreshSidebarForContext();
26| await refreshTagsForContext();
27|
28| // Synchroniser le dashboard et les fichiers récents
29| if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.load) {
30| DashboardRecentWidget.load(vaultName);
31| }
32| if (typeof DashboardBookmarkWidget !== "undefined" && DashboardBookmarkWidget.load) {
33| DashboardBookmarkWidget.load(vaultName);
34| }
35| if (state.activeSidebarTab === "recent") {
36| loadRecentFiles(vaultName === "all" ? null : vaultName);
37| }
38|
39| showWelcome();
40| if (options && options.focusVault && vaultName !== "all") {
41| await focusVaultInSidebar(vaultName);
42| }
43|}
44|
45|function syncVaultSelectors() {
46| const filter = document.getElementById("vault-filter");
47| const quickSelect = document.getElementById("vault-quick-select");
48| const recentFilter = document.getElementById("recent-vault-filter");
49| const dashboardFilter = document.getElementById("dashboard-vault-filter");
50| const contextText = document.getElementById("vault-context-text");
51|
52| if (filter) filter.value = state.selectedContextVault;
53| if (quickSelect) quickSelect.value = state.selectedContextVault;
54| if (recentFilter) recentFilter.value = state.selectedContextVault === "all" ? "" : state.selectedContextVault;
55| if (dashboardFilter) dashboardFilter.value = state.selectedContextVault;
56|
57| // Mise à jour visuelle des dropdowns personnalisés
58| updateCustomDropdownVisual("vault-filter-dropdown", state.selectedContextVault);
59| updateCustomDropdownVisual("vault-quick-select-dropdown", state.selectedContextVault);
60|
61| // Update vault context indicator
62| if (contextText) {
63| contextText.textContent = state.selectedContextVault === "all" ? "All" : state.selectedContextVault;
64| }
65|}
66|
67|/**
68| * Updates the visual state of a custom dropdown based on its current value.
69| */
70|function updateCustomDropdownVisual(dropdownId, value) {
71| const dropdown = document.getElementById(dropdownId);
72| if (!dropdown) return;
73|
74| const selectedText = dropdown.querySelector(".custom-dropdown-selected");
75| const options = dropdown.querySelectorAll(".custom-dropdown-option");
76|
77| options.forEach((opt) => {
78| const optValue = opt.getAttribute("data-value");
79| if (optValue === value) {
80| opt.classList.add("selected");
81| if (selectedText) selectedText.textContent = opt.textContent;
82| } else {
83| opt.classList.remove("selected");
84| }
85| });
86|}
87|
88|function scrollTreeItemIntoView(element, alignToTop) {
89| if (!element) return;
90| const scrollContainer = document.getElementById("sidebar-panel-vaults");
91| if (!scrollContainer) return;
92|
93| const containerRect = scrollContainer.getBoundingClientRect();
94| const elementRect = element.getBoundingClientRect();
95| const isAbove = elementRect.top < containerRect.top;
96| const isBelow = elementRect.bottom > containerRect.bottom;
97|
98| if (!isAbove && !isBelow && !alignToTop) return;
99|
100| const currentTop = scrollContainer.scrollTop;
101| const offsetTop = element.offsetTop;
102| const shouldCenter = alignToTop === "center";
103| const centeredTop = Math.max(0, currentTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2));
104| const targetTop = shouldCenter
105| ? centeredTop
106| : alignToTop
107| ? Math.max(0, offsetTop - 60)
108| : Math.max(0, currentTop + (elementRect.top - containerRect.top) - containerRect.height * 0.35);
109|
110| scrollContainer.scrollTo({
111| top: targetTop,
112| behavior: "smooth",
113| });
114|}
115|
116|async function refreshSidebarForContext() {
117| const container = document.getElementById("vault-tree");
118| container.innerHTML = "";
119|
120| const vaultsToShow = state.selectedContextVault === "all" ? state.allVaults : state.allVaults.filter((v) => v.name === state.selectedContextVault);
121|
122| vaultsToShow.forEach((v) => {
123| 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)]);
124| vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
125|
126| vaultItem.addEventListener("contextmenu", (e) => {
127| e.preventDefault();
128| const isReadonly = false;
129| ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
130| });
131| attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
132| attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
133|
134| container.appendChild(vaultItem);
135|
136| const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
137| container.appendChild(childContainer);
138| });
139|
140| safeCreateIcons();
141|}
142|
143|async function focusVaultInSidebar(vaultName) {
144| switchSidebarTab("vaults");
145| const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`);
146| if (!vaultItem) return;
147| document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused"));
148| vaultItem.classList.add("focused");
149| const childContainer = document.getElementById(`vault-children-${vaultName}`);
150| if (childContainer && childContainer.classList.contains("collapsed")) {
151| await toggleVault(vaultItem, vaultName, true);
152| }
153| scrollTreeItemIntoView(vaultItem, false);
154|}
155|
156|async function refreshTagsForContext() {
157| const vaultParam = state.selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(state.selectedContextVault)}`;
158| const data = await api(`/api/tags${vaultParam}`);
159| const filteredTags = TagFilterService.filterTags(data.tags);
160| renderTagCloud(filteredTags);
161|}
162|
163|// ---------------------------------------------------------------------------
164|// Helper: Check if path should be displayed based on hideHiddenFiles setting
165|// ---------------------------------------------------------------------------
166|function shouldDisplayPath(path, vaultName) {
167| // Get hideHiddenFiles setting for this vault (default: false = show all)
168| const settings = state.vaultSettings[vaultName] || { hideHiddenFiles: false };
169|
170| if (!settings.hideHiddenFiles) {
171| // Show all files
172| return true;
173| }
174|
175| // Check if any segment of the path starts with a dot (hidden)
176| const segments = path.split("/").filter(Boolean);
177| for (const segment of segments) {
178| if (segment.startsWith(".")) {
179| return false; // Hide this path
180| }
181| }
182|
183| return true; // Show this path
184|}
185|
186|async function loadVaultSettings() {
187| try {
188| const settings = await api("/api/vaults/settings/all");
189| state.vaultSettings = settings;
190| } catch (err) {
191| console.error("Failed to load vault settings:", err);
192| state.vaultSettings = {};
193| }
194|}
195|
196|// ---------------------------------------------------------------------------
197|// Sidebar — Vault tree
198|// ---------------------------------------------------------------------------
199|async function loadVaults() {
200| const vaults = await api("/api/vaults");
201| state.allVaults = vaults;
202| const container = document.getElementById("vault-tree");
203| container.innerHTML = "";
204|
205| // Prepare dropdown options
206| const dropdownOptions = [{ value: "all", text: "Tous les vaults" }, ...vaults.map((v) => ({ value: v.name, text: v.name }))];
207|
208| // Populate custom dropdowns
209| populateCustomDropdown("vault-filter-dropdown", dropdownOptions, "all");
210| populateCustomDropdown("vault-quick-select-dropdown", dropdownOptions, "all");
211|
212| // Populate standard selects
213| _populateRecentVaultFilter();
214| if (typeof DashboardRecentWidget !== "undefined" && DashboardRecentWidget.populateVaultFilter) {
215| DashboardRecentWidget.populateVaultFilter();
216| }
217|
218| vaults.forEach((v) => {
219| // Sidebar tree entry
220| 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)]);
221| vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
222|
223| vaultItem.addEventListener("contextmenu", (e) => {
224| e.preventDefault();
225| const isReadonly = false;
226| ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
227| });
228| attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
229| attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
230|
231| container.appendChild(vaultItem);
232|
233| const childContainer = el("div", { class: "tree-children collapsed", id: `vault-children-${v.name}` });
234| container.appendChild(childContainer);
235| });
236|
237| syncVaultSelectors();
238| safeCreateIcons();
239|}
240|
241|/**
242| * Refreshes the sidebar tree while preserving the expanded state of vaults and folders.
243| * Optimized to avoid a full sidebar wipe and minimize visible loading states.
244| */
245|/**
246| * Incrementally update a directory container without wiping existing DOM.
247| * Only adds new items, removes deleted ones, and updates changed ones.
248| */
249|async function incrementalLoadDirectory(vaultName, dirPath, container) {
250| let data;
251| try {
252| const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
253| data = await api(url);
254| } catch (err) {
255| // Server unavailable — keep existing content
256| return;
257| }
258|
259| // Build a map of existing DOM elements by path
260| const existingItems = {};
261| const existingChildren = {}; // path -> child container (for directories)
262| for (let i = 0; i < container.children.length; i++) {
263| const child = container.children[i];
264| if (child.classList.contains("tree-item") && child.dataset.path) {
265| existingItems[child.dataset.path] = child;
266| // The next sibling should be the tree-children container for this directory
267| if (i + 1 < container.children.length) {
268| const next = container.children[i + 1];
269| if (next.classList.contains("tree-children")) {
270| existingChildren[child.dataset.path] = next;
271| }
272| }
273| }
274| }
275|
276| const fragment = document.createDocumentFragment();
277|
278| data.items.forEach((item) => {
279| if (!shouldDisplayPath(item.path, vaultName)) return;
280|
281| const existing = existingItems[item.path];
282|
283| if (existing) {
284| // Item already exists — reuse it, but update text/badge if needed
285| const textEl = existing.querySelector(".tree-item-text");
286| const displayName = item.type === "file" && item.name.match(/\.md$/i)
287| ? item.name.replace(/\.md$/i, "")
288| : item.name;
289| if (textEl && textEl.textContent !== displayName) {
290| textEl.textContent = displayName;
291| }
292| // Update badge for directories
293| if (item.type === "directory") {
294| const badge = existing.querySelector(".badge-small");
295| const newBadge = `(${item.children_count})`;
296| if (badge && badge.textContent !== newBadge) {
297| badge.textContent = newBadge;
298| } else if (!badge) {
299| existing.appendChild(smallBadge(item.children_count));
300| }
301| }
302| fragment.appendChild(existing);
303| // Also re-add the child container for directories
304| if (item.type === "directory" && existingChildren[item.path]) {
305| fragment.appendChild(existingChildren[item.path]);
306| } else if (item.type === "directory") {
307| // Directory existed but no child container — create one
308| const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
309| fragment.appendChild(subContainer);
310| }
311| } else {
312| // New item — create it
313| if (item.type === "directory") {
314| 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)]);
315| attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
316| attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
317| fragment.appendChild(dirItem);
318|
319| const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
320| fragment.appendChild(subContainer);
321|
322| dirItem.addEventListener("click", async () => {
323| scrollTreeItemIntoView(dirItem, false);
324| if (subContainer.classList.contains("collapsed")) {
325| if (subContainer.children.length === 0) {
326| await loadDirectory(vaultName, item.path, subContainer);
327| }
328| subContainer.classList.remove("collapsed");
329| const chev = dirItem.querySelector("[data-lucide]");
330| if (chev) chev.setAttribute("data-lucide", "chevron-down");
331| safeCreateIcons();
332| } else {
333| subContainer.classList.add("collapsed");
334| const chev = dirItem.querySelector("[data-lucide]");
335| if (chev) chev.setAttribute("data-lucide", "chevron-right");
336| safeCreateIcons();
337| }
338| });
339|
340| dirItem.addEventListener("contextmenu", (e) => {
341| e.preventDefault();
342| ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
343| });
344| } else {
345| const fileIconName = getFileIcon(item.name);
346| const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
347| 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)])]);
348| attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
349| attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
350| fileItem.addEventListener("click", () => {
351| scrollTreeItemIntoView(fileItem, false);
352| TabManager.openPreview(vaultName, item.path);
353| closeMobileSidebar();
354| });
355|
356| fileItem.addEventListener("dblclick", (e) => {
357| e.preventDefault();
358| TabManager.openPersistent(vaultName, item.path);
359| });
360|
361| fileItem.addEventListener("contextmenu", (e) => {
362| e.preventDefault();
363| ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false);
364| });
365|
366| fragment.appendChild(fileItem);
367| }
368| }
369| });
370|
371| // Replace container content in a single batch operation to avoid flash
372| container.textContent = "";
373| container.appendChild(fragment);
374|}
375|
376|async function refreshSidebarTreePreservingState() {
377| // 1. Capture expanded states
378| const expandedVaults = Array.from(document.querySelectorAll(".vault-item"))
379| .filter((v) => {
380| const children = document.getElementById(`vault-children-${v.dataset.vault}`);
381| return children && !children.classList.contains("collapsed");
382| })
383| .map((v) => v.dataset.vault);
384|
385| const expandedDirs = Array.from(document.querySelectorAll(".tree-item[data-path]"))
386| .filter((item) => {
387| const vault = item.dataset.vault;
388| const path = item.dataset.path;
389| const children = document.getElementById(`dir-${vault}-${path}`);
390| return children && !children.classList.contains("collapsed");
391| })
392| .map((item) => ({ vault: item.dataset.vault, path: item.dataset.path }));
393|
394| const selectedItem = document.querySelector(".tree-item.path-selected");
395| const selectedState = selectedItem ? { vault: selectedItem.dataset.vault, path: selectedItem.dataset.path } : null;
396|
397| // 2. Soft update: vault names/counts without wiping the tree
398| try {
399| const vaults = await api("/api/vaults");
400| state.allVaults = vaults;
401| vaults.forEach((v) => {
402| const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`);
403| if (vItem) {
404| const badge = vItem.querySelector(".badge-small");
405| if (badge) badge.textContent = `(${v.file_count})`;
406| }
407| });
408| } catch (e) {
409| console.warn("Soft vault refresh failed, falling back to full reload", e);
410| await loadVaults();
411| return;
412| }
413|
414| // 3. Incrementally update expanded vaults (no DOM wipe)
415| for (const vName of expandedVaults) {
416| const container = document.getElementById(`vault-children-${vName}`);
417| if (container) {
418| await incrementalLoadDirectory(vName, "", container);
419| }
420| }
421|
422| // 4. Incrementally update expanded directories (parents first, no DOM wipe)
423| expandedDirs.sort((a, b) => a.path.split("/").length - b.path.split("/").length);
424| for (const dir of expandedDirs) {
425| const container = document.getElementById(`dir-${dir.vault}-${dir.path}`);
426| if (container) {
427| try {
428| await incrementalLoadDirectory(dir.vault, dir.path, container);
429| container.classList.remove("collapsed");
430| const dItem = document.querySelector(`.tree-item[data-vault="${CSS.escape(dir.vault)}"][data-path="${CSS.escape(dir.path)}"]`);
431| if (dItem) {
432| const chev = dItem.querySelector("[data-lucide]");
433| if (chev) chev.setAttribute("data-lucide", "chevron-down");
434| }
435| } catch (e) {
436| console.error(`Failed to refresh directory ${dir.vault}/${dir.path}`, e);
437| }
438| }
439| }
440|
441| // 5. Restore selection
442| if (selectedState) {
443| await focusPathInSidebar(selectedState.vault, selectedState.path, { alignToTop: false });
444| }
445|
446| safeCreateIcons();
447|}
448|
449|async function toggleVault(itemEl, vaultName, forceExpand) {
450| const childContainer = document.getElementById(`vault-children-${vaultName}`);
451| if (!childContainer) return;
452|
453| scrollTreeItemIntoView(itemEl, false);
454|
455| const shouldExpand = forceExpand || childContainer.classList.contains("collapsed");
456|
457| if (shouldExpand) {
458| // Expand — load children if empty
459| if (childContainer.children.length === 0) {
460| await loadDirectory(vaultName, "", childContainer);
461| }
462| childContainer.classList.remove("collapsed");
463| // Swap chevron
464| const chevron = itemEl.querySelector("[data-lucide]");
465| if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
466| safeCreateIcons();
467| } else {
468| childContainer.classList.add("collapsed");
469| const chevron = itemEl.querySelector("[data-lucide]");
470| if (chevron) chevron.setAttribute("data-lucide", "chevron-right");
471| safeCreateIcons();
472| }
473|}
474|
475|async function expandDirectoryInSidebar(vaultName, dirPath, dirItem) {
476| const subContainer = document.getElementById(`dir-${vaultName}-${dirPath}`);
477| if (!subContainer) return null;
478|
479| if (subContainer.children.length === 0) {
480| await loadDirectory(vaultName, dirPath, subContainer);
481| }
482|
483| subContainer.classList.remove("collapsed");
484| if (dirItem) {
485| const chevron = dirItem.querySelector("[data-lucide]");
486| if (chevron) chevron.setAttribute("data-lucide", "chevron-down");
487| }
488| safeCreateIcons();
489| return subContainer;
490|}
491|
492|async function focusPathInSidebar(vaultName, targetPath, options) {
493| switchSidebarTab("vaults");
494|
495| const vaultItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(vaultName)}"]`);
496| if (!vaultItem) return;
497|
498| document.querySelectorAll(".vault-item.focused").forEach((el) => el.classList.remove("focused"));
499| vaultItem.classList.add("focused");
500|
501| const vaultContainer = document.getElementById(`vault-children-${vaultName}`);
502| if (!vaultContainer) return;
503|
504| if (vaultContainer.classList.contains("collapsed")) {
505| await toggleVault(vaultItem, vaultName, true);
506| }
507|
508| if (!targetPath) {
509| // Clear any previous path selection
510| document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected"));
511| scrollTreeItemIntoView(vaultItem, options && options.alignToTop);
512| return;
513| }
514|
515| const segments = targetPath.split("/").filter(Boolean);
516| let currentContainer = vaultContainer;
517| let cumulativePath = "";
518| let lastTargetItem = null;
519|
520| for (let index = 0; index < segments.length; index++) {
521| cumulativePath += (cumulativePath ? "/" : "") + segments[index];
522|
523| let targetItem = null;
524| try {
525| targetItem = currentContainer.querySelector(`.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(cumulativePath)}"]`);
526| } catch (e) {
527| targetItem = null;
528| }
529|
530| if (!targetItem) {
531| return;
532| }
533|
534| lastTargetItem = targetItem;
535|
536| const isLastSegment = index === segments.length - 1;
537| if (!isLastSegment) {
538| const nextContainer = await expandDirectoryInSidebar(vaultName, cumulativePath, targetItem);
539| if (nextContainer) {
540| currentContainer = nextContainer;
541| }
542| }
543| }
544|
545| if (lastTargetItem && options && options.expandTarget) {
546| await expandDirectoryInSidebar(vaultName, targetPath, lastTargetItem);
547| }
548|
549| // Clear previous path selections and highlight the final target
550| document.querySelectorAll(".tree-item.path-selected").forEach((el) => el.classList.remove("path-selected"));
551| if (lastTargetItem) {
552| lastTargetItem.classList.add("path-selected");
553| }
554|
555| scrollTreeItemIntoView(lastTargetItem, options && options.alignToTop);
556|}
557|
558|function getParentDirectoryPath(filePath) {
559| if (!filePath) return "";
560| const segments = filePath.split("/").filter(Boolean);
561| if (segments.length <= 1) return "";
562| segments.pop();
563| return segments.join("/");
564|}
565|
566|function syncActiveFileTreeItem(vaultName, filePath) {
567| document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
568| if (!vaultName || !filePath) return;
569| const selector = `.tree-item[data-vault="${CSS.escape(vaultName)}"][data-path="${CSS.escape(filePath)}"]`;
570| try {
571| const active = document.querySelector(selector);
572| if (active) active.classList.add("active");
573| } catch (e) {
574| /* selector might fail on special chars */
575| }
576|}
577|
578|async function loadDirectory(vaultName, dirPath, container) {
579| // Only show the loading spinner if the container is currently empty
580| const isEmpty = container.children.length === 0;
581| if (isEmpty) {
582| container.innerHTML = '<div class="tree-loading"><div class="loading-spinner" style="width:16px;height:16px;border-width:2px"></div></div>';
583| }
584|
585| var data;
586| try {
587| const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
588| data = await api(url);
589| } catch (err) {
590| container.innerHTML = '<div class="tree-loading" style="color:var(--text-muted);font-size:0.75rem;padding:4px 16px">Erreur de chargement</div>';
591| return;
592| }
593| container.innerHTML = "";
594|
595| const fragment = document.createDocumentFragment();
596|
597| data.items.forEach((item) => {
598| // Apply client-side filtering for hidden files
599| if (!shouldDisplayPath(item.path, vaultName)) {
600| return; // Skip this item
601| }
602|
603| if (item.type === "directory") {
604| 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)]);
605| attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
606| attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
607| fragment.appendChild(dirItem);
608|
609| const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
610| fragment.appendChild(subContainer);
611|
612| dirItem.addEventListener("click", async () => {
613| scrollTreeItemIntoView(dirItem, false);
614| if (subContainer.classList.contains("collapsed")) {
615| if (subContainer.children.length === 0) {
616| await loadDirectory(vaultName, item.path, subContainer);
617| }
618| subContainer.classList.remove("collapsed");
619| const chev = dirItem.querySelector("[data-lucide]");
620| if (chev) chev.setAttribute("data-lucide", "chevron-down");
621| safeCreateIcons();
622| } else {
623| subContainer.classList.add("collapsed");
624| const chev = dirItem.querySelector("[data-lucide]");
625| if (chev) chev.setAttribute("data-lucide", "chevron-right");
626| safeCreateIcons();
627| }
628| });
629|
630| dirItem.addEventListener("contextmenu", (e) => {
631| e.preventDefault();
632| const isReadonly = false;
633| ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly);
634| });
635| } else {
636| const fileIconName = getFileIcon(item.name);
637| const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
638| 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)])]);
639| attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
640| attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
641| fileItem.addEventListener("click", () => {
642| scrollTreeItemIntoView(fileItem, false);
643| TabManager.openPreview(vaultName, item.path);
644| closeMobileSidebar();
645| });
646|
647| fileItem.addEventListener("dblclick", (e) => {
648| e.preventDefault();
649| TabManager.openPersistent(vaultName, item.path);
650| });
651|
652| fileItem.addEventListener("contextmenu", (e) => {
653| e.preventDefault();
654| const isReadonly = false;
655| ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly);
656| });
657|
658| fragment.appendChild(fileItem);
659| }
660| });
661|
662| container.appendChild(fragment);
663| safeCreateIcons();
664|}
665|
666|// ---------------------------------------------------------------------------
667|// Sidebar filter
668|// ---------------------------------------------------------------------------
669|function initSidebarFilter() {
670| const input = document.getElementById("sidebar-filter-input");
671| const caseBtn = document.getElementById("sidebar-filter-case-btn");
672| const clearBtn = document.getElementById("sidebar-filter-clear-btn");
673|
674| input.addEventListener("input", () => {
675| const hasText = input.value.length > 0;
676| clearBtn.style.display = hasText ? "flex" : "none";
677| clearTimeout(state.filterDebounce);
678| state.filterDebounce = setTimeout(async () => {
679| const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
680| if (hasText) {
681| if (state.activeSidebarTab === "vaults") {
682| await performTreeSearch(q);
683| } else {
684| filterTagCloud(q);
685| }
686| } else {
687| if (state.activeSidebarTab === "vaults") {
688| await restoreSidebarTree();
689| } else {
690| filterTagCloud("");
691| }
692| }
693| }, 220);
694| });
695|
696| caseBtn.addEventListener("click", async () => {
697| state.sidebarFilterCaseSensitive = !state.sidebarFilterCaseSensitive;
698| caseBtn.classList.toggle("active");
699| const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
700| if (input.value.trim()) {
701| if (state.activeSidebarTab === "vaults") {
702| await performTreeSearch(q);
703| } else {
704| filterTagCloud(q);
705| }
706| }
707| });
708|
709| clearBtn.addEventListener("click", async () => {
710| input.value = "";
711| clearBtn.style.display = "none";
712| state.sidebarFilterCaseSensitive = false;
713| caseBtn.classList.remove("active");
714| clearTimeout(state.filterDebounce);
715| if (state.activeSidebarTab === "vaults") {
716| await restoreSidebarTree();
717| } else {
718| filterTagCloud("");
719| }
720| });
721|
722| clearBtn.style.display = "none";
723|}
724|
725|async function performTreeSearch(query) {
726| if (!query) {
727| await restoreSidebarTree();
728| return;
729| }
730|
731| try {
732| const vaultParam = state.selectedContextVault === "all" ? "all" : state.selectedContextVault;
733| const url = `/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultParam)}`;
734| const data = await api(url);
735| renderFilteredSidebarResults(query, data.results);
736| } catch (err) {
737| console.error("Tree search error:", err);
738| renderFilteredSidebarResults(query, []);
739| }
740|}
741|
742|async function restoreSidebarTree() {
743| await refreshSidebarForContext();
744| if (state.currentVault) {
745| focusPathInSidebar(state.currentVault, currentPath || "", { alignToTop: false }).catch(() => {});
746| }
747|}
748|
749|function renderFilteredSidebarResults(query, results) {
750| const container = document.getElementById("vault-tree");
751| container.innerHTML = "";
752|
753| const grouped = new Map();
754| results.forEach((result) => {
755| if (!grouped.has(result.vault)) {
756| grouped.set(result.vault, []);
757| }
758| grouped.get(result.vault).push(result);
759| });
760|
761| if (grouped.size === 0) {
762| container.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode("Aucun répertoire ou fichier correspondant.")]));
763| return;
764| }
765|
766| grouped.forEach((entries, vaultName) => {
767| entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: "base" }));
768|
769| const vaultHeader = el("div", { class: "tree-item vault-item filter-results-header", "data-vault": vaultName }, [getVaultIcon(vaultName, 16), document.createTextNode(` ${vaultName} `), smallBadge(entries.length)]);
770| container.appendChild(vaultHeader);
771|
772| const resultsWrapper = el("div", { class: "filter-results-group" });
773| entries.forEach((entry) => {
774| const resultItem = el(
775| "div",
776| {
777| class: `tree-item filter-result-item filter-result-${entry.type}`,
778| "data-vault": entry.vault,
779| "data-path": entry.path,
780| "data-type": entry.type,
781| },
782| [icon(entry.type === "directory" ? "folder" : getFileIcon(entry.name), 16)],
783| );
784|
785| const textWrap = el("div", { class: "filter-result-text" });
786| const primary = el("div", { class: "filter-result-primary" });
787| appendHighlightedText(primary, entry.name, query, state.sidebarFilterCaseSensitive);
788| const secondary = el("div", { class: "filter-result-secondary" });
789| appendHighlightedText(secondary, entry.path, query, state.sidebarFilterCaseSensitive);
790| textWrap.appendChild(primary);
791| textWrap.appendChild(secondary);
792| resultItem.appendChild(textWrap);
793|
794| resultItem.addEventListener("click", async () => {
795| const input = document.getElementById("sidebar-filter-input");
796| const clearBtn = document.getElementById("sidebar-filter-clear-btn");
797| if (input) input.value = "";
798| if (clearBtn) clearBtn.style.display = "none";
799| await restoreSidebarTree();
800| if (entry.type === "directory") {
801| await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true, expandTarget: true });
802| } else {
803| await TabManager.openPreview(entry.vault, entry.path);
804| await focusPathInSidebar(entry.vault, getParentDirectoryPath(entry.path), { alignToTop: true, expandTarget: true });
805| syncActiveFileTreeItem(entry.vault, entry.path);
806| }
807| closeMobileSidebar();
808| });
809|
810| resultsWrapper.appendChild(resultItem);
811| });
812|
813| container.appendChild(resultsWrapper);
814| });
815|
816| flushIcons();
817|}
818|
819|function filterSidebarTree(query) {
820| const tree = document.getElementById("vault-tree");
821| const items = tree.querySelectorAll(".tree-item");
822| const containers = tree.querySelectorAll(".tree-children");
823|
824| if (!query) {
825| items.forEach((item) => item.classList.remove("filtered-out"));
826| containers.forEach((c) => {
827| c.classList.remove("filtered-out");
828| // Keep current collapsed state when clearing filter
829| });
830| return;
831| }
832|
833| // First pass: mark all as filtered out
834| items.forEach((item) => item.classList.add("filtered-out"));
835| containers.forEach((c) => c.classList.add("filtered-out"));
836|
837| // Second pass: find matching items and mark them + ancestors + descendants
838| const matchingItems = new Set();
839|
840| items.forEach((item) => {
841| const text = sidebarFilterCaseSensitive ? item.textContent : item.textContent.toLowerCase();
842| const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase();
843| if (text.includes(searchQuery)) {
844| matchingItems.add(item);
845| item.classList.remove("filtered-out");
846|
847| // Show all ancestor containers
848| let parent = item.parentElement;
849| while (parent && parent !== tree) {
850| parent.classList.remove("filtered-out");
851| if (parent.classList.contains("tree-children")) {
852| parent.classList.remove("collapsed");
853| }
854| parent = parent.parentElement;
855| }
856|
857| // If this is a directory (has a children container after it), show all descendants
858| const nextEl = item.nextElementSibling;
859| if (nextEl && nextEl.classList.contains("tree-children")) {
860| nextEl.classList.remove("filtered-out");
861| nextEl.classList.remove("collapsed");
862| // Recursively show all children in this container
863| showAllDescendants(nextEl);
864| }
865| }
866| });
867|
868| // Third pass: show items that are descendants of matching directories
869| // and ensure their containers are visible
870| matchingItems.forEach((item) => {
871| const nextEl = item.nextElementSibling;
872| if (nextEl && nextEl.classList.contains("tree-children")) {
873| const children = nextEl.querySelectorAll(".tree-item");
874| children.forEach((child) => child.classList.remove("filtered-out"));
875| }
876| });
877|}
878|
879|function showAllDescendants(container) {
880| const items = container.querySelectorAll(".tree-item");
881| items.forEach((item) => {
882| item.classList.remove("filtered-out");
883| // If this item has children, also show them
884| const nextEl = item.nextElementSibling;
885| if (nextEl && nextEl.classList.contains("tree-children")) {
886| nextEl.classList.remove("filtered-out");
887| nextEl.classList.remove("collapsed");
888| }
889| });
890| // Also ensure all nested containers are visible
891| const nestedContainers = container.querySelectorAll(".tree-children");
892| nestedContainers.forEach((c) => {
893| c.classList.remove("filtered-out");
894| c.classList.remove("collapsed");
895| });
896|}
897|
898|function filterTagCloud(query) {
899| const tags = document.querySelectorAll("#tag-cloud .tag-item");
900| tags.forEach((tag) => {
901| const text = sidebarFilterCaseSensitive ? tag.textContent : tag.textContent.toLowerCase();
902| const searchQuery = sidebarFilterCaseSensitive ? query : query.toLowerCase();
903| if (!query || text.includes(searchQuery)) {
904| tag.classList.remove("filtered-out");
905| } else {
906| tag.classList.add("filtered-out");
907| }
908| });
909|}
910|
911|// ---------------------------------------------------------------------------
912|// Tag Filter Service
913|// ---------------------------------------------------------------------------
914|const TagFilterService = {
915| defaultFilters: [
916| { pattern: "#<% ... %>", regex: "#<%.*%>", enabled: true },
917| { pattern: "#{{ ... }}", regex: "#\\{\\{.*\\}\\}", enabled: true },
918| { pattern: "#{ ... }", regex: "#\\{.*\\}", enabled: true },
919| ],
920|
921| getConfig() {
922| const stored = localStorage.getItem("obsigate-tag-filters");
923| if (stored) {
924| try {
925| return JSON.parse(stored);
926| } catch (e) {
927| return { tagFilters: this.defaultFilters };
928| }
929| }
930| return { tagFilters: this.defaultFilters };
931| },
932|
933| saveConfig(config) {
934| localStorage.setItem("obsigate-tag-filters", JSON.stringify(config));
935| },
936|
937| patternToRegex(pattern) {
938| // 1. Escape ALL special regex characters
939| // We use a broader set including * and .
940| let regex = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
941|
942| // 2. Convert escaped '*' to '.*' (wildcard)
943| regex = regex.replace(/\\\*/g, ".*");
944|
945| // 3. Convert escaped '...' (or any sequence of 2+ dots like ..) to '.*'
946| // We also handle optional whitespace around it to make it more user-friendly
947| regex = regex.replace(/\s*\\\.{2,}\s*/g, ".*");
948|
949| return regex;
950| },
951|
952| isTagFiltered(tag) {
953| const config = this.getConfig();
954| const filters = config.tagFilters || this.defaultFilters;
955| const tagWithHash = `#${tag}`;
956|
957| for (const filter of filters) {
958| if (!filter.enabled) continue;
959| try {
960| // Robustly handle regex with or without ^/$
961| let patternStr = filter.regex;
962| if (!patternStr.startsWith("^")) patternStr = "^" + patternStr;
963| if (!patternStr.endsWith("$")) patternStr = patternStr + "$";
964|
965| const regex = new RegExp(patternStr);
966| if (regex.test(tagWithHash)) {
967| return true;
968| }
969| } catch (e) {
970| console.warn("Invalid regex:", filter.regex, e);
971| }
972| }
973| return false;
974| },
975|
976| filterTags(tags) {
977| const filtered = {};
978| Object.entries(tags).forEach(([tag, count]) => {
979| if (!this.isTagFiltered(tag)) {
980| filtered[tag] = count;
981| }
982| });
983| return filtered;
984| },
985|};
986|
987|// ---------------------------------------------------------------------------
988|// Tags
989|// ---------------------------------------------------------------------------
990|async function loadTags() {
991| const data = await api("/api/tags");
992| const filteredTags = TagFilterService.filterTags(data.tags);
993| renderTagCloud(filteredTags);
994|}
995|
996|function renderTagCloud(tags) {
997| const cloud = document.getElementById("tag-cloud");
998| cloud.innerHTML = "";
999|
1000| const counts = Object.values(tags);
1001| if (counts.length === 0) return;
1002|
1003| const maxCount = Math.max(...counts);
1004| const minSize = 0.7;
1005| const maxSize = 1.25;
1006|
1007| Object.entries(tags).forEach(([tag, count]) => {
1008| const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0;
1009| const size = minSize + ratio * (maxSize - minSize);
1010| const tagEl = el("span", { class: "tag-item", style: `font-size:${size}rem` }, [document.createTextNode(`#${tag}`)]);
1011| tagEl.addEventListener("click", () => searchByTag(tag));
1012| cloud.appendChild(tagEl);
1013| });
1014|}
1015|
1016|function addTagFilter(tag) {
1017| if (!state.selectedTags.includes(tag)) {
1018| state.selectedTags.push(tag);
1019| performTagSearch();
1020| }
1021|}
1022|
1023|function removeTagFilter(tag) {
1024| state.selectedTags = state.selectedTags.filter((t) => t !== tag);
1025| if (state.selectedTags.length > 0) {
1026| performTagSearch();
1027| } else {
1028| const input = document.getElementById("search-input");
1029| if (input.value.trim()) {
1030| performAdvancedSearch(input.value.trim(), document.getElementById("vault-filter").value, null);
1031| } else {
1032| showWelcome();
1033| }
1034| }
1035|}
1036|
1037|function performTagSearch() {
1038| const input = document.getElementById("search-input");
1039| const query = input.value.trim();
1040| const vault = document.getElementById("vault-filter").value;
1041| performAdvancedSearch(query, vault, state.selectedTags.length > 0 ? state.selectedTags.join(",") : null);
1042|}
1043|
1044|function buildSearchResultsHeader(data, query, tagFilter) {
1045| const header = el("div", { class: "search-results-header" });
1046| const summaryText = el("span", { class: "search-results-summary-text" });
1047|
1048| if (query && tagFilter) {
1049| summaryText.textContent = `${data.count} résultat(s) pour "${query}" avec les tags`;
1050| } else if (query) {
1051| summaryText.textContent = `${data.count} résultat(s) pour "${query}"`;
1052| } else if (tagFilter) {
1053| summaryText.textContent = `${data.count} fichier(s) avec les tags`;
1054| } else {
1055| summaryText.textContent = `${data.count} résultat(s)`;
1056| }
1057|
1058| header.appendChild(summaryText);
1059|
1060| if (state.selectedTags.length > 0) {
1061| const activeTags = el("div", { class: "search-results-active-tags" });
1062| state.selectedTags.forEach((tag) => {
1063| const removeBtn = el(
1064| "button",
1065| {
1066| class: "search-results-active-tag-remove",
1067| title: `Retirer ${tag} du filtre`,
1068| "aria-label": `Retirer ${tag} du filtre`,
1069| },
1070| [document.createTextNode("×")],
1071| );
1072| removeBtn.addEventListener("click", (e) => {
1073| e.stopPropagation();
1074| removeTagFilter(tag);
1075| });
1076|
1077| const chip = el("span", { class: "search-results-active-tag" }, [document.createTextNode(`#${tag}`), removeBtn]);
1078| activeTags.appendChild(chip);
1079| });
1080| header.appendChild(activeTags);
1081| }
1082|
1083| return header;
1084|}
1085|
1086|function searchByTag(tag) {
1087| addTagFilter(tag);
1088|}
1089|
1090|
1091|export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, refreshTagsForContext };
1092|