├── favicon.ico ├── og-image.jpg ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── .gitignore ├── site.webmanifest ├── .npmignore ├── .github └── workflows │ └── pages.yml ├── LICENSE ├── src └── lib │ ├── stats.js │ ├── assets.js │ ├── scanner.js │ ├── utils.js │ ├── links.js │ ├── callouts.js │ ├── cli.js │ ├── csv.js │ ├── frontmatter.js │ ├── zip.js │ └── enrich.test.js ├── package.json ├── logo.svg ├── .dev-docs ├── NEXT_STEPS.md ├── ENRICHMENT_SUMMARY.md ├── REFACTORING_STATUS.md ├── REFACTORING_PLAN.md └── NOTION_API_ENRICHMENT_PLAN.md ├── CLAUDE.md ├── bun.lock ├── CHANGELOG.md └── style.css /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitbonsai/notion2obsidian/HEAD/favicon.ico -------------------------------------------------------------------------------- /og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitbonsai/notion2obsidian/HEAD/og-image.jpg -------------------------------------------------------------------------------- /favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitbonsai/notion2obsidian/HEAD/favicon-16x16.png -------------------------------------------------------------------------------- /favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitbonsai/notion2obsidian/HEAD/favicon-32x32.png -------------------------------------------------------------------------------- /apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitbonsai/notion2obsidian/HEAD/apple-touch-icon.png -------------------------------------------------------------------------------- /android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitbonsai/notion2obsidian/HEAD/android-chrome-192x192.png -------------------------------------------------------------------------------- /android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitbonsai/notion2obsidian/HEAD/android-chrome-512x512.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | bun.lockb 4 | 5 | # Backup files created during migration 6 | *.backup 7 | 8 | # Test directories 9 | test-export/ 10 | my-export/ 11 | notion-export/ 12 | 13 | # OS files 14 | .DS_Store 15 | Thumbs.db 16 | 17 | # IDE 18 | .vscode/ 19 | .idea/ 20 | .claude/ 21 | *.swp 22 | *.swo 23 | 24 | # Logs 25 | *.log 26 | npm-debug.log* 27 | 28 | # Environment 29 | .env 30 | .env.local 31 | -------------------------------------------------------------------------------- /site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion2obsidian", 3 | "short_name": "n2o", 4 | "description": "Fast Notion to Obsidian migration tool", 5 | "icons": [ 6 | { 7 | "src": "/android-chrome-192x192.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "/android-chrome-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "theme_color": "#8250E7", 18 | "background_color": "#141420", 19 | "display": "standalone", 20 | "start_url": "/" 21 | } 22 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Development documentation 2 | .dev-docs/ 3 | .claude/ 4 | 5 | # Test files 6 | *.test.js 7 | src/**/*.test.js 8 | src/lib/*.test.js 9 | 10 | # GitHub specific 11 | .github/ 12 | 13 | # Website files (not needed in npm package) 14 | index.html 15 | style.css 16 | *.svg 17 | *.png 18 | *.jpg 19 | *.ico 20 | *.webmanifest 21 | og-image.jpg 22 | logo.svg 23 | android-chrome-*.png 24 | apple-touch-icon.png 25 | favicon*.png 26 | favicon.ico 27 | 28 | # Git 29 | .git/ 30 | .gitignore 31 | 32 | # Bun specific 33 | bun.lockb 34 | 35 | # Development files 36 | *.log 37 | .DS_Store 38 | .vscode/ 39 | .idea/ 40 | *.swp 41 | *.swo 42 | 43 | # Environment 44 | .env 45 | .env.local 46 | 47 | # Test directories 48 | test-*/ 49 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "pages" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup Pages 28 | uses: actions/configure-pages@v4 29 | 30 | - name: Upload artifact 31 | uses: actions/upload-pages-artifact@v3 32 | with: 33 | path: '.' 34 | 35 | - name: Deploy to GitHub Pages 36 | id: deployment 37 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 mwolff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/stats.js: -------------------------------------------------------------------------------- 1 | // ============================================================================ 2 | // Migration Statistics 3 | // ============================================================================ 4 | 5 | export class MigrationStats { 6 | constructor() { 7 | this.totalFiles = 0; 8 | this.processedFiles = 0; 9 | this.renamedFiles = 0; 10 | this.renamedDirs = 0; 11 | this.totalLinks = 0; 12 | this.namingConflicts = []; 13 | this.duplicates = 0; 14 | this.csvFilesProcessed = 0; 15 | this.csvIndexesCreated = 0; 16 | this.calloutsConverted = 0; 17 | } 18 | 19 | addNamingConflict(filePath, resolution) { 20 | this.namingConflicts.push({ filePath, resolution }); 21 | } 22 | 23 | getSummary() { 24 | return { 25 | totalFiles: this.totalFiles, 26 | processedFiles: this.processedFiles, 27 | renamedFiles: this.renamedFiles, 28 | renamedDirs: this.renamedDirs, 29 | totalLinks: this.totalLinks, 30 | namingConflictCount: this.namingConflicts.length, 31 | duplicates: this.duplicates, 32 | csvFilesProcessed: this.csvFilesProcessed, 33 | csvIndexesCreated: this.csvIndexesCreated, 34 | calloutsConverted: this.calloutsConverted 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/assets.js: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { spawn } from "node:child_process"; 3 | import chalk from "chalk"; 4 | 5 | // ============================================================================ 6 | // Directory Opening 7 | // ============================================================================ 8 | 9 | export async function openDirectory(dirPath, migrationTime, sizeStr) { 10 | const fullPath = resolve(dirPath); 11 | 12 | console.log(chalk.cyan.bold('\n🎉 Migration Complete!')); 13 | if (migrationTime && sizeStr) { 14 | console.log(`Time: ${chalk.green(migrationTime + 's')} • Size: ${chalk.green(sizeStr)}`); 15 | } 16 | console.log(`Directory: ${chalk.blue(fullPath)}`); 17 | console.log(chalk.gray('\nYour Notion export is now ready for Obsidian!')); 18 | 19 | try { 20 | // Detect platform and use appropriate open command 21 | const platform = process.platform; 22 | let openCommand; 23 | 24 | if (platform === 'darwin') { 25 | openCommand = 'open'; 26 | } else if (platform === 'win32') { 27 | openCommand = 'start'; 28 | } else { 29 | openCommand = 'xdg-open'; 30 | } 31 | 32 | spawn(openCommand, [fullPath], { detached: true, stdio: 'ignore' }); 33 | 34 | console.log(chalk.green('✓ Opening directory...')); 35 | } catch (err) { 36 | console.log(chalk.yellow(`Could not open directory automatically.`)); 37 | } 38 | 39 | console.log(); 40 | } 41 | 42 | // ============================================================================ 43 | // User Confirmation 44 | // ============================================================================ 45 | 46 | export async function promptForConfirmation(dryRun) { 47 | if (dryRun) { 48 | console.log(chalk.yellow.bold('\n🔍 DRY RUN MODE - No changes will be made\n')); 49 | return; 50 | } 51 | 52 | // No confirmation prompt - removed to streamline UX 53 | // User can use Ctrl+C to cancel during migration if needed 54 | console.log(); 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion2obsidian", 3 | "version": "2.6.0", 4 | "description": "High-performance CLI tool to migrate Notion exports to Obsidian-compatible markdown format", 5 | "type": "module", 6 | "main": "./notion2obsidian.js", 7 | "bin": { 8 | "notion2obsidian": "./notion2obsidian.js" 9 | }, 10 | "exports": { 11 | ".": "./notion2obsidian.js", 12 | "./lib/*": "./src/lib/*.js" 13 | }, 14 | "files": [ 15 | "notion2obsidian.js", 16 | "src/lib/assets.js", 17 | "src/lib/callouts.js", 18 | "src/lib/cli.js", 19 | "src/lib/csv.js", 20 | "src/lib/enrich.js", 21 | "src/lib/frontmatter.js", 22 | "src/lib/links.js", 23 | "src/lib/scanner.js", 24 | "src/lib/stats.js", 25 | "src/lib/utils.js", 26 | "src/lib/zip.js", 27 | "README.md", 28 | "LICENSE", 29 | "CHANGELOG.md" 30 | ], 31 | "scripts": { 32 | "migrate": "bun run notion2obsidian.js", 33 | "dry-run": "bun run notion2obsidian.js --dry-run", 34 | "help": "bun run notion2obsidian.js --help", 35 | "test": "bun test" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/bitbonsai/notion2obsidian.git" 40 | }, 41 | "bugs": { 42 | "url": "https://github.com/bitbonsai/notion2obsidian/issues" 43 | }, 44 | "homepage": "https://bitbonsai.github.io/notion2obsidian/", 45 | "dependencies": { 46 | "chalk": "^5.3.0", 47 | "fflate": "^0.8.2", 48 | "gray-matter": "^4.0.3", 49 | "ora": "^9.0.0", 50 | "remark": "^15.0.1", 51 | "remark-frontmatter": "^5.0.0", 52 | "unist-util-visit": "^5.0.0" 53 | }, 54 | "devDependencies": { 55 | "bun-types": "latest" 56 | }, 57 | "engines": { 58 | "bun": ">=1.0.0" 59 | }, 60 | "keywords": [ 61 | "notion", 62 | "obsidian", 63 | "migration", 64 | "markdown", 65 | "converter", 66 | "cli", 67 | "export", 68 | "import", 69 | "notes", 70 | "knowledge-base" 71 | ], 72 | "author": "bitbonsai", 73 | "license": "MIT" 74 | } 75 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/lib/scanner.js: -------------------------------------------------------------------------------- 1 | import { join } from "node:path"; 2 | import { readdir, lstat, realpath } from "node:fs/promises"; 3 | import { stat } from "node:fs/promises"; 4 | 5 | // ============================================================================ 6 | // Glob Pattern Resolution 7 | // ============================================================================ 8 | 9 | export async function resolveGlobPatterns(patterns) { 10 | const { Glob } = await import('bun'); 11 | const resolvedPaths = []; 12 | const errors = []; 13 | 14 | for (const pattern of patterns) { 15 | try { 16 | // Check if it's a literal path first (not a glob pattern) 17 | if (!pattern.includes('*') && !pattern.includes('?') && !pattern.includes('[')) { 18 | // Check if the literal path exists 19 | try { 20 | await stat(pattern); 21 | resolvedPaths.push(pattern); 22 | } catch (err) { 23 | errors.push(`Path not found: ${pattern}`); 24 | } 25 | continue; 26 | } 27 | 28 | // It's a glob pattern, resolve it 29 | const glob = new Glob(pattern); 30 | const matches = []; 31 | 32 | for await (const file of glob.scan({ 33 | cwd: process.cwd(), 34 | absolute: true, 35 | dot: false, 36 | onlyFiles: true 37 | })) { 38 | matches.push(file); 39 | } 40 | 41 | if (matches.length === 0) { 42 | errors.push(`No files found matching pattern: ${pattern}`); 43 | } else { 44 | resolvedPaths.push(...matches); 45 | } 46 | } catch (err) { 47 | errors.push(`Error resolving pattern '${pattern}': ${err.message}`); 48 | } 49 | } 50 | 51 | return { resolvedPaths, errors }; 52 | } 53 | 54 | // ============================================================================ 55 | // Directory Operations 56 | // ============================================================================ 57 | 58 | export async function getAllDirectories(dir) { 59 | const dirs = []; 60 | const visited = new Set(); 61 | 62 | async function scan(currentDir) { 63 | // Resolve symlinks to detect circular references 64 | let realPath; 65 | try { 66 | realPath = await realpath(currentDir); 67 | } catch { 68 | return; // Skip if can't resolve path 69 | } 70 | 71 | if (visited.has(realPath)) { 72 | return; // Already visited (circular symlink) 73 | } 74 | visited.add(realPath); 75 | 76 | const entries = await readdir(currentDir, { withFileTypes: true }); 77 | 78 | for (const entry of entries) { 79 | const fullPath = join(currentDir, entry.name); 80 | 81 | // Check if it's a symlink 82 | const stats = await lstat(fullPath).catch(() => null); 83 | if (!stats || stats.isSymbolicLink()) { 84 | continue; // Skip symlinks 85 | } 86 | 87 | if (entry.isDirectory()) { 88 | dirs.push(fullPath); 89 | await scan(fullPath); 90 | } 91 | } 92 | } 93 | 94 | await scan(dir); 95 | return dirs; 96 | } 97 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | import { extname } from "node:path"; 2 | 3 | // ============================================================================ 4 | // Configuration & Constants 5 | // ============================================================================ 6 | 7 | export const PATTERNS = { 8 | hexId: /^[0-9a-fA-F]{32}$/, 9 | mdLink: /\[([^\]]+)\]\(([^)]+)\)/g, 10 | frontmatter: /^\uFEFF?\s*---\s*\n/, // Only accept --- delimiters (Obsidian requirement) 11 | notionIdExtract: /\s([0-9a-fA-F]{32})(?:\.[^.]+)?$/, 12 | // Visual patterns for Notion callouts 13 | notionCallout: /[^\s*\n\s*\n\s*\*\*([^*]+)\*\*\s*\n\s*\n\s*([\s\S]*?)(?=