feat: implement initial ObsiGate application with backend API, indexing, search, and basic frontend.
This commit is contained in:
parent
d6ae501f51
commit
c5e395005f
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
164
frontend/app.js
164
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();
|
||||
|
||||
@ -282,6 +282,11 @@
|
||||
<i data-lucide="tag" style="width:13px;height:13px"></i>
|
||||
<span>Tags</span>
|
||||
</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>
|
||||
|
||||
<!-- Vaults panel -->
|
||||
@ -304,6 +309,20 @@
|
||||
<div class="tag-cloud" id="tag-cloud"></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>
|
||||
|
||||
<!-- Sidebar resize handle -->
|
||||
@ -383,6 +402,16 @@
|
||||
</div>
|
||||
</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 -->
|
||||
<section class="config-section">
|
||||
<h2>Paramètres backend <span class="config-badge-restart">Redémarrage requis</span></h2>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user