obsidian-migration-deep-dive
Strategy and tools for re-platforming existing note systems into an Obsidian vault.
Install
mkdir -p .claude/skills/obsidian-migration-deep-dive && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/1211" && unzip -o skill.zip -d .claude/skills/obsidian-migration-deep-dive && rm skill.zipInstalls to .claude/skills/obsidian-migration-deep-dive
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Execute major Obsidian plugin rewrites and migration strategies.Key capabilities
- →Migrate notes from Notion, Evernote, Roam Research, Bear, and Apple Notes to Obsidian.
- →Convert internal links to Obsidian wikilinks.
- →Relocate attachments and update references.
- →Generate frontmatter for Obsidian compatibility.
- →Assess migration with file counts and total size.
How it works
The skill processes exported data from various note-taking applications, converts their specific link formats to Obsidian wikilinks, and regenerates frontmatter for compatibility.
Inputs & outputs
When to use obsidian-migration-deep-dive
- →Moving a knowledge base from Notion to Obsidian
- →Converting Evernote archives to Markdown
- →Structuring unstructured notes for linking
About this skill
Obsidian Migration Deep Dive
Current State
!node --version 2>/dev/null || echo 'N/A'
!ls *.enex *.json *.zip 2>/dev/null | head -10 || echo 'No export files in cwd'
Overview
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
- Node.js 18+ for running migration scripts
- Backup of source data before starting
Instructions
Step 1: Pre-Migration Assessment
#!/bin/bash
# assess-migration.sh <export-directory>
EXPORT_DIR="${1:-.}"
echo "=== Migration Assessment: $EXPORT_DIR ==="
echo "File counts:"
for ext in md html enex json csv pdf png jpg gif zip; do
count=$(find "$EXPORT_DIR" -name "*.$ext" 2>/dev/null | wc -l)
[ "$count" -gt 0 ] && echo " .$ext: $count"
done
echo "Total size: $(du -sh "$EXPORT_DIR" 2>/dev/null | cut -f1)"
echo "Max directory depth: $(find "$EXPORT_DIR" -type d | awk -F/ '{print NF-1}' | sort -n | tail -1)"
echo "Sample filenames:"
find "$EXPORT_DIR" -type f | head -5
Step 2: Notion Export Migration
Notion exports as a zip containing markdown files, CSV databases, and attachments. The markdown uses Notion-style links and has UUIDs appended to filenames.
// notion-to-obsidian.mjs
import { readdir, readFile, writeFile, mkdir, copyFile } from 'fs/promises';
import { join, basename, extname, dirname } from 'path';
const NOTION_EXPORT = process.argv[2]; // Unzipped Notion export
const VAULT_DIR = process.argv[3]; // Target Obsidian vault
if (!NOTION_EXPORT || !VAULT_DIR) {
console.error('Usage: node notion-to-obsidian.mjs <notion-export-dir> <vault-dir>');
process.exit(1);
}
// Step 1: Build a filename map (strip Notion UUIDs from names)
// Notion appends " abc123def456" to every filename
function cleanNotionName(filename) {
return filename.replace(/\s+[a-f0-9]{32}(?=\.\w+$|$)/, '');
}
async function* walkDir(dir) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) yield* walkDir(fullPath);
else yield fullPath;
}
}
async function migrate() {
const fileMap = new Map(); // original path -> clean path
const attachments = [];
const notes = [];
// Categorize files
for await (const filePath of walkDir(NOTION_EXPORT)) {
const ext = extname(filePath).toLowerCase();
const relPath = filePath.slice(NOTION_EXPORT.length + 1);
const cleanPath = relPath.split('/').map(cleanNotionName).join('/');
fileMap.set(relPath, cleanPath);
if (ext === '.md') notes.push({ src: filePath, dest: cleanPath });
else if (ext === '.csv') notes.push({ src: filePath, dest: cleanPath.replace('.csv', '.md'), isCSV: true });
else attachments.push({ src: filePath, dest: join('attachments', basename(cleanPath)) });
}
// Process markdown notes
for (const note of notes) {
let content;
if (note.isCSV) {
content = await convertCSVToMarkdown(note.src);
} else {
content = await readFile(note.src, 'utf-8');
}
// Convert Notion links to Obsidian wikilinks
// Notion: Page Title
// Obsidian: [[Page Title]]
content = content.replace(
/\[([^\]]+)\]\(([^)]+\.md)\)/g,
(match, text, href) => {
const decoded = decodeURIComponent(href);
const clean = cleanNotionName(basename(decoded, '.md'));
return `[[${clean}]]`;
}
);
// Convert Notion image references to Obsidian
// Notion: !description
// Obsidian: ![[image-name.png]]
content = content.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
(match, alt, src) => {
const decoded = decodeURIComponent(src);
if (decoded.startsWith('http')) return match; // Keep external URLs
const clean = cleanNotionName(basename(decoded));
return `![[${clean}]]`;
}
);
// Add frontmatter
const title = basename(note.dest, extname(note.dest));
content = `---\ntitle: "${title}"\nsource: notion\nmigrated: ${new Date().toISOString().split('T')[0]}\n---\n\n${content}`;
const destPath = join(VAULT_DIR, note.dest);
await mkdir(dirname(destPath), { recursive: true });
await writeFile(destPath, content);
}
// Copy attachments
await mkdir(join(VAULT_DIR, 'attachments'), { recursive: true });
for (const att of attachments) {
await copyFile(att.src, join(VAULT_DIR, att.dest));
}
console.log(`Migrated ${notes.length} notes, ${attachments.length} attachments`);
}
async function convertCSVToMarkdown(csvPath) {
const raw = await readFile(csvPath, 'utf-8');
const lines = raw.trim().split('\n');
if (lines.length === 0) return '';
const headers = lines[0].split(',').map(h => h.replace(/^"|"$/g, ''));
const rows = lines.slice(1).map(line =>
line.split(',').map(c => c.replace(/^"|"$/g, ''))
);
let md = `| ${headers.join(' | ')} |\n`;
md += `| ${headers.map(() => '---').join(' | ')} |\n`;
for (const row of rows) {
md += `| ${row.join(' | ')} |\n`;
}
return md;
}
migrate().catch(console.error);
Run it:
unzip Notion-Export-*.zip -d notion-export
node notion-to-obsidian.mjs notion-export ~/my-vault
Step 3: Evernote ENEX Migration
ENEX files are XML containing notes with HTML content and embedded attachments (base64).
// evernote-to-obsidian.mjs
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { parseString } from 'xml2js'; // npm install xml2js
import TurndownService from 'turndown'; // npm install turndown
const ENEX_FILE = process.argv[2];
const VAULT_DIR = process.argv[3];
const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
function parseENEX(xml) {
return new Promise((resolve, reject) => {
parseString(xml, (err, result) => {
if (err) reject(err);
else resolve(result['en-export']?.note || []);
});
});
}
function sanitizeFilename(name) {
return name.replace(/[<>:"/\\|?*]/g, '-').replace(/\s+/g, ' ').trim();
}
async function migrate() {
const xml = await readFile(ENEX_FILE, 'utf-8');
const notes = await parseENEX(xml);
await mkdir(join(VAULT_DIR, 'attachments'), { recursive: true });
let count = 0;
for (const note of notes) {
const title = sanitizeFilename(note.title?.[0] || `Untitled-${count}`);
const html = note.content?.[0] || '';
const created = note.created?.[0] || '';
const tags = note.tag || [];
// Convert HTML to Markdown
// Strip ENEX wrapper: <en-note>...</en-note>
const bodyHtml = html.replace(/<\/?en-note[^>]*>/g, '');
let markdown = turndown.turndown(bodyHtml);
// Build frontmatter
const fm = [
'---',
`title: "${title}"`,
`source: evernote`,
`created: ${formatEvernoteDate(created)}`,
`migrated: ${new Date().toISOString().split('T')[0]}`,
];
if (tags.length > 0) {
fm.push(`tags: [${tags.map(t => `"${t}"`).join(', ')}]`);
}
fm.push('---', '');
// Extract attachments (base64 resources)
const resources = note.resource || [];
for (const res of resources) {
const mime = res.mime?.[0] || 'application/octet-stream';
const data = res.data?.[0]?._ || res.data?.[0] || '';
const filename = res['resource-attributes']?.[0]?.['file-name']?.[0]
|| `attachment-${count}-${resources.indexOf(res)}.${mime.split('/')[1] || 'bin'}`;
const attPath = join(VAULT_DIR, 'attachments', sanitizeFilename(filename));
await writeFile(attPath, Buffer.from(data, 'base64'));
// Replace en-media tags in markdown with Obsidian embeds
markdown = markdown.replace(
new RegExp(`\\[.*?\\]\\(.*?${escapeRegex(filename)}.*?\\)`, 'g'),
`![[${sanitizeFilename(filename)}]]`
);
}
const content = fm.join('\n') + '\n' + markdown;
await writeFile(join(VAULT_DIR, `${title}.md`), content);
count++;
}
console.log(`Migrated ${count} notes from Evernote`);
}
function formatEvernoteDate(d) {
// ENEX: 20231015T120000Z -> 2023-10-15
if (!d) return '';
return `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`;
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
migrate().catch(console.error);
Run it:
npm install xml2js turndown
node evernote-to-obsidian.mjs My-Notes.enex ~/my-vault
Step 4: Roam Research JSON Migration
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 mkd
---
*Content truncated.*
Prerequisites
Limitations
- →Notion CSV databases may need manual review.
- →Nested Notion page hierarchies may need folder restructuring.
- →Malformed Evernote XML exports may require splitting into smaller chunks.
How it compares
This skill automates the conversion of various proprietary note formats and link types to Obsidian's specific markdown and wikilink structure, unlike manual copy-pasting.
Compared to similar skills
obsidian-migration-deep-dive side by side with the closest alternatives in the catalog.
| Skill | Installs | Updated | Safety | Difficulty |
|---|---|---|---|---|
| obsidian-migration-deep-dive (this skill) | 4 | 25d | Review | Intermediate |
| deepwiki-rs | 25 | 9mo | Review | Intermediate |
| codex-cli-bridge | 9 | 9mo | Review | Intermediate |
| skill-development | 17 | 8mo | Review | Intermediate |
Try saying
Example prompts that trigger this skill in your AI assistant.
More by jeremylongshore
View all by jeremylongshore →You might also like
deepwiki-rs
sopaco
AI-powered Rust documentation generation engine for comprehensive codebase analysis, C4 architecture diagrams, and automated technical documentation. Use when Claude needs to analyze source code, understand software architecture, generate technical specs, or create professional documentation from any programming language.
codex-cli-bridge
alirezarezvani
Bridge between Claude Code and OpenAI Codex CLI - generates AGENTS.md from CLAUDE.md, provides Codex CLI execution helpers, and enables seamless interoperability between both tools
skill-development
anthropics
This skill should be used when the user wants to "create a skill", "add a skill to plugin", "write a new skill", "improve skill description", "organize skill content", or needs guidance on skill structure, progressive disclosure, or skill development best practices for Claude Code plugins.
skill-writer
pytorch
Guide users through creating Agent Skills for Claude Code. Use when the user wants to create, write, author, or design a new Skill, or needs help with SKILL.md files, frontmatter, or skill structure.
openapi-spec-generation
wshobson
Generate and maintain OpenAPI 3.1 specifications from code, design-first specs, and validation patterns. Use when creating API documentation, generating SDKs, or ensuring API contract compliance.
korean-skill-creator
clwmfksek
한글 기반 클로드 스킬 자동 생성 도구. 사용자가 "클로드 스킬을 만들어줘" 또는 "[요구사항] 스킬 만들어줘"라고 요청할 때 사용. Progressive disclosure 원칙을 따르는 한글 문서 구조(SKILL.md + references/)를 자동으로 생성하고, 실전 예시를 포함한 일관성 있는 스킬 템플릿을 제공.