Skip to main content
Glama
markdown_writer.py13.1 kB
"""Markdown writer for Obsidian vault integration. Clean-room implementation for writing markdown files with YAML frontmatter and wikilinks. Does NOT use Basic Memory MCP code (AGPL license). """ import time from datetime import datetime from pathlib import Path from typing import Any import frontmatter from ..security.paths import ( ensure_within_directory, sanitize_filename, validate_folder_path, validate_vault_path, ) class MarkdownWriter: """Write markdown files to Obsidian vault with proper formatting.""" def __init__(self, vault_path: Path): """ Initialize markdown writer. Args: vault_path: Path to Obsidian vault root directory Raises: ValueError: If vault_path is invalid or contains path traversal """ # Validate and normalize vault path (prevents path traversal) self.vault_path = validate_vault_path(vault_path, "vault_path") self.vault_path.mkdir(parents=True, exist_ok=True) def create_wikilink(self, target: str, alias: str | None = None) -> str: """ Create a wikilink string. Args: target: Target note title alias: Optional display alias Returns: Formatted wikilink string Examples: >>> create_wikilink("Note Title") '[[Note Title]]' >>> create_wikilink("Note Title", "Display Text") '[[Note Title|Display Text]]' """ if alias: return f"[[{target}|{alias}]]" return f"[[{target}]]" def write_note( self, title: str, content: str, folder: str = "", *, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, wikilinks: list[str] | None = None, created_at: int | None = None, modified_at: int | None = None, ) -> Path: """ Write a markdown note to the vault. Args: title: Note title (used for filename) content: Note content (markdown) folder: Subfolder within vault (default: root) tags: List of tags metadata: Additional YAML frontmatter metadata wikilinks: List of wikilink targets to include in frontmatter created_at: Creation timestamp (Unix epoch) modified_at: Modification timestamp (Unix epoch) Returns: Path to created file Raises: ValueError: If folder contains path traversal or file path escapes vault Note: - Filename is sanitized from title (spaces to hyphens, lowercase) - YAML frontmatter is added automatically - Relations stored in frontmatter for backlink compatibility """ # Validate folder path (prevents path traversal) if folder: folder = validate_folder_path(folder, "folder") # Sanitize filename (prevents path traversal via filename) filename = sanitize_filename(title, "title") + ".md" # Determine full path if folder: folder_path = self.vault_path / folder folder_path.mkdir(parents=True, exist_ok=True) file_path = folder_path / filename else: file_path = self.vault_path / filename # Final safeguard: ensure path is within vault (prevents symlink attacks) file_path = ensure_within_directory(file_path, self.vault_path, "file_path") # Build frontmatter fm: dict[str, Any] = { "title": title, "created": datetime.fromtimestamp(created_at or int(time.time())).isoformat(), "modified": datetime.fromtimestamp(modified_at or int(time.time())).isoformat(), } if tags: fm["tags"] = tags if wikilinks: fm["links"] = wikilinks # Add custom metadata if metadata: fm.update(metadata) # Create frontmatter post post = frontmatter.Post(content, **fm) # Write to file with open(file_path, "w", encoding="utf-8") as f: f.write(frontmatter.dumps(post)) return file_path def update_note( self, file_path: Path, content: str | None = None, *, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, append_content: str | None = None, ) -> None: """ Update an existing note. Args: file_path: Path to note file content: New content (replaces existing if provided) tags: New tags (replaces existing if provided) metadata: Metadata to update (merged with existing) append_content: Content to append to existing content Raises: FileNotFoundError: If note doesn't exist """ if not file_path.exists(): raise FileNotFoundError(f"Note not found: {file_path}") # Load existing note with open(file_path, encoding="utf-8") as f: post = frontmatter.load(f) # Update content if content is not None: post.content = content elif append_content is not None: post.content += "\n\n" + append_content # Update metadata if tags is not None: post["tags"] = tags if metadata: for key, value in metadata.items(): post[key] = value # Update modified timestamp post["modified"] = datetime.now().isoformat() # Write updated note with open(file_path, "w", encoding="utf-8") as f: f.write(frontmatter.dumps(post)) def read_note(self, file_path: Path) -> tuple[str, dict[str, Any]]: """ Read a markdown note. Args: file_path: Path to note file Returns: Tuple of (content, frontmatter_dict) Raises: FileNotFoundError: If note doesn't exist """ if not file_path.exists(): raise FileNotFoundError(f"Note not found: {file_path}") with open(file_path, encoding="utf-8") as f: post = frontmatter.load(f) return post.content, dict(post.metadata) def delete_note(self, file_path: Path) -> None: """ Delete a note. Args: file_path: Path to note file Raises: FileNotFoundError: If note doesn't exist """ if not file_path.exists(): raise FileNotFoundError(f"Note not found: {file_path}") file_path.unlink() def find_note_by_title(self, title: str) -> Path | None: """ Find a note by its title (from frontmatter). Args: title: Title to search for Returns: Path to note if found, None otherwise """ # Search all markdown files for md_file in self.vault_path.rglob("*.md"): try: with open(md_file, encoding="utf-8") as f: post = frontmatter.load(f) if post.get("title") == title: return md_file except Exception: # nosec B112 - intentionally skipping unparseable files # Skip files that can't be parsed continue return None def _sanitize_filename(self, title: str) -> str: """ Sanitize a title for use as filename. This method is deprecated and maintained for backwards compatibility. New code should use sanitize_filename() from security.paths directly. Args: title: Title to sanitize Returns: Sanitized filename (without extension) Raises: ValueError: If title is invalid Examples: >>> _sanitize_filename("My Note Title") 'my-note-title' >>> _sanitize_filename("Invalid/Name?") 'invalid-name' """ # Use the security module's sanitize_filename function # Note: The old implementation was less strict; this might reject some edge cases try: return sanitize_filename(title, "title") except ValueError: # Fallback for backward compatibility: return "untitled" return "untitled" def get_note_path(self, title: str, folder: str = "") -> Path: """ Get the expected path for a note given its title and folder. Args: title: Note title folder: Subfolder within vault Returns: Expected path to note file Raises: ValueError: If folder contains path traversal """ # Validate folder path if folder: folder = validate_folder_path(folder, "folder") filename = sanitize_filename(title, "title") + ".md" if folder: file_path = self.vault_path / folder / filename else: file_path = self.vault_path / filename # Ensure within vault return ensure_within_directory(file_path, self.vault_path, "file_path") def list_notes(self, folder: str | None = None) -> list[Path]: """ List all notes in vault or a specific folder. Args: folder: Optional folder to filter by Returns: List of paths to markdown files Raises: ValueError: If folder contains path traversal """ # Validate folder path if folder: folder = validate_folder_path(folder, "folder") search_path = self.vault_path / folder # Ensure search path is within vault search_path = ensure_within_directory(search_path, self.vault_path, "search_path") else: search_path = self.vault_path if not search_path.exists(): return [] return list(search_path.rglob("*.md")) def create_folder(self, folder_name: str) -> Path: """ Create a folder in the vault. Args: folder_name: Name of folder to create Returns: Path to created folder Raises: ValueError: If folder_name contains path traversal """ # Validate folder path folder_name = validate_folder_path(folder_name, "folder_name") folder_path = self.vault_path / folder_name # Ensure folder is within vault folder_path = ensure_within_directory(folder_path, self.vault_path, "folder_path") folder_path.mkdir(parents=True, exist_ok=True) return folder_path def main() -> int: """CLI entry point for markdown writer operations.""" import argparse import sys parser = argparse.ArgumentParser(description="Write markdown notes to Obsidian vault") parser.add_argument( "vault_path", type=Path, help="Path to Obsidian vault", ) subparsers = parser.add_subparsers(dest="command", help="Command to execute") # Create command create_parser = subparsers.add_parser("create", help="Create a new note") create_parser.add_argument("title", help="Note title") create_parser.add_argument("content", help="Note content") create_parser.add_argument("--folder", default="", help="Folder within vault") create_parser.add_argument("--tags", nargs="+", help="Tags for the note") # List command list_parser = subparsers.add_parser("list", help="List notes") list_parser.add_argument("--folder", help="Folder to list") # Read command read_parser = subparsers.add_parser("read", help="Read a note") read_parser.add_argument("title", help="Note title to read") args = parser.parse_args() if not args.command: parser.print_help() return 1 try: writer = MarkdownWriter(vault_path=args.vault_path) if args.command == "create": file_path = writer.write_note( title=args.title, content=args.content, folder=args.folder, tags=args.tags, ) print(f"✓ Note created: {file_path}") elif args.command == "list": notes = writer.list_notes(folder=args.folder) print(f"\nNotes ({len(notes)}):\n") for note_file in notes: print(f" - {note_file.relative_to(writer.vault_path)}") elif args.command == "read": note_path = writer.find_note_by_title(args.title) if not note_path: print(f"Note not found: {args.title}", file=sys.stderr) return 1 content, metadata = writer.read_note(note_path) print(f"\n{note_path}:") print(f"\nMetadata: {metadata}") print(f"\nContent:\n{content}") return 0 except Exception as e: print(f"Error: {e}", file=sys.stderr) return 1 if __name__ == "__main__": import sys sys.exit(main())

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/prefrontalsys/mnemex'

If you have feedback or need assistance with the MCP directory API, please join our Discord server