Skip to content

Instantly share code, notes, and snippets.

@jhonasn
Last active May 30, 2026 13:56
Show Gist options
  • Select an option

  • Save jhonasn/479f28360041834a064163352d06e9fc to your computer and use it in GitHub Desktop.

Select an option

Save jhonasn/479f28360041834a064163352d06e9fc to your computer and use it in GitHub Desktop.
A script to convert google keep notes to a bunch markdown files aiming mainly to nextcloud notes
#!/usr/bin/env python3
# from pdb import set_trace as bkp
from sys import argv, exit
from os import listdir, path, mkdir, utime
from json import loads as to_dict
from datetime import datetime as date
if '-h' in argv or '--help' in argv:
print('''
The commands will print those informations on notes:
-a all
-p pinned
-c note color
-l labels (it will print only when there's more than one)
-le last edit date
-s people who the note is shared with
None will be printed by default
''')
exit()
is_printing = {
'pinned': '-a' in argv or '-p' in argv,
'color': '-a' in argv or '-c' in argv,
'labels': '-a' in argv or '-l' in argv,
'last-edit': '-a' in argv or '-c' in argv,
'shared': '-a' in argv or '-c' in argv,
'none': len(argv) == 1
}
keep_dir = './Keep'
labels_file = 'Labels.txt'
nextcloud_dir = './nextcloud-notes'
if not path.exists(keep_dir):
print('"Keep" folder not found. Place this file on the same folder as "Keep" backup folder')
exit()
# get labels to create folders with the labels names
with open(path.join(keep_dir, labels_file), 'a+') as f:
f.seek(0)
labels = f.read().split('\n')
if len(labels): labels.pop()
labels.append('trash')
labels.append('archived')
# read files and separate the note jsons from attachments
files = listdir(keep_dir)
notes = list(filter(lambda f: '.json' in f, files))
attachments = list(filter(lambda f: '.json' not in f and '.html' not in f and f != labels_file, files))
print('{} notes found\n\n'.format(len(notes)))
def fix_filename(filename):
new_name = filename
for char in ['<', '>', ':', '"', '/', '\\', '|', '?', '*']:
new_name = new_name.replace(char, '-')
return new_name
# create nextcloud and label folders
if not path.exists(nextcloud_dir):
mkdir(nextcloud_dir)
print('created nextcloud folder')
for label in labels:
label_dir = path.join(nextcloud_dir, fix_filename(label))
if not path.exists(label_dir):
mkdir(label_dir)
print('created "{}" folder inside nextcloud folder'.format(label_dir))
# create notes
for note_file in notes:
# if note_file == 'file-to-debug.json': bkp()
print('processing note "{}"'.format(note_file))
note = to_dict(open(path.join(keep_dir, note_file), encoding='utf-8').read())
text = ''
note_labels = []
# simplify labels array
if 'labels' in note:
for l in note['labels']:
note_labels.append(l['name'])
# create note body
if note['title']: text += '# ' + note['title'] + '\n'
if is_printing['pinned'] and note['isPinned']: text += '**PINNED**\n\n'
elif note['title']: text += '\n'
#
# create note content
#
if 'textContent' in note: text += note['textContent']
elif 'listContent' in note:
# print MD list
for item in note['listContent']:
text += '- [{}] {}\n'.format('x' if item['isChecked'] else ' ', item['text'])
# attachments
if 'attachments' in note:
for a in note['attachments']:
text += '![]({})\n'.format(a['filePath'])
is_space_added = False
# add space before last information if it isn't added yet
def add_space():
global is_space_added
global text
if not is_space_added:
text += '\n'
is_space_added = True
# print color
if is_printing['color'] and ('color' in note and note['color'] != 'DEFAULT'):
add_space()
text += '\ncolor: ' + note['color']
# print label when it has more than one
if is_printing['labels'] and len(note_labels) > 1:
add_space()
text += '\nlabels: ' + str(note_labels)[1:len(str(note_labels))-1].replace('\'', '')
# print last edit time
if is_printing['last-edit']:
add_space()
timestamp = int(int(note['userEditedTimestampUsec']) / 1000 / 1000)
date_str = date.utcfromtimestamp(timestamp).strftime('%d %B %Y %H:%M:%S')
text += '\nlast edit: {}'.format(date_str)
# print if is shared
if is_printing['shared'] and 'sharees' in note:
add_space()
text += 'shared with: '
for u in note['sharees']:
text += '{} {}'.format(u['email'], 'is the owner\n' if u['isOwner'] else '')
#
# end note content creation
#
# decide path
path_name = ''
if len(note_labels): path_name = fix_filename(note_labels[0])
if note['isArchived']: path_name = 'archived'
elif note['isTrashed']: path_name = 'trash'
# create note
fname = '{}.{}'.format(note_file[0:len(note_file)-5], 'md')
with open(path.join(nextcloud_dir, path_name, fname), 'w', encoding='utf-8') as f:
# use the original modification time for the new file
original_mod_time = path.getmtime(path.join(keep_dir, note_file))
utime(path.join(nextcloud_dir, path_name, fname), (original_mod_time, original_mod_time))
f.write(text)
print('note created inside "{}" folder\n'.format(path_name or 'nextcloud'))
print('\nAll notes converted and placed into {} folder'.format(nextcloud_dir))
@Mageek627

Mageek627 commented Nov 26, 2020

Copy link
Copy Markdown

This is very useful! Thanks a lot.

I would suggest a small improvement, in my case I didn't have any label, and so the file "Labels.txt" didn't exist, which resulted in "FileNotFoundError". I think line 41 should be replaced by:

with open(path.join(keep_dir, labels_file), 'a+') as f:
  f.seek(0)
  labels = f.read().split('\n')

Or alternatively, replace line 41 and 42 with:

try:
  labels = open(path.join(keep_dir, labels_file)).read().split('\n')
  labels.pop()
except:
  labels = []

I didn't thoroughly test it, but it seems to work fine like that.

@jhonasn

jhonasn commented Dec 7, 2020

Copy link
Copy Markdown
Author

Hello Mageek627 thanks for the suggestions, I'm very happy that this was useful for someone!

I will pick your first suggestion, I'm not using the script in the moment too so i will change it and commit for the next one who will use it, if there's a problem i hope the people notify me.

Thanks man!

@cidetraq

Copy link
Copy Markdown

Just wanted to say thanks for providing this. Seems to have worked perfectly to convert, now I just need to import the resulting files into nextcloud.

@3ceepio

3ceepio commented Jun 14, 2021

Copy link
Copy Markdown

Many thanks for this, very useful. I hit errors to do with Unicode character mapping e.g.:

UnicodeEncodeError: 'charmap' codec can't encode character '\u200b' in position 1: character maps to <undefined>

So I changed line 149 from:

f = open(path.join(nextcloud_dir, path_name, fname), 'w')

to:

f = open(path.join(nextcloud_dir, path_name, fname), 'w', encoding="utf-8")

I know, hardcoding character for an environment is wrong but I figured this would be okay for a one-off conversion.

@jhonasn

jhonasn commented Jun 25, 2021

Copy link
Copy Markdown
Author

Just wanted to say thanks for providing this. Seems to have worked perfectly to convert, now I just need to import the resulting files into nextcloud.

I'm very glad that this had helped you! :)

@jhonasn

jhonasn commented Jun 25, 2021

Copy link
Copy Markdown
Author

Many thanks for this, very useful. I hit errors to do with Unicode character mapping e.g.:

UnicodeEncodeError: 'charmap' codec can't encode character '\u200b' in position 1: character maps to <undefined>

So I changed line 149 from:

f = open(path.join(nextcloud_dir, path_name, fname), 'w')

to:

f = open(path.join(nextcloud_dir, path_name, fname), 'w', encoding="utf-8")

I know, hardcoding character for an environment is wrong but I figured this would be okay for a one-off conversion.

thanks for the suggestion, i'll implement here, i'm very glad that this helped you!

@naudster

Copy link
Copy Markdown

Thanks for this useful script.

This small change sets the file modification time so it matches the original Google file. I find this helpful as then the Notes app can sort by date.

Line 5:

from os import listdir, path, mkdir, utime

Insert at line 153:

# use the original modification time for the new file
original_mod_time = path.getmtime(path.join(keep_dir, note_file))
utime(path.join(nextcloud_dir, path_name, fname), (original_mod_time, original_mod_time))

@jhonasn

jhonasn commented Oct 19, 2021

Copy link
Copy Markdown
Author

Thanks for this useful script.

This small change sets the file modification time so it matches the original Google file. I find this helpful as then the Notes app can sort by date.

Line 5:

from os import listdir, path, mkdir, utime

Insert at line 153:

# use the original modification time for the new file original_mod_time = path.getmtime(path.join(keep_dir, note_file)) utime(path.join(nextcloud_dir, path_name, fname), (original_mod_time, original_mod_time))

You're welcome, thanks for the suggestion i'll make it right now

@dmonder

dmonder commented Feb 14, 2022

Copy link
Copy Markdown

The utime call needs to follow the f.close() statement. Otherwise, the close call will update the modification time to the current time. :)

@jhonasn

jhonasn commented Mar 26, 2022

Copy link
Copy Markdown
Author

The utime call needs to follow the f.close() statement. Otherwise, the close call will update the modification time to the current time. :)

man, there's a lot of time that I don't put my hands on this, can you send me an example for me to update the script?

@Expello

Expello commented Jun 24, 2024

Copy link
Copy Markdown

does anyone have an idea how to extend this ingenious script so that the german umlauts (ä, ö, ü, ß) are converted correctly?

EDIT: ok it was easier than I thought

Line 77:

From: note = to_dict(open(path.join(keep_dir, note_file)).read())
to this: note = to_dict(open(path.join(keep_dir, note_file), encoding='utf-8').read())

@jhonasn

jhonasn commented Nov 26, 2024

Copy link
Copy Markdown
Author

updated line 77 as @Expello mentioned

@Jesus-M

Jesus-M commented May 30, 2026

Copy link
Copy Markdown

Hello,

thanks for the script, it saved my day :-)

I think there is a small mistake, line 27 'last-edit': '-a' in argv or '-c' in argv, should be 'last-edit': '-a' in argv or '-le' in argv,

And related to this, to keep even better control of the imported notes, could the updated timestamp of the new files be changed to that 'last edit' date?

I did it with a small script after importing the notes, but I think it'd be natural to do it during the import time.

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment