Claude
Skills
Sign in
Back

obsidian-migration-deep-dive

Included with Lifetime
$97 forever

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

Generalsaasobsidianmigration

What this skill does

# 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
    // 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:

```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)}.*?

Related in General