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
+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
# ---------------------------------------------------------------------------