├── LICENSE ├── README.md ├── r2o.py └── requirements.txt /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rene Schallner 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 | # rj2obs - Roam JSON To Obsidian Converter 2 | 3 | Converts Roam JSON export to Obsidian Markdown files. 4 | 5 | I wrote this to convert my own roam export into an Obsidian friendly format. 6 | 7 | Output has the following directory structure: 8 | 9 | * `md/` : contains all normal Markdown files 10 | * `md/daily/`: contains all daily notes 11 | 12 | ### Features: 13 | 14 | * Daily note format is changed to YYYY-MM-DD 15 | * roam's block IDs are appended (only) to blocks actually referenced 16 | * e.g. `* this block gets referenced ^someroamid` 17 | * Blockrefs, block mentions, block embeds are replaced by their content with an appended Obsidian blockref link 18 | * e.g. `this block gets referenced [[orignote#^someblockid]]` 19 | * All notes are prefixed with a yaml header containing title and creation date: 20 | ```yaml 21 | --- 22 | title: 23 | created: YYYY-MM-DD 24 | --- 25 | 26 | ``` 27 | 28 | * Top level roam blocks that don't contain children are not formatted as list 29 | * Roam blocks containing linebreaks are broken down into multiple bullets 30 | * roam: 31 | ```markdown 32 | 33 | * line 1 34 | line 2 35 | * next block 36 | ``` 37 | * 38 | * becomes: 39 | ```markdown 40 | * line 1 41 | * line 2 42 | 43 | * next block 44 | ``` 45 | 46 | **Note:** Please run Obsidian's Markdown importer after this conversion. It will fix #tag links and formattings (todo syntax, highlights, etc). 47 | 48 | I might make it more user friendly and less hardcoded later. It did the job, though. 49 | 50 | # Install 51 | No need to install. But you need python3. Google is your friend. 52 | 53 | To install the required python packages: 54 | 55 | ```bash 56 | pip install -r requirements.txt 57 | ``` 58 | 59 | # Usage: 60 | ```bash 61 | python r2o.py my-roam-export.json 62 | ``` 63 | 64 | -------------------------------------------------------------------------------- /r2o.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import json 4 | from tqdm import tqdm 5 | import re 6 | from dateutil.parser import parse 7 | from datetime import datetime 8 | 9 | yaml = '''--- 10 | title: {title} 11 | created: {created} 12 | --- 13 | 14 | ''' 15 | 16 | re_daily = re.compile(r'(January|February|March|April|May|June|July|August|September|October|November|December) ([0-9]+)[a-z]{2}, ([0-9]{4})') 17 | re_daylink = re.compile(r'(\[\[)([January|February|March|April|May|June|July|August|September|October|November|December [0-9]+[a-z]{2}, [0-9]{4})(\]\])') 18 | re_blockmentions = re.compile(r'({{mentions: \(\()(.{9})(\)\)}})') 19 | re_blockembed = re.compile(r'({{embed: \(\()(.{9})(\)\)}})') 20 | re_blockref = re.compile(r'(\(\()(.{9})(\)\))') 21 | 22 | def scan(jdict, page): 23 | u2b = {jdict['uid']: jdict} 24 | for child in jdict.get('children', []): 25 | child['page'] = page 26 | u2b.update(scan(child, page)) 27 | return u2b 28 | 29 | 30 | def replace_daylinks(s): 31 | new_s = s 32 | while True: 33 | m = re_daylink.search(new_s) 34 | if not m: 35 | break 36 | else: 37 | head = new_s[:m.end(1)] 38 | dt = parse(m.group(2)) 39 | replacement = dt.isoformat()[:10] 40 | tail = ']]' + new_s[m.end(0):] 41 | new_s = head + replacement + tail 42 | return new_s 43 | 44 | 45 | def replace_blockrefs(s, uid2block, referenced_uids): 46 | new_s = s 47 | while True: 48 | m = re_blockembed.search(new_s) 49 | if m is None: 50 | m = re_blockmentions.search(new_s) 51 | if m is None: 52 | m = re_blockref.search(new_s) 53 | if m is None: 54 | break 55 | else: 56 | uid = m.group(2) 57 | if uid not in uid2block: 58 | print('************** uid not found:', uid) 59 | else: 60 | referenced_uids.add(uid) 61 | head = new_s[:m.start(1)] 62 | r_block = uid2block[uid] 63 | # shall we replace with the text or the link or both 64 | replacement = r_block['string'] 65 | replacement += f' [[{r_block["page"]["title"]}#^{r_block["uid"]}]]' 66 | tail = new_s[m.end(3):] 67 | new_s = head + replacement + tail 68 | return replace_daylinks(new_s) 69 | 70 | 71 | def expand_children(block, uid2block, referenced_uids, level=0): 72 | lines = [] 73 | for b in block.get('children', []): 74 | prefix = '' 75 | if level >= 1: 76 | prefix = ' ' * level 77 | s = b['string'] 78 | children = b.get('children', None) 79 | 80 | headinglevel = b.get('heading', None) 81 | if headinglevel is not None: 82 | prefix = '#' * (headinglevel) + ' ' + prefix 83 | 84 | if children is None and level == 0: 85 | pass 86 | else: 87 | prefix += '* ' 88 | 89 | uid = b['uid'] 90 | if uid in referenced_uids: 91 | postfix = f' ^{uid}' 92 | else: 93 | postfix = '' 94 | 95 | # b id magic 96 | s = prefix + replace_blockrefs(s, uid2block, referenced_uids) + postfix 97 | if '\n' in s: 98 | new_s = s[:-1] 99 | new_s = new_s.replace('\n', '\n'+prefix) 100 | new_s += s[-1] 101 | s = new_s + '\n' 102 | 103 | lines.append(s) 104 | lines.extend(expand_children(b, uid2block, referenced_uids, level + 1)) 105 | return lines 106 | 107 | 108 | j = json.load(open(sys.argv[1], mode='rt', encoding='utf-8', errors='ignore')) 109 | 110 | odir = 'md' 111 | ddir = 'md/daily' 112 | os.makedirs(ddir, exist_ok=True) 113 | 114 | print('Pass 1: scan all pages') 115 | 116 | uid2block = {} 117 | referenced_uids = set() 118 | pages = [] 119 | 120 | for page in tqdm(j): 121 | title = page['title'] 122 | created = page.get('create-time', page['edit-time']) 123 | created = datetime.fromtimestamp(created/1000).isoformat()[:10] 124 | children = page.get('children', []) 125 | 126 | is_daily = False 127 | m = re_daily.match(title) 128 | if m: 129 | is_daily = True 130 | dt = parse(title) 131 | title = dt.isoformat().split('T')[0] 132 | 133 | page = { 134 | 'uid': None, 135 | 'title': title, 136 | 'created': created, 137 | 'children': children, 138 | 'daily': is_daily, 139 | } 140 | uid2block.update(scan(page, page)) 141 | pages.append(page) 142 | 143 | print('Pass 2: track blockrefs') 144 | for p in tqdm(pages): 145 | expand_children(p, uid2block, referenced_uids) 146 | 147 | print('Pass 3: generate') 148 | error_pages = [] 149 | for p in tqdm(pages): 150 | title = p['title'] 151 | if not title: 152 | continue 153 | ofiln = f'{odir}/{p["title"]}.md' 154 | if p['daily']: 155 | ofiln = f'{ddir}/{p["title"]}.md' 156 | 157 | # hack for crazy slashes in titles 158 | if '/' in title: 159 | d = odir 160 | for part in title.split('/')[:-1]: 161 | d = os.path.join(d, part) 162 | os.makedirs(d, exist_ok=True) 163 | 164 | lines = expand_children(p, uid2block, referenced_uids) 165 | try: 166 | with open(ofiln, mode='wt', encoding='utf-8') as f: 167 | f.write(yaml.format(**p)) 168 | f.write('\n'.join(lines)) 169 | except: 170 | error_pages.append({'page':p, 'content': lines}) 171 | 172 | if error_pages: 173 | print('The following pages had errors:') 174 | for ep in error_pages: 175 | p = ep['page'] 176 | t = p['title'] 177 | c = ep['content'] 178 | print(f'Title: >{t}<') 179 | print(f'Content:') 180 | print(' ' + '\n '.join(c)) 181 | print('Done!') 182 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dateutil==2.8.0 2 | tqdm==4.36.1 3 | --------------------------------------------------------------------------------