├── 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 |
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]*?)(?=