Skip to content

Instantly share code, notes, and snippets.

@shawnyeager
Created April 2, 2025 14:38
Show Gist options
  • Save shawnyeager/ee5b4394c24ba9426782a11580d51cd4 to your computer and use it in GitHub Desktop.
Save shawnyeager/ee5b4394c24ba9426782a11580d51cd4 to your computer and use it in GitHub Desktop.
Python script to convert Drafts app export in JSON format to a drop-in-ready folder of Obsidian markdown files
import json
import os
import re
import argparse
def sanitize_filename(title):
"""Convert a title into a safe filename by replacing invalid characters, preserving spaces."""
# Replace invalid filesystem characters with spaces
invalid_chars = r'[\/:*?"<>|]'
sanitized = re.sub(invalid_chars, ' ', title.strip())
# Replace multiple spaces with a single space
sanitized = re.sub(r'\s+', ' ', sanitized).strip()
return sanitized if sanitized else "Untitled"
def smart_filename(title, char_limit=30):
"""Generate a filename using complete words within the character limit, preserving spaces."""
sanitized = sanitize_filename(title)
if sanitized == "Untitled":
return sanitized
words = sanitized.split(' ')
if not words:
return "Untitled"
result = []
current_length = 0
for word in words:
word_length = len(word)
if result:
word_length += 1 # Account for space
if current_length + word_length <= char_limit:
result.append(word)
current_length += word_length
else:
break
if not result:
return words[0][:char_limit] if words[0] else "Untitled"
return ' '.join(result)
def get_unique_filename(base_name, used_filenames, output_dir):
"""Generate a unique filename, appending a number if necessary."""
filename = f"{base_name}.md"
full_path = os.path.join(output_dir, filename)
if full_path not in used_filenames:
used_filenames.add(full_path)
return filename
i = 1
while True:
new_filename = f"{base_name} {i}.md"
full_path = os.path.join(output_dir, new_filename)
if full_path not in used_filenames:
used_filenames.add(full_path)
return new_filename
i += 1
def clean_text(text):
"""Remove Markdown formatting from text for title use."""
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
text = re.sub(r'(\*|_)(.*?)\1', r'\2', text)
text = re.sub(r'^#+ ', '', text)
return text.strip()
def extract_h1(content):
"""Extract the first H1 heading from content."""
lines = content.split('\n')
for line in lines:
if line.startswith('# '):
h1_text = line[2:].strip()
return clean_text(h1_text)
return None
def get_first_line_title(content):
"""Get the full first line of content, cleaned, for use as title."""
lines = content.split('\n')
if lines:
first_line = lines[0].strip()
return clean_text(first_line)
return "Untitled"
def get_date_only(timestamp):
"""Extract date (YYYY-MM-DD) from ISO timestamp."""
if timestamp:
return timestamp.split('T')[0]
return ""
def main():
parser = argparse.ArgumentParser(description="Convert Drafts JSON to Obsidian Markdown files.")
parser.add_argument("input_json", help="Path to your exported Drafts JSON file")
parser.add_argument("output_dir", help="Directory where Markdown files will be saved")
parser.add_argument("--prefer-h1", action="store_true", help="Prefer H1 heading over JSON title")
args = parser.parse_args()
output_dir = os.path.expanduser(args.output_dir)
if not os.path.exists(output_dir):
os.makedirs(output_dir)
try:
with open(args.input_json, 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception as e:
print(f"Error reading JSON file: {e}")
return
drafts = data if isinstance(data, list) else data.get('notes', data.get('drafts', []))
if not drafts or not isinstance(drafts, list):
print("Error: JSON does not contain a list of drafts.")
return
uuid_to_title = {draft.get('uuid', ''): draft.get('title', 'Untitled') for draft in drafts if 'uuid' in draft}
used_filenames = set()
for draft in drafts:
json_title = draft.get('title', '')
content = draft.get('content', '')
tags = draft.get('tags', [])
created = get_date_only(draft.get('created_at', ''))
h1_title = extract_h1(content)
# Determine title for filename
if args.prefer_h1:
if h1_title:
title = h1_title
elif json_title:
title = json_title
else:
title = get_first_line_title(content)
else:
if json_title:
title = json_title
elif h1_title:
title = h1_title
else:
title = get_first_line_title(content)
# Generate filename using complete words within 25 characters, with spaces
base_name = smart_filename(title)
filename = get_unique_filename(base_name, used_filenames, output_dir)
# Replace UUID links in content
def replace_link(match):
uuid = match.group(1)
return f"[[{uuid_to_title.get(uuid, uuid)}]]"
content = re.sub(r"\[\[([a-f0-9\-]+)\]\]", replace_link, content)
# Format the tags section
tags_yaml = "tags: []" if not tags else "tags:\n" + "\n".join([f" - {tag}" for tag in tags])
# Construct frontmatter with created first
frontmatter = f"""---
created: {created}
{tags_yaml}
---
"""
# File content: frontmatter at the top, followed by content
markdown_content = f"{frontmatter}\n{content}"
try:
with open(os.path.join(output_dir, filename), 'w', encoding='utf-8') as f:
f.write(markdown_content)
print(f"Created: {filename}")
except Exception as e:
print(f"Error writing {filename}: {e}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment