obsidian-migration-deep-dive

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".

1,868 stars

Best use case

obsidian-migration-deep-dive is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

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".

Teams using obsidian-migration-deep-dive should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/obsidian-migration-deep-dive/SKILL.md --create-dirs "https://raw.githubusercontent.com/jeremylongshore/claude-code-plugins-plus-skills/main/plugins/saas-packs/obsidian-pack/skills/obsidian-migration-deep-dive/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/obsidian-migration-deep-dive/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How obsidian-migration-deep-dive Compares

Feature / Agentobsidian-migration-deep-diveStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

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".

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

Related Guides

SKILL.md Source

# 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

```bash
#!/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.

```javascript
// 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](Page%20Title%20abc123def456.md)
    // 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](Image%20Name%20abc123.png)
    // 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:
```bash
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).

```javascript
// 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:
```bash
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.

```javascript
// 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.

```bash
#!/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](https://github.com/HamburgChimworking/apple-notes-liberator) or export via AppleScript (macOS only):

```bash
# 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
```

### Step 7: Post-Migration Validation

After any migration, run a validation pass:

```bash
#!/bin/bash
# validate-migration.sh <vault-dir>
VAULT="$1"
echo "=== Migration Validation ==="

# Broken wikilinks (link targets that don't exist as files)
echo "Broken wikilinks:"
grep -roh '\[\[[^]|]*\]\]' "$VAULT"/*.md 2>/dev/null | \
  sed 's/\[\[//;s/\]\]//' | sort -u | while read link; do
    find "$VAULT" -name "${link}.md" -print -quit 2>/dev/null | grep -q . || echo "  MISSING: [[$link]]"
  done

# Orphaned attachments
echo "Orphaned attachments:"
[ -d "$VAULT/attachments" ] && for att in "$VAULT/attachments"/*; do
  attname=$(basename "$att")
  grep -rl "$attname" "$VAULT"/*.md 2>/dev/null | grep -q . || echo "  ORPHAN: $attname"
done

# Encoding issues
echo "Encoding issues:"
find "$VAULT" -name '*.md' -exec file {} \; | grep -v 'UTF-8\|ASCII\|empty' | head -10

# Summary
echo "=== Summary ==="
echo "Notes: $(find "$VAULT" -name '*.md' -not -path '*/.obsidian/*' | wc -l)"
echo "Attachments: $(find "$VAULT/attachments" -type f 2>/dev/null | wc -l)"
echo "Unique tags: $(grep -roh '#[a-zA-Z][a-zA-Z0-9/_-]*' "$VAULT"/*.md 2>/dev/null | sort -u | wc -l)"
```

## Output
- 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.

## Resources
- [Obsidian Importer Plugin](https://help.obsidian.md/import) — official import tool
- [Notion Export](https://www.notion.so/help/export-your-content)
- [Evernote ENEX Format](https://evernote.com/blog/how-evernotes-xml-export-format-works)

## Next Steps
Fix broken links from validation. For ongoing sync, see `obsidian-data-handling`.

Related Skills

workhuman-upgrade-migration

1868
from jeremylongshore/claude-code-plugins-plus-skills

Workhuman upgrade migration for employee recognition and rewards API. Use when integrating Workhuman Social Recognition, or building recognition workflows with HRIS systems. Trigger: "workhuman upgrade migration".

wispr-upgrade-migration

1868
from jeremylongshore/claude-code-plugins-plus-skills

Wispr Flow upgrade migration for voice-to-text API integration. Use when integrating Wispr Flow dictation, WebSocket streaming, or building voice-powered applications. Trigger: "wispr upgrade migration".

windsurf-upgrade-migration

1868
from jeremylongshore/claude-code-plugins-plus-skills

Upgrade Windsurf IDE, migrate settings from VS Code or Cursor, and handle breaking changes. Use when upgrading Windsurf versions, migrating from another editor, or handling configuration changes after updates. Trigger with phrases like "upgrade windsurf", "windsurf update", "migrate to windsurf", "windsurf from cursor", "windsurf from vscode".

windsurf-migration-deep-dive

1868
from jeremylongshore/claude-code-plugins-plus-skills

Migrate to Windsurf from VS Code, Cursor, or other AI IDEs with full configuration transfer. Use when migrating a team to Windsurf, transferring Cursor rules, or evaluating Windsurf against other AI editors. Trigger with phrases like "migrate to windsurf", "switch to windsurf", "windsurf from cursor", "windsurf from copilot", "windsurf evaluation".

webflow-upgrade-migration

1868
from jeremylongshore/claude-code-plugins-plus-skills

Analyze, plan, and execute Webflow SDK upgrades (webflow-api v1 to v3) with breaking change detection, API v1-to-v2 migration, and deprecation handling. Trigger with phrases like "upgrade webflow", "webflow migration", "webflow breaking changes", "update webflow SDK", "webflow v1 to v2".

webflow-migration-deep-dive

1868
from jeremylongshore/claude-code-plugins-plus-skills

Execute major Webflow migrations — from other CMS platforms to Webflow CMS, between Webflow sites, or large-scale content re-architecture using the Data API v2 bulk endpoints, strangler fig pattern, and data validation. Trigger with phrases like "migrate to webflow", "webflow migration", "import into webflow", "webflow replatform", "move content to webflow", "webflow bulk import", "wordpress to webflow".

vercel-upgrade-migration

1868
from jeremylongshore/claude-code-plugins-plus-skills

Upgrade Vercel CLI, Node.js runtime, and Next.js framework versions with breaking change detection. Use when upgrading Vercel CLI versions, migrating Node.js runtimes, or updating Next.js between major versions on Vercel. Trigger with phrases like "upgrade vercel", "vercel migration", "vercel breaking changes", "update vercel CLI", "next.js upgrade on vercel".

vercel-migration-deep-dive

1868
from jeremylongshore/claude-code-plugins-plus-skills

Migrate to Vercel from other platforms or re-architecture existing Vercel deployments. Use when migrating from Netlify, AWS, or Cloudflare to Vercel, or when re-platforming an existing Vercel application. Trigger with phrases like "migrate to vercel", "vercel migration", "switch to vercel", "netlify to vercel", "aws to vercel", "vercel replatform".

veeva-upgrade-migration

1868
from jeremylongshore/claude-code-plugins-plus-skills

Veeva Vault upgrade migration for REST API and clinical operations. Use when working with Veeva Vault document management and CRM. Trigger: "veeva upgrade migration".

veeva-migration-deep-dive

1868
from jeremylongshore/claude-code-plugins-plus-skills

Veeva Vault migration deep dive for enterprise operations. Use when implementing advanced Veeva Vault patterns. Trigger: "veeva migration deep dive".

vastai-upgrade-migration

1868
from jeremylongshore/claude-code-plugins-plus-skills

Upgrade Vast.ai CLI, migrate API versions, and handle breaking changes. Use when upgrading vastai CLI, detecting deprecations, or migrating between API versions. Trigger with phrases like "upgrade vastai", "vastai migration", "vastai breaking changes", "update vastai CLI".

vastai-migration-deep-dive

1868
from jeremylongshore/claude-code-plugins-plus-skills

Migrate GPU workloads to or from Vast.ai, or between GPU providers. Use when switching from AWS/GCP/Azure GPU instances to Vast.ai, migrating between GPU types, or re-platforming ML infrastructure. Trigger with phrases like "migrate to vastai", "vastai migration", "switch to vastai", "vastai from aws", "vastai from lambda".