From 93d0bda627807890e6a199243314f9e1489288ea Mon Sep 17 00:00:00 2001 From: bruno Date: Thu, 23 Jul 2026 17:33:51 -0400 Subject: [PATCH] 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); +}