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);
+}