Skip to content

Commit db6b809

Browse files
committed
Add first public version of the script
1 parent 5d0eea7 commit db6b809

File tree

5 files changed

+398
-0
lines changed

5 files changed

+398
-0
lines changed

.gitignore

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# mypy
2+
.mypy_cache/
3+
.dmypy.json
4+
dmypy.json
5+
6+
# Pyre type checker
7+
.pyre/
8+
9+
config.ini
10+
image.png

README.md

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Google Keep -> Notion
2+
3+
Exports notes and lists from Google Keep and imports them into your Notion.
4+
5+
## Features
6+
7+
Supports exporting:
8+
9+
- Notes
10+
- TODO lists
11+
- Images and audio
12+
- Categorization via labels
13+
14+
## Installation
15+
16+
This script requires Python 3.9+, https://github.com/kiwiz/gkeepapi, https://github.com/jamalex/notion-py, and a couple more libraries to run. Install the pre-requisite libraries via [Pip3](https://pypi.org/project/pip/):
17+
18+
```
19+
pip3 install -r requirements.txt
20+
```
21+
22+
Optional: make the script executable:
23+
24+
```
25+
chmod +x gkeep2notion.py
26+
```
27+
28+
### Preventing "Authentication failed" on some systems
29+
30+
On some systems the authentication fails even with valid credentials. This may happen because of two reasons: either Google issues a CAPTCHA for your IP address, or SSL certificate validation fails.
31+
32+
To fix the CAPTCHA problem, try using the [Unlock CAPTCHA link](https://accounts.google.com/DisplayUnlockCaptcha) before retrying login.
33+
34+
To try fixing the SSL problem, revert to an older version of the following library:
35+
36+
```
37+
pip3 install requests==2.23.0
38+
```
39+
40+
## Configuration
41+
42+
Before use, copy _config.example.ini_ to _config.ini_ and edit its contents. See configuration explanation below:
43+
44+
```ini
45+
[gkeep]
46+
email=your_name@gmail.com # Your Google account
47+
import_notes=true # Set to false if you don't want to import notes
48+
import_todos=true # Set to false if you don't want to import TODO lists
49+
import_media=true # Set to false if you don't need to import images and audio
50+
51+
[notion]
52+
token=Copy it from Notion cookies: token_v2 # See documentation below
53+
root_url=https://notion.so/PAGE-ID Create a root url in your Notion # See documentation below
54+
```
55+
56+
### Obtaining Notion token
57+
58+
The importer needs to access your Notion account and it needs to know the root URL in which to import all the Google Keep contents.
59+
60+
To get the access token, you need to copy the value of `token_v2` cookie for https://notion.so website. Example actions for Google Chrome:
61+
62+
1. Log into your Notion account at https://notion.so
63+
1. Open Chrome Developer Tools and find _Application -> Cookies -> https://www.notion.so_
64+
1. Find `token_v2` in the variables list and copy the value. Paste the value to `token` in _config.ini_
65+
66+
### Configuring the root_url
67+
68+
This script imports all the content under a certain page in Notion that has to exist already. It is recommended to create a special page for the imported content, and then migrate it to your regular Notion structure from there.
69+
70+
Navigate to your import root in the Notion tree, and choose "Copy link" in the context menu. Paste that link to `root_url` in the _config.ini_.
71+
72+
## Usage
73+
74+
### Google authentication
75+
76+
The first time you run `gkeep2notion` it will ask for your Google Account's password to authenticate into your Google Keep account. After obtaining an authentication token, `gkeep2notion` saves it in your system's keyring. Next time you won't need to enter the password again.
77+
78+
### Import everything
79+
80+
_Note: export/import takes a considerable amount of time. Especially when working with notes containing media files. So you may want to try importing a subset of your records before importing everything._
81+
82+
By default gkeep2notion exports everything in your Google Keep account and imports it into Notion. It can be done as simple as:
83+
84+
```bash
85+
./gkeep2notion.py
86+
```
87+
88+
### Google Keep search query
89+
90+
You can use the search function built into Google Keep to import notes matching a search query:
91+
92+
```bash
93+
./gkeep2notion.py -q 'Orange apple'
94+
```
95+
96+
### Import specific labels
97+
98+
You can import notes and tasklists containing specific label(s) in Google Keep using the `-l` option.
99+
100+
An example with one label:
101+
102+
```bash
103+
./gkeep2notion.py -l cooking
104+
```
105+
106+
An example with multiple labels, comma separated:
107+
108+
```bash
109+
./gkeep2notion.py -l 'work, business, management'
110+
```
111+
112+
## Credits
113+
114+
This tool uses the [unofficial Google Keep API for Python](https://github.com/kiwiz/gkeepapi) by [kiwiz](https://github.com/kiwiz). Google Keep is of course a registered trademark of Google and neither the API nor this script are affiliated with Google, Inc.
115+
116+
Thanks to [jamalex](https://github.com/jamalex) for the [unofficial Notion Python API](https://github.com/jamalex/notion-py). Neither that API nor this script are affiliated with Notion. Notion is a trademark of Notion Labs, Inc.

config.example.ini

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[gkeep]
2+
email=your_name@gmail.com
3+
import_notes=true
4+
import_todos=true
5+
import_media=true
6+
7+
[notion]
8+
token=Copy it from Notion cookies: token_v2
9+
root_url=https://www.notion.so/PAGE-ID Create a root url in your Notion

gkeep2notion.py

+260
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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

Comments
 (0)