From f71d97e06c843f8693d551217bda98fab7d6233c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 27 Mar 2026 13:54:08 -0400 Subject: [PATCH] feat: Implement core application structure with frontend styling, JavaScript, and Python backend services. --- backend/history.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++ backend/main.py | 53 +++++++++++++++++++++++++++++++++-- frontend/app.js | 28 ++++++++----------- frontend/style.css | 40 +++++++++++--------------- 4 files changed, 150 insertions(+), 41 deletions(-) create mode 100644 backend/history.py diff --git a/backend/history.py b/backend/history.py new file mode 100644 index 0000000..2ae51a6 --- /dev/null +++ b/backend/history.py @@ -0,0 +1,70 @@ +# backend/history.py +import json +import os +import time +import logging +import shutil +from pathlib import Path +from typing import List, Dict, Any, Optional + +logger = logging.getLogger("obsigate.history") + +HISTORY_DIR = Path("data/history") + +def _get_history_file(username: str) -> Path: + HISTORY_DIR.mkdir(parents=True, exist_ok=True) + return HISTORY_DIR / f"{username}.json" + +def _read_history(username: str) -> List[Dict[str, Any]]: + h_file = _get_history_file(username) + if not h_file.exists(): + return [] + try: + return json.loads(h_file.read_text(encoding="utf-8")) + except Exception as e: + logger.error(f"Failed to read history for {username}: {e}") + return [] + +def _write_history(username: str, history: List[Dict[str, Any]]): + h_file = _get_history_file(username) + try: + tmp = h_file.with_suffix(".tmp") + tmp.write_text(json.dumps(history, indent=2, ensure_ascii=False), encoding="utf-8") + shutil.move(str(tmp), str(h_file)) + except Exception as e: + logger.error(f"Failed to write history for {username}: {e}") + +def record_open(username: str, vault: str, path: str, title: str = ""): + """Record that a file was opened by a user.""" + if not username: + return + + history = _read_history(username) + + # Remove existing entry for the same file if any + history = [item for item in history if not (item["vault"] == vault and item["path"] == path)] + + # Add new entry at the beginning + history.insert(0, { + "vault": vault, + "path": path, + "title": title, + "opened_at": time.time() + }) + + # Limit history size (e.g., 100 entries) + history = history[:100] + + _write_history(username, history) + +def get_recent_opened(username: str, vault_filter: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]: + """Get the most recently opened files for a user.""" + if not username: + return [] + + history = _read_history(username) + + if vault_filter: + history = [item for item in history if item["vault"] == vault_filter] + + return history[:limit] diff --git a/backend/main.py b/backend/main.py index d1870ff..95715f1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -50,6 +50,7 @@ from backend.vault_settings import ( get_all_vault_settings, delete_vault_setting, ) +from backend.history import record_open, get_recent_opened logging.basicConfig( level=logging.INFO, @@ -641,12 +642,54 @@ def humanize_mtime(mtime: float) -> str: @app.get("/api/recent") -async def api_recent(limit: Optional[int] = Query(None), vault: Optional[str] = Query(None), current_user=Depends(require_auth)): +async def api_recent(limit: Optional[int] = Query(None), vault: Optional[str] = Query(None), mode: Optional[str] = Query("opened"), current_user=Depends(require_auth)): config = _load_config() actual_limit = limit if limit is not None else config.get("recent_files_limit", 20) + username = current_user.get("username") user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) + if mode == "opened" and username: + # Use history file for "last opened" + history = get_recent_opened(username, vault_filter=vault, limit=actual_limit) + files_resp = [] + for item in history: + v_name = item["vault"] + if "*" not in user_vaults and v_name not in user_vaults: + continue + + # Find in index to get metadata/preview + f_idx = find_file_in_index(item["path"], v_name) + if f_idx: + files_resp.append({ + "path": f_idx["path"], + "title": f_idx.get("title") or item["path"].split("/")[-1], + "vault": v_name, + "mtime": item["opened_at"], + "mtime_human": humanize_mtime(item["opened_at"]), + "size_bytes": f_idx.get("size", 0), + "tags": [f"#{t}" for t in f_idx.get("tags", [])][:5], + "preview": f_idx.get("content_preview", "")[:120] + }) + else: + # File might have been renamed/deleted since last open + files_resp.append({ + "path": item["path"], + "title": item.get("title") or item["path"].split("/")[-1], + "vault": v_name, + "mtime": item["opened_at"], + "mtime_human": humanize_mtime(item["opened_at"]), + "tags": [], + "preview": "" + }) + return { + "files": files_resp, + "total": len(files_resp), + "limit": actual_limit, + "mode": "opened" + } + + # Fallback to "last modified" (original logic) all_files = [] for v_name, v_data in index.items(): if vault and v_name != vault: @@ -685,7 +728,7 @@ async def api_recent(limit: Optional[int] = Query(None), vault: Optional[str] = "files": files_resp, "total": len(all_files), "limit": actual_limit, - "generated_at": time.time() + "mode": "modified" } @@ -840,6 +883,9 @@ async def api_file_download(vault_name: str, path: str = Query(..., description= if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") + # Record history + record_open(current_user.get("username"), vault_name, path) + return FileResponse( path=str(file_path), filename=file_path.name, @@ -955,6 +1001,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") + # Record history + record_open(current_user.get("username"), vault_name, path, title=file_path.name) + try: raw = file_path.read_text(encoding="utf-8", errors="replace") except PermissionError as e: diff --git a/frontend/app.js b/frontend/app.js index 67c852f..429677b 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2868,11 +2868,12 @@ _currentFilter: "", async load(vaultFilter = "") { - this._currentFilter = vaultFilter; + const v = vaultFilter || selectedContextVault || "all"; + this._currentFilter = v; this.showLoading(); - let url = "/api/recent"; - if (vaultFilter) url += `?vault=${encodeURIComponent(vaultFilter)}`; + let url = "/api/recent?mode=opened"; + if (v !== "all") url += `&vault=${encodeURIComponent(v)}`; try { const data = await api(url); @@ -2954,7 +2955,7 @@ title.title = file.title || file.path; card.appendChild(title); - // Path + // Path (compact) const pathParts = file.path.split("/"); if (pathParts.length > 1) { const path = document.createElement("div"); @@ -2964,14 +2965,6 @@ card.appendChild(path); } - // Preview - if (file.preview) { - const preview = document.createElement("p"); - preview.className = "dashboard-card-preview"; - preview.textContent = file.preview; - card.appendChild(preview); - } - // Footer with time and tags const footer = document.createElement("div"); footer.className = "dashboard-card-footer"; @@ -3057,12 +3050,12 @@ const select = document.getElementById("dashboard-vault-filter"); if (select) { select.addEventListener("change", () => { - this.load(select.value || null); + this.load(select.value); }); } this.populateVaultFilter(); - this.load(null); + this.load(selectedContextVault); }, }; @@ -4663,8 +4656,11 @@ // Show the dashboard widget instead of simple welcome message if (typeof DashboardRecentWidget !== "undefined") { // Re-init the widget to refresh data and populate vault filter - DashboardRecentWidget.populateVaultFilter(); - DashboardRecentWidget.load(DashboardRecentWidget._currentFilter || null); + const dashboardVaultFilter = document.getElementById("dashboard-vault-filter"); + if (dashboardVaultFilter) { + dashboardVaultFilter.value = selectedContextVault === "all" ? "" : selectedContextVault; + } + DashboardRecentWidget.load(selectedContextVault); } } diff --git a/frontend/style.css b/frontend/style.css index 6d401df..288cc14 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -999,6 +999,7 @@ select { .content-area { flex: 1; overflow-y: auto; + overflow-x: hidden; padding: clamp(16px, 3vw, 40px) clamp(16px, 4vw, 40px) 60px; transition: background 200ms ease, @@ -4648,9 +4649,8 @@ body.popup-mode .content-area { --------------------------------------------------------------------------- */ .dashboard-home { - padding: 24px; - height: 100%; - overflow-y: auto; + padding: 0; + width: 100%; display: flex; flex-direction: column; } @@ -4713,9 +4713,10 @@ body.popup-mode .content-area { /* Grid */ .dashboard-recent-grid { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; flex: 1; + width: 100%; } @media (max-width: 1200px) { @@ -4743,13 +4744,13 @@ body.popup-mode .content-area { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 12px; - padding: 16px; + padding: 12px; cursor: pointer; - transition: all 0.2s ease; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); position: relative; display: flex; flex-direction: column; - gap: 8px; + gap: 4px; animation: fadeSlideIn 0.3s ease forwards; opacity: 0; } @@ -4813,11 +4814,11 @@ body.popup-mode .content-area { } .dashboard-card-icon { - width: 36px; - height: 36px; - padding: 8px; + width: 28px; + height: 28px; + padding: 6px; background: var(--accent); - border-radius: 8px; + border-radius: 6px; color: white; flex-shrink: 0; } @@ -4848,6 +4849,7 @@ body.popup-mode .content-area { text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; } @@ -4863,15 +4865,7 @@ body.popup-mode .content-area { /* Card preview */ .dashboard-card-preview { - font-size: 12px; - color: var(--text-muted); - line-height: 1.5; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - margin: 0; + display: none; } /* Card footer */ @@ -4880,9 +4874,9 @@ body.popup-mode .content-area { align-items: center; justify-content: space-between; gap: 8px; - margin-top: auto; - padding-top: 8px; - border-top: 1px solid var(--border); + margin-top: 4px; + padding-top: 6px; + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); } .dashboard-card-time {