Add comprehensive Obsidian image rendering with multi-strategy resolution, attachment indexing, and API endpoints for image serving with MIME type detection and rescan functionality

This commit is contained in:
2026-03-23 10:00:48 -04:00
parent 29bdc52cef
commit 175ac3dea8
8 changed files with 1309 additions and 17 deletions
+306
View File
@@ -0,0 +1,306 @@
import asyncio
import logging
import threading
from pathlib import Path
from typing import Dict, List, Optional, Set
logger = logging.getLogger("obsigate.attachment_indexer")
# Image file extensions to index
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
# Global attachment index: {vault_name: {filename_lower: [absolute_path, ...]}}
attachment_index: Dict[str, Dict[str, List[Path]]] = {}
# Resolution cache: {(vault_name, filename): resolved_path}
_resolution_cache: Dict[tuple, Optional[Path]] = {}
# Thread-safe lock for index updates
_attachment_lock = threading.Lock()
def clear_resolution_cache(vault_name: Optional[str] = None) -> None:
"""Clear the resolution cache for a specific vault or all vaults.
Args:
vault_name: Vault to clear cache for, or None to clear all.
"""
with _attachment_lock:
if vault_name is None:
_resolution_cache.clear()
else:
keys_to_remove = [k for k in _resolution_cache.keys() if k[0] == vault_name]
for key in keys_to_remove:
del _resolution_cache[key]
def _scan_vault_attachments(vault_name: str, vault_path: str) -> Dict[str, List[Path]]:
"""Synchronously scan a vault directory for image attachments.
Walks the vault tree and builds a filename -> absolute path mapping
for all image files.
Args:
vault_name: Display name of the vault.
vault_path: Absolute filesystem path to the vault root.
Returns:
Dict mapping lowercase filenames to lists of absolute paths.
"""
vault_root = Path(vault_path)
index: Dict[str, List[Path]] = {}
if not vault_root.exists():
logger.warning(f"Vault path does not exist for attachment scan: {vault_path}")
return index
attachment_count = 0
try:
for fpath in vault_root.rglob("*"):
# Skip hidden files and directories
rel_parts = fpath.relative_to(vault_root).parts
if any(part.startswith(".") for part in rel_parts):
continue
# Only process files
if not fpath.is_file():
continue
# Check if it's an image file
ext = fpath.suffix.lower()
if ext not in IMAGE_EXTENSIONS:
continue
# Add to index
filename_lower = fpath.name.lower()
if filename_lower not in index:
index[filename_lower] = []
index[filename_lower].append(fpath)
attachment_count += 1
except PermissionError as e:
logger.warning(f"Permission denied scanning attachments in vault '{vault_name}': {e}")
except Exception as e:
logger.error(f"Error scanning attachments in vault '{vault_name}': {e}")
logger.info(f"Vault '{vault_name}': indexed {attachment_count} attachments")
return index
async def build_attachment_index(vault_config: Dict[str, Dict[str, any]]) -> None:
"""Build the attachment index for all configured vaults.
Runs vault scans concurrently in a thread pool, then performs
an atomic swap of the global index under a lock.
Args:
vault_config: Dict mapping vault names to their configuration
(must include 'path' key).
"""
global attachment_index
if not vault_config:
logger.warning("No vaults configured for attachment indexing.")
return
loop = asyncio.get_event_loop()
new_index: Dict[str, Dict[str, List[Path]]] = {}
tasks = []
for name, config in vault_config.items():
vault_path = config.get("path")
if not vault_path:
logger.warning(f"Vault '{name}' has no path configured, skipping attachment scan")
continue
# Check if scanning is enabled (default: True)
scan_enabled = config.get("scanAttachmentsOnStartup", True)
if not scan_enabled:
logger.info(f"Vault '{name}': attachment scanning disabled")
new_index[name] = {}
continue
tasks.append((name, loop.run_in_executor(None, _scan_vault_attachments, name, vault_path)))
for name, task in tasks:
new_index[name] = await task
# Atomic swap under lock
with _attachment_lock:
attachment_index.clear()
attachment_index.update(new_index)
_resolution_cache.clear()
total_attachments = sum(len(files) for vault_idx in attachment_index.values() for files in vault_idx.values())
logger.info(f"Attachment index built: {len(attachment_index)} vaults, {total_attachments} total attachments")
async def rescan_vault_attachments(vault_name: str, vault_path: str) -> int:
"""Rescan attachments for a single vault.
Args:
vault_name: Name of the vault to rescan.
vault_path: Absolute path to the vault root.
Returns:
Number of attachments indexed.
"""
loop = asyncio.get_event_loop()
new_vault_index = await loop.run_in_executor(None, _scan_vault_attachments, vault_name, vault_path)
with _attachment_lock:
attachment_index[vault_name] = new_vault_index
clear_resolution_cache(vault_name)
count = sum(len(paths) for paths in new_vault_index.values())
logger.info(f"Vault '{vault_name}' rescanned: {count} attachments")
return count
def resolve_image_path(
image_src: str,
vault_name: str,
vault_root: Path,
current_file_path: Optional[Path] = None,
attachments_path: Optional[str] = None
) -> Optional[Path]:
"""Resolve an image source path using multi-strategy resolution.
Applies 7 resolution strategies in priority order:
1. Absolute path (if exists)
2. Config attachments folder (if configured)
3. Startup index - exact filename match (if unique)
4. Same directory as current markdown file
5. Vault root relative
6. Startup index - closest path match (if multiple)
7. Fallback: None
Args:
image_src: The image source path from markdown.
vault_name: Name of the vault.
vault_root: Absolute path to vault root.
current_file_path: Absolute path to the current markdown file being rendered.
attachments_path: Optional configured attachments folder (vault-relative).
Returns:
Resolved absolute Path to the image, or None if not found.
"""
# Check cache first
cache_key = (vault_name, image_src)
with _attachment_lock:
if cache_key in _resolution_cache:
return _resolution_cache[cache_key]
src_path = Path(image_src)
resolved = None
# Strategy 1: Absolute path
if src_path.is_absolute():
if src_path.exists() and src_path.is_file():
resolved = src_path
logger.debug(f"Image resolved via strategy 1 (absolute): {image_src}")
# Strategy 2: Config attachments folder
if resolved is None and attachments_path:
attachments_dir = vault_root / attachments_path
candidate = attachments_dir / src_path
if candidate.exists() and candidate.is_file():
resolved = candidate
logger.debug(f"Image resolved via strategy 2 (config attachments): {image_src}")
# Strategy 3: Startup index - exact filename match (if unique)
if resolved is None:
filename_lower = src_path.name.lower()
vault_index = attachment_index.get(vault_name, {})
candidates = vault_index.get(filename_lower, [])
if len(candidates) == 1:
resolved = candidates[0]
logger.debug(f"Image resolved via strategy 3 (unique index match): {image_src}")
elif len(candidates) > 1:
# Multiple matches - will try strategy 6 later
pass
# Strategy 4: Same directory as current markdown file
if resolved is None and current_file_path:
current_dir = current_file_path.parent
candidate = current_dir / src_path
if candidate.exists() and candidate.is_file():
resolved = candidate
logger.debug(f"Image resolved via strategy 4 (same directory): {image_src}")
# Strategy 5: Vault root relative
if resolved is None:
candidate = vault_root / src_path
if candidate.exists() and candidate.is_file():
resolved = candidate
logger.debug(f"Image resolved via strategy 5 (vault root relative): {image_src}")
# Strategy 6: Startup index - closest path match
if resolved is None:
filename_lower = src_path.name.lower()
vault_index = attachment_index.get(vault_name, {})
candidates = vault_index.get(filename_lower, [])
if len(candidates) > 1:
# Find the candidate whose path best matches the src partial path
src_str_lower = str(src_path).lower().replace("\\", "/")
best_match = None
best_score = -1
for candidate in candidates:
try:
rel_path = candidate.relative_to(vault_root)
rel_str_lower = str(rel_path).lower().replace("\\", "/")
# Score based on how much of the src path matches
score = 0
if src_str_lower in rel_str_lower:
score = len(src_str_lower)
elif rel_str_lower.endswith(src_str_lower):
score = len(src_str_lower) - 1
if score > best_score:
best_score = score
best_match = candidate
except ValueError:
continue
if best_match:
resolved = best_match
logger.debug(f"Image resolved via strategy 6 (closest path match): {image_src}")
# Strategy 7: Fallback - None (will show placeholder)
if resolved is None:
logger.debug(f"Image not resolved (fallback): {image_src}")
# Cache the result
with _attachment_lock:
_resolution_cache[cache_key] = resolved
return resolved
def get_attachment_stats(vault_name: Optional[str] = None) -> Dict[str, int]:
"""Get attachment statistics for a vault or all vaults.
Args:
vault_name: Vault to get stats for, or None for all vaults.
Returns:
Dict with vault names as keys and attachment counts as values.
"""
stats = {}
with _attachment_lock:
if vault_name:
vault_index = attachment_index.get(vault_name, {})
count = sum(len(paths) for paths in vault_index.values())
stats[vault_name] = count
else:
for vname, vault_index in attachment_index.items():
count = sum(len(paths) for paths in vault_index.values())
stats[vname] = count
return stats
+197
View File
@@ -0,0 +1,197 @@
import re
import logging
from pathlib import Path
from typing import Optional, Tuple
from html import escape as html_escape
from backend.attachment_indexer import resolve_image_path
logger = logging.getLogger("obsigate.image_processor")
def preprocess_images(
content: str,
vault_name: str,
vault_root: Path,
current_file_path: Optional[Path] = None,
attachments_path: Optional[str] = None
) -> str:
"""Preprocess markdown content to transform all Obsidian image syntaxes.
Handles 4 image syntax formats:
1. Standard Markdown with HTML attributes: [<img ... src="path"/>](url)
2. Obsidian wiki-link embed with full path: ![[path/to/image.ext]]
3. Obsidian wiki-link embed with filename only: ![[image.ext]]
4. Standard Markdown image: ![alt](path)
All image paths are resolved using the multi-strategy resolver and
transformed to use the /api/image endpoint.
Args:
content: Raw markdown content.
vault_name: Name of the vault.
vault_root: Absolute path to vault root.
current_file_path: Absolute path to the current markdown file.
attachments_path: Optional configured attachments folder.
Returns:
Preprocessed markdown with resolved image paths.
"""
# Process in order of specificity to avoid conflicts
# 1. Handle [<img ... src="path"/>](url) - HTML img in markdown link
content = _process_html_img_in_link(content, vault_name, vault_root, current_file_path, attachments_path)
# 2. Handle ![[image]] - Obsidian wiki-link embeds
content = _process_wikilink_embeds(content, vault_name, vault_root, current_file_path, attachments_path)
# 3. Handle ![alt](path) - Standard markdown images
content = _process_standard_images(content, vault_name, vault_root, current_file_path, attachments_path)
return content
def _process_html_img_in_link(
content: str,
vault_name: str,
vault_root: Path,
current_file_path: Optional[Path],
attachments_path: Optional[str]
) -> str:
"""Process [<img ... src="path"/>](url) syntax.
Transforms to: <a href="url"><img src="/api/image/vault?path=resolved" width="..." height="..."/></a>
"""
# Pattern: [<img ... src="path" ... />](url)
pattern = r'\[<img\s+([^>]*?)\s*/?>\]\(([^)]+)\)'
def replace_html_img(match):
img_attrs = match.group(1)
link_url = match.group(2)
# Extract src attribute
src_match = re.search(r'src\s*=\s*["\']([^"\']+)["\']', img_attrs)
if not src_match:
return match.group(0) # No src, return unchanged
src_path = src_match.group(1)
# Extract width and height if present
width_match = re.search(r'width\s*=\s*["\']([^"\']+)["\']', img_attrs)
height_match = re.search(r'height\s*=\s*["\']([^"\']+)["\']', img_attrs)
width = width_match.group(1) if width_match else None
height = height_match.group(1) if height_match else None
# Resolve the image path
resolved_path = resolve_image_path(
src_path, vault_name, vault_root, current_file_path, attachments_path
)
if resolved_path:
# Build the API URL
try:
rel_path = resolved_path.relative_to(vault_root)
api_url = f'/api/image/{vault_name}?path={str(rel_path).replace(chr(92), "/")}'
except ValueError:
# Path is outside vault - use absolute path encoding
api_url = f'/api/image/{vault_name}?path={str(resolved_path).replace(chr(92), "/")}'
# Build img tag with attributes
img_tag = f'<img src="{api_url}"'
if width:
img_tag += f' width="{html_escape(width)}"'
if height:
img_tag += f' height="{html_escape(height)}"'
img_tag += ' />'
# Wrap in link
return f'<a href="{html_escape(link_url)}">{img_tag}</a>'
else:
# Image not found - show placeholder
placeholder = f'<span class="image-not-found" title="Image not found: {html_escape(src_path)}">[image not found: {html_escape(Path(src_path).name)}]</span>'
return f'<a href="{html_escape(link_url)}">{placeholder}</a>'
return re.sub(pattern, replace_html_img, content)
def _process_wikilink_embeds(
content: str,
vault_name: str,
vault_root: Path,
current_file_path: Optional[Path],
attachments_path: Optional[str]
) -> str:
"""Process ![[image]] and ![[path/to/image]] wiki-link embeds.
Transforms to: ![](resolved_path)
"""
# Pattern: ![[path/to/image.ext]] or ![[image.ext]]
pattern = r'!\[\[([^\]]+?\.(?:png|jpg|jpeg|gif|svg|webp|bmp|ico))\]\]'
def replace_wikilink(match):
image_target = match.group(1).strip()
# Resolve the image path
resolved_path = resolve_image_path(
image_target, vault_name, vault_root, current_file_path, attachments_path
)
if resolved_path:
# Build the API URL
try:
rel_path = resolved_path.relative_to(vault_root)
api_url = f'/api/image/{vault_name}?path={str(rel_path).replace(chr(92), "/")}'
except ValueError:
api_url = f'/api/image/{vault_name}?path={str(resolved_path).replace(chr(92), "/")}'
# Transform to standard markdown image
return f'![{Path(image_target).stem}]({api_url})'
else:
# Image not found - show placeholder
return f'<span class="image-not-found" title="Image not found: {html_escape(image_target)}">[image not found: {html_escape(Path(image_target).name)}]</span>'
return re.sub(pattern, replace_wikilink, content, flags=re.IGNORECASE)
def _process_standard_images(
content: str,
vault_name: str,
vault_root: Path,
current_file_path: Optional[Path],
attachments_path: Optional[str]
) -> str:
"""Process ![alt](path) standard markdown images.
Resolves the path and updates to use /api/image endpoint.
"""
# Pattern: ![alt](path)
pattern = r'!\[([^\]]*)\]\(([^)]+?\.(?:png|jpg|jpeg|gif|svg|webp|bmp|ico))\)'
def replace_standard_img(match):
alt_text = match.group(1)
image_path = match.group(2)
# Skip if it's already an absolute URL (http://, https://, //)
if re.match(r'^(https?://|//)', image_path):
return match.group(0) # Keep external URLs unchanged
# Resolve the image path
resolved_path = resolve_image_path(
image_path, vault_name, vault_root, current_file_path, attachments_path
)
if resolved_path:
# Build the API URL
try:
rel_path = resolved_path.relative_to(vault_root)
api_url = f'/api/image/{vault_name}?path={str(rel_path).replace(chr(92), "/")}'
except ValueError:
api_url = f'/api/image/{vault_name}?path={str(resolved_path).replace(chr(92), "/")}'
return f'![{alt_text}]({api_url})'
else:
# Image not found - show placeholder
return f'<span class="image-not-found" title="Image not found: {html_escape(image_path)}">[image not found: {html_escape(Path(image_path).name)}]</span>'
return re.sub(pattern, replace_standard_img, content, flags=re.IGNORECASE)
+36 -11
View File
@@ -9,13 +9,15 @@ from typing import Dict, List, Optional, Any
import frontmatter
from backend.attachment_indexer import build_attachment_index
logger = logging.getLogger("obsigate.indexer")
# Global in-memory index
index: Dict[str, Dict[str, Any]] = {}
# Vault config: {name: path}
vault_config: Dict[str, str] = {}
# Vault config: {name: {path, attachmentsPath, scanAttachmentsOnStartup}}
vault_config: Dict[str, Dict[str, Any]] = {}
# Thread-safe lock for index updates
_index_lock = threading.Lock()
@@ -42,24 +44,40 @@ SUPPORTED_EXTENSIONS = {
}
def load_vault_config() -> Dict[str, str]:
"""Read VAULT_N_NAME / VAULT_N_PATH env vars and return {name: path}.
def load_vault_config() -> Dict[str, Dict[str, Any]]:
"""Read VAULT_N_* env vars and return vault configuration.
Scans environment variables ``VAULT_1_NAME``/``VAULT_1_PATH``,
``VAULT_2_NAME``/``VAULT_2_PATH``, etc. in sequential order.
Stops at the first missing pair.
Also reads optional configuration:
- VAULT_N_ATTACHMENTS_PATH: relative path to attachments folder
- VAULT_N_SCAN_ATTACHMENTS: "true"/"false" to enable/disable scanning
Returns:
Dict mapping vault display names to filesystem paths.
Dict mapping vault names to configuration dicts with keys:
- path: filesystem path (required)
- attachmentsPath: relative attachments folder (optional)
- scanAttachmentsOnStartup: boolean (default True)
"""
vaults: Dict[str, str] = {}
vaults: Dict[str, Dict[str, Any]] = {}
n = 1
while True:
name = os.environ.get(f"VAULT_{n}_NAME")
path = os.environ.get(f"VAULT_{n}_PATH")
if not name or not path:
break
vaults[name] = path
# Optional configuration
attachments_path = os.environ.get(f"VAULT_{n}_ATTACHMENTS_PATH")
scan_attachments = os.environ.get(f"VAULT_{n}_SCAN_ATTACHMENTS", "true").lower() == "true"
vaults[name] = {
"path": path,
"attachmentsPath": attachments_path,
"scanAttachmentsOnStartup": scan_attachments,
}
n += 1
return vaults
@@ -251,7 +269,7 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
continue
logger.info(f"Vault '{vault_name}': indexed {len(files)} files, {len(paths)} paths, {len(tag_counts)} unique tags")
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths}
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}}
async def build_index() -> None:
@@ -272,11 +290,15 @@ async def build_index() -> None:
new_index: Dict[str, Dict[str, Any]] = {}
tasks = []
for name, path in vault_config.items():
tasks.append((name, loop.run_in_executor(None, _scan_vault, name, path)))
for name, config in vault_config.items():
vault_path = config["path"]
tasks.append((name, loop.run_in_executor(None, _scan_vault, name, vault_path)))
for name, task in tasks:
new_index[name] = await task
vault_data = await task
# Store vault config in the index
vault_data["config"] = vault_config[name]
new_index[name] = vault_data
# Build O(1) lookup table for wikilink resolution
new_lookup: Dict[str, List[Dict[str, str]]] = {}
@@ -306,6 +328,9 @@ async def build_index() -> None:
total_files = sum(len(v["files"]) for v in index.values())
logger.info(f"Index built: {len(index)} vaults, {total_files} total files")
# Build attachment index
await build_attachment_index(vault_config)
async def reload_index() -> Dict[str, Any]:
+92 -4
View File
@@ -1,6 +1,7 @@
import re
import html as html_mod
import logging
import mimetypes
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional, List, Dict, Any
@@ -9,7 +10,7 @@ import frontmatter
import mistune
from fastapi import FastAPI, HTTPException, Query, Body
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.responses import HTMLResponse, FileResponse, Response
from pydantic import BaseModel, Field
from backend.indexer import (
@@ -25,6 +26,8 @@ from backend.indexer import (
SUPPORTED_EXTENSIONS,
)
from backend.search import search, get_all_tags
from backend.image_processor import preprocess_images
from backend.attachment_indexer import rescan_vault_attachments, get_attachment_stats
logging.basicConfig(
level=logging.INFO,
@@ -257,19 +260,31 @@ def _convert_wikilinks(content: str, current_vault: str) -> str:
return re.sub(pattern, _replace, content)
def _render_markdown(raw_md: str, vault_name: str) -> str:
"""Render a markdown string to HTML with wikilink support.
def _render_markdown(raw_md: str, vault_name: str, current_file_path: Optional[Path] = None) -> str:
"""Render a markdown string to HTML with wikilink and image support.
Uses the cached singleton mistune renderer for performance.
Args:
raw_md: Raw markdown text (frontmatter already stripped).
vault_name: Current vault for wikilink resolution context.
current_file_path: Absolute path to the current markdown file.
Returns:
HTML string.
"""
# Get vault data for image resolution
vault_data = get_vault_data(vault_name)
vault_root = Path(vault_data["path"]) if vault_data else None
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
# Preprocess images first
if vault_root:
raw_md = preprocess_images(raw_md, vault_name, vault_root, current_file_path, attachments_path)
# Convert wikilinks
converted = _convert_wikilinks(raw_md, vault_name)
return _markdown_renderer(converted)
@@ -584,7 +599,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
tags = _extract_tags(post)
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
html_content = _render_markdown(post.content, vault_name)
html_content = _render_markdown(post.content, vault_name, file_path)
return {
"vault": vault_name,
@@ -707,6 +722,79 @@ async def api_reload():
return {"status": "ok", "vaults": stats}
@app.get("/api/image/{vault_name}")
async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image")):
"""Serve an image file with proper MIME type.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
Image file with appropriate content-type header.
"""
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"Image not found: {path}")
# Determine MIME type
mime_type, _ = mimetypes.guess_type(str(file_path))
if not mime_type:
# Default to octet-stream if unknown
mime_type = "application/octet-stream"
try:
# Read and return the image file
content = file_path.read_bytes()
return Response(content=content, media_type=mime_type)
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except Exception as e:
logger.error(f"Error serving image {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error serving image: {str(e)}")
@app.post("/api/attachments/rescan/{vault_name}")
async def api_rescan_attachments(vault_name: str):
"""Rescan attachments for a specific vault.
Args:
vault_name: Name of the vault to rescan.
Returns:
Dict with status and attachment count.
"""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_path = vault_data["path"]
count = await rescan_vault_attachments(vault_name, vault_path)
logger.info(f"Rescanned attachments for vault '{vault_name}': {count} attachments")
return {"status": "ok", "vault": vault_name, "attachment_count": count}
@app.get("/api/attachments/stats")
async def api_attachment_stats(vault: Optional[str] = Query(None, description="Vault filter")):
"""Get attachment statistics for vaults.
Args:
vault: Optional vault name to filter stats.
Returns:
Dict with vault names as keys and attachment counts as values.
"""
stats = get_attachment_stats(vault)
return {"vaults": stats}
# ---------------------------------------------------------------------------
# Static files & SPA fallback
# ---------------------------------------------------------------------------