Implement multi-stage Docker build with security hardening, add health check endpoint, optimize in-memory search with O(1) wikilink lookup, extract inline tags from markdown content, and enhance documentation with architecture diagrams and performance metrics
This commit is contained in:
+111
-11
@@ -9,6 +9,7 @@
|
||||
let currentVault = null;
|
||||
let currentPath = null;
|
||||
let searchTimeout = null;
|
||||
let searchAbortController = null;
|
||||
let showingSource = false;
|
||||
let cachedRawSource = null;
|
||||
let allVaults = [];
|
||||
@@ -20,6 +21,7 @@
|
||||
let fallbackEditorEl = null;
|
||||
let sidebarFilterCaseSensitive = false;
|
||||
let searchCaseSensitive = false;
|
||||
let _iconDebounceTimer = null;
|
||||
const panelState = {
|
||||
vault: true,
|
||||
tag: true,
|
||||
@@ -78,7 +80,26 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe CDN helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Debounced icon creation — batches multiple rapid calls into one
|
||||
* DOM scan to avoid excessive reflows when building large trees.
|
||||
*/
|
||||
function safeCreateIcons() {
|
||||
if (typeof lucide === "undefined" || !lucide.createIcons) return;
|
||||
if (_iconDebounceTimer) return; // already scheduled
|
||||
_iconDebounceTimer = requestAnimationFrame(() => {
|
||||
_iconDebounceTimer = null;
|
||||
try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ }
|
||||
});
|
||||
}
|
||||
|
||||
/** Force-flush icon creation immediately (use sparingly). */
|
||||
function flushIcons() {
|
||||
if (_iconDebounceTimer) {
|
||||
cancelAnimationFrame(_iconDebounceTimer);
|
||||
_iconDebounceTimer = null;
|
||||
}
|
||||
if (typeof lucide !== "undefined" && lucide.createIcons) {
|
||||
try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ }
|
||||
}
|
||||
@@ -292,12 +313,60 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toast notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Display a brief toast message at the bottom of the viewport. */
|
||||
function showToast(message, type) {
|
||||
type = type || "error";
|
||||
let container = document.getElementById("toast-container");
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = "toast-container";
|
||||
container.className = "toast-container";
|
||||
container.setAttribute("aria-live", "polite");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
var toast = document.createElement("div");
|
||||
toast.className = "toast toast-" + type;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
// Trigger entrance animation
|
||||
requestAnimationFrame(function () { toast.classList.add("show"); });
|
||||
setTimeout(function () {
|
||||
toast.classList.remove("show");
|
||||
toast.addEventListener("transitionend", function () { toast.remove(); });
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function api(path) {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
|
||||
/**
|
||||
* Fetch JSON from an API endpoint with optional AbortSignal support.
|
||||
* Surfaces errors to the user via toast instead of silently failing.
|
||||
*
|
||||
* @param {string} path - API URL path.
|
||||
* @param {object} [opts] - Fetch options (may include signal).
|
||||
* @returns {Promise<any>} Parsed JSON response.
|
||||
*/
|
||||
async function api(path, opts) {
|
||||
var res;
|
||||
try {
|
||||
res = await fetch(path, opts || {});
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") throw err; // let callers handle abort
|
||||
showToast("Erreur réseau — vérifiez votre connexion");
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) {
|
||||
var detail = "";
|
||||
try { var body = await res.json(); detail = body.detail || ""; } catch (_) { /* no json body */ }
|
||||
showToast(detail || "Erreur API : " + res.status);
|
||||
throw new Error(detail || "API error: " + res.status);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -564,8 +633,17 @@
|
||||
}
|
||||
|
||||
async function loadDirectory(vaultName, dirPath, container) {
|
||||
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
|
||||
const data = await api(url);
|
||||
// Show inline loading indicator while fetching directory contents
|
||||
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();
|
||||
@@ -905,9 +983,17 @@
|
||||
if (active) active.classList.add("active");
|
||||
} catch (e) { /* selector might fail on special chars */ }
|
||||
|
||||
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
|
||||
const data = await api(url);
|
||||
renderFile(data);
|
||||
// Show loading state while fetching
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
|
||||
|
||||
try {
|
||||
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
|
||||
const data = await api(url);
|
||||
renderFile(data);
|
||||
} catch (err) {
|
||||
area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderFile(data) {
|
||||
@@ -1324,13 +1410,26 @@
|
||||
}
|
||||
|
||||
async function performSearch(query, vaultFilter, tagFilter) {
|
||||
// Cancel any in-flight search request
|
||||
if (searchAbortController) {
|
||||
searchAbortController.abort();
|
||||
}
|
||||
searchAbortController = new AbortController();
|
||||
|
||||
showLoading();
|
||||
|
||||
|
||||
let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`;
|
||||
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
|
||||
|
||||
const data = await api(url);
|
||||
renderSearchResults(data, query, tagFilter);
|
||||
try {
|
||||
const data = await api(url, { signal: searchAbortController.signal });
|
||||
renderSearchResults(data, query, tagFilter);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") return; // superseded by newer request
|
||||
showWelcome();
|
||||
} finally {
|
||||
searchAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSearchResults(data, query, tagFilter) {
|
||||
@@ -1764,6 +1863,7 @@
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize ObsiGate:", err);
|
||||
showToast("Erreur lors de l'initialisation");
|
||||
}
|
||||
|
||||
safeCreateIcons();
|
||||
|
||||
Reference in New Issue
Block a user