|
| 1 | +#!/usr/local/bin/python3 |
| 2 | +from argparse import ArgumentParser |
| 3 | +from configparser import ConfigParser |
| 4 | +import os |
| 5 | +import getpass |
| 6 | +import re |
| 7 | +import keyring |
| 8 | +import urllib.request |
| 9 | + |
| 10 | +from gkeepapi import Keep, node |
| 11 | +from notion.block import AudioBlock, BulletedListBlock, ImageBlock, NumberedListBlock, PageBlock, QuoteBlock, TextBlock, TodoBlock |
| 12 | +from notion.client import NotionClient |
| 13 | + |
| 14 | + |
| 15 | +class Config: |
| 16 | + def __init__(self, ini: ConfigParser): |
| 17 | + self.email = ini['gkeep']['email'] |
| 18 | + self.import_notes = ini['gkeep']['import_notes'].lower() == 'true' |
| 19 | + self.import_todos = ini['gkeep']['import_todos'].lower() == 'true' |
| 20 | + self.import_media = ini['gkeep']['import_media'].lower() == 'true' |
| 21 | + self.token = ini['notion']['token'] |
| 22 | + self.root_url = ini['notion']['root_url'] |
| 23 | + |
| 24 | + |
| 25 | +def get_config(path='config.ini') -> Config: |
| 26 | + if not os.path.isfile(path): |
| 27 | + print(f'Config file {path} not found') |
| 28 | + exit() |
| 29 | + |
| 30 | + ini = ConfigParser(interpolation=None, inline_comment_prefixes=('#', ';')) |
| 31 | + ini.read(path) |
| 32 | + |
| 33 | + return Config(ini) |
| 34 | + |
| 35 | + |
| 36 | +def authenticate(keep: Keep, email: str): |
| 37 | + print('Logging into Google Keep') |
| 38 | + password = getpass.getpass('Password: ') |
| 39 | + print('Authenticating, this may take a while...') |
| 40 | + try: |
| 41 | + keep.login(email, password) |
| 42 | + except Exception as e: |
| 43 | + print('Authentication failed') |
| 44 | + print(e) |
| 45 | + exit() |
| 46 | + |
| 47 | + # Save the auth token in keyring |
| 48 | + print('Authentication is successful, saving token in keyring') |
| 49 | + token = keep.getMasterToken() |
| 50 | + keyring.set_password('gkeep2notion', email, token) |
| 51 | + print('Token saved. Have fun with other commands!') |
| 52 | + |
| 53 | + |
| 54 | +def login(keep: Keep, email: str): |
| 55 | + print('Loading access token from keyring') |
| 56 | + token = keyring.get_password('gkeep2notion', email) |
| 57 | + if token: |
| 58 | + print('Authorization, this may take a while...') |
| 59 | + try: |
| 60 | + keep.resume(email, token) |
| 61 | + except: |
| 62 | + authenticate(keep, email) |
| 63 | + else: |
| 64 | + authenticate(keep, email) |
| 65 | + |
| 66 | + |
| 67 | +def downloadFile(url, path): |
| 68 | + urllib.request.urlretrieve(url, path) |
| 69 | + |
| 70 | + |
| 71 | +def renderUrls(text: str) -> str: |
| 72 | + return re.sub(r'(https?://[\w\-\.]+\.[a-z]+(/[\w_\.%\-&\?=/#]*)*)', r'[\1](\1)', text, flags=re.MULTILINE) |
| 73 | + |
| 74 | +# Supported block types: |
| 75 | +# - TextBlock |
| 76 | +# - BulletedListBlock: starting with - or * |
| 77 | +# - NumberedListBlock |
| 78 | +# - QuoteBlock: starting with > |
| 79 | + |
| 80 | + |
| 81 | +def parseBlock(p: str) -> dict: |
| 82 | + if re.match(r'^(\d+)\.', p): |
| 83 | + return { |
| 84 | + 'type': NumberedListBlock, |
| 85 | + 'title': p, |
| 86 | + } |
| 87 | + # TODO: support nested lists |
| 88 | + m = re.match(r'^\s*(\*|-)\s+(.+)', p) |
| 89 | + if m: |
| 90 | + return { |
| 91 | + 'type': BulletedListBlock, |
| 92 | + 'title': m.group(2), |
| 93 | + } |
| 94 | + m = re.match(r'^>\s+(.+)', p) |
| 95 | + if m: |
| 96 | + return { |
| 97 | + 'type': QuoteBlock, |
| 98 | + 'title': m.group(1) |
| 99 | + } |
| 100 | + return { |
| 101 | + 'type': TextBlock, |
| 102 | + 'title': p, |
| 103 | + } |
| 104 | + |
| 105 | + |
| 106 | +def parsePage(text: str) -> list[dict]: |
| 107 | + lines = text.splitlines() |
| 108 | + print(f"Parsing {len(lines)} blocks") |
| 109 | + return [parseBlock(p) for p in lines] |
| 110 | + |
| 111 | + |
| 112 | +def renderBlocks(page: PageBlock, blocks: list[dict]): |
| 113 | + for b in blocks: |
| 114 | + page.children.add_new(b['type'], title=b['title']) |
| 115 | + |
| 116 | + |
| 117 | +def getNoteCategories(note: node.TopLevelNode) -> list[str]: |
| 118 | + categories = [] |
| 119 | + for label in note.labels.all(): |
| 120 | + categories.append(label.name) |
| 121 | + return categories |
| 122 | + |
| 123 | + |
| 124 | +def importCategories(note: node.TopLevelNode, root: PageBlock, default: str, categories: dict[str, PageBlock]) -> PageBlock: |
| 125 | + # Extract categories |
| 126 | + rootName = root.title |
| 127 | + cats = getNoteCategories(note) |
| 128 | + |
| 129 | + # Use first category as the main (parent) |
| 130 | + if len(cats) == 0: |
| 131 | + parent = root |
| 132 | + else: |
| 133 | + parentName = cats[0] |
| 134 | + parentKey = f"{rootName}.{parentName}" |
| 135 | + if parentKey in categories: |
| 136 | + parent = categories[parentKey] |
| 137 | + else: |
| 138 | + parent = root.children.add_new(PageBlock, title=parentName) |
| 139 | + categories[parentKey] = parent |
| 140 | + cats = cats[1:] |
| 141 | + page: PageBlock = parent.children.add_new(PageBlock, title=note.title) |
| 142 | + |
| 143 | + # Insert to other categories as alias |
| 144 | + for catName in cats: |
| 145 | + catKey = f"{rootName}.{catName}" |
| 146 | + if catKey in categories: |
| 147 | + cat = categories[catKey] |
| 148 | + else: |
| 149 | + cat = root.children.add_new(PageBlock, title=catName) |
| 150 | + categories[catKey] = cat |
| 151 | + cat.children.add_alias(page) |
| 152 | + |
| 153 | + return page |
| 154 | + |
| 155 | + |
| 156 | +def parseNote(note: node.TopLevelNode, page: PageBlock, keep: Keep, config: Config): |
| 157 | + # TODO add background colors (currently unsupported by notion-py) |
| 158 | + # color = str(note.color)[len('ColorValue.'):].lower() |
| 159 | + # if color != 'default': |
| 160 | + # parent.background = color |
| 161 | + |
| 162 | + if config.import_media: |
| 163 | + # Images |
| 164 | + if len(note.images) > 0: |
| 165 | + for blob in note.images: |
| 166 | + print('Importing image ', blob.text) |
| 167 | + url = keep.getMediaLink(blob) |
| 168 | + downloadFile(url, 'image.png') |
| 169 | + img: ImageBlock = page.children.add_new( |
| 170 | + ImageBlock, title=blob.text) |
| 171 | + img.upload_file('image.png') |
| 172 | + |
| 173 | + # Audio |
| 174 | + if len(note.audio) > 0: |
| 175 | + for blob in note.audio: |
| 176 | + print('Importing audio ', blob.text) |
| 177 | + url = keep.getMediaLink(blob) |
| 178 | + downloadFile(url, 'audio.mp3') |
| 179 | + img: AudioBlock = page.children.add_new( |
| 180 | + AudioBlock, title=blob.text) |
| 181 | + img.upload_file('audio.mp3') |
| 182 | + |
| 183 | + # Text |
| 184 | + text = note.text |
| 185 | + # Render URLs |
| 186 | + text = renderUrls(text) |
| 187 | + # Render page blocks |
| 188 | + blocks = parsePage(text) |
| 189 | + renderBlocks(page, blocks) |
| 190 | + |
| 191 | + |
| 192 | +def parseList(list: node.List, page: PageBlock): |
| 193 | + item: node.ListItem |
| 194 | + for item in list.items: # type: node.ListItem |
| 195 | + page.children.add_new(TodoBlock, title=item.text, checked=item.checked) |
| 196 | + |
| 197 | + |
| 198 | +argparser = ArgumentParser( |
| 199 | + description='Export from Google Keep and import to Notion') |
| 200 | +argparser.add_argument('-l', '--labels', type=str, |
| 201 | + help='Search by labels, comma separated') |
| 202 | +argparser.add_argument('-q', '--query', type=str, help='Search by title query') |
| 203 | + |
| 204 | +args = argparser.parse_args() |
| 205 | + |
| 206 | +config = get_config() |
| 207 | + |
| 208 | +keep = Keep() |
| 209 | +login(keep, config.email) |
| 210 | + |
| 211 | +print('Logging into Notion') |
| 212 | +client = NotionClient(token_v2=config.token) |
| 213 | + |
| 214 | +root = client.get_block(config.root_url) |
| 215 | + |
| 216 | +notes = root.children.add_new(PageBlock, title='Notes') |
| 217 | +todos = root.children.add_new(PageBlock, title='TODOs') |
| 218 | + |
| 219 | +categories = { |
| 220 | +} |
| 221 | + |
| 222 | +glabels = [] |
| 223 | +if args.labels: |
| 224 | + labels = args.labels.split(',') |
| 225 | + labels = [l.strip() for l in labels] |
| 226 | + labels = list(filter(lambda l: l != '', labels)) |
| 227 | + for l in labels: |
| 228 | + glabel = keep.findLabel(l) |
| 229 | + glabels.append(glabel) |
| 230 | + |
| 231 | +query = '' |
| 232 | +if args.query: |
| 233 | + query = args.query.strip() |
| 234 | + |
| 235 | +gnotes = [] |
| 236 | +if len(glabels) > 0: |
| 237 | + gnotes = keep.find(labels=glabels) |
| 238 | +elif len(query) > 0: |
| 239 | + gnotes = keep.find(query=query) |
| 240 | +else: |
| 241 | + gnotes = keep.all() |
| 242 | + |
| 243 | +i = 0 |
| 244 | +for gnote in gnotes: |
| 245 | + i += 1 |
| 246 | + if isinstance(gnote, node.List): |
| 247 | + if not config.import_todos: |
| 248 | + continue |
| 249 | + print(f'Importing TODO #{i}: {gnote.title}') |
| 250 | + page = importCategories(gnote, todos, 'TODOs', categories) |
| 251 | + parseList(gnote, page) |
| 252 | + else: |
| 253 | + if not config.import_notes: |
| 254 | + continue |
| 255 | + print(f'Importing note #{i}: {gnote.title}') |
| 256 | + page = importCategories(gnote, notes, 'Notes', categories) |
| 257 | + parseNote(gnote, page, keep, config) |
| 258 | + |
| 259 | + if i == 12: |
| 260 | + break |
0 commit comments