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:
@@ -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
|
||||
@@ -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: 
|
||||
|
||||
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  - 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: 
|
||||
"""
|
||||
# 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''
|
||||
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  standard markdown images.
|
||||
|
||||
Resolves the path and updates to use /api/image endpoint.
|
||||
"""
|
||||
# Pattern: 
|
||||
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''
|
||||
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
@@ -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
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user