├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── main.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | test_export -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}/main.js", 15 | "console": "integratedTerminal", 16 | "windows": { 17 | "program": "${workspaceFolder}\\main.js" 18 | } 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Conner Tennery 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > Note: I'm unfortunately very tied up with work at this time, but feel free to open issues with any requests, suggestions, questions, or bugs! I'll try to address them as soon as life settles down again. Pull requests are welcome! 2 | > 3 | > Thank you! <3 4 | 5 | # Notion to Obsidian Converter 6 | 7 | This is a simple script to convert exported Notion notes to Obsidian (or maybe other systems too). 8 | 9 | ## Usage 10 | 11 | 1. Download Notion data from Notion>Settings & Members>Settings>Export content>Export all workspace content 12 | 2. Unzip the data using 7-Zip (or something better than Window's default) 13 | 3. Get the script 14 | 4. Run `node main` 15 | 5. Input the path where your Notion notes are 16 | 6. Move notes folder into Obsidian directory 17 | 18 | _Warning: Notion pages that contain parentheses or dashes in the title will have them removed by Notion while exporting your data so the file will be created without them, even though the link itself will still retain them._ 19 | 20 | ```sh 21 | node main.js [args] [path_to_export] 22 | 23 | node main.js /my/notion/export 24 | 25 | node main.js my_export 26 | 27 | node main.js -v[vv] my_export 28 | 29 | node main.js --help 30 | ``` 31 | 32 | ## How it works 33 | 34 | **Paths:** 35 | 36 | The script searches through every path and removes the long uuid at the end of both the directory paths and the file paths. 37 | 38 | **Conversion Features:** 39 | 40 | - Markdown links are converted from `[Link Text](Notion\Link\Path)` to `[[Link Text]]`. It isn't perfect due to name collision, but it works for most links. Some links contain `www.notion.so` when they are related table records and those are converted from `https://www.notion.so/The-Page-Title-2d41ab7b61d14cec885357ab17d48536` to `[[The Page Title]]`. 41 | 42 | - CSV links are converted from `../Relative%20Path/To/Page%20Title.md` to `[[Page Title]]`. Again, not perfect but it works for most links. 43 | 44 | - After CSV's have their links corrected a secondary Markdown file is created with the same name and all of its contents converted into a Markdown table. 45 | 46 | - URL links found in Markdown are left as-is: `[Link Text](URL)` because Obsidian renders these correctly. The signifier for a "valid URL" is just containing `://` or if it matches a standard IP address structure, so it captures `http://`, `https://` and other networks like `ipfs://` as well as `xxx.xxx.xxx.xxx`. 47 | 48 | - If a link contains illegal characters `*"/\<>:|?` the character is replaced with a space. 49 | 50 | - Image links are converted from `![Page%20Title%20c5ae5f01ba5d4fb9a94d13d99397100c/Image%20Name.png](Page%20Title%20c5ae5f01ba5d4fb9a94d13d99397100c/Image%20Name.png)` to `![Page Title/Image Name.png]` 51 | 52 | ## Why 53 | 54 | Windows can't handle large paths. After unzipping the Notion data I wasn't able to move the folder because Windows doesn't like long paths and Notion put a long uuid on every directory and file. 55 | 56 | ## Note 57 | 58 | This is not made to be robust. Don't run it twice on the same export or it's likely to fail and truncate paths unnecessarily. 59 | 60 | 61 | # Contributors 62 | - [me](https://github.com/connertennery) 63 | - [zeyus](https://github.com/zeyus) 64 | - [nguyentran0212](https://github.com/nguyentran0212) 65 | - [CodeMySky](https://github.com/CodeMySky) 66 | - all of the users who have given helpful feedback to make the project more stable and helped cover edge cases! 67 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const readline = require('readline'); 3 | const npath = require('path'); 4 | 5 | let exportPath; 6 | 7 | const flags = { 8 | logging: 1, 9 | } 10 | 11 | const argHelp = [ 12 | { 13 | arg: `-v`, //logging 2 14 | help: `Enables basic logging. Logs major operations and the current directory the converter is working in.` 15 | }, 16 | { 17 | arg: `-vv`, //logging 3 18 | help: `Enables verbose logging. Logs most operations, the current directory the converter is working in, as well as every file the converter processes. Note: This can reduce performance!` 19 | }, 20 | { 21 | arg: `-vvv`, //logging 4 22 | help: `Enables complete verbose logging. Logs every operation, the current directory the converter is working in, as well as every file the converter processes. Note: This can reduce performance!` 23 | }, 24 | { 25 | arg: `-q, --quiet`, 26 | help: `Disables all logging.` 27 | }, 28 | { 29 | arg: `--help, -h, -?`, 30 | help: `Prints this message!` 31 | }, 32 | ] 33 | 34 | function printHelp() { 35 | vlog(4, `Printing help message`); 36 | console.log( 37 | `Notion-to-Obisidian-Converter 38 | by Conner, the contributors, and the community 39 | repo: https://github.com/connertennery/Notion-to-Obsidian-Converter 40 | `); 41 | 42 | console.warn(`WARNINGS:`); 43 | console.warn(`\t• Please make a backup of your export and read the warnings in the README so your data isn't mangled!`); 44 | console.warn(`\t• Notion pages that contain parentheses or dashes in the title will have them removed by Notion while exporting your data so the file will be created without them, even though the link itself will still retain them.`); 45 | console.warn(`\t• This is not made to be robust. Don't run it twice on the same export or it's likely to fail and truncate paths unnecessarily.`); 46 | 47 | console.log(`\nUsage: 48 | node main.js [args] [path_to_export] 49 | node main.js /my/notion/export 50 | node main.js -v my_export`); 51 | 52 | console.log(`\nArgs:`); 53 | argHelp.map(arg => console.log(`\t${arg.arg}\n\t\t${arg.help}`)); 54 | } 55 | 56 | 57 | function parseArgs(args) { 58 | vlog(3, `Parsing arguments: ${args}`); 59 | const unknownArgs = []; 60 | args.slice(2).forEach(arg => { 61 | switch (arg) { 62 | case `-v`: 63 | vlog(4, `Setting logging to 2`); 64 | flags.logging = 2; 65 | break; 66 | case `-vv`: 67 | vlog(4, `Setting logging to 3`); 68 | flags.logging = 3; 69 | break; 70 | case `-vvv`: 71 | vlog(4, `Setting logging to 4`); 72 | flags.logging = 4; 73 | break; 74 | case `--quiet`: 75 | case `-q`: 76 | vlog(4, `Setting logging to 0`); 77 | flags.logging = 0; 78 | break; 79 | case `--help`: 80 | case `-h`: 81 | case `-?`: 82 | printHelp(); 83 | break; 84 | default: 85 | vlog(4, `Adding to unknownArgs: ${arg}`); 86 | unknownArgs.push(arg); 87 | } 88 | }); 89 | 90 | if (unknownArgs.length) { 91 | vlog(4, `Checking unknown args`); 92 | unknownArgs.forEach(arg => { 93 | vlog(4, `Checking if ${arg} exists`); 94 | const exists = fs.existsSync(arg); 95 | if (exists) { 96 | vlog(4, `Checking if ${arg} is a directory`); 97 | const isDir = fs.lstatSync(arg).isDirectory(); 98 | if (isDir) { 99 | if (exportPath === undefined) { 100 | vlog(4, `Setting exportPath to: ${arg}`); 101 | exportPath = arg; 102 | } 103 | else if (exportPath !== undefined) { 104 | console.warn(`Provided multiple paths - right now the converter can only operate on one directory and its subdirectories`); 105 | process.exit(1); 106 | } 107 | } 108 | else { 109 | console.warn(`Path goes to a file, or something else weird`); 110 | process.exit(1); 111 | } 112 | } 113 | else { 114 | console.warn(`Unknown arg: ${arg}\n\tIf this is supposed to be the target directory, the converter is unable to find it. Please make sure it's typed in correctly and try again`); 115 | process.exit(1); 116 | } 117 | }); 118 | } 119 | } 120 | 121 | 122 | function main() { 123 | //Must happen immediately 124 | if (process.argv.includes(`-vvv`)) 125 | flags.logging = 4; 126 | 127 | parseArgs(process.argv); 128 | 129 | if (!exportPath) { 130 | vlog(4, `Path not detected in arguments - asking user for path`); 131 | const rl = readline.createInterface({ 132 | input: process.stdin, 133 | output: process.stdout, 134 | }); 135 | 136 | rl.question(`Notion Export Path:\n`, (path) => { 137 | rl.close(); 138 | vlog(4, `Input: \`${path}\``); 139 | exportPath = path.trim(); 140 | startConversion(exportPath); 141 | }); 142 | } 143 | else { 144 | startConversion(exportPath); 145 | } 146 | } 147 | 148 | 149 | 150 | const truncateFileName = (name) => { 151 | vlog(4, `Truncating file name: ${name}`); 152 | let bn = npath.basename(name); 153 | bn = bn.lastIndexOf(' ') > 0 ? bn.substring(0, bn.lastIndexOf(' ')) : bn; 154 | return npath.resolve( 155 | npath.format({ 156 | dir: npath.dirname(name), 157 | base: bn + npath.extname(name), 158 | }) 159 | ); 160 | }; 161 | 162 | const truncateDirName = (name) => { 163 | vlog(4, `Truncating directory name: ${name}`); 164 | let bn = npath.basename(name); 165 | bn = bn.lastIndexOf(' ') > 0 ? bn.substring(0, bn.lastIndexOf(' ')) : bn; 166 | return npath.resolve( 167 | npath.format({ 168 | dir: npath.dirname(name), 169 | base: bn, 170 | }) 171 | ); 172 | }; 173 | 174 | const ObsidianIllegalNameRegex = /[\*\"\/\\\<\>\:\|\?]/g; 175 | const URLRegex = /(:\/\/)|(w{3})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/; 176 | const correctMarkdownLinks = (content) => { 177 | //* [Link Text](Link Directory + uuid/And Page Name + uuid) => [[LinkText]] 178 | 179 | vlog(4, `Finding Markdown links with ~regex~`); 180 | const linkFullMatches = content.match(/(\[(.*?)\])(\((.*?)\))/gi); 181 | const linkTextMatches = content.match(/(\[(.*?)\])(\()/gi); 182 | const linkFloaterMatches = content.match(/([\S]*\.md(\))?)/gi); 183 | const linkNotionMatches = content.match(/([\S]*notion.so(\S*))/g); 184 | if (!linkFullMatches && !linkFloaterMatches && !linkNotionMatches) 185 | return { content: content, links: 0 }; 186 | 187 | let totalLinks = 0; 188 | 189 | let out = content; 190 | if (linkFullMatches) { 191 | totalLinks += linkFullMatches.length; 192 | for (let i = 0; i < linkFullMatches.length; i++) { 193 | if (URLRegex.test(linkFullMatches[i])) { 194 | continue; 195 | } 196 | let linkDecoded = tryDecodeURI(linkTextMatches[i]); 197 | 198 | let linkText = linkDecoded.substring( 199 | 1, 200 | linkDecoded.length - 2 201 | ); 202 | vlog(4, `Fixing Markdown link: ${linkText}`); 203 | if (linkText.includes('.png')) { 204 | linkText = convertPNGPath(linkText); 205 | } else if (linkFullMatches[i].includes('.png')) { 206 | linkText = convertPNGLink(linkFullMatches[i]); 207 | } else { 208 | linkText = linkText.replace(ObsidianIllegalNameRegex, ' '); 209 | } 210 | 211 | if (linkText[0] === '[') 212 | out = out.replace(linkFullMatches[i], linkText); 213 | else 214 | out = out.replace(linkFullMatches[i], `[[${linkText}]]`); 215 | } 216 | } 217 | 218 | //! Convert free-floating relativePaths and Notion.so links 219 | if (linkFloaterMatches) { 220 | totalLinks += linkFullMatches 221 | ? linkFloaterMatches.length - linkFullMatches.length 222 | : linkFloaterMatches.length; 223 | vlog(4, `Converting relative paths`); 224 | out = out.replace(/([\S]*\.md(\))?)/gi, convertRelativePath); 225 | } 226 | 227 | if (linkNotionMatches) { 228 | vlog(4, `Converting Notion.so links`); 229 | out = out.replace(/([\S]*notion.so(\S*))/g, convertNotionLinks); 230 | totalLinks += linkNotionMatches.length; 231 | } 232 | 233 | return { 234 | content: out, 235 | links: totalLinks, 236 | }; 237 | }; 238 | 239 | /** 240 | * Strips Notion UUID from PNG link 241 | * @param {string} link Markdown link of format [title](link%20to/image.png) 242 | * @returns {string} Markdown link 243 | */ 244 | const convertPNGLink = (link) => { 245 | vlog(4, `Converting PNG link: ${link}`); 246 | const linkSplit = link.lastIndexOf('/'); 247 | const linkPath = link.substring(0, linkSplit).split('%20').slice(0, -1).join('%20'); 248 | const imageTitle = link.substring(linkSplit); 249 | return `${linkPath}${imageTitle}`; 250 | }; 251 | 252 | const convertPNGPath = (path) => { 253 | vlog(4, `Converting PNG path: ${path}`); 254 | let imageTitle = path 255 | .substring(path.lastIndexOf('/') + 1) 256 | .split('%20') 257 | .join(' '); 258 | path = convertRelativePath(path.substring(0, path.lastIndexOf('/'))); 259 | path = tryDecodeURI(path.substring(2, path.length - 2)); 260 | 261 | return `${path}/${tryDecodeURI(imageTitle)}`; 262 | }; 263 | 264 | const convertNotionLinks = (match, p1, p2, p3) => { 265 | vlog(4, `Converting Notion.so link: ${match}`); 266 | return `[[${tryDecodeURI(match 267 | .substring(match.lastIndexOf('/') + 1) 268 | .split('-') 269 | .slice(0, -1) 270 | .join(' '))}]]`; 271 | }; 272 | 273 | const convertRelativePath = (path) => { 274 | vlog(4, `Converting relative path: ${path}`); 275 | return `[[${tryDecodeURI(path.split('/').pop().split('%20').slice(0, -1).join(' '))}]]`; 276 | }; 277 | 278 | const correctCSVLinks = (content) => { 279 | //* ../Relative%20Path/To/File%20Name.md => [[File Name]] 280 | let lines = content.split('\n'); 281 | let links = 0; 282 | for (let x = 0; x < lines.length; x++) { 283 | let line = lines[x]; 284 | cells = line.split(','); 285 | 286 | for (let y = 0; y < cells.length; y++) { 287 | let cell = cells[y]; 288 | if (cell.includes('.md')) { 289 | vlog(4, `Converting CSV link: ${cell}`); 290 | cells[y] = convertRelativePath(cell); 291 | links++; 292 | } 293 | } 294 | lines[x] = cells.join(','); 295 | } 296 | return { content: lines.join('\n'), links: links }; 297 | }; 298 | 299 | const convertCSVToMarkdown = (content) => { 300 | vlog(4, `Converting CSV to Markdown`); 301 | const csvCommaReplace = (match, p1, p2, p3, offset, string) => { 302 | return `${p1}|${p3}`; 303 | }; 304 | 305 | let fix = content 306 | .replace(/(\S)(\,)((\S)|(\n)|($))/g, csvCommaReplace) 307 | .split('\n') 308 | .map((l) => "|" + l.trim() + "|"); 309 | const headersplit = '|' + '---|'.repeat( 310 | fix[0].split('').filter((char) => char === '|').length - 1 311 | ); 312 | fix.splice(1, 0, headersplit); 313 | return fix.join('\n'); 314 | }; 315 | 316 | const convertDirectory = function (path) { 317 | const start = Date.now(); 318 | 319 | vlog(2, `Converting directory: ${path}`); 320 | 321 | let directories = []; 322 | let files = []; 323 | let markdownLinks = 0; 324 | let csvLinks = 0; 325 | let totalElapsedTime = 0; 326 | 327 | vlog(4, `Reading directory: ${path}`); 328 | let currentDirectory = fs.readdirSync(path, { withFileTypes: true }); 329 | 330 | vlog(4, `Organizing directory contents`); 331 | for (let i = 0; i < currentDirectory.length; i++) { 332 | let currentPath = npath.format({ 333 | dir: path, 334 | base: currentDirectory[i].name, 335 | }); 336 | if (currentDirectory[i].isDirectory()) directories.push(currentPath); 337 | if (currentDirectory[i].isFile()) files.push(currentPath); 338 | } 339 | 340 | for (let i = 0; i < files.length; i++) { 341 | let file = files[i]; 342 | vlog(3, `Converting file: ${file}`); 343 | if (!file.includes('.png')) { 344 | let trunc = truncateFileName(file); 345 | vlog(3, `Renaming file ${file} -> ${trunc}`); 346 | fs.renameSync(file, trunc); 347 | file = trunc; 348 | files[i] = trunc; 349 | } 350 | 351 | //Fix Markdown Links 352 | if (npath.extname(file) === '.md') { 353 | vlog(3, `Fixing Markdown links`); 354 | const correctedFileContents = correctMarkdownLinks( 355 | fs.readFileSync(file, 'utf8') 356 | ); 357 | if (correctedFileContents.links) 358 | markdownLinks += correctedFileContents.links; 359 | vlog(4, `Writing corrected Markdown links to disk`); 360 | fs.writeFileSync(file, correctedFileContents.content, 'utf8'); 361 | } else if (npath.extname(file) === '.csv') { 362 | vlog(3, `Fixing CSV links`); 363 | const correctedFileContents = correctCSVLinks( 364 | fs.readFileSync(file, 'utf8') 365 | ); 366 | vlog(3, `Converting CSV to Markdown`); 367 | const csvConverted = convertCSVToMarkdown( 368 | correctedFileContents.content 369 | ); 370 | if (correctedFileContents.links) 371 | csvLinks += correctedFileContents.links; 372 | vlog(4, `Writing corrected CSV links to disk`); 373 | fs.writeFileSync(file, correctedFileContents.content, 'utf8'); 374 | vlog(4, `Writing converted CSV -> Markdown file to disk`); 375 | fs.writeFileSync( 376 | npath.resolve( 377 | npath.format({ 378 | dir: npath.dirname(file), 379 | base: npath.basename(file, `.csv`) + '.md', 380 | }) 381 | ), 382 | csvConverted, 383 | 'utf8' 384 | ); 385 | } 386 | 387 | vlog(3, `Finished converting file: ${file}`); 388 | } 389 | 390 | vlog(3, `Renaming child directories`); 391 | for (let i = 0; i < directories.length; i++) { 392 | let dir = directories[i]; 393 | vlog(4, `Truncating directory name: ${dir}`); 394 | let dest = truncateDirName(dir); 395 | while (fs.existsSync(dest)) { 396 | vlog(4, `Truncated directory name already exists" ${dest}`); 397 | dest = `${dest} - ${Math.random().toString(36).slice(2)}`; 398 | } 399 | vlog(3, `Renaming directory ${dir} -> ${dest}`); 400 | fs.renameSync(dir, dest); 401 | directories[i] = dest; 402 | } 403 | 404 | vlog(3, `Recursively converting children directory`); 405 | directories.forEach((dir) => { 406 | vlog(4, `Recursively converting child directory: ${dir}`); 407 | const stats = convertDirectory(dir); 408 | directories = directories.concat(stats.directories); 409 | files = files.concat(stats.files); 410 | markdownLinks += stats.markdownLinks; 411 | csvLinks += stats.csvLinks; 412 | totalElapsedTime += stats.elapsed 413 | }); 414 | 415 | const elapsed = Date.now() - start; 416 | vlog(3, `Converted directory ${path} in: ${elapsed}ms`); 417 | return { 418 | directories, 419 | files, 420 | markdownLinks, 421 | csvLinks, 422 | elapsed, 423 | totalElapsedTime 424 | }; 425 | }; 426 | 427 | function startConversion(path) { 428 | vlog(1, `Starting conversion`); 429 | const output = convertDirectory(exportPath); 430 | 431 | vlog(1, 432 | `Fixed in ${output.elapsed}ms 433 | ${'-'.repeat(8)} 434 | Directories: ${output.directories.length} 435 | Files: ${output.files.length} 436 | Markdown Links: ${output.markdownLinks} 437 | CSV Links: ${output.csvLinks}` 438 | ); 439 | } 440 | 441 | function tryDecodeURI(encoded) { 442 | let linkDecoded = encoded; 443 | try { 444 | linkDecoded = decodeURI(linkDecoded); 445 | } 446 | catch (e) { 447 | verror(1, `Error decoding text: ${encoded}`); 448 | } 449 | return linkDecoded 450 | } 451 | 452 | function vlog(level, message) { 453 | if (flags.logging >= level) 454 | console.log(message); 455 | } 456 | 457 | function verror(level, message) { 458 | if (flags.logging >= level) 459 | console.error(message); 460 | } 461 | 462 | main(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-to-obsidian", 3 | "version": "0.0.1", 4 | "description": "This is a simple script to convert exported Notion notes to Obsidian (or maybe other systems too).", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/connertennery/Notion-to-Obsidian-Converter" 12 | }, 13 | "author": "Conner Tennery", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/connertennery/Notion-to-Obsidian-Converter/issues" 17 | }, 18 | "homepage": "https://github.com/connertennery/Notion-to-Obsidian-Converter/README.md" 19 | } 20 | --------------------------------------------------------------------------------