/* ObsiGate — Viewer module */
import { api } from './auth.js';
import { state } from './state.js';
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon } from './utils.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
import { syncActiveFileTreeItem, searchByTag, TagFilterService } from './sidebar.js';
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
import { initDashboardTabs } from './sync.js';
import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js';
// ---------------------------------------------------------------------------
// Outline/TOC Manager
// ---------------------------------------------------------------------------
const OutlineManager = {
/**
* Slugify text to create valid IDs
*/
slugify(text) {
return (
text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim() || "heading"
);
},
/**
* Parse headings from markdown content
*/
parseHeadings() {
const contentArea = document.querySelector(".md-content");
if (!contentArea) return [];
const headings = [];
const h2s = contentArea.querySelectorAll("h2");
const h3s = contentArea.querySelectorAll("h3");
const allHeadings = [...h2s, ...h3s].sort((a, b) => {
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
});
const usedIds = new Map();
allHeadings.forEach((heading) => {
const text = heading.textContent.trim();
if (!text) return;
const level = parseInt(heading.tagName[1]);
let id = this.slugify(text);
// Handle duplicate IDs
if (usedIds.has(id)) {
const count = usedIds.get(id) + 1;
usedIds.set(id, count);
id = `${id}-${count}`;
} else {
usedIds.set(id, 1);
}
// Inject ID into heading if not present
if (!heading.id) {
heading.id = id;
} else {
id = heading.id;
}
headings.push({
id,
level,
text,
element: heading,
});
});
return headings;
},
/**
* Render outline list
*/
renderOutline(headings) {
const outlineList = document.getElementById("outline-list");
const outlineEmpty = document.getElementById("outline-empty");
if (!outlineList) return;
outlineList.innerHTML = "";
if (!headings || headings.length === 0) {
outlineList.hidden = true;
if (outlineEmpty) {
outlineEmpty.hidden = false;
safeCreateIcons();
}
return;
}
outlineList.hidden = false;
if (outlineEmpty) outlineEmpty.hidden = true;
headings.forEach((heading) => {
const item = el(
"a",
{
class: `outline-item level-${heading.level}`,
href: `#${heading.id}`,
"data-heading-id": heading.id,
role: "link",
},
[document.createTextNode(heading.text)],
);
item.addEventListener("click", (e) => {
e.preventDefault();
this.scrollToHeading(heading.id);
});
outlineList.appendChild(item);
});
state.headingsCache = headings;
},
/**
* Scroll to heading with smooth behavior
*/
scrollToHeading(headingId) {
const heading = document.getElementById(headingId);
if (!heading) return;
const contentArea = document.getElementById("content-area");
if (!contentArea) return;
// Calculate offset for fixed header (if any)
const headerHeight = 80;
const headingTop = heading.offsetTop;
contentArea.scrollTo({
top: headingTop - headerHeight,
behavior: "smooth",
});
// Update active state immediately
this.setActiveHeading(headingId);
},
/**
* Set active heading in outline
*/
setActiveHeading(headingId) {
if (state.activeHeadingId === headingId) return;
state.activeHeadingId = headingId;
const items = document.querySelectorAll(".outline-item");
items.forEach((item) => {
if (item.getAttribute("data-heading-id") === headingId) {
item.classList.add("active");
item.setAttribute("aria-current", "location");
// Scroll outline item into view
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
} else {
item.classList.remove("active");
item.removeAttribute("aria-current");
}
});
},
/**
* Initialize outline for current document
*/
init() {
const headings = this.parseHeadings();
this.renderOutline(headings);
ScrollSpyManager.init(headings);
ReadingProgressManager.init();
},
/**
* Cleanup
*/
destroy() {
ScrollSpyManager.destroy();
ReadingProgressManager.destroy();
state.headingsCache = [];
state.activeHeadingId = null;
},
};
// ---------------------------------------------------------------------------
// Scroll Spy Manager
// ---------------------------------------------------------------------------
const ScrollSpyManager = {
observer: null,
headings: [],
init(headings) {
this.destroy();
this.headings = headings;
if (!headings || headings.length === 0) return;
const contentArea = document.getElementById("content-area");
if (!contentArea) return;
const options = {
root: contentArea,
rootMargin: "-20% 0px -70% 0px",
threshold: [0, 0.3, 0.5, 1.0],
};
this.observer = new IntersectionObserver((entries) => {
// Find the most visible heading
let mostVisible = null;
let maxRatio = 0;
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
maxRatio = entry.intersectionRatio;
mostVisible = entry.target;
}
});
if (mostVisible && mostVisible.id) {
OutlineManager.setActiveHeading(mostVisible.id);
}
}, options);
// Observe all headings
headings.forEach((heading) => {
if (heading.element) {
this.observer.observe(heading.element);
}
});
},
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
this.headings = [];
},
};
// ---------------------------------------------------------------------------
// Reading Progress Manager
// ---------------------------------------------------------------------------
const ReadingProgressManager = {
scrollHandler: null,
init() {
this.destroy();
const contentArea = document.getElementById("content-area");
if (!contentArea) return;
this.scrollHandler = this.throttle(() => {
this.updateProgress();
}, 100);
contentArea.addEventListener("scroll", this.scrollHandler);
this.updateProgress();
},
updateProgress() {
const contentArea = document.getElementById("content-area");
const progressFill = document.getElementById("reading-progress-fill");
const progressText = document.getElementById("reading-progress-text");
if (!contentArea || !progressFill || !progressText) return;
const scrollTop = contentArea.scrollTop;
const scrollHeight = contentArea.scrollHeight;
const clientHeight = contentArea.clientHeight;
const maxScroll = scrollHeight - clientHeight;
const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0;
progressFill.style.width = `${percentage}%`;
progressText.textContent = `${percentage}%`;
},
throttle(func, delay) {
let lastCall = 0;
return function (...args) {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
func.apply(this, args);
}
};
},
destroy() {
const contentArea = document.getElementById("content-area");
if (contentArea && this.scrollHandler) {
contentArea.removeEventListener("scroll", this.scrollHandler);
}
this.scrollHandler = null;
// Reset progress
const progressFill = document.getElementById("reading-progress-fill");
const progressText = document.getElementById("reading-progress-text");
if (progressFill) progressFill.style.width = "0%";
if (progressText) progressText.textContent = "0%";
},
};
// ---------------------------------------------------------------------------
// File viewer
// ---------------------------------------------------------------------------
export async function openFile(vaultName, filePath) {
state.currentVault = vaultName;
state.currentPath = filePath;
state.showingSource = false;
state.cachedRawSource = null;
// Highlight active
syncActiveFileTreeItem(vaultName, filePath);
// Show loading state while fetching
const area = document.getElementById("content-area");
area.innerHTML = '
';
try {
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
const data = await api(url);
renderFile(data);
} catch (err) {
area.innerHTML = 'Impossible de charger le fichier.
';
}
}
async function renderBacklinksPanel(vault, path, container) {
try {
const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`);
if (!data.backlinks || data.backlinks.length === 0) return;
const panel = el("div", { class: "backlinks-panel" });
const header = el("div", { class: "backlinks-header" }, [
icon("link", 14),
document.createTextNode(` ${data.total} lien(s) entrant(s)`),
]);
panel.appendChild(header);
const list = el("div", { class: "backlinks-list" });
data.backlinks.forEach((bl) => {
const item = el("div", { class: "backlink-item" });
const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]);
const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]);
item.appendChild(icon(getFileIcon(bl.path), 12));
item.appendChild(vaultBadge);
item.appendChild(titleEl);
item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path));
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); });
list.appendChild(item);
});
panel.appendChild(list);
container.appendChild(panel);
} catch (err) {
// Silently ignore — backlinks are optional
console.debug("Backlinks fetch failed:", err);
}
}
export function renderFile(data) {
const area = document.getElementById("content-area");
// Handle unsupported (binary) files
if (data.unsupported) {
const sizeStr = data.size_bytes
? data.size_bytes < 1024 ? `${data.size_bytes} o`
: data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko`
: `${(data.size_bytes / 1048576).toFixed(1)} Mo`
: "";
area.innerHTML = `
${escapeHtml(data.path.split("/").pop())}
Ce fichier est binaire et ne peut pas être affiché.
${sizeStr ? `
Taille : ${sizeStr}
` : ""}
`;
lucide.createIcons();
document.getElementById("unsupported-download-btn").addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
window.open(dlUrl, "_blank");
});
return;
}
// Breadcrumb
const parts = data.path.split("/");
const breadcrumbEls = [];
breadcrumbEls.push(
makeBreadcrumbSpan(data.vault, () => {
focusPathInSidebar(data.vault, "", { alignToTop: "center" });
}),
);
let accumulated = "";
parts.forEach((part, i) => {
breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")]));
accumulated += (accumulated ? "/" : "") + part;
const p = accumulated;
if (i < parts.length - 1) {
breadcrumbEls.push(
makeBreadcrumbSpan(part, () => {
focusPathInSidebar(data.vault, p, { alignToTop: "center" });
}),
);
} else {
breadcrumbEls.push(
makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => {
focusPathInSidebar(data.vault, data.path, { alignToTop: "center" });
}),
);
}
});
const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls);
// Tags
const tagsDiv = el("div", { class: "file-tags" });
(data.tags || []).forEach((tag) => {
if (!TagFilterService.isTagFiltered(tag)) {
const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
t.addEventListener("click", () => searchByTag(tag));
tagsDiv.appendChild(t);
}
});
// Action buttons
const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]);
copyBtn.addEventListener("click", async () => {
try {
// Fetch raw content if not already cached
if (!state.cachedRawSource) {
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
const rawData = await api(rawUrl);
state.cachedRawSource = rawData.raw;
}
await navigator.clipboard.writeText(state.cachedRawSource);
copyBtn.lastChild.textContent = "Copié !";
setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500);
} catch (err) {
console.error("Copy error:", err);
showToast("Erreur lors de la copie", "error");
}
});
const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]);
// MD download button
const mdBtn = el("button", { class: "btn-action", title: "Télécharger en .md" }, [icon("file-text", 14), document.createTextNode(".md")]);
mdBtn.addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
const a = document.createElement("a");
a.href = dlUrl;
a.download = data.path.split("/").pop();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
// PDF download button
const pdfBtn = el("button", { class: "btn-action", title: "Télécharger en PDF" }, [icon("file", 14), document.createTextNode("PDF")]);
pdfBtn.addEventListener("click", () => {
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`;
window.open(pdfUrl, "_blank");
});
const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]);
editBtn.addEventListener("click", () => {
openEditor(data.vault, data.path);
});
const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenêtre" }, [icon("external-link", 14), document.createTextNode("pop-out")]);
openNewWindowBtn.addEventListener("click", () => {
const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}`;
window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no");
});
const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]);
tocBtn.addEventListener("click", () => {
RightSidebarManager.toggle();
});
// Share button — check if already shared
const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]);
// Check if already shared and color the button
(async () => {
try {
const shares = await api("/api/shares");
if (shares.some(s => s.vault === data.vault && s.path === data.path)) {
shareBtn.classList.add("shared");
shareBtn.title = "Document partagé — cliquer pour gérer";
}
} catch (e) { /* ignore */ }
})();
shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path));
// Bookmark button — check if already bookmarked
const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: "Ajouter/Retirer des bookmarks" }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]);
// Check bookmark status and color the button
(async () => {
try {
const bms = await api("/api/bookmarks");
if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) {
bookmarkBtn.classList.add("active");
bookmarkBtn.title = "Retirer des bookmarks";
}
} catch (e) { /* ignore */ }
})();
bookmarkBtn.addEventListener("click", async () => {
try {
const res = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: data.vault, path: data.path, title: data.title }) });
bookmarkBtn.classList.toggle("active", res.bookmarked);
bookmarkBtn.title = res.bookmarked ? "Retirer des bookmarks" : "Ajouter aux bookmarks";
showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success");
if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load();
} catch (err) { showToast("Erreur: " + err.message, "error"); }
});
// Frontmatter — Accent Card
let fmSection = null;
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
fmSection = buildFrontmatterCard(data.frontmatter);
}
// Content container (rendered HTML)
const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" });
mdDiv.innerHTML = data.html;
// Raw source container (hidden initially)
const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" });
// Source button toggle logic
sourceBtn.addEventListener("click", async () => {
const rendered = document.getElementById("file-rendered-content");
const raw = document.getElementById("file-raw-content");
if (!rendered || !raw) return;
state.showingSource = !state.showingSource;
if (state.showingSource) {
sourceBtn.classList.add("active");
if (!state.cachedRawSource) {
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
const rawData = await api(rawUrl);
state.cachedRawSource = rawData.raw;
}
raw.textContent = state.cachedRawSource;
rendered.style.display = "none";
raw.style.display = "block";
} else {
sourceBtn.classList.remove("active");
rendered.style.display = "block";
raw.style.display = "none";
}
});
// Assemble
area.innerHTML = "";
area.appendChild(breadcrumb);
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, [copyBtn, sourceBtn, mdBtn, pdfBtn, editBtn, openNewWindowBtn, tocBtn, shareBtn, bookmarkBtn])]));
if (fmSection) area.appendChild(fmSection);
area.appendChild(mdDiv);
area.appendChild(rawDiv);
// Backlinks panel
if (data.is_markdown) {
renderBacklinksPanel(data.vault, data.path, area);
}
// Highlight code blocks
area.querySelectorAll("pre code").forEach((block) => {
safeHighlight(block);
});
// Wire up wikilinks
area.querySelectorAll(".wikilink").forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
const v = link.getAttribute("data-vault");
const p = link.getAttribute("data-path");
if (v && p) openFile(v, p);
});
});
safeCreateIcons();
area.scrollTop = 0;
// Initialize outline/TOC for this document
OutlineManager.init();
}
// ---------------------------------------------------------------------------
// Helpers (escapeHtml imported from utils.js)
// ---------------------------------------------------------------------------
export function el(tag, attrs, children) {
const e = document.createElement(tag);
if (attrs) {
Object.entries(attrs).forEach(([k, v]) => {
// Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug
if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) {
return;
}
e.setAttribute(k, v);
});
}
if (children) {
children.forEach((c) => {
if (c) e.appendChild(c);
});
}
return e;
}
export function icon(name, size) {
const i = document.createElement("i");
i.setAttribute("data-lucide", name);
i.style.width = size + "px";
i.style.height = size + "px";
i.classList.add("icon");
return i;
}
export function smallBadge(count) {
const s = document.createElement("span");
s.className = "badge-small";
s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px";
s.textContent = `(${count})`;
return s;
}
function getContextMenuPositionFromElement(target) {
const rect = target.getBoundingClientRect();
return {
x: Math.min(rect.right - 8, window.innerWidth - 16),
y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16),
};
}
export function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) {
const button = document.createElement("button");
button.type = "button";
button.className = "tree-item-action-btn";
button.setAttribute("aria-label", "Afficher le menu d’actions");
button.setAttribute("title", "Actions");
const iconEl = icon("more-vertical", 16);
button.appendChild(iconEl);
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const pos = getContextMenuPositionFromElement(button);
ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly);
});
itemEl.appendChild(button);
// Ensure Lucide icons are rendered for the button
setTimeout(() => {
safeCreateIcons();
}, 0);
}
export function attachTreeItemLongPress(itemEl, getMenuData) {
let pressTimer = null;
let pressHandled = false;
let startX = 0;
let startY = 0;
const longPressDelay = 550;
const moveThreshold = 10;
const clearPressTimer = () => {
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
};
itemEl.addEventListener("touchstart", (e) => {
if (!e.touches || e.touches.length !== 1) return;
pressHandled = false;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
clearPressTimer();
pressTimer = setTimeout(() => {
const data = getMenuData();
if (!data) return;
pressHandled = true;
ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly);
}, longPressDelay);
}, { passive: true });
itemEl.addEventListener("touchmove", (e) => {
if (!e.touches || e.touches.length !== 1) return;
const dx = Math.abs(e.touches[0].clientX - startX);
const dy = Math.abs(e.touches[0].clientY - startY);
if (dx > moveThreshold || dy > moveThreshold) {
clearPressTimer();
}
}, { passive: true });
itemEl.addEventListener("touchend", () => {
clearPressTimer();
}, { passive: true });
itemEl.addEventListener("touchcancel", () => {
clearPressTimer();
}, { passive: true });
itemEl.addEventListener("click", (e) => {
if (pressHandled) {
e.preventDefault();
e.stopPropagation();
setTimeout(() => {
pressHandled = false;
}, 0);
}
}, true);
}
export function getVaultIcon(vaultName, size = 16) {
const v = state.allVaults.find((val) => val.name === vaultName);
const type = v ? v.type : "VAULT";
if (type === "DIR") {
const i = icon("folder", size);
i.style.color = "#eab308"; // yellow tint
return i;
} else {
const purple = "#8b5cf6";
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("xmlns", svgNS);
svg.setAttribute("width", size);
svg.setAttribute("height", size);
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", purple);
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.classList.add("icon");
const path1 = document.createElementNS(svgNS, "path");
path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z");
const path2 = document.createElementNS(svgNS, "path");
path2.setAttribute("d", "M11 3 8 9l4 12");
const path3 = document.createElementNS(svgNS, "path");
path3.setAttribute("d", "M12 21l4-12-3-6");
const path4 = document.createElementNS(svgNS, "path");
path4.setAttribute("d", "M2 9h20");
svg.appendChild(path1);
svg.appendChild(path2);
svg.appendChild(path3);
svg.appendChild(path4);
return svg;
}
}
function makeBreadcrumbSpan(text, onClick) {
const s = document.createElement("span");
s.textContent = text;
if (onClick) {
s.addEventListener("click", async (event) => {
event.preventDefault();
if (s.dataset.busy === "true") return;
s.dataset.busy = "true";
s.style.pointerEvents = "none";
try {
await onClick(event);
} finally {
s.dataset.busy = "false";
s.style.pointerEvents = "";
}
});
}
return s;
}
export function appendHighlightedText(container, text, query, caseSensitive) {
container.textContent = "";
if (!query) {
container.appendChild(document.createTextNode(text));
return;
}
const source = caseSensitive ? text : text.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
let start = 0;
let index = source.indexOf(needle, start);
if (index === -1) {
container.appendChild(document.createTextNode(text));
return;
}
while (index !== -1) {
if (index > start) {
container.appendChild(document.createTextNode(text.slice(start, index)));
}
const mark = el("mark", { class: "filter-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
container.appendChild(mark);
start = index + query.length;
index = source.indexOf(needle, start);
}
if (start < text.length) {
container.appendChild(document.createTextNode(text.slice(start)));
}
}
export function highlightSearchText(container, text, query, caseSensitive) {
container.textContent = "";
if (!query || !text) {
container.appendChild(document.createTextNode(text || ""));
return;
}
const source = caseSensitive ? text : text.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
let start = 0;
let index = source.indexOf(needle, start);
if (index === -1) {
container.appendChild(document.createTextNode(text));
return;
}
while (index !== -1) {
if (index > start) {
container.appendChild(document.createTextNode(text.slice(start, index)));
}
const mark = el("mark", { class: "search-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
container.appendChild(mark);
start = index + query.length;
index = source.indexOf(needle, start);
}
if (start < text.length) {
container.appendChild(document.createTextNode(text.slice(start)));
}
}
export function showWelcome() {
hideProgressBar();
// Restore or rebuild the dashboard with tabbed sections
const area = document.getElementById("content-area");
const home = document.getElementById("dashboard-home");
if (area && !home) {
area.innerHTML = `
Aucun bookmark
Épinglez des fichiers pour les retrouver ici.
Aucun fichier récent
Ouvrez un fichier pour le voir apparaître ici
Aucun document partagé
Partagez un document pour le voir apparaître ici
`;
// Re-initialize widgets and dashboard tabs
if (typeof DashboardRecentWidget !== "undefined") {
DashboardRecentWidget.init();
}
initDashboardTabs();
safeCreateIcons();
} else if (home) {
// Dashboard already exists, show it with default tab
home.style.display = "";
// Reset tabs to default
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]');
const defaultPanel = document.getElementById("dashboard-panel-stats");
if (defaultTab) defaultTab.classList.add("active");
if (defaultPanel) defaultPanel.classList.add("active");
}
// Load all widgets (they handle missing elements gracefully)
if (typeof DashboardStatsWidget !== "undefined") {
DashboardStatsWidget.load();
}
if (typeof DashboardConflictsWidget !== "undefined") {
DashboardConflictsWidget.load();
}
if (typeof DashboardRecentWidget !== "undefined") {
DashboardRecentWidget.load(state.selectedContextVault);
}
if (typeof DashboardBookmarkWidget !== "undefined") {
DashboardBookmarkWidget.load(state.selectedContextVault);
}
if (typeof DashboardSharedWidget !== "undefined") {
DashboardSharedWidget.load();
}
// Load saved searches sidebar
loadSavedSearches();
}
async function loadSavedSearches() {
const list = document.getElementById("saved-searches-list");
const empty = document.getElementById("saved-searches-empty");
if (!list) return;
try {
const searches = await api("/api/saved-searches");
if (!searches.length) {
list.innerHTML = "";
if (empty) empty.style.display = "";
return;
}
if (empty) empty.style.display = "none";
list.innerHTML = searches.map(s => {
const badges = [];
if (s.case_sensitive) badges.push('Aa');
if (s.whole_word) badges.push('wd');
if (s.regex) badges.push('.*');
const pathFilters = [];
if (s.include_paths) pathFilters.push(`📥 ${escapeHtml(s.include_paths)}`);
if (s.exclude_paths) pathFilters.push(`📤 ${escapeHtml(s.exclude_paths)}`);
const vaultStr = s.vault && s.vault !== "all" ? `📁 ${escapeHtml(s.vault)}` : "";
return `
${escapeHtml(s.query)}
${badges.join("")}
${vaultStr}
${pathFilters.length ? '
' + pathFilters.join(" ") + '
' : ""}
`}).join("");
list.querySelectorAll(".saved-search-item").forEach(item => {
item.addEventListener("click", (e) => {
if (e.target.classList.contains("saved-search-delete")) return;
const idx = Array.from(list.children).indexOf(item);
const s = searches[idx];
if (!s) return;
// Apply the saved search
const input = document.getElementById("search-input");
if (input) input.value = s.query;
state.searchCaseSensitive = s.case_sensitive || false;
state.searchWholeWord = s.whole_word || false;
state.searchRegex = s.regex || false;
if (typeof _updateToggleUI === "function") _updateToggleUI();
if (s.include_paths) {
const incl = document.getElementById("search-include-input");
if (incl) incl.value = s.include_paths;
}
if (s.exclude_paths) {
const excl = document.getElementById("search-exclude-input");
if (excl) excl.value = s.exclude_paths;
}
// Execute the search — suppress dropdown from appearing
AutocompleteDropdown.hide();
AutocompleteDropdown._suppressNext = true;
const vault = s.vault || "all";
if (input) { input.dispatchEvent(new Event("input")); }
clearTimeout(state.searchTimeout);
advancedSearchOffset = 0;
performAdvancedSearch(s.query, vault, null);
});
});
list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => {
e.stopPropagation();
await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" });
loadSavedSearches();
}));
safeCreateIcons();
} catch (err) { /* silently ignore */ }
}
export function showLoading() {
const area = document.getElementById("content-area");
area.innerHTML = `
`;
showProgressBar();
}
export function showProgressBar() {
const bar = document.getElementById("search-progress-bar");
if (bar) bar.classList.add("active");
}
export function hideProgressBar() {
const bar = document.getElementById("search-progress-bar");
if (bar) bar.classList.remove("active");
}
function goHome() {
const searchInput = document.getElementById("search-input");
if (searchInput) searchInput.value = "";
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
state.currentVault = null;
state.currentPath = null;
state.showingSource = false;
state.cachedRawSource = null;
closeMobileSidebar();
showWelcome();
}
// ---------------------------------------------------------------------------
// SSE Client — IndexUpdateManager
// ---------------------------------------------------------------------------
const IndexUpdateManager = (() => {
let eventSource = null;
let reconnectTimer = null;
let reconnectDelay = 1000;
const MAX_RECONNECT_DELAY = 30000;
let recentEvents = [];
const MAX_RECENT_EVENTS = 20;
let connectionState = "disconnected"; // disconnected | connecting | connected
function connect() {
if (eventSource) {
eventSource.close();
}
connectionState = "connecting";
_updateBadge();
eventSource = new EventSource("/api/events");
eventSource.addEventListener("connected", (e) => {
connectionState = "connected";
reconnectDelay = 1000;
_updateBadge();
});
eventSource.addEventListener("index_updated", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_updated", data);
_onIndexUpdated(data);
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_reloaded", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_reloaded", data);
_onIndexReloaded(data);
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("vault_added", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("vault_added", data);
showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("vault_removed", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("vault_removed", data);
showToast(`Vault "${data.vault}" supprimé`, "info");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_start", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_start", data);
connectionState = "syncing";
_updateBadge();
showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info");
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_progress", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_progress", data);
connectionState = "syncing";
_updateBadge();
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_complete", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_complete", data);
connectionState = "connected";
_updateBadge();
showToast(`Indexation terminée (${data.total_files} fichiers)`, "success");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.onerror = () => {
connectionState = "disconnected";
_updateBadge();
eventSource.close();
eventSource = null;
_scheduleReconnect();
};
}
function _scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
connect();
}, reconnectDelay);
}
function _addEvent(type, data) {
recentEvents.unshift({
type,
data,
timestamp: new Date().toISOString(),
});
if (recentEvents.length > MAX_RECENT_EVENTS) {
recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
}
}
async function _onIndexUpdated(data) {
// Brief syncing state
connectionState = "syncing";
_updateBadge();
const n = data.total_changes || 0;
const vaults = (data.vaults || []).join(", ");
// Toast removed: silent auto-indexing — no notification needed
// Refresh sidebar and tags if affected vault matches current context
const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault);
if (affectsCurrentVault) {
try {
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
// Refresh current file if it was updated
if (state.currentVault && state.currentPath) {
const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath);
if (changed) {
openFile(state.currentVault, state.currentPath);
}
}
} catch (err) {
console.error("Error refreshing after index update:", err);
}
}
// Refresh recent tab if it is active
if (state.activeSidebarTab === "recent") {
const vaultFilter = document.getElementById("recent-vault-filter");
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
}
setTimeout(() => {
connectionState = "connected";
_updateBadge();
}, 1500);
}
async function _onIndexReloaded(data) {
connectionState = "syncing";
_updateBadge();
showToast("Index complet rechargé", "info");
try {
await Promise.all([loadVaults(), loadTags()]);
} catch (err) {
console.error("Error refreshing after full reload:", err);
}
setTimeout(() => {
connectionState = "connected";
_updateBadge();
}, 1500);
}
function _updateBadge() {
const badge = document.getElementById("sync-badge");
if (!badge) return;
badge.className = "sync-badge sync-badge--" + connectionState;
const labels = {
disconnected: "Déconnecté",
connecting: "Connexion...",
connected: "Synchronisé",
syncing: "Mise à jour...",
};
badge.title = labels[connectionState] || connectionState;
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
connectionState = "disconnected";
_updateBadge();
}
function getState() {
return connectionState;
}
function getRecentEvents() {
return recentEvents;
}
return { connect, disconnect, getState, getRecentEvents };
})();
function initSyncStatus() {
const badge = document.getElementById("sync-badge");
if (!badge) return;
badge.addEventListener("click", (e) => {
e.stopPropagation();
toggleSyncPanel();
});
IndexUpdateManager.connect();
}
function toggleSyncPanel() {
let panel = document.getElementById("sync-panel");
if (panel) {
panel.remove();
return;
}
// Auto reconnect if disconnected when user opens the panel
if (IndexUpdateManager.getState() === "disconnected") {
IndexUpdateManager.connect();
}
panel = document.createElement("div");
panel.id = "sync-panel";
panel.className = "sync-panel";
_renderSyncPanel(panel);
document.body.appendChild(panel);
// Close on outside click
setTimeout(() => {
document.addEventListener("click", _closeSyncPanelOutside, { once: true });
}, 0);
}
function _closeSyncPanelOutside(e) {
const panel = document.getElementById("sync-panel");
if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
panel.remove();
}
}
function _renderSyncPanel(panel) {
const state = IndexUpdateManager.getState();
const events = IndexUpdateManager.getRecentEvents();
const stateLabels = {
disconnected: "Déconnecté",
connecting: "Connexion...",
connected: "Connecté",
syncing: "Synchronisation...",
};
let html = ``;
if (events.length === 0) {
html += `Aucun événement récent
`;
} else {
html += ``;
events.slice(0, 10).forEach((ev) => {
const time = new Date(ev.timestamp).toLocaleTimeString();
const typeLabels = {
index_updated: "Mise à jour",
index_reloaded: "Rechargement",
vault_added: "Vault ajouté",
vault_removed: "Vault supprimé",
index_start: "Démarrage index.",
index_progress: "Vault indexé",
index_complete: "Indexation tech.",
};
const label = typeLabels[ev.type] || ev.type;
let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`;
if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
html += `
${label}
${detail}
${time}
`;
});
html += `
`;
}
panel.innerHTML = html;
}