Created
April 2, 2025 14:38
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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