Execute major Obsidian plugin rewrites and migration strategies.
Use when migrating to or from Obsidian, performing major plugin rewrites,
or re-platforming existing note systems to Obsidian.
Trigger with phrases like "migrate to obsidian", "obsidian migration",
"convert notes to obsidian", "obsidian replatform".
Migrate notes from Notion, Evernote, Roam Research, Bear, and Apple Notes into Obsidian -- handling attachment relocation, internal link conversion to [[wikilinks]], tag migration, and frontmatter generation.
Prerequisites
Exported data from the source application (see each section for format)
A target Obsidian vault created and opened at least once
Notion exports as a zip containing markdown files, CSV databases, and attachments. The markdown uses Notion-style links and has UUIDs appended to filenames.
Roam exports as JSON with a flat array of pages containing children blocks.
// roam-to-obsidian.mjs
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
const ROAM_JSON = process.argv[2];
const VAULT_DIR = process.argv[3];
function convertBlock(block, depth = 0) {
let md = '';
const indent = ' '.repeat(depth);
const text = convertRoamSyntax(block.string || '');
if (depth === 0) md += text + '\n\n';
else md += `${indent}- ${text}\n`;
for (const child of block.children || []) {
md += convertBlock(child, depth + 1);
}
return md;
}
function convertRoamSyntax(text) {
// ((block-refs)) -> just the text (can't resolve without full graph)
text = text.replace(/\(\(([^)]+)\)\)/g, '$1');
// {{[[TODO]]}} -> - [ ]
text = text.replace(/\{\{(\[\[)?TODO(\]\])?\}\}/g, '- [ ]');
// {{[[DONE]]}} -> - [x]
text = text.replace(/\{\{(\[\[)?DONE(\]\])?\}\}/g, '- [x]');
// [[page links]] -> [[page links]] (already wikilink format)
// #[[tag]] -> #tag
text = text.replace(/#\[\[([^\]]+)\]\]/g, '#$1');
// ^^highlight^^ -> ==highlight==
text = text.replace(/\^\^(.+?)\^\^/g, '==$1==');
return text;
}
async function migrate() {
const raw = await readFile(ROAM_JSON, 'utf-8');
const pages = JSON.parse(raw);
await mkdir(VAULT_DIR, { recursive: true });
let count = 0;
for (const page of pages) {
const title = (page.title || `Untitled-${count}`).replace(/[<>:"/\\|?*]/g, '-');
const editTime = page['edit-time'] ? new Date(page['edit-time']).toISOString().split('T')[0] : '';
let content = '---\n';
content += `title: "${title}"\n`;
content += `source: roam\n`;
if (editTime) content += `modified: ${editTime}\n`;
content += `migrated: ${new Date().toISOString().split('T')[0]}\n`;
content += '---\n\n';
content += `# ${title}\n\n`;
for (const child of page.children || []) {
content += convertBlock(child);
}
await writeFile(join(VAULT_DIR, `${title}.md`), content);
count++;
}
console.log(`Migrated ${count} pages from Roam Research`);
}
migrate().catch(console.error);
Step 5: Bear Notes Migration
Bear exports markdown with Bear-specific tags (#tag/subtag#) and image references that need conversion.
#!/bin/bash
# bear-to-obsidian.sh <bear-export-dir> <vault-dir>
BEAR_DIR="$1"
VAULT_DIR="$2"
ATTACH_DIR="$VAULT_DIR/attachments"
mkdir -p "$ATTACH_DIR"
count=0
for note in "$BEAR_DIR"/*.md; do
[ -f "$note" ] || continue
filename=$(basename "$note")
# Fix Bear nested tags: #project/active# -> #project/active
# Fix Bear tag spacing: #tag1 #tag2 (already compatible)
content=$(sed -E 's/#([a-zA-Z0-9/_-]+)#/#\1/g' "$note")
# Convert Bear image syntax: [image:UUID/filename.png]
content=$(echo "$content" | sed -E 's/\[image:([^]]+\/)?([^]]+)\]/![[\2]]/g')
# Add frontmatter if missing
if ! echo "$content" | head -1 | grep -q '^---'; then
title=$(echo "$filename" | sed 's/\.md$//')
content="---
title: \"$title\"
source: bear
migrated: $(date +%Y-%m-%d)
---
$content"
fi
echo "$content" > "$VAULT_DIR/$filename"
count=$((count + 1))
done
# Copy Bear attachments (usually in a parallel directory)
if [ -d "$BEAR_DIR/assets" ]; then
cp -r "$BEAR_DIR/assets/"* "$ATTACH_DIR/" 2>/dev/null
fi
echo "Migrated $count notes from Bear"
Step 6: Apple Notes Migration
Apple Notes has no native export. Use apple-notes-liberator or export via AppleScript (macOS only):
# Export Apple Notes to HTML, then convert to Markdown
osascript -e '
tell application "Notes"
repeat with n in every note
set fp to (POSIX path of (path to desktop)) & name of n & ".html"
set f to open for access fp with write permission
write body of n to f as «class utf8»
close access f
end repeat
end tell
'
# Convert exported HTML files to Markdown with frontmatter
npm install turndown
for f in ~/Desktop/*.html; do
node -e "
const td = new (require('turndown'))({headingStyle:'atx'});
const html = require('fs').readFileSync('$f','utf-8');
const title = require('path').basename('$f','.html');
const md = '---\ntitle: \"'+title+'\"\nsource: apple-notes\nmigrated: ${new Date().toISOString().split('T')[0]}\n---\n\n'+td.turndown(html);
require('fs').writeFileSync('$1/'+title+'.md', md);
" ~/my-vault
done
Markdown notes with [[wikilink]] syntax and frontmatter (title, source, migrated, tags)
Attachments relocated to attachments/ with ![[embed]] references
Validation report listing broken links, orphaned attachments, and encoding issues
Error Handling
Issue
Cause
Solution
Encoding errors (\ufffd characters)
Source notes not UTF-8
Detect encoding with file command, convert with iconv -f LATIN1 -t UTF-8
Broken wikilinks after migration
File renamed or in subfolder
Run validation script; fix with search-and-replace
Missing attachments
Source export didn't include them
Re-export from source app with "include attachments" option
Duplicate filenames
Same title in different notebooks/folders
Prefix with source folder name: Notebook - Title.md
ENEX parse failure
Malformed XML (common with large exports)
Split ENEX into smaller chunks; export one notebook at a time
Notion CSV issues
Commas or quotes in cell values
Use csv-parse instead of string splitting
Examples
Notion (500 notes): Unzip export, run notion-to-obsidian.mjs, then validate-migration.sh. Typical issues: CSV databases need manual review, nested page hierarchies may need folder restructuring.
Evernote (2000 notes): Export one notebook at a time as ENEX to avoid XML parsing issues. Tags map directly to Obsidian frontmatter tags. Embedded images are extracted as attachments.
Roam Research: Wikilinks already compatible. Main work is converting (()) block refs and {{TODO}}/{{DONE}} syntax.