Sync Apple Notes to Markdown
· 5 min read
How to get Apple Notes in Markdown format? The code below answers that question. Yes, it was written by Claude Code but I tested it.
But why Markdown?
- Markdown has become the format for AI Agents especially Claude Code
- Markdown can be plain English or include code snippets (like this exact blog post is written in markdown)
- HTML Markup is what inspired Markdown. HTML Markup sucks to write. Markdown is easy.
- Markdown has always been my go-to for writing technical content
- It’s plaintext at it’s core (no vendor-specific artifacts like
.docor.pages) - Markdown is great for blog articles
- The format is easy to learn.
- Reddit and many sites support markdown for comments (even if it’s basic markdown)
Okay cool. Anything else? Well yes. I am officially a obsidian.md fan. Why?
- Obsidian takes what I love about Apple Notes and brings Markdown front and center.
- Obsidian is a free app unless you use their services to Sync or Publish. I use just Sync so iPhone version of Obsidian has the same stuff
- All files are stored as
.mdso you can use any or all of the document sync services you might already use: iCloud, Dropbox, One Drive, Google Drive, Github, AWS S3, Cloudflare R2, with or without Obsidian Sync.
Here’s a script you can use to extract your Apple notes too:
Python Script
Create a file sync_apple_notes.py.
Personally I did:
mkdir -p ~/Notes
cd ~/Notes
echo "" > sync_apple_notes.py
The in sync_apple_notes.py:
#!/usr/bin/env python3
"""
Sync Apple Notes to Markdown files.
Usage:
uv run sync_apple_notes.py [--output-dir PATH] [--watch]
Dependencies are managed inline via uv script syntax.
"""
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "html2text",
# ]
# ///
import subprocess
import json
import re
import os
import sys
import argparse
import time
from pathlib import Path
from datetime import datetime
import html2text
def get_notes_via_jxa(verbose: bool = True) -> list[dict]:
"""Use JavaScript for Automation to extract notes from Apple Notes."""
# First, get note count for progress reporting
if verbose:
count_script = 'Application("Notes").notes().length'
count_result = subprocess.run(
["osascript", "-l", "JavaScript", "-e", count_script],
capture_output=True,
text=True
)
if count_result.returncode == 0:
total = count_result.stdout.strip()
print(f"Found {total} notes, fetching content (this may take a few minutes)...")
# Use a more efficient approach: process in batches and output progress via stderr
jxa_script = '''
const app = Application("Notes");
const notes = [];
let count = 0;
const total = app.notes().length;
for (const folder of app.folders()) {
const folderName = folder.name();
for (const note of folder.notes()) {
try {
notes.push({
id: note.id(),
name: note.name(),
folder: folderName,
body: note.body(),
creationDate: note.creationDate().toISOString(),
modificationDate: note.modificationDate().toISOString()
});
count++;
if (count % 25 === 0) {
ObjC.import("Foundation");
const msg = ` Progress: ${count}/${total} notes...\\n`;
$.NSFileHandle.fileHandleWithStandardError.writeData($(msg).dataUsingEncoding($.NSUTF8StringEncoding));
}
} catch (e) {
// Skip notes that can't be read
}
}
}
JSON.stringify(notes);
'''
result = subprocess.run(
["osascript", "-l", "JavaScript", "-e", jxa_script],
capture_output=False,
text=True,
stdout=subprocess.PIPE
)
if result.returncode != 0:
print("Error accessing Apple Notes", file=sys.stderr)
print("\nMake sure Terminal/your app has access to Apple Notes in:", file=sys.stderr)
print("System Settings > Privacy & Security > Automation", file=sys.stderr)
sys.exit(1)
return json.loads(result.stdout.strip())
def html_to_markdown(html_content: str) -> str:
"""Convert HTML content from Apple Notes to Markdown."""
h = html2text.HTML2Text()
h.ignore_links = False
h.ignore_images = False
h.body_width = 0 # Don't wrap lines
h.ignore_emphasis = False
h.single_line_break = True
markdown = h.handle(html_content)
# Clean up extra whitespace
markdown = re.sub(r'\n{3,}', '\n\n', markdown)
return markdown.strip()
def sanitize_filename(name: str) -> str:
"""Create a safe filename from note title."""
# Remove or replace invalid characters
safe = re.sub(r'[<>:"/\\|?*]', '', name)
safe = re.sub(r'\s+', ' ', safe).strip()
# Limit length
if len(safe) > 100:
safe = safe[:100]
return safe or "Untitled"
def sync_notes(output_dir: Path, verbose: bool = True) -> dict:
"""Sync all Apple Notes to markdown files in output_dir."""
notes = get_notes_via_jxa(verbose=verbose)
if verbose:
print(f"Processing {len(notes)} notes...")
stats = {"created": 0, "updated": 0, "skipped": 0}
for note in notes:
# Create folder structure
folder_name = sanitize_filename(note["folder"])
folder_path = output_dir / folder_name
folder_path.mkdir(parents=True, exist_ok=True)
# Create filename
note_name = sanitize_filename(note["name"])
file_path = folder_path / f"{note_name}.md"
# Convert content
markdown_content = html_to_markdown(note["body"])
# Add metadata header
mod_date = note["modificationDate"][:10]
create_date = note["creationDate"][:10]
full_content = f"""---
title: {note["name"]}
folder: {note["folder"]}
created: {create_date}
modified: {mod_date}
---
{markdown_content}
"""
# Check if file needs updating
if file_path.exists():
existing = file_path.read_text(encoding="utf-8")
if existing == full_content:
stats["skipped"] += 1
continue
stats["updated"] += 1
else:
stats["created"] += 1
file_path.write_text(full_content, encoding="utf-8")
if verbose:
action = "Updated" if stats["updated"] > stats["created"] else "Created"
print(f" {action}: {folder_name}/{note_name}.md")
return stats
def watch_mode(output_dir: Path, interval: int = 60):
"""Continuously sync notes at specified interval."""
print(f"Watching Apple Notes, syncing every {interval} seconds...")
print("Press Ctrl+C to stop\n")
try:
while True:
stats = sync_notes(output_dir, verbose=False)
timestamp = datetime.now().strftime("%H:%M:%S")
if stats["created"] or stats["updated"]:
print(f"[{timestamp}] Created: {stats['created']}, Updated: {stats['updated']}")
else:
print(f"[{timestamp}] No changes")
time.sleep(interval)
except KeyboardInterrupt:
print("\nStopped watching")
def main():
parser = argparse.ArgumentParser(description="Sync Apple Notes to Markdown")
parser.add_argument(
"--output-dir", "-o",
type=Path,
default=Path.cwd(),
help="Output directory for markdown files (default: current directory)"
)
parser.add_argument(
"--watch", "-w",
action="store_true",
help="Continuously watch and sync notes"
)
parser.add_argument(
"--interval", "-i",
type=int,
default=60,
help="Sync interval in seconds for watch mode (default: 60)"
)
args = parser.parse_args()
output_dir = args.output_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Output directory: {output_dir}\n")
if args.watch:
watch_mode(output_dir, args.interval)
else:
stats = sync_notes(output_dir)
print(f"\nDone! Created: {stats['created']}, Updated: {stats['updated']}, Unchanged: {stats['skipped']}")
if __name__ == "__main__":
main()
Usage
You’ll need to install Astral’s UV so you can:
cd ~/Notes
# One-time sync to current directory
uv run sync_apple_notes.py
# Sync to a specific directory
mkdir -p ~/Desktop/Notes
uv run sync_apple_notes.py --output-dir ~/Desktop/Notes
# Watch mode - continuously sync every 60 seconds
uv run sync_apple_notes.py --watch
# Watch mode with custom interval
uv run sync_apple_notes.py --watch --interval 30