From c5e395005f9f9be76a7e6ea6cbacd9ee762775d8 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 24 Mar 2026 12:56:00 -0400 Subject: [PATCH] feat: implement initial ObsiGate application with backend API, indexing, search, and basic frontend. --- backend/main.py | 61 ++++++++++++++++ frontend/app.js | 164 +++++++++++++++++++++++++++++++++++++++++++- frontend/index.html | 29 ++++++++ frontend/style.css | 66 ++++++++++++++++++ 4 files changed, 318 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index d24c86d..39482fe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,8 +7,10 @@ import logging import mimetypes import secrets import string +import time from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager +from datetime import datetime from functools import partial from pathlib import Path from typing import Optional, List, Dict, Any @@ -614,6 +616,64 @@ async def api_vaults(current_user=Depends(require_auth)): return result +def humanize_mtime(mtime: float) -> str: + delta = time.time() - mtime + if delta < 60: return "à l'instant" + if delta < 3600: return f"il y a {int(delta/60)} min" + if delta < 86400: return f"il y a {int(delta/3600)} h" + if delta < 604800: return f"il y a {int(delta/86400)} j" + return datetime.fromtimestamp(mtime).strftime("%d %b %Y") + + +@app.get("/api/recent") +async def api_recent(limit: Optional[int] = Query(None), vault: Optional[str] = Query(None), current_user=Depends(require_auth)): + config = _load_config() + actual_limit = limit if limit is not None else config.get("recent_files_limit", 20) + + user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) + + all_files = [] + for v_name, v_data in index.items(): + if vault and v_name != vault: + continue + if "*" not in user_vaults and v_name not in user_vaults: + continue + for f in v_data.get("files", []): + all_files.append((v_name, f)) + + # Sort descending by ISO string "modified" + all_files.sort(key=lambda x: x[1].get("modified", ""), reverse=True) + recent = all_files[:actual_limit] + + files_resp = [] + for v_name, f in recent: + iso_modified = f.get("modified", "") + try: + mtime_dt = datetime.fromisoformat(iso_modified.replace("Z", "+00:00")) + mtime_val = mtime_dt.timestamp() + except Exception: + mtime_val = time.time() + + files_resp.append({ + "path": f["path"], + "title": f["title"], + "vault": v_name, + "mtime": mtime_val, + "mtime_human": humanize_mtime(mtime_val), + "mtime_iso": iso_modified, + "size_bytes": f.get("size", 0), + "tags": [f"#{t}" for t in f.get("tags", [])][:5], + "preview": f.get("content_preview", "")[:120] + }) + + return { + "files": files_resp, + "total": len(all_files), + "limit": actual_limit, + "generated_at": time.time() + } + + @app.get("/api/browse/{vault_name}", response_model=BrowseResponse) async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)): """Browse directories and files in a vault at a given path level. @@ -1348,6 +1408,7 @@ _DEFAULT_CONFIG = { "watcher_debounce": 2.0, "tag_boost": 2.0, "prefix_max_expansions": 50, + "recent_files_limit": 20, } diff --git a/frontend/app.js b/frontend/app.js index 0f59556..b5cee4a 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2261,6 +2261,150 @@ area.scrollTop = 0; } + // --------------------------------------------------------------------------- + // Recent files + // --------------------------------------------------------------------------- + let _recentRefreshTimer = null; + let _recentTimestampTimer = null; + let _recentFilesCache = []; + + async function loadRecentFiles(vaultFilter) { + const listEl = document.getElementById("recent-list"); + const emptyEl = document.getElementById("recent-empty"); + if (!listEl) return; + + let url = "/api/recent"; + if (vaultFilter) url += `?vault=${encodeURIComponent(vaultFilter)}`; + try { + const data = await api(url); + _recentFilesCache = data.files || []; + renderRecentList(_recentFilesCache); + } catch (err) { + console.error("Failed to load recent files:", err); + listEl.innerHTML = ''; + if (emptyEl) { emptyEl.classList.remove("hidden"); } + } + } + + function renderRecentList(files) { + const listEl = document.getElementById("recent-list"); + const emptyEl = document.getElementById("recent-empty"); + if (!listEl) return; + listEl.innerHTML = ""; + + if (!files || files.length === 0) { + if (emptyEl) { emptyEl.classList.remove("hidden"); safeCreateIcons(); } + return; + } + if (emptyEl) emptyEl.classList.add("hidden"); + + files.forEach((f) => { + const item = el("div", { class: "recent-item", "data-vault": f.vault, "data-path": f.path }); + + // Header row: time + vault badge + const header = el("div", { class: "recent-item-header" }); + const timeSpan = el("span", { class: "recent-time" }, [ + icon("clock", 11), + document.createTextNode(f.mtime_human), + ]); + const badge = el("span", { class: "recent-vault-badge" }, [document.createTextNode(f.vault)]); + header.appendChild(timeSpan); + header.appendChild(badge); + item.appendChild(header); + + // Title + const titleEl = el("div", { class: "recent-item-title" }, [ + document.createTextNode(f.title || f.path.split("/").pop()), + ]); + item.appendChild(titleEl); + + // Path breadcrumb + const pathParts = f.path.split("/"); + if (pathParts.length > 1) { + const pathEl = el("div", { class: "recent-item-path" }, [ + document.createTextNode(pathParts.slice(0, -1).join(" / ")), + ]); + item.appendChild(pathEl); + } + + // Preview + if (f.preview) { + const previewEl = el("div", { class: "recent-item-preview" }, [ + document.createTextNode(f.preview), + ]); + item.appendChild(previewEl); + } + + // Tags + if (f.tags && f.tags.length > 0) { + const tagsEl = el("div", { class: "recent-item-tags" }); + f.tags.forEach((t) => { + tagsEl.appendChild(el("span", { class: "tag-pill" }, [document.createTextNode(t)])); + }); + item.appendChild(tagsEl); + } + + // Click handler + item.addEventListener("click", () => { + openFile(f.vault, f.path); + closeMobileSidebar(); + }); + + listEl.appendChild(item); + }); + safeCreateIcons(); + } + + function _humanizeDelta(mtime) { + const delta = (Date.now() / 1000) - mtime; + if (delta < 60) return "à l'instant"; + if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`; + if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`; + if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`; + return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" }); + } + + function _refreshRecentTimestamps() { + if (activeSidebarTab !== "recent" || !_recentFilesCache.length) return; + const items = document.querySelectorAll(".recent-item"); + items.forEach((item, i) => { + if (i < _recentFilesCache.length) { + const timeSpan = item.querySelector(".recent-time"); + if (timeSpan) { + // keep the icon, update text + const textNode = timeSpan.lastChild; + if (textNode && textNode.nodeType === Node.TEXT_NODE) { + textNode.textContent = _humanizeDelta(_recentFilesCache[i].mtime); + } + } + } + }); + } + + function _populateRecentVaultFilter() { + const select = document.getElementById("recent-vault-filter"); + if (!select) return; + // keep first option "Tous les vaults" + while (select.options.length > 1) select.remove(1); + allVaults.forEach((v) => { + const opt = document.createElement("option"); + opt.value = v.name; + opt.textContent = v.name; + select.appendChild(opt); + }); + } + + function initRecentTab() { + const select = document.getElementById("recent-vault-filter"); + if (select) { + select.addEventListener("change", () => { + loadRecentFiles(select.value || null); + }); + } + // Periodic timestamp refresh (every 60s) + _recentTimestampTimer = setInterval(_refreshRecentTimestamps, 60000); + } + // --------------------------------------------------------------------------- // Sidebar tabs // --------------------------------------------------------------------------- @@ -2283,14 +2427,21 @@ }); const filterInput = document.getElementById("sidebar-filter-input"); if (filterInput) { - filterInput.placeholder = tab === "vaults" ? "Filtrer fichiers..." : "Filtrer tags..."; + const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "" }; + filterInput.placeholder = placeholders[tab] || ""; } const query = filterInput ? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : ""; if (query) { if (tab === "vaults") performTreeSearch(query); - else filterTagCloud(query); + else if (tab === "tags") filterTagCloud(query); + } + // Auto-load recent files when switching to the recent tab + if (tab === "recent") { + _populateRecentVaultFilter(); + const vaultFilter = document.getElementById("recent-vault-filter"); + loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); } } @@ -2477,6 +2628,7 @@ _setField("cfg-title-boost", data.title_boost); _setField("cfg-tag-boost", data.tag_boost); _setField("cfg-prefix-exp", data.prefix_max_expansions); + _setField("cfg-recent-limit", data.recent_files_limit || 20); // Watcher config _setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false); _setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true); @@ -2527,6 +2679,7 @@ title_boost: _getFieldNum("cfg-title-boost", 3.0), tag_boost: _getFieldNum("cfg-tag-boost", 2.0), prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50), + recent_files_limit: _getFieldNum("cfg-recent-limit", 20), watcher_enabled: _getCheckbox("cfg-watcher-enabled"), watcher_use_polling: _getCheckbox("cfg-watcher-polling"), watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0), @@ -3871,6 +4024,12 @@ } } + // Refresh recent tab if it is active + if (activeSidebarTab === "recent") { + const vaultFilter = document.getElementById("recent-vault-filter"); + loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); + } + setTimeout(() => { connectionState = "connected"; _updateBadge(); @@ -4039,6 +4198,7 @@ initEditor(); initSyncStatus(); initLoginForm(); + initRecentTab(); // Check auth status first const authOk = await AuthManager.initAuth(); diff --git a/frontend/index.html b/frontend/index.html index f252ab7..0c7fe81 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -282,6 +282,11 @@ Tags + @@ -304,6 +309,20 @@
+ + + @@ -383,6 +402,16 @@ + +
+

📋 Historique récent Redémarrage non requis

+
+ + + Entre 5 et 100 fichiers (sauvegardé sur le serveur) +
+
+

Paramètres backend Redémarrage requis

diff --git a/frontend/style.css b/frontend/style.css index c1627b0..862e396 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -3353,3 +3353,69 @@ body.popup-mode .content-area { box-shadow: 0 8px 30px rgba(0,0,0,0.15); box-sizing: border-box; } + +/* --------------------------------------------------------------------------- + Recent Tab Styles + --------------------------------------------------------------------------- */ + +/* Liste récente */ +.recent-list { display: flex; flex-direction: column; gap: 2px; padding: 4px 0; } + +/* Item */ +.recent-item { + padding: 10px 12px; + border-radius: 6px; + cursor: pointer; + border-left: 3px solid transparent; + transition: background 0.15s, border-color 0.15s; +} +.recent-item:hover { + background: var(--bg-secondary); + border-left-color: var(--accent); +} +.recent-item.active { border-left-color: var(--accent); background: var(--bg-secondary); } + +/* Header: temps + badge vault */ +.recent-item-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 3px; +} +.recent-time { font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; } +.recent-vault-badge { + font-size: 10px; font-weight: 600; padding: 1px 6px; + border-radius: 10px; background: var(--accent); color: #fff; opacity: 0.85; +} + +/* Titre */ +.recent-item-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 2px; } + +/* Breadcrumb path */ +.recent-item-path { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; font-family: monospace; } + +/* Preview */ +.recent-item-preview { + font-size: 12px; color: var(--text-muted); + display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; line-height: 1.4; margin-bottom: 5px; +} + +/* Tags */ +.recent-item-tags { display: flex; flex-wrap: wrap; gap: 4px; } +.recent-item-tags .tag-pill { + font-size: 10px; padding: 1px 6px; border-radius: 10px; + background: var(--bg-secondary); color: var(--accent); border: 1px solid var(--border); +} + +/* Filtre vault */ +.recent-filter-bar { padding: 8px 12px 4px; } +.recent-filter-bar select { + width: 100%; padding: 4px 8px; font-size: 12px; + background: var(--bg-secondary); color: var(--text-primary); + border: 1px solid var(--border); border-radius: 4px; cursor: pointer; +} + +/* État vide */ +.recent-empty { + display: flex; flex-direction: column; align-items: center; + padding: 32px 16px; color: var(--text-muted); gap: 8px; font-size: 13px; +}