├── .gitignore ├── METHODOLOGY.md ├── N2O.py ├── N2Omodule.py ├── README.md └── media ├── export1.png ├── export2.png ├── export3.png ├── vault.png └── vaulticon.png /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | __pycache__/N2Omodule.cpython-38.pyc 3 | N2O-TS1.py 4 | -------------------------------------------------------------------------------- /METHODOLOGY.md: -------------------------------------------------------------------------------- 1 | # Migrating from Notion to Obsidian 2 | For those considering migrating content from [Notion](https://www.notion.so/) to [Obsidian](https://obsidian.md/), I've outlined detailed steps here that cover all the changes needed to make your export fully interlinked when opened as, or added to, an Obsidian vault. 3 | 4 | At the bottom you can also find a GitHub link to a Python script I wrote based on these steps. It will do the conversion for you in mere seconds. 5 | 6 | If you follow this guide, use some tools to batch process your conversion. I've suggested some inline based on working within the Windows OS. Other OS users will want to substitute the suggestions for tools compatible on your system. [Alternativeto.net](https://alternativeto.net/) is a great resource to find what you need. 7 | 8 | Aside from Notion comments. This guide will give you full internal link and backlink integration for your content in Obsidian. 9 | 10 | # The Notion Export 11 | Out of the box, the export files that Notion provides do not migrate to Obsidian very well. All external links will work, but: 12 | 13 | - The hierarchical structure of your pages can only be navigated using Obsidian’s file explorer. 14 | - None of the internal navigation links work, which also means there won’t be any backlinks or connections in Obsidian's Graph View. 15 | - None of the content in your Notion tables will be viewable. 16 | - Embedded images also won’t show. 17 | 18 | All of this can be remedied by following the instructions below. Note however, that Notion comments do NOT appear to be included in their export files. 19 | 20 | There will be five types of links in the exported files: 21 | 22 | - External links which can be ignored, as they are in a compatible format 23 | - User generated Internal Links 24 | - Two types of Notion generated Internal Links 25 | - And possibly some blank links that need correction 26 | 27 | There may also be some *.csv* files that will need to be converted to *.md* files. 28 | 29 | Let's get started! 30 | 31 | ## Export Your Full Notion Database 32 | 1. From your Notion app, click the **Settings & Members** tab in the sidebar 33 | ![Settings&Members](media/export1.png) 34 | 2. Find and click the **Settings** tab. Find the **Export content** section. Click the **Export all workspace content** button 35 | ![Settings](media/export2.png) 36 | 3. Select **Markdown & CSV** as Export Format and click the **Export** button 37 | ![Export](media/export3.png) 38 | 4. Save the resulting .zip file to your computer 39 | 5. Extract the .zip contents to a known location 40 | 41 | # Remove the UID from all files and folders 42 | 43 | All user-generated content will show a 32 digit alphanumeric Unique Identifier (UID) as a filename suffix. 44 | 45 | They look like this: 46 | 47 | `Meeting Notes 38f9b024692a4d0fbc14088d47c72d67` 48 | 49 | `Random Notes 49330b16a1f54b4d92b442b25ea986de.md` 50 | 51 | We’ll want to remove these UIDs from all of your files and folders. Use a file renaming tool like [ReNamer](http://www.den4b.com/products/renamer) to remove the last 33 characters (UID + space) of every file and folder. The steps that follow are based on Renamer. Interpret them as needed for whatever app you're using. 52 | 53 | ## Trim the directory names first 54 | 1. Filter Settings > Add folders as files; Include subfolders 55 | 2. Delete > Until > Delimiter = " " (space) > From right to left 56 | 3. From your zip extraction, drag the whole directory into app 57 | 4. Sort Decending by Path 58 | (this prevents the issue of no longer finding subfolders after the parent folder is renamed) 59 | 5. Click **Preview**. Make sure things look good 60 | 6. Click **Rename** 61 | 62 | ## Trim the file names 63 | 1. Filter Settings > Add files within folders; Include subfolders 64 | 1. Delete > Until > Delimiter = " " (space) > from right to left 65 | 1. Drag whole directory in 66 | 1. Select all > Fix conflicting new names `Shift-F` 67 | 1. Click **Preview**. Make sure things look good 68 | 1. Click **Rename** 69 | 70 | ### Duplicate filenames 71 | If there are multiple pages with the same name in the same directory, you'll have to combine or otherwise mitigate that content on your own at some point. 72 | 73 | Notion differentiates notes with the UID which allows their users to work with multiple notes with the same filename. Since we can’t have multiple files with the same name in our operating system, a reasonable solution to this is to combine the contents of files having the same name within a common directory. 74 | 75 | # Convert Notion Style Links to Obsidian Style Links 76 | Any page in the export package may have links that need conversion to an Obsidian format. A good search and replace tool that’s capable of batch processing multiple files will make this work much easier. I’ve found [notepad++](https://notepad-plus-plus.org/) to be a great tool for this. Use whatever works for you. If you're familiar with Regex, this can make your batch processing even easier to manage. [Regexr](https://regexr.com/) is a great online tool to test and refine your regex. 77 | 78 | ## Process the .md Files 79 | 80 | Your Notion export will contain *.md* files, *.csv* files, and may also contain image or other attachment files. The *.csv* files will eventually be converted to *.md* files. As a matter of organization simplicity, we'll process all the .md files first before introducing the *.csv* files. 81 | 82 | ### Convert Links to Obsidian Format 83 | There will be five types of links in the exported files to process. When exported from Notion, they are all in the same "inline" wiki link format. But they differ enough to confidently identify and batch process each type with your search and replace tools. 84 | 85 | - `[link name](external.web/address)` 86 | - `[link name](notion.so/username/page+UID)` 87 | - `[link name](subfolder+UID)` 88 | - `[link name](about:blank#subfolder/page)` 89 | - `[link name]` 90 | 91 | ### External Links 92 | 93 | Notion and Obsidian use the same format for external links. These links should be left alone. They look like this: 94 | 95 | `[Link Name\](http://external.web.address)` 96 | 97 | ### User Generated Internal Links 98 | 99 | These are links that the user had manually generated to tie notes together within Notion. They can be identified within your pages by the URL containing the notion domain and their username. 100 | 101 | `[Link Name](https://notion.so/username/note_name+UID)` 102 | 103 | We’re primarily interested in the note filename so that we can build complete link and backlink threads between our content. But there’s also enough information in these links to build pretty links, and also maintain a URL link to the original content on the Notion servers. Here, I’ll be including the source link as a footnote, should you ever need the original reference. I originally built these in so that if a link didn’t work in obsidian, we could still find our content easily. So far all links have worked but we can keep the links as a simple, unobtrusive safeguard. 104 | 105 | Process Link Names: 106 | 107 | 1. Isolate and save the {URL} portion 108 | 2. Isolate and save the {Link Name} portion 109 | 3. Isolate the {Note Name} portion to make it Obsidian friendly 110 | 4. Search and replace any symbols in the {Note Name} to a space. **Only alphanumerics, underscores, and spaces** are retained in Notion exported filenames 111 | 5. Remove any duplicate spaces and leading/trailing spaces from the {Note Name} 112 | 6. Reconstruct Internal Links as pretty links with the source URL as a footnote 113 | 1. If {Link Name} is the same as {Note Name} 114 | `[[Note Name]] ^[URL]` 115 | 2. If {Link Name} is different than {Note Name} 116 | `[[Note Name|Link Name]] ^[URL]` 117 | 118 | #### Regex 119 | 120 | This regex will capture what we need with the following groups: 121 | 122 | `\[(.[^\[\]\(\)]*)\]\((https:\/\/www.notion.so\/(?:.[^\/]*)\/(.[^\[\]\(\)]*)-.[^\[\]\(\)]*)\)` 123 | 124 | - Group 1: Pretty Link Title 125 | - Group 2: URL 126 | - Group 3: target file name in web URL form (but not yet in Obsidian form) 127 | 128 | ### Structural Links 129 | 130 | Many of the connections between pages in Notion are inferred by hierarchy. When exporting this shows up as a directory structure. When using Notion, there’s no obvious visual difference between a directory and a page that you've created. Directories render as if it was a normal page. 131 | 132 | Notion exports a representational *.md* file for these directories (that simply look like a page in Notion). This file will exist as a sidecar file alongside its respective directory. It will also have the same name as its respective directory. This sidecar *.md* file contains relative links to all the contents within the directory. Once processed, these will provide uninterrupted threads between all of your content in Obsidian! 133 | 134 | Note that some directories will have a sidecar *.csv* file instead of a sidecar *.md* file. Notion exports these when a full-page database has been created and contains no other content blocks besides the Table, Board, List, or Gallery. See the CSV Conversion section below. 135 | 136 | The relative links within these sidecar *.md* files are structured differently than user generated internal links. They each have their own line and follow this pattern: 137 | 138 | `[Note Title](RelativePath+UID/filename+UID.fileExtension)` 139 | 140 | For example: 141 | 142 | `[Micronutrient Smoothie](Bodywork%20731fe478ea6048e1ac0df8c7f7ed95bf/Micronutrient%20Smoothie%2021e2b0c0922d46f387c8b353a17ff734.md)` 143 | 144 | We want to preserve the relative path structure that these links contain. This helps mitigate the differences between how Notion and Obsidian deal with notes that have the same name. 145 | 146 | Process the relative paths in each line, in this order: 147 | 148 | 1. Remove the UIDs and the single leading URL space encoding (%20) in front of the UIDs 149 | 2. Remove the file extension (*.md* or *.csv*) 150 | 3. Search and replace the all remaining URL space encoding characters (%20) with a normal space character 151 | 4. Remove parentheses 152 | 5. Restructure the links into Obsidian Pretty Link format 153 | 154 | Example Results: 155 | 156 | `[[Relative/Path/filename|Note Title]]` 157 | 158 | `[[Bodywork/Micronutrient Smoothie|Micronutrient Smoothie]]` 159 | 160 | #### Regex 161 | 162 | This regex will capture what we need with the following groups: 163 | 164 | `^\[(.+)\]\(([^\(]*)(?:\.md|\.csv)\)$` 165 | 166 | - Group 1: Note Title 167 | - Group 2: Relative Path and Filename 168 | 169 | ### Broken Links 170 | 171 | There may be broken links within Notion. Often a broken link in Notion still has an associated file. It’s worth capturing and converting any broken links. These links are easily identified by the `about:blank\#` string where a `notion.io` URL should be. Here are a couple examples: 172 | 173 | `[Evaluate on Tuesday](about:blank#Evaluate%20on%20Tuesday)` 174 | 175 | `[2017-1-15 19:14](about:blank#2017-1-15%2019%3A14)` 176 | 177 | Process the broken links: 178 | 179 | 1. Remove the URL section in parentheses 180 | 2. **Only alphanumerics, underscores, and spaces** are retained in Notion exported filenames. Convert all other symbols to a space 181 | 3. Replace duplicate spaces with a single space 182 | 4. Remove any leading or trailing spaces 183 | 5. Frame the modified title in Obsidian double square brackets 184 | 185 | Results: 186 | 187 | `[[Evaluate on Tuesday]]` 188 | 189 | `[[2017 1 15 19 14]]` 190 | 191 | #### Regex 192 | 193 | This regex will capture what we need with the following groups: 194 | 195 | `\[(.[^\[\]\(\)]*)\]\(about:blank#.[^\[\]\(\)]*\)` 196 | 197 | - Group 1: Note Title 198 | - Group 2: Page name 199 | (these two match in all instances I've seen. So just process one or the other) 200 | 201 | ### Convert tags in lines starting with "Tags: " 202 | 203 | Notion renders tags in the .md page exports on a single line starting with “Tags: ” near the top of a page. 204 | 205 | Any words that follow will be separated with a comma. 206 | 207 | `Tags: Routine, Structure` 208 | 209 | Each of these words should be converted to the Obsidian tag format. 210 | 211 | 1. Find all words after `Tags : ` 212 | 2. Prefix each of them with a hash `#` 213 | 214 | Result: 215 | 216 | `Tags: #Routine, #Structure` 217 | 218 | #### Regex 219 | 220 | This regex will find the list of tags as a captured group: 221 | 222 | `^Tags:\s(.+)` 223 | 224 | ## Process the CSV files 225 | 226 | Notion exports a CSV file for every Table, Board, List, or Gallery. The first column in each of these CSV files can be easily converted to an Internal Link for Obsidian. 227 | 228 | ### Delete all but the first column 229 | 230 | Beyond the first Internal Link column, all the data in the following columns exist within the target file. Keeping the data here is liable to become a maintenance issue, as the additional data will not be mirrored between this table representation and the actual file content. Deleting all but the first column is suggested, but feel free to leave the redundant content if you don’t plan on changing the linked files or if there’s some other value. 231 | 232 | ### Modify Internal Links 233 | 234 | Search and replace to modify the Internal Links in this order: 235 | 236 | 1. If there's a web link in the title, and the link includes URL identifier (http, https, ftp). It must be removed because Notion exports these pages without the URL identifier in the filename. 237 | 2. **Only alphanumerics, underscores, and spaces** are retained in Notion exported filenames. All other symbols need to be converted to a space. 238 | 3. Replace duplicate spaces with a single space. 239 | 4. Remove any leading spaces 240 | 5. Notion cuts all filenames to a maximum of 50 characters. So cut the title to 50 characters if it’s longer. 241 | 6. Finally, remove any trailing spaces. 242 | 243 | Now that all the Internal Links match their respective file names, wrap each one in double square brackets to be an Obsidian Internal Link. 244 | 245 | `[[filename one]]` 246 | 247 | `[[filename two]]` 248 | 249 | ### Rename CSV to MD 250 | 251 | Once the Internal Links have been converted in a Notion CSV file, change the file extension to *.md*. Again, [ReNamer](http://www.den4b.com/products/renamer) can be used to streamline this step once all the content has been corrected. 252 | 253 | # Final Steps 254 | 255 | Nice work! You’re finished. Time to import everything into Obsidian. 256 | 257 | 1. Place all the converted files into a directory of your choosing 258 | 2. Open Obsidian and click the Vault Icon ![vault icon](media/vaulticon.png) 259 | 3. Select **Open folder as vault** 260 | ![open vault](media/vault.png) 261 | 4. Use the Select Folder window to navigate to the directory with your newly converted files 262 | 263 | Enjoy the shift to Obsidian! 264 | 265 | # Notion-2-Obsidian Python script on GitHub 266 | Find an automated Python script based on this outline in [my GitHub repository](https://github.com/visualcurrent/Notion-2-Obsidan) 267 | 268 | -------------------------------------------------------------------------------- /N2O.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created on Thu Jun 18 13:34:37 2020 4 | 5 | @author: books 6 | """ 7 | 8 | from os import makedirs, path 9 | from re import compile 10 | from shutil import copyfileobj, make_archive 11 | from zipfile import ZipFile 12 | from pathlib import Path 13 | import N2Omodule 14 | from tempfile import TemporaryDirectory 15 | from easygui import fileopenbox 16 | 17 | 18 | NotionZip = Path(fileopenbox(filetypes = ['*.zip'])) 19 | 20 | 21 | # Load zip file 22 | notionsData = ZipFile(NotionZip, 'r') 23 | 24 | NotionPathRaw = [] 25 | ObsidianPathRaw = [] 26 | NotionPaths = [] 27 | ObsidianPaths = [] 28 | 29 | 30 | 31 | # Generate a list of file paths for all zip content 32 | [NotionPathRaw.append(line.rstrip()) for line in notionsData.namelist()] 33 | 34 | verbose = False 35 | def debug_print(msg): 36 | if verbose: 37 | print(msg) 38 | 39 | # Clean paths for Obsidian destination 40 | regexUID = compile("\s+\w{32}") 41 | regexForbitCharacter = compile("[<>?:/\|*\"]") 42 | 43 | for line in NotionPathRaw: 44 | ObsidianPathRaw.append(regexUID.sub("", line)) 45 | 46 | 47 | ### PATHS IN PROPER OS FORM BY PATHLIB ### 48 | [NotionPaths.append(Path(line)) for line in NotionPathRaw] 49 | [ObsidianPaths.append(Path(line)) for line in ObsidianPathRaw] 50 | 51 | 52 | 53 | # Get all the relevant indices (folders, .md, .csv, others) 54 | mdIndex, csvIndex, othersIndex, folderIndex, folderTree = N2Omodule.ObsIndex(ObsidianPaths) 55 | 56 | 57 | # Rename the .csv files to .md files for the conversion 58 | for i in csvIndex: 59 | ObsidianPaths[i] = Path(str(ObsidianPaths[i])[0:-3]+"md") 60 | 61 | 62 | ## Create a temporary directory to work with 63 | unzipt = TemporaryDirectory() 64 | tempPath = Path(unzipt.name) 65 | 66 | 67 | ## Create temp directory paths that match zip directory tree 68 | tempDirectories = [] 69 | 70 | # Construct complete directory paths (/) 71 | for d in folderTree: 72 | tempDirectories.append(tempPath / d) 73 | 74 | ## Create the temporary directory structure for future archive 75 | for d in tempDirectories: 76 | makedirs(d, exist_ok=True) 77 | 78 | 79 | 80 | 81 | 82 | 83 | # Process all CSV files 84 | for n in csvIndex: 85 | 86 | # Access the original CSV file 87 | with notionsData.open(NotionPathRaw[n], "r") as csvFile: 88 | 89 | # Convert CSV content into Obsidian Internal Links 90 | mdTitle = N2Omodule.N2Ocsv(csvFile) 91 | 92 | ## Make temp destination file path 93 | newfilepath = tempPath / ObsidianPaths[n] 94 | 95 | # Check if file exists, append if true 96 | if path.exists(newfilepath): 97 | append_write = 'a' # append if already exists 98 | else: 99 | append_write = 'w' # make a new file if not 100 | 101 | # Save CSV internal links as new .md file 102 | with open(newfilepath, append_write, encoding='utf-8') as tempFile: 103 | [print(line.rstrip(), file=tempFile) for line in mdTitle] 104 | 105 | 106 | num_link = [0, 0, 0, 0] 107 | # Process all MD files 108 | for n in mdIndex: 109 | 110 | # Access the original MD file 111 | with notionsData.open(NotionPathRaw[n], "r") as mdFile: 112 | 113 | # Find and convert Internal Links to Obsidian style 114 | mdContent, cnt = N2Omodule.N2Omd(mdFile) 115 | num_link = [cnt[i]+num_link[i] for i in range(len(num_link))] 116 | 117 | # Exported md file include header in first line 118 | # '# title of file' 119 | # Get full file name by first line of exported md file instead file name ObsidianPaths[n] 120 | ## Make temp destination file path 121 | new_file_name = mdContent[0].replace('# ', '') + '.md' 122 | new_file_name = regexForbitCharacter.sub("", new_file_name) 123 | newfilepath = tempPath / path.dirname(ObsidianPaths[n]) / new_file_name 124 | 125 | # Check if file exists, append if true 126 | if path.exists(newfilepath): 127 | append_write = 'a' # append if already exists 128 | else: 129 | append_write = 'w' # make a new file if not 130 | 131 | # Save modified content as new .md file 132 | with open(newfilepath, append_write, encoding='utf-8') as tempFile: 133 | [print(line.rstrip(), file=tempFile) for line in mdContent] 134 | 135 | 136 | 137 | 138 | #### Process all attachment files using othersIndex #### 139 | for n in othersIndex: 140 | 141 | # Move the file from NotionPathRaw[n] in zip to newfilepath = tempPath / ObsidianPaths[n] 142 | newfilepath = tempPath / ObsidianPaths[n] 143 | 144 | # Manage chance of attachments being corrupt. Save a file listing bad files 145 | try: 146 | ## if no issue, copy the file 147 | with notionsData.open(NotionPathRaw[n]) as zf: 148 | with open(newfilepath, 'wb') as f: 149 | copyfileobj(zf, f) 150 | except: 151 | ## If there's issue, List bad files in a log file 152 | with open(tempPath / 'ProblemFiles.md', 'a+', encoding='utf-8') as e: 153 | if path.getsize(tempPath / 'ProblemFiles.md') == 0: 154 | print('# List of corrupt files from', NotionZip, file=e) 155 | print('', file=e) 156 | print(' !!File Exception!!',ObsidianPaths[n]) 157 | print(NotionPathRaw[n], file=e) 158 | print('', file=e) 159 | 160 | 161 | print(f"\nTotal converted links:") 162 | print(f" - Internal links: {num_link[0]}") 163 | print(f" - Embedded links: {num_link[1]}") 164 | print(f" - Blank links : {num_link[2]}") 165 | print(f" - Number tags : {num_link[3]}") 166 | 167 | 168 | # Save temporary file collection to new zip 169 | make_archive( NotionZip.parent / (NotionZip.name[:-4]+'-ObsidianReady'), 'zip', tempPath) 170 | 171 | 172 | 173 | 174 | # Close out! 175 | notionsData.close() -------------------------------------------------------------------------------- /N2Omodule.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created on Thu Jun 25 14:16:18 2020 4 | 5 | @author: books 6 | """ 7 | 8 | from io import TextIOWrapper 9 | from os import path 10 | from re import compile, search 11 | from csv import DictReader 12 | from pathlib import Path 13 | 14 | 15 | def str_slash_char_remove(string): 16 | 17 | #regex_forbidden_characters = compile('[\\/*?:"<>|]') 18 | regexSlash = compile("\/") 19 | string = regexSlash.sub('', string) 20 | 21 | return string 22 | 23 | 24 | def str_forbid_char_remove(string): 25 | 26 | #regex_forbidden_characters = compile('[\\/*?:"<>|]') 27 | regex_forbidden_characters = compile('[\\*?:"<>|]') 28 | string = regex_forbidden_characters.sub('', string) 29 | 30 | return string 31 | 32 | 33 | # convert %20 to ' ' 34 | def str_space_utf8_replace(string): 35 | 36 | regex_utf8_space = compile("%20") 37 | string = regex_utf8_space.sub(' ', string) 38 | 39 | return string 40 | 41 | 42 | def str_notion_uid_remove(string): 43 | 44 | regexUID = compile("%20\w{32}") 45 | string = regexUID.sub('', string) 46 | 47 | return string 48 | 49 | 50 | def ObsIndex(contents): 51 | """ 52 | Function to return all the relevant indices 53 | Requires: contents are pre-conditioned by pathlib.Path() 54 | Returns: (mdIndex, csvIndex, othersIndex, folderIndex, folderTree) 55 | """ 56 | 57 | ## index the directory structure 58 | folderIndex = [] 59 | folderTree = [] 60 | 61 | for line in enumerate(contents): 62 | if path.isdir(line[1]): 63 | folderIndex.append(line[0]) #save index 64 | folderTree.append(line[1]) 65 | ## Case: directories are implicit 66 | if not folderIndex: 67 | Tree = list(set([path.dirname(x) for x in contents])) 68 | [folderTree.append(Path(l)) for l in Tree] 69 | 70 | 71 | ## Index the .md files 72 | mdIndex = [] 73 | for line in enumerate(contents): 74 | if line[1].suffix == ".md": 75 | mdIndex.append(line[0]) #save index 76 | 77 | 78 | ## Index the .csv files 79 | csvIndex = [] 80 | for line in enumerate(contents): 81 | if line[1].suffix == ".csv": 82 | csvIndex.append(line[0]) #save index 83 | 84 | 85 | ## index the other files using set difference 86 | othersIndex = list(set(range(0,len(contents))) 87 | - (set(folderIndex)|set(mdIndex)|set(csvIndex))) 88 | 89 | return mdIndex, csvIndex, othersIndex, folderIndex, folderTree 90 | 91 | 92 | def N2Ocsv(csvFile): 93 | 94 | # Convert csv to dictionary object 95 | reader = DictReader(TextIOWrapper(csvFile, "utf-8-sig"), delimiter=',', quotechar='"') 96 | 97 | dictionry = {} 98 | for row in reader: # I don't know how this works but it does what I want 99 | for column, value in row.items(): 100 | dictionry.setdefault(column, []).append(value) 101 | 102 | IntLinks = list(dictionry.keys())[0] # Only want 1st column header 103 | oldTitle = dictionry.get(IntLinks) 104 | 105 | Title = [] 106 | mdTitle = [] 107 | 108 | # Clean Internal Links 109 | regexURLid = compile("(?:https?|ftp):\/\/") 110 | 111 | # Clean symbol invalid window path < > : " / \ | ? * 112 | regexSymbols = compile("[<>?:/\|*\"]") 113 | regexSpaces = compile("\s+") 114 | 115 | for line in oldTitle: 116 | line = line.rstrip() 117 | #1 Replace URL identifiers and/or symbols with a space 118 | line = regexURLid.sub(" ",line) 119 | line = regexSymbols.sub(" ",line) 120 | #2 Remove duplicate spaces 121 | line = regexSpaces.sub(" ", line) 122 | #3 Remove any spaces at beginning 123 | line = line.lstrip() 124 | #4 Cut title at 50 characters 125 | line = str(line) 126 | #5 Remove any spaces at end 127 | line = line.rstrip() 128 | if line: 129 | Title.append(line) 130 | 131 | ## convert Titles to [[internal link]] 132 | for line in Title: 133 | mdTitle.append("[["+line+"]] ") 134 | 135 | return mdTitle 136 | 137 | 138 | def convertBlankLink(line): 139 | # converts Notion about:blank links (found by regex) to Obsidian pretty links 140 | 141 | regexSymbols = compile("[^\w\s]") 142 | regexSpaces = compile("\s+") 143 | num_matchs = 0 144 | # about:blank links (lost or missing links within Notion) 145 | ## Group1:Pretty Link Title 146 | regexBlankLink = compile("\[(.[^\[\]\(\)]*)\]\(about:blank#.[^\[\]\(\)]*\)") 147 | matchBlank = regexBlankLink.search(line) 148 | if matchBlank: 149 | 150 | InternalTitle = matchBlank.group(1) 151 | 152 | # Replace symbols with space 153 | InternalLink = regexSymbols.sub(" ",InternalTitle) 154 | 155 | # Remove duplicate spaces 156 | InternalLink = regexSpaces.sub( " ", InternalLink) 157 | 158 | # Remove any spaces at beginning 159 | InternalLink = InternalLink.lstrip() 160 | 161 | # Cut title at 50 characters 162 | InternalLink = InternalLink[0:50] 163 | 164 | # Remove any spaces at end 165 | InternalLink = InternalLink.rstrip() 166 | 167 | # Reconstruct Internal Links as pretty links 168 | PrettyLink = "[["+InternalLink+"]] " 169 | 170 | line, num_matchs = regexBlankLink.subn(PrettyLink, line) 171 | if num_matchs > 1: 172 | print(f"Warning: {line} replaced {num_matchs} matchs!!") 173 | 174 | return line, num_matchs 175 | 176 | def embedded_link_convert(line): 177 | ''' 178 | This internal links combine: 179 | - Link to local page 180 | - External notion page 181 | - Link to Database ~ exported *.csv file 182 | - png in notion 183 | ''' 184 | 185 | # folder style links 186 | #regexPath = compile("^\[(.+)\]\(([^\(]*)(?:\.md|\.csv)\)$") # Overlap incase multiple links in same line 187 | #regexRelativePathImage = compile("(?:\.png|\.jpg|\.gif|\.bmp|\.jpeg|\.svg)") 188 | 189 | regexPath = compile("!\[(.*?)\]\((.*?)\)") 190 | regex20 = compile("%20") 191 | 192 | num_matchs = 0 193 | # Identify and group relative paths 194 | # While for incase multiple match on single line 195 | pathMatch = regexPath.search(line) 196 | if pathMatch: 197 | # modify paths into local links. just remove UID and convert spaces 198 | Title = pathMatch.group(1) 199 | relativePath = pathMatch.group(2) 200 | #is_image = regexRelativePathImage.search(relativePath) 201 | 202 | regexSpecialUtf8 = compile("%([A-F0-9][A-F0-9])%([A-F0-9][A-F0-9])%([A-F0-9][A-F0-9])") 203 | regexutf8 = compile("%([A-F0-9][A-F0-9])%([A-F0-9][A-F0-9])") 204 | regexUID = compile("%20\w{32}") 205 | 206 | relativePath = str_forbid_char_remove(relativePath) 207 | relativePath = regexUID.sub("", relativePath) 208 | relativePath = str_space_utf8_replace(relativePath) 209 | 210 | utf8_match = regexutf8.search(relativePath) 211 | while utf8_match: 212 | is_special_utf8 = False 213 | utf8_match = regexutf8.search(relativePath) 214 | if utf8_match: 215 | byte_1 = "0x" + utf8_match.group(1) 216 | byte_2 = "0x" + utf8_match.group(2) 217 | 218 | if (byte_1[0:3] == "0xE") and (byte_1[3] in ['1', '2', '3', '4', '5', '6']): 219 | 220 | special_utf8_match = regexSpecialUtf8.search(relativePath) 221 | byte_3 = "0x" + special_utf8_match.group(3) 222 | bytes_unicode = bytes([int(byte_1,0), int(byte_2,0), int(byte_3,0)]) 223 | is_special_utf8 = True 224 | else: 225 | bytes_unicode = bytes([int(byte_1,0), int(byte_2,0)]) 226 | 227 | try: 228 | unicode_str = str(bytes_unicode, 'utf-8') 229 | except: 230 | print("ERROR: convert unicode failed") 231 | print(f" {bytes_unicode} in - {line}") 232 | break 233 | 234 | if is_special_utf8: 235 | relativePath = regexSpecialUtf8.sub(unicode_str, relativePath, 1) 236 | else: 237 | relativePath = regexutf8.sub(unicode_str, relativePath, 1) 238 | 239 | line, num_matchs = regexPath.subn("[["+relativePath+"]]", line) 240 | 241 | if num_matchs > 1: 242 | print(f"Warning: {line} replaced {num_matchs} matchs!!") 243 | 244 | return line, num_matchs 245 | 246 | 247 | def internal_link_convert(line): 248 | ''' 249 | This internal links combine: 250 | - Link to local page 251 | - External notion page 252 | - Link to Database ~ exported *.csv file 253 | - png in notion 254 | ''' 255 | 256 | # folder style links 257 | #regexPath = compile("^\[(.+)\]\(([^\(]*)(?:\.md|\.csv)\)$") # Overlap incase multiple links in same line 258 | regexPath = compile("\[(.*?)\]\((.*?)\)") 259 | regex20 = compile("%20") 260 | regexRelativePathNotion = compile("https:\/\/www\.notion\.so") 261 | regexRelativePathMdCsv = compile("(?:\.md|\.csv)") 262 | regexRelativePathImage = compile("(?:\.png|\.jpg|\.gif|\.bmp|\.jpeg|\.svg)") 263 | regexSlash = compile("\/") 264 | 265 | num_matchs = 0 266 | # Identify and group relative paths 267 | # While for incase multiple match on single line 268 | pathMatch = regexPath.search(line) 269 | if pathMatch: 270 | # modify paths into local links. just remove UID and convert spaces 271 | # Title = pathMatch.group(1) 272 | relativePath = pathMatch.group(2) 273 | notionMatch = regexRelativePathNotion.search(relativePath) 274 | is_md_or_csv = regexRelativePathMdCsv.search(relativePath) 275 | is_image = regexRelativePathImage.search(relativePath) 276 | 277 | if is_md_or_csv or notionMatch: 278 | # Replace all matchs 279 | # line = regexPath.sub("[["++"]]", line) 280 | line, num_matchs = regexPath.subn("[["+'\\1'''+"]]", line) 281 | 282 | regexMarkdownLink = compile("\[\[(.*?)\]\]") 283 | markdownLinkMatch = regexMarkdownLink.search(line) 284 | if markdownLinkMatch: 285 | title = markdownLinkMatch.group(1) 286 | title = str_notion_uid_remove(title) 287 | title = str_space_utf8_replace(title) 288 | title = str_forbid_char_remove(title) 289 | title = str_slash_char_remove(title) 290 | 291 | if title != markdownLinkMatch.group(1): 292 | print(line) 293 | line = regexMarkdownLink.sub("[["+title+"]]", line) 294 | print(f" remove forbid {line}\n") 295 | 296 | return line, num_matchs 297 | 298 | 299 | def feature_tags_convert(line): 300 | 301 | # Convert tags after lines starting with "Tags:" 302 | regexTags = "^Tags:\s(.+)" 303 | 304 | # Search for Internal Links. Will give match.group(1) & match.group(2) 305 | tagMatch = search(regexTags,line) 306 | 307 | Otags = [] 308 | num_tag = 0 309 | if tagMatch: 310 | Ntags = tagMatch.group(1).split(",") 311 | for t in enumerate(Ntags): 312 | Otags.append("#"+t[1].strip()) 313 | num_tag += 1 314 | line = "Tags: "+", ".join(Otags) 315 | 316 | return line, num_tag 317 | 318 | 319 | def N2Omd(mdFile): 320 | 321 | newLines = [] 322 | em_link_cnt = 0 323 | in_link_cnt = 0 324 | bl_link_cnt = 0 325 | tags_cnt = 0 326 | 327 | for line in mdFile: 328 | 329 | line = line.decode("utf-8").rstrip() 330 | 331 | line, cnt = embedded_link_convert(line) 332 | em_link_cnt += cnt 333 | 334 | line, cnt = internal_link_convert(line) 335 | in_link_cnt += cnt 336 | 337 | line, cnt = convertBlankLink(line) 338 | bl_link_cnt += cnt 339 | 340 | line, cnt = feature_tags_convert(line) 341 | tags_cnt += cnt 342 | 343 | newLines.append(line) 344 | 345 | 346 | 347 | return newLines, [in_link_cnt, em_link_cnt, bl_link_cnt,tags_cnt] 348 | 349 | 350 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion-2-Obsidian 2 | 3 | For those considering switching from [Notion](https://www.notion.so/) to [Obsidian](https://obsidian.md/), here is a Python 3 script that converts your Notion export into an Obsidian friendly format. 4 | 5 | When you run the N2O.py script, it will: 6 | 1. Launch an Open-File dialogue where you'll navigate to your Notion-Export.zip file 7 | 2. Convert all Internal Links in your Notion pages to an Obsidian friendly markdown format 8 | 3. Repackages all the files into a new zip archive that is Obsidian vault compatible 9 | 10 | The script will leave your orginal Notion archive unmodified. 11 | 12 | The resulting archive can be extracted and opened as, or added to, an Obsidian vault. 13 | 14 | ## The Problem with your Notion Export 15 | Out of the box, the export files that Notion provides do not migrate to Obsidian very well. All external links will work, but: 16 | 17 | - The hierarchical structure of your pages can only be navigated using Obsidian’s file explorer. 18 | - None of the internal navigation links work, which also means there won’t be any backlinks or connections in Obsidian's Graph View. 19 | - None of the content in your Notion tables will be viewable. 20 | - Embedded images also won’t show. 21 | 22 | All of this is remedied by this script. Note however, that Notion comments do NOT appear to be included in their export files. 23 | 24 | ## Methodology 25 | 26 | If you're interested, the full sequence of modifications needed to make your Notion export compatible with Obsidian can be found in the write-up found in the [Methodology.md](METHODOLOGY.md) file in this git. 27 | 28 | # Supporting the Work 29 | 30 | I’m happy to offer you this script and the conversion methodology. If you're able and inclined, a donation for the convenience and time savings would be genuinely appreciated. There's a couple donation links at the bottom of this page. 31 | 32 | I estimate that anyone using the [Methodology.md](METHODOLOGY.md) can convert their Notion export in a day or less of work. Without this guide, it would likely take several days of troubleshooting. If you’re a confident programmer, it may take you just a couple hours with the guide. I encourage everyone to go through the process. It is satisfying. 33 | 34 | However, if your time is worth more spent elsewhere. Feel free to use the code and switch to Obsidian in mere seconds! 35 | 36 | # Export Your Full Notion Database 37 | If you haven't already, you'll need to export your content from Notion. 38 | 39 | 1. From your Notion app, click the **Settings & Members** tab in the sidebar 40 | ![Settings&Members](media/export1.png) 41 | 2. Find and click the **Settings** tab. Find the **Export content** section. Click the **Export all workspace content** button 42 | ![Settings](media/export2.png) 43 | 3. Select **Markdown & CSV** as Export Format and click the **Export** button 44 | ![Export](media/export3.png) 45 | 4. Save the resulting .zip file to your computer 46 | 5. Extract the .zip contents to a known location 47 | 48 | # Run the N2O.py Script 49 | - Make sure `N2O.py` and `N2Omodule.py` are in the same directory. 50 | - Run `Python3 N2O.py` 51 | - Use the Open-File dialog that pops up to navigate to your NotionExport.zip file. 52 | - When the script finishes you'll find a new zip file in the same directory that's ready for Obsidian. 53 | 54 | # Importing or Integrating into Obsidian 55 | 56 | Time to import everything into Obsidian 57 | 58 | 1. Place all the converted files into a directory of your choosing 59 | 2. Open Obsidian and click the Vault Icon ![vault icon](media/vaulticon.png) 60 | 3. Select **Open folder as vault** 61 | ![open vault](media/vault.png) 62 | 4. Use the Select Folder window to navigate to the directory with your newly converted files 63 | 64 | Enjoy the shift to Obsidian! 65 | 66 | # Donation Links 67 | If the instructions or code have been useful for you, please consider the time you've saved and treat me to half a lunch or so :) My hole-in-the-bucket Covid-19 era income would greatly appreciate it. 68 | 69 | Here are some donation links for me: 70 | * PayPal: https://www.paypal.me/GabrielKrause 71 | * Venmo: @Gabriel-Krause 72 | * Etherium: 0xeAE10E05427845aE816E61605eCC779A2d5e59A4 73 | -------------------------------------------------------------------------------- /media/export1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/visualcurrent/Notion-2-Obsidan/a2af4a49c4698593988798c4a05bfd18e3e6dccc/media/export1.png -------------------------------------------------------------------------------- /media/export2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/visualcurrent/Notion-2-Obsidan/a2af4a49c4698593988798c4a05bfd18e3e6dccc/media/export2.png -------------------------------------------------------------------------------- /media/export3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/visualcurrent/Notion-2-Obsidan/a2af4a49c4698593988798c4a05bfd18e3e6dccc/media/export3.png -------------------------------------------------------------------------------- /media/vault.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/visualcurrent/Notion-2-Obsidan/a2af4a49c4698593988798c4a05bfd18e3e6dccc/media/vault.png -------------------------------------------------------------------------------- /media/vaulticon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/visualcurrent/Notion-2-Obsidan/a2af4a49c4698593988798c4a05bfd18e3e6dccc/media/vaulticon.png --------------------------------------------------------------------------------