Created
April 10, 2026 16:04
-
-
Save kasrak/986e9568417d5e77f0d44ca7f0561afd to your computer and use it in GitHub Desktop.
Script to export Apples notes to plain text
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
| #!/bin/bash | |
| # Export Apple Notes to Markdown files with YAML frontmatter | |
| # Uses JXA (JavaScript for Automation) to access Notes.app | |
| OUTPUT_DIR="${1:-$(pwd)/apple-notes-export}" | |
| mkdir -p "$OUTPUT_DIR" | |
| echo "Exporting Apple Notes..." | |
| osascript -l JavaScript <<'JSEOF' | OUTPUT_DIR="$OUTPUT_DIR" python3 -c " | |
| import json, sys, os, re | |
| output_dir = os.environ['OUTPUT_DIR'] | |
| def sanitize_filename(name): | |
| # Remove or replace characters invalid in filenames | |
| name = re.sub(r'[/\\\\:*?\"<>|]', '-', name) | |
| name = re.sub(r'\\s+', ' ', name).strip() | |
| if not name: | |
| name = 'Untitled' | |
| # Truncate to reasonable length | |
| if len(name) > 200: | |
| name = name[:200] | |
| return name | |
| data = json.load(sys.stdin) | |
| print(f'Found {len(data)} notes') | |
| # Track duplicate filenames | |
| name_counts = {} | |
| for note in data: | |
| title = note['name'] or 'Untitled' | |
| folder = note['folder'] or 'Unfiled' | |
| created = note['created'] | |
| modified = note['modified'] | |
| body = note['body'] | |
| # Create folder subdirectory | |
| folder_dir = os.path.join(output_dir, sanitize_filename(folder)) | |
| os.makedirs(folder_dir, exist_ok=True) | |
| # Handle duplicate filenames | |
| base_name = sanitize_filename(title) | |
| key = os.path.join(folder_dir, base_name) | |
| if key in name_counts: | |
| name_counts[key] += 1 | |
| filename = f'{base_name} ({name_counts[key]}).md' | |
| else: | |
| name_counts[key] = 0 | |
| filename = f'{base_name}.md' | |
| filepath = os.path.join(folder_dir, filename) | |
| # Build frontmatter | |
| frontmatter = '---\n' | |
| frontmatter += f'title: \"{title.replace(chr(34), chr(92)+chr(34))}\"\n' | |
| frontmatter += f'folder: \"{folder}\"\n' | |
| frontmatter += f'created: \"{created}\"\n' | |
| frontmatter += f'updated: \"{modified}\"\n' | |
| frontmatter += '---\n\n' | |
| content = frontmatter + (body or '') | |
| with open(filepath, 'w', encoding='utf-8') as f: | |
| f.write(content) | |
| print(f'Exported to {output_dir}/') | |
| for folder in sorted(os.listdir(output_dir)): | |
| folder_path = os.path.join(output_dir, folder) | |
| if os.path.isdir(folder_path): | |
| count = len([f for f in os.listdir(folder_path) if f.endswith('.md')]) | |
| print(f' {folder}: {count} notes') | |
| " | |
| // JXA script to extract all notes as JSON | |
| var Notes = Application('Notes'); | |
| Notes.includeStandardAdditions = true; | |
| var results = []; | |
| var accounts = Notes.accounts(); | |
| for (var i = 0; i < accounts.length; i++) { | |
| var folders = accounts[i].folders(); | |
| for (var j = 0; j < folders.length; j++) { | |
| var folderName = folders[j].name(); | |
| var notes = folders[j].notes(); | |
| for (var k = 0; k < notes.length; k++) { | |
| try { | |
| var note = notes[k]; | |
| var created = note.creationDate().toISOString(); | |
| var modified = note.modificationDate().toISOString(); | |
| results.push({ | |
| name: note.name(), | |
| folder: folderName, | |
| created: created, | |
| modified: modified, | |
| body: note.plaintext() | |
| }); | |
| } catch(e) { | |
| // Skip notes that can't be read (e.g. password protected) | |
| results.push({ | |
| name: "Error reading note", | |
| folder: folderName, | |
| created: "", | |
| modified: "", | |
| body: "Could not export this note: " + e.message | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| JSON.stringify(results); | |
| JSEOF | |
| echo "Done!" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment