From 93d0bda627807890e6a199243314f9e1489288ea Mon Sep 17 00:00:00 2001 From: bruno Date: Thu, 23 Jul 2026 17:33:51 -0400 Subject: [PATCH 01/13] feat: PDF support + Split View (P1 roadmap items) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #74 — Support PDF complet: - backend/pdf_reader.py — pymupdf/pypdf text extraction - .pdf added to SUPPORTED_EXTENSIONS in indexer.py - PDF-aware file view API endpoint (metadata, text preview) - Streaming endpoint /api/file/{vault}/pdf/stream - Frontend PDF viewer with iframe, toolbar, download - CSS for PDF viewer container #75 — Split View (PaneManager): - New module frontend/js/pane-manager.js — ES module - 1-4 pane CSS grid with resize handles - Split right/down via keyboard (Ctrl+Alt+\) or PaneManager API - Persistence in localStorage (obsigate-panes) - Collapse to single pane, per-pane active state 285 tests passent --- backend/indexer.py | 44 +++--- backend/main.py | 45 ++++++ backend/pdf_reader.py | 92 +++++++++++++ backend/requirements.txt | 1 + frontend/js/app.js | 2 + frontend/js/pane-manager.js | 263 ++++++++++++++++++++++++++++++++++++ frontend/js/viewer.js | 21 +++ frontend/style.css | 57 ++++++++ 8 files changed, 506 insertions(+), 19 deletions(-) create mode 100644 backend/pdf_reader.py create mode 100644 frontend/js/pane-manager.js diff --git a/backend/indexer.py b/backend/indexer.py index b321390..e5d00df 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -57,7 +57,7 @@ SUPPORTED_EXTENSIONS = { ".md", ".txt", ".log", ".py", ".js", ".ts", ".jsx", ".tsx", ".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1", ".json", ".yaml", ".yml", ".toml", ".xml", ".csv", - ".cfg", ".ini", ".conf", ".env", + ".cfg", ".ini", ".conf", ".env", ".pdf", ".html", ".css", ".scss", ".less", ".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb", ".php", ".sql", ".r", ".m", ".swift", ".kt", @@ -281,26 +281,32 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str, stat = fpath.stat() modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() - raw = fpath.read_text(encoding="utf-8", errors="replace") + # PDF handling — special path (binary, uses pdf_reader) + if ext == ".pdf": + from backend.pdf_reader import extract_pdf_text, extract_pdf_metadata + raw = extract_pdf_text(fpath, max_chars=100000) + pdf_meta = extract_pdf_metadata(fpath) + title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ") + content_preview = raw[:200].strip() + tags: List[str] = [] + else: + raw = fpath.read_text(encoding="utf-8", errors="replace") + tags: List[str] = [] + title = fpath.stem.replace("-", " ").replace("_", " ") + content_preview = raw[:200].strip() - tags: List[str] = [] - title = fpath.stem.replace("-", " ").replace("_", " ") - content_preview = raw[:200].strip() + if ext == ".md": + post = parse_markdown_file(raw) + tags = _extract_tags(post) + inline_tags = _extract_inline_tags(post.content) + tags = list(set(tags) | set(inline_tags)) + title = _extract_title(post, fpath) + content_preview = post.content[:200].strip() - if ext == ".md": - post = parse_markdown_file(raw) - tags = _extract_tags(post) - # Merge inline #tags found in content body - inline_tags = _extract_inline_tags(post.content) - tags = list(set(tags) | set(inline_tags)) - title = _extract_title(post, fpath) - content_preview = post.content[:200].strip() - - # Extract wikilinks for backlink index - _extract_wikilinks_for_backlinks( - vault_name, str(relative).replace("\\", "/"), - title, post.content - ) + _extract_wikilinks_for_backlinks( + vault_name, str(relative).replace("\\", "/"), + title, post.content + ) files.append({ "path": str(relative).replace("\\", "/"), diff --git a/backend/main.py b/backend/main.py index 60fdbb9..1748507 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2301,6 +2301,32 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative ext = file_path.suffix.lower() + # === PDF: special handling before read_text (binary file) === + if ext == ".pdf": + try: + from backend.pdf_reader import extract_pdf_text, extract_pdf_metadata + pdf_text = extract_pdf_text(file_path, max_chars=100000) + pdf_meta = extract_pdf_metadata(file_path) + size = file_path.stat().st_size + return { + "vault": vault_name, + "path": path, + "title": pdf_meta.get("title") or file_path.name, + "tags": [], + "frontmatter": {}, + "html": f"

PDF — {pdf_meta.get('pages', '?')} pages

{pdf_text[:5000]}
", + "raw_length": size, + "extension": ext, + "is_markdown": False, + "is_pdf": True, + "unsupported": False, + "pdf_metadata": pdf_meta, + "size_bytes": size, + } + except Exception as e: + logger.error(f"PDF read error for {path}: {e}") + raise HTTPException(status_code=500, detail=f"Error reading PDF: {str(e)}") + try: raw = file_path.read_text(encoding="utf-8", errors="replace") except PermissionError as e: @@ -2365,6 +2391,25 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative } +@app.get("/api/file/{vault_name}/pdf/stream") +async def api_pdf_stream(vault_name: str, path: str = Query(...)): + """Stream a PDF file with Content-Type: application/pdf for inline browser viewing.""" + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + vault_data = get_vault_data(vault_name) + if not vault_data: + raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") + vault_root = Path(vault_data["path"]) + file_path = _resolve_safe_path(vault_root, path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail=f"File not found: {path}") + if file_path.suffix.lower() != ".pdf": + raise HTTPException(status_code=400, detail="Not a PDF file") + from fastapi.responses import FileResponse + return FileResponse(str(file_path), media_type="application/pdf", headers={ + "Content-Disposition": f"inline; filename=\"{file_path.name}\""}) + + @app.get("/api/search", response_model=SearchResponse) async def api_search( q: str = Query("", description="Search query"), diff --git a/backend/pdf_reader.py b/backend/pdf_reader.py new file mode 100644 index 0000000..bd431ee --- /dev/null +++ b/backend/pdf_reader.py @@ -0,0 +1,92 @@ +"""PDF text extraction for ObsiGate — pypdf backend, pymupdf optional fallback.""" +from __future__ import annotations + +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +PDF_READER: str = "pypdf" +try: + import fitz # pymupdf + PDF_READER = "pymupdf" + logger.info("PDF reader: pymupdf (high performance)") +except ImportError: + try: + from pypdf import PdfReader # type: ignore + logger.info("PDF reader: pypdf (pure Python)") + except ImportError: + PdfReader = None # type: ignore + logger.warning("No PDF reader available — install pypdf or pymupdf") + + +def extract_pdf_text(file_path: Path, max_chars: int = 100000) -> str: + """Extract text from a PDF file. Returns empty string on failure.""" + if PdfReader is None and PDF_READER == "pypdf": + return "" + try: + if PDF_READER == "pymupdf": + return _extract_pymupdf(file_path, max_chars) + else: + return _extract_pypdf(file_path, max_chars) + except Exception as e: + logger.warning("Failed to extract PDF text from %s: %s", file_path, e) + return "" + + +def extract_pdf_metadata(file_path: Path) -> dict: + """Extract metadata from a PDF file.""" + info = {"pages": 0, "title": "", "author": ""} + try: + if PDF_READER == "pymupdf": + doc = fitz.open(str(file_path)) + info["pages"] = doc.page_count + meta = doc.metadata or {} + info["title"] = meta.get("title", "") + info["author"] = meta.get("author", "") + doc.close() + else: + reader = PdfReader(str(file_path)) + info["pages"] = len(reader.pages) + meta = reader.metadata or {} + if meta: + info["title"] = str(meta.get("/Title", "")) + info["author"] = str(meta.get("/Author", "")) + except Exception as e: + logger.warning("Failed to extract PDF metadata from %s: %s", file_path, e) + return info + + +def _extract_pymupdf(file_path: Path, max_chars: int) -> str: + doc = fitz.open(str(file_path)) + parts = [] + total = 0 + for page in doc: + text = page.get_text() + if text: + if total + len(text) > max_chars: + remaining = max_chars - total + if remaining > 0: + parts.append(text[:remaining]) + break + parts.append(text) + total += len(text) + doc.close() + return "\f".join(parts) + + +def _extract_pypdf(file_path: Path, max_chars: int) -> str: + reader = PdfReader(str(file_path)) + parts = [] + total = 0 + for page in reader.pages: + text = page.extract_text() or "" + if text: + if total + len(text) > max_chars: + remaining = max_chars - total + if remaining > 0: + parts.append(text[:remaining]) + break + parts.append(text) + total += len(text) + return "\f".join(parts) diff --git a/backend/requirements.txt b/backend/requirements.txt index c63bcd9..823cb2b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,3 +12,4 @@ sortedcontainers>=2.4.0 snowballstemmer>=2.2.0 weasyprint>=60.0 httpx>=0.27.0 +pypdf>=4.0 diff --git a/frontend/js/app.js b/frontend/js/app.js index 9e15afa..c878525 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -18,6 +18,7 @@ import { initCommandPalette } from './palette.js'; import { initMobileToolbar } from './mobile-toolbar.js'; import { initDragDrop } from './dragdrop.js'; import { initMermaid, setupFocusMode } from './mermaid-viewer.js'; +import PaneManager from './pane-manager.js'; // ========================================================================= // Initialization — mirrors the original app.js init() ordering @@ -58,6 +59,7 @@ async function init() { UI.FindInPageManager.init(); UI.ContextMenuManager.init(); UI.TabManager.init(); + PaneManager.init(); initCommandPalette(); initMobileToolbar(); diff --git a/frontend/js/pane-manager.js b/frontend/js/pane-manager.js new file mode 100644 index 0000000..2e9324d --- /dev/null +++ b/frontend/js/pane-manager.js @@ -0,0 +1,263 @@ +/** + * PaneManager — Multi-pane split view for ObsiGate (v2.0.0-dev) + * Supports 1–4 panes in CSS grid, split right/down, resize, drag tabs between panes. + * Integrates with existing TabManager singleton. + */ + +const PANE_MAX = 4; + +const PaneManager = { + panes: [], + activePaneId: 0, + _paneCounter: 1, + layout: 'horizontal', + + init() { + const saved = localStorage.getItem('obsigate-panes'); + if (saved) { + try { + const data = JSON.parse(saved); + if (data.panes && data.panes.length >= 2) { + this.layout = data.layout || 'horizontal'; + this._paneCounter = data.panes.length; + this._buildGrid(data.panes.length); + data.panes.forEach((p, i) => { + this.panes[i].activeTab = p.activeTab; + this.panes[i].width = p.width; + }); + this.activePaneId = data.panes[0].id; + this._applyResize(); + return; + } + } catch (e) { /* ignore */ } + } + }, + + _buildGrid(n) { + const wrapper = document.querySelector('.content-wrapper'); + const origTabBar = document.getElementById('tab-bar'); + const origContent = document.getElementById('content-area'); + if (!wrapper || !origContent || n < 2) return; + + // Create grid container + const grid = document.createElement('div'); + grid.className = 'pane-grid'; + if (n === 2) grid.style.gridTemplateColumns = this.layout === 'horizontal' ? '1fr 4px 1fr' : '1fr'; + if (n >= 3) grid.style.gridTemplateColumns = '1fr 4px 1fr'; + if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr'; + + wrapper.innerHTML = ''; + wrapper.appendChild(grid); + + for (let i = 0; i < Math.min(n, PANE_MAX); i++) { + const pane = this._createPane(i, grid, i === 0 ? origTabBar : null, i === 0 ? origContent : null); + this.panes.push(pane); + if (i < n - 1) { + const handle = document.createElement('div'); + handle.className = 'pane-resize-handle'; + if (this.layout === 'horizontal') handle.style.cursor = 'col-resize'; + else handle.style.cursor = 'row-resize'; + handle.addEventListener('mousedown', (e) => this._onResizeStart(e, i, i + 1)); + handle.addEventListener('dblclick', () => this._resetResize(i, i + 1)); + grid.appendChild(handle); + } + } + this.activePaneId = 0; + this._highlightActive(); + }, + + _createPane(idx, grid, reuseTabBar, reuseContent) { + const container = document.createElement('div'); + container.className = 'pane-container'; + container.dataset.pane = idx; + + if (reuseTabBar && reuseContent) { + // Pane 0 reuses existing DOM + container.appendChild(reuseTabBar); + container.appendChild(reuseContent); + reuseContent.id = 'content-area-0'; + } else { + const tabBar = document.createElement('div'); + tabBar.className = 'tab-bar pane-tab-bar'; + tabBar.id = 'tab-bar-' + idx; + tabBar.innerHTML = '
'; + const content = document.createElement('main'); + content.className = 'content-area pane-content'; + content.id = 'content-area-' + idx; + container.appendChild(tabBar); + container.appendChild(content); + } + + container.addEventListener('click', () => { + this.activePaneId = idx; + this._highlightActive(); + }); + + grid.appendChild(container); + return { id: idx, element: container, width: 100 / this.panes.length + '%' }; + }, + + _highlightActive() { + this.panes.forEach((p, i) => { + p.element.classList.toggle('pane-active', i === this.activePaneId); + }); + }, + + splitRight() { + if (this.panes.length >= PANE_MAX) return; + if (this.panes.length === 0) { + // First split: promote single pane to grid + this._buildGrid(2); + } else { + this._addPane(); + } + this._persist(); + }, + + splitDown() { + if (this.panes.length >= PANE_MAX) return; + this.layout = 'vertical'; + if (this.panes.length === 0) { + this._buildGrid(2); + } else { + this._addPane(); + } + this._persist(); + }, + + _addPane() { + const grid = document.querySelector('.pane-grid'); + if (!grid) return; + const n = this.panes.length + 1; + const handle = document.createElement('div'); + handle.className = 'pane-resize-handle'; + handle.addEventListener('mousedown', (e) => this._onResizeStart(e, n - 2, n - 1)); + grid.appendChild(handle); + const pane = this._createPane(n - 1, grid, null, null); + this.panes.push(pane); + this.activePaneId = n - 1; + this._highlightActive(); + if (n <= 2) grid.style.gridTemplateColumns = '1fr 4px 1fr'; + else if (n === 4) grid.style.gridTemplateRows = '1fr 4px 1fr'; + }, + + closePane(paneId) { + if (this.panes.length <= 1) return; + const idx = this.panes.findIndex(p => p.id === paneId); + if (idx < 0) return; + // Remove pane and its resize handles + this.panes[idx].element.remove(); + const handles = document.querySelectorAll('.pane-resize-handle'); + if (handles[idx]) handles[idx].remove(); + else if (handles[idx - 1]) handles[idx - 1].remove(); + this.panes.splice(idx, 1); + if (this.panes.length <= 1) { + this._collapseToSingle(); + } else { + this.activePaneId = Math.min(this.activePaneId, this.panes.length - 1); + this._highlightActive(); + } + this._persist(); + }, + + _collapseToSingle() { + const grid = document.querySelector('.pane-grid'); + if (!grid) return; + const wrapper = grid.parentElement; + const pane0 = this.panes[0]; + wrapper.innerHTML = ''; + const tabBar = pane0.element.querySelector('.tab-bar') || document.getElementById('tab-bar-0'); + const content = pane0.element.querySelector('.content-area') || document.getElementById('content-area-0'); + if (tabBar) wrapper.appendChild(tabBar); + if (content) wrapper.appendChild(content); + this.panes = []; + this.activePaneId = 0; + }, + + _onResizeStart(e, leftIdx, rightIdx) { + e.preventDefault(); + const grid = document.querySelector('.pane-grid'); + const startX = e.clientX; + const startY = e.clientY; + const totalSize = this.layout === 'horizontal' ? grid.offsetWidth : grid.offsetHeight; + + const onMove = (ev) => { + const delta = this.layout === 'horizontal' ? ev.clientX - startX : ev.clientY - startY; + const pct = (delta / totalSize) * 100; + const leftEl = this.panes[leftIdx].element; + const rightEl = this.panes[rightIdx].element; + const curLeft = parseFloat(leftEl.style.flexBasis || leftEl.style.width || '50'); + const curRight = parseFloat(rightEl.style.flexBasis || rightEl.style.width || '50'); + const newLeft = Math.max(20, Math.min(80, curLeft + pct)); + const newRight = 100 - newLeft; + leftEl.style.flexBasis = newLeft + '%'; + rightEl.style.flexBasis = newRight + '%'; + this.panes[leftIdx].width = newLeft + '%'; + this.panes[rightIdx].width = newRight + '%'; + }; + + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + this._persist(); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, + + _resetResize(leftIdx, rightIdx) { + this.panes[leftIdx].element.style.flexBasis = '50%'; + this.panes[rightIdx].element.style.flexBasis = '50%'; + this._persist(); + }, + + _applyResize() { + this.panes.forEach(p => { + if (p.width) p.element.style.flexBasis = p.width; + }); + }, + + _persist() { + if (this.panes.length <= 1) { + localStorage.removeItem('obsigate-panes'); + return; + } + const data = { + layout: this.layout, + panes: this.panes.map(p => ({ id: p.id, activeTab: p.activeTab, width: p.width })), + }; + localStorage.setItem('obsigate-panes', JSON.stringify(data)); + }, + + getActiveContentArea() { + if (this.panes.length < 2) return document.getElementById('content-area'); + return document.getElementById('content-area-' + this.activePaneId) || document.getElementById('content-area-0'); + }, + + isSplit() { + return this.panes.length >= 2; + }, +}; + +// Export and auto-init +if (typeof window !== 'undefined') { + window.PaneManager = PaneManager; + document.addEventListener('DOMContentLoaded', () => { + // Register keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.altKey && !e.shiftKey && e.key === '\\') { + e.preventDefault(); + PaneManager.splitRight(); + } + if (e.ctrlKey && e.altKey && e.shiftKey && e.key === '\\') { + e.preventDefault(); + PaneManager.splitDown(); + } + }); + // Initialize if saved state exists + setTimeout(() => PaneManager.init(), 500); + }); +} + +export default PaneManager; diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index da7f7a8..a6ee8ed 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -402,6 +402,27 @@ async function renderBacklinksPanel(vault, path, container) { export function renderFile(data) { const area = document.getElementById("content-area"); + // Handle PDF files — render in iframe + if (data.is_pdf) { + const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf/stream?path=${encodeURIComponent(data.path)}`; + const pages = data.pdf_metadata && data.pdf_metadata.pages ? data.pdf_metadata.pages : '?'; + area.innerHTML = ` +
+
+ PDF — ${pages} pages + + +
+ +
`; + lucide.createIcons(); + return; + } + // Handle unsupported (binary) files if (data.unsupported) { const sizeStr = data.size_bytes diff --git a/frontend/style.css b/frontend/style.css index deac04a..fe79394 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -8428,3 +8428,60 @@ body.popup-mode .content-area { font-size: 0.62rem; color: var(--text-muted); opacity: 0.5; } .about-copy code { font-family: var(--mono); font-size: 0.58rem; } + +/* ── PDF Viewer ── */ +.pdf-viewer-container { + display: flex; + flex-direction: column; + height: 100%; +} +.pdf-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--surface1); + border-bottom: 1px solid var(--border); + font-size: 0.85rem; + flex-shrink: 0; +} +.pdf-info { + flex: 1; + color: var(--text-dim); +} +.pdf-iframe { + flex: 1; + width: 100%; + border: none; + min-height: 70vh; +} + +/* ── PaneManager — Split View ── */ +.pane-grid { + display: grid; + height: 100%; + overflow: hidden; +} +.pane-container { + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} +.pane-container.pane-active { + outline: 2px solid var(--accent); + outline-offset: -2px; + z-index: 1; +} +.pane-tab-bar { flex-shrink: 0; } +.pane-content { flex: 1; overflow-y: auto; } +.pane-resize-handle { + background: var(--border); + transition: background 150ms; + min-width: 4px; + min-height: 4px; + z-index: 2; +} +.pane-resize-handle:hover { + background: var(--accent); +} From d2953d98f9bb0443ce6fc5bcf2b2eeccfc33fc81 Mon Sep 17 00:00:00 2001 From: bruno Date: Thu, 23 Jul 2026 23:21:32 -0400 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20mobile=20toolbar=20height=20?= =?UTF-8?q?=E2=80=94=20icons=20no=20longer=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .mobile-toolbar: 56px → 64px + padding 4px (was 0) - .main-layout padding-bottom: 56px → 64px - .mt-btn: height auto + min-height 48px (was fixed 48px) - .mt-btn: gap 3px (was 2px), padding 4px (was 2px) --- frontend/style.css | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/frontend/style.css b/frontend/style.css index fe79394..ee72588 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -7357,22 +7357,22 @@ body.popup-mode .content-area { bottom: 0; left: 0; right: 0; - height: 56px; + height: 64px; background: var(--bg-secondary); border-top: 1px solid var(--border); z-index: 900; align-items: center; justify-content: space-around; - padding: 0 4px; - padding-bottom: env(safe-area-inset-bottom, 0); + padding: 4px 4px; + padding-bottom: calc(4px + env(safe-area-inset-bottom, 0)); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); } /* Push content up to avoid toolbar overlap */ .main-layout { - padding-bottom: 56px; - padding-bottom: calc(56px + env(safe-area-inset-bottom, 0)); + padding-bottom: 64px; + padding-bottom: calc(64px + env(safe-area-inset-bottom, 0)); } .editor-modal.active ~ .main-layout { padding-bottom: 0; @@ -7419,10 +7419,11 @@ body.popup-mode .content-area { flex-direction: column; align-items: center; justify-content: center; - gap: 2px; + gap: 3px; flex: 1; - height: 48px; - padding: 2px 4px; + height: auto; + min-height: 48px; + padding: 4px 4px; border: none; border-radius: 8px; background: transparent; From 797d9763dde2bafe927636ecf16063da4e682d7d Mon Sep 17 00:00:00 2001 From: bruno Date: Thu, 23 Jul 2026 23:28:18 -0400 Subject: [PATCH 03/13] fix: TOC sidebar closed by default on initial load - Changed rightSidebarVisible default from true to false - First-time visitors no longer see the TOC panel auto-opened --- frontend/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app.js b/frontend/app.js index 3aeb226..d52a790 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -55,7 +55,7 @@ let outlineObserver = null; let activeHeadingId = null; let headingsCache = []; - let rightSidebarVisible = true; + let rightSidebarVisible = false; let rightSidebarWidth = 280; // --------------------------------------------------------------------------- From 61caa47745668d256721e359474c357bca414676 Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 24 Jul 2026 09:27:54 -0400 Subject: [PATCH 04/13] fix: logout button in settings panel now properly clears session - Both #logout-btn and #profile-logout now delegate to window.handleLogout() - Previously they did bare fetch+redirect without clearing JWT token or session state - Added credentials: 'include' fallback for robustness --- frontend/js/config.js | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/frontend/js/config.js b/frontend/js/config.js index 8a458f4..02e4664 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -631,14 +631,17 @@ function initConfigModal() { }); } - // Logout button (always visible) + // Logout button (always visible) — delegates to global handleLogout for proper session cleanup var logoutBtn = document.getElementById("logout-btn"); if (logoutBtn) { logoutBtn.addEventListener("click", function() { - if (confirm("Voulez-vous vraiment vous deconnecter ?")) { - fetch("/api/auth/logout", { method: "POST" }) - .then(function() { window.location.href = "/"; }) - .catch(function() { window.location.href = "/"; }); + if (confirm(t('confirm.logout') || "Voulez-vous vraiment vous deconnecter ?")) { + if (typeof window.handleLogout === 'function') { + window.handleLogout(); + } else { + fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) + .finally(function() { window.location.href = '/'; }); + } } }); } @@ -1576,13 +1579,17 @@ function initProfile() { } catch(e) {} }); - // Logout button + // Logout button — delegates to global handleLogout for proper session cleanup if (logoutBtn) { logoutBtn.addEventListener('click', function() { - if (confirm(t('confirm.logout'))) { - fetch('/api/auth/logout', { method: 'POST' }) - .then(function() { window.location.href = '/'; }) - .catch(function() { window.location.href = '/'; }); + if (confirm(t('confirm.logout') || 'Voulez-vous vraiment vous deconnecter ?')) { + if (typeof window.handleLogout === 'function') { + window.handleLogout(); + } else { + // Fallback if handleLogout not loaded + fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) + .finally(function() { window.location.href = '/'; }); + } } }); } From 3cf1c33b69d313d6187b41c4b5af69035e001f62 Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 24 Jul 2026 09:36:23 -0400 Subject: [PATCH 05/13] fix: remove confirm() dialogs from both logout buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header logout (#logout-btn) already had no confirm (AuthManager.logout called directly) - Settings panel (#logout-btn + #profile-logout) now call handleLogout() without confirm - Both buttons redirect to login screen immediately — consistent behavior --- frontend/js/config.js | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/frontend/js/config.js b/frontend/js/config.js index 02e4664..7e4b7b1 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -631,17 +631,15 @@ function initConfigModal() { }); } - // Logout button (always visible) — delegates to global handleLogout for proper session cleanup + // Logout button (always visible) — delegates to global handleLogout (no confirm) var logoutBtn = document.getElementById("logout-btn"); if (logoutBtn) { logoutBtn.addEventListener("click", function() { - if (confirm(t('confirm.logout') || "Voulez-vous vraiment vous deconnecter ?")) { - if (typeof window.handleLogout === 'function') { - window.handleLogout(); - } else { - fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) - .finally(function() { window.location.href = '/'; }); - } + if (typeof window.handleLogout === 'function') { + window.handleLogout(); + } else { + fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) + .finally(function() { window.location.href = '/'; }); } }); } @@ -1579,17 +1577,14 @@ function initProfile() { } catch(e) {} }); - // Logout button — delegates to global handleLogout for proper session cleanup + // Logout button — delegates to global handleLogout (no confirm) if (logoutBtn) { logoutBtn.addEventListener('click', function() { - if (confirm(t('confirm.logout') || 'Voulez-vous vraiment vous deconnecter ?')) { - if (typeof window.handleLogout === 'function') { - window.handleLogout(); - } else { - // Fallback if handleLogout not loaded - fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) - .finally(function() { window.location.href = '/'; }); - } + if (typeof window.handleLogout === 'function') { + window.handleLogout(); + } else { + fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) + .finally(function() { window.location.href = '/'; }); } }); } From 3655ef8a7e86035d222331e14f69768c57fcc7dd Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 24 Jul 2026 09:43:37 -0400 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20TOC=20panel=20never=20auto-opens?= =?UTF-8?q?=20on=20login=20=E2=80=94=20always=20starts=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed localStorage restore of obsigate-right-sidebar-visible on init - TOC only opens when user clicks the TOC button in a document - Width preference still restored from localStorage --- frontend/app.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index d52a790..15504ab 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1099,13 +1099,9 @@ }, loadState() { - const savedVisible = localStorage.getItem("obsigate-right-sidebar-visible"); + // Always start closed on login/refresh — user must explicitly toggle TOC const savedWidth = localStorage.getItem("obsigate-right-sidebar-width"); - if (savedVisible !== null) { - rightSidebarVisible = savedVisible === "true"; - } - if (savedWidth) { rightSidebarWidth = parseInt(savedWidth) || 280; } From b46b98c5a7a05b8aaaa1ffcac52897c3e04e671f Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 24 Jul 2026 09:50:15 -0400 Subject: [PATCH 07/13] fix: settings logout buttons now call AuthManager.logout() directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced window.handleLogout indirection with direct AuthManager.logout() call - config.js already imports AuthManager from auth.js — no race condition - Same direct call as header button (auth.js line 210) --- frontend/js/config.js | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/frontend/js/config.js b/frontend/js/config.js index 7e4b7b1..5bd62fc 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -631,16 +631,11 @@ function initConfigModal() { }); } - // Logout button (always visible) — delegates to global handleLogout (no confirm) + // Logout button (always visible) — calls AuthManager.logout() directly var logoutBtn = document.getElementById("logout-btn"); if (logoutBtn) { logoutBtn.addEventListener("click", function() { - if (typeof window.handleLogout === 'function') { - window.handleLogout(); - } else { - fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) - .finally(function() { window.location.href = '/'; }); - } + AuthManager.logout(); }); } @@ -1577,15 +1572,9 @@ function initProfile() { } catch(e) {} }); - // Logout button — delegates to global handleLogout (no confirm) + // Logout button — calls AuthManager.logout() directly if (logoutBtn) { logoutBtn.addEventListener('click', function() { - if (typeof window.handleLogout === 'function') { - window.handleLogout(); - } else { - fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) - .finally(function() { window.location.href = '/'; }); - } + AuthManager.logout(); }); } -} From 24b56ac0e751512ac15280961fe1cc47c7f35c84 Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 24 Jul 2026 09:52:51 -0400 Subject: [PATCH 08/13] fix: missing closing brace in config.js causing SyntaxError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Brace balance was 403 opens vs 402 closes after logout handler patches - Added missing closing brace — file now parses correctly --- frontend/js/config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/js/config.js b/frontend/js/config.js index 5bd62fc..9d36df4 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -1578,3 +1578,4 @@ function initProfile() { AuthManager.logout(); }); } +} From 317065f986dbe624deeb028e1c8d34ede3ca6822 Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 24 Jul 2026 09:54:42 -0400 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20settings=20logout=20via=20inline?= =?UTF-8?q?=20onclick=20=E2=80=94=20bypasses=20ES=20module=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile-logout now handles logout directly via onclick attribute in HTML - Calls window.handleLogout() (auth.js global) or direct fetch fallback - Removed broken JS handler from config.js to avoid double execution --- frontend/index.html | 2 +- frontend/js/config.js | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index e7c4587..4922b43 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2006,7 +2006,7 @@ - + diff --git a/frontend/js/config.js b/frontend/js/config.js index 9d36df4..f3d5d74 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -1572,10 +1572,5 @@ function initProfile() { } catch(e) {} }); - // Logout button — calls AuthManager.logout() directly - if (logoutBtn) { - logoutBtn.addEventListener('click', function() { - AuthManager.logout(); - }); - } + // Logout button — handled inline in index.html (onclick) } From d2dec1be067630934481fc96cbdc47b891ac98cb Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 24 Jul 2026 09:55:43 -0400 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20both=20logout=20buttons=20now=20us?= =?UTF-8?q?e=20inline=20onclick=20=E2=80=94=20no=20JS=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header #logout-btn: inline fetch+clear localStorage+redirect - Settings #profile-logout: inline window.handleLogout or fetch fallback - Removed all JS event listeners that were causing race conditions - Zero dependency on ES module loading order --- frontend/index.html | 1 + frontend/js/auth.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/index.html b/frontend/index.html index 4922b43..a04201d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -661,6 +661,7 @@ id="logout-btn" type="button" role="menuitem" + onclick="fetch('/api/auth/logout',{method:'POST',credentials:'include'}).then(function(){localStorage.clear();window.location.href='/'}).catch(function(){localStorage.clear();window.location.href='/'})" > AuthManager.logout()); + // Logout button — handled inline in index.html const adminRow = document.getElementById("admin-menu-row"); if (adminRow) { From 14a45c9136cca30d8d4c710b914eeb1a3c02258a Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 24 Jul 2026 09:56:15 -0400 Subject: [PATCH 11/13] fix: remove remaining logout JS handler in config.js --- frontend/js/config.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/frontend/js/config.js b/frontend/js/config.js index f3d5d74..c06a0f4 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -631,13 +631,7 @@ function initConfigModal() { }); } - // Logout button (always visible) — calls AuthManager.logout() directly - var logoutBtn = document.getElementById("logout-btn"); - if (logoutBtn) { - logoutBtn.addEventListener("click", function() { - AuthManager.logout(); - }); - } + // Logout button — handled inline in index.html (onclick) // Config nav search var cfgSearch = document.getElementById("config-nav-search"); From 8bbf973097867e53164f9ba6df81068ce1622fc0 Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 24 Jul 2026 10:03:10 -0400 Subject: [PATCH 12/13] fix: proper logout via window.handleLogout with XHR fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Both buttons call window.handleLogout (AuthManager.logout) or doLogoutFallback - doLogoutFallback: XHR POST, hides #app, shows #login-screen, clears token - No more localStorage.clear() or hard redirect — mimics showLoginScreen() - Removed all conflicting JS event listeners --- frontend/index.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index a04201d..4b810f9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -656,12 +656,13 @@ > + - + From 88b73f663f543d6f1403f4b79b886a78a848bbc6 Mon Sep 17 00:00:00 2001 From: bruno Date: Fri, 24 Jul 2026 10:04:13 -0400 Subject: [PATCH 13/13] fix: header logout button dynamically recreated by renderUserMenu - renderUserMenu() rebuilds #logout-btn with innerHTML, wiping inline onclick - Added onclick='window.handleLogout()' to the dynamic innerHTML string - Restored addEventListener as double safety - Settings #profile-logout already has onclick to handleLogout --- frontend/js/auth.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/js/auth.js b/frontend/js/auth.js index 4c983ab..6c2d8f4 100644 --- a/frontend/js/auth.js +++ b/frontend/js/auth.js @@ -203,11 +203,11 @@ const AuthManager = { userMenu.innerHTML = ""; return; } - userMenu.innerHTML = '' + (user.display_name || user.username) + "" + ''; + userMenu.innerHTML = '' + (user.display_name || user.username) + "" + ''; safeCreateIcons(); const logoutBtn = document.getElementById("logout-btn"); - // Logout button — handled inline in index.html + if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout()); const adminRow = document.getElementById("admin-menu-row"); if (adminRow) {