feat: implement initial ObsiGate application with backend API, indexing, search, and basic frontend.

This commit is contained in:
Bruno Charest 2026-03-24 12:56:00 -04:00
parent d6ae501f51
commit c5e395005f
4 changed files with 318 additions and 2 deletions

View File

@ -7,8 +7,10 @@ import logging
import mimetypes import mimetypes
import secrets import secrets
import string import string
import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
@ -614,6 +616,64 @@ async def api_vaults(current_user=Depends(require_auth)):
return result 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) @app.get("/api/browse/{vault_name}", response_model=BrowseResponse)
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)): 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. """Browse directories and files in a vault at a given path level.
@ -1348,6 +1408,7 @@ _DEFAULT_CONFIG = {
"watcher_debounce": 2.0, "watcher_debounce": 2.0,
"tag_boost": 2.0, "tag_boost": 2.0,
"prefix_max_expansions": 50, "prefix_max_expansions": 50,
"recent_files_limit": 20,
} }

View File

@ -2261,6 +2261,150 @@
area.scrollTop = 0; 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 // Sidebar tabs
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -2283,14 +2427,21 @@
}); });
const filterInput = document.getElementById("sidebar-filter-input"); const filterInput = document.getElementById("sidebar-filter-input");
if (filterInput) { 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 const query = filterInput
? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) ? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase())
: ""; : "";
if (query) { if (query) {
if (tab === "vaults") performTreeSearch(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-title-boost", data.title_boost);
_setField("cfg-tag-boost", data.tag_boost); _setField("cfg-tag-boost", data.tag_boost);
_setField("cfg-prefix-exp", data.prefix_max_expansions); _setField("cfg-prefix-exp", data.prefix_max_expansions);
_setField("cfg-recent-limit", data.recent_files_limit || 20);
// Watcher config // Watcher config
_setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false); _setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false);
_setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true); _setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true);
@ -2527,6 +2679,7 @@
title_boost: _getFieldNum("cfg-title-boost", 3.0), title_boost: _getFieldNum("cfg-title-boost", 3.0),
tag_boost: _getFieldNum("cfg-tag-boost", 2.0), tag_boost: _getFieldNum("cfg-tag-boost", 2.0),
prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50), prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50),
recent_files_limit: _getFieldNum("cfg-recent-limit", 20),
watcher_enabled: _getCheckbox("cfg-watcher-enabled"), watcher_enabled: _getCheckbox("cfg-watcher-enabled"),
watcher_use_polling: _getCheckbox("cfg-watcher-polling"), watcher_use_polling: _getCheckbox("cfg-watcher-polling"),
watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0), 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(() => { setTimeout(() => {
connectionState = "connected"; connectionState = "connected";
_updateBadge(); _updateBadge();
@ -4039,6 +4198,7 @@
initEditor(); initEditor();
initSyncStatus(); initSyncStatus();
initLoginForm(); initLoginForm();
initRecentTab();
// Check auth status first // Check auth status first
const authOk = await AuthManager.initAuth(); const authOk = await AuthManager.initAuth();

View File

@ -282,6 +282,11 @@
<i data-lucide="tag" style="width:13px;height:13px"></i> <i data-lucide="tag" style="width:13px;height:13px"></i>
<span>Tags</span> <span>Tags</span>
</button> </button>
<button class="sidebar-tab" id="sidebar-tab-recent" role="tab"
aria-selected="false" aria-controls="sidebar-panel-recent" data-tab="recent">
<i data-lucide="clock" style="width:13px;height:13px"></i>
<span>Récent</span>
</button>
</div> </div>
<!-- Vaults panel --> <!-- Vaults panel -->
@ -304,6 +309,20 @@
<div class="tag-cloud" id="tag-cloud"></div> <div class="tag-cloud" id="tag-cloud"></div>
</div> </div>
<!-- Recent panel -->
<div class="sidebar-tab-panel" id="sidebar-panel-recent" role="tabpanel" aria-labelledby="sidebar-tab-recent">
<div class="recent-filter-bar">
<select id="recent-vault-filter">
<option value="">Tous les vaults</option>
</select>
</div>
<div id="recent-list" class="recent-list"></div>
<div id="recent-empty" class="recent-empty hidden">
<i data-lucide="inbox" style="width:32px;height:32px"></i>
<span>Aucun fichier récent</span>
</div>
</div>
</aside> </aside>
<!-- Sidebar resize handle --> <!-- Sidebar resize handle -->
@ -383,6 +402,16 @@
</div> </div>
</section> </section>
<!-- Historique récent -->
<section class="config-section">
<h2>📋 Historique récent <span class="config-badge-restart">Redémarrage non requis</span></h2>
<div class="config-row">
<label class="config-label" for="cfg-recent-limit">Nombre de fichiers dans l'historique</label>
<input type="number" id="cfg-recent-limit" class="config-input config-input--num" min="5" max="100" step="5" value="20">
<span class="config-hint">Entre 5 et 100 fichiers (sauvegardé sur le serveur)</span>
</div>
</section>
<!-- Performance Settings — Backend --> <!-- Performance Settings — Backend -->
<section class="config-section"> <section class="config-section">
<h2>Paramètres backend <span class="config-badge-restart">Redémarrage requis</span></h2> <h2>Paramètres backend <span class="config-badge-restart">Redémarrage requis</span></h2>

View File

@ -3353,3 +3353,69 @@ body.popup-mode .content-area {
box-shadow: 0 8px 30px rgba(0,0,0,0.15); box-shadow: 0 8px 30px rgba(0,0,0,0.15);
box-sizing: border-box; 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;
}