├── .gitignore ├── LICENSE ├── README.md ├── notes_to_keep ├── __init__.py ├── gkeep.py ├── note.py ├── notes_to_keep.py └── scan_notes.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2018 Adam Yi 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notes to Keep 2 | Export all your [Apple iCloud Notes](https://www.icloud.com/notes) on macOS to [Google Keep](https://keep.corp.google.com). 3 | 4 | ## Installation 5 | ``` 6 | pip install notes_to_keep 7 | ``` 8 | 9 | ## Usage 10 | ``` 11 | Usage: 12 | notes_to_keep [options] 13 | notes_to_keep --help 14 | notes_to_keep --version 15 | 16 | Arguments: 17 | Your Google account 18 | The password of your Google account 19 | 20 | Options: 21 | --num= The number of notes to be exported to Google 22 | Keep (default: all notes will be exported) 23 | --prefix= Append a prefix before the title of all notes. 24 | A pair of [] will be put around it 25 | automatically. (Default: empty) 26 | --meta-header Add a header message to the beginning of each 27 | note to include the original creation time 28 | of the note and the import time. 29 | --no-label Do not create a label for all imported notes. 30 | By default, we will create a new label for 31 | all imported notes. 32 | --folders Create labels that correspond to the folders 33 | in your Notes db. 34 | ``` 35 | 36 | ## License 37 | Copyright 2018 Adam Yi 38 | 39 | [MIT License](LICENSE) 40 | 41 | ## Known Issues 42 | This is still Alpha-quality, and is likely to have bugs. Use at your own risks. Below are some currently known issues waiting to be fixed: 43 | 44 | * It doesn't upload any photos, attachments, etc. to Google Keep. It uploads text, and only text. 45 | * It doesn't shorten the title, so in some extreme cases Google back-end might throw a 500 (also 500 for some other situations like certain special characters that Google doesn't support). But for most of your notes (almost all), it's gonna be just fine (2 in my 2000+ notes went wrong). 46 | 47 | ## Contribute 48 | All submissions, including submissions by project members, require review. We use Github pull requests for this purpose. 49 | 50 | ## Disclaimer 51 | This is not an official Google product. It is neither endorsed nor supported by either Google LLC or Apple Inc. 52 | -------------------------------------------------------------------------------- /notes_to_keep/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __version__ = '0.0.4' 4 | -------------------------------------------------------------------------------- /notes_to_keep/gkeep.py: -------------------------------------------------------------------------------- 1 | import gkeepapi 2 | from datetime import datetime 3 | from gkeepapi import node as g_node 4 | from gkeepapi.exception import LoginException 5 | from bs4 import BeautifulSoup 6 | import logging 7 | import sys 8 | import re 9 | 10 | try: 11 | re._pattern_type = re.Pattern 12 | except AttributeError: 13 | pass 14 | 15 | 16 | log = logging.getLogger("gkeep") 17 | 18 | def login(gaia, pwd): 19 | # gkeepapi.node.DEBUG = True 20 | keep = gkeepapi.Keep() 21 | success = keep.login(gaia, pwd) 22 | return keep 23 | 24 | def parseHTML(html): 25 | soup = BeautifulSoup(html, "html5lib") 26 | return soup.get_text("\n") 27 | 28 | def generateBody(note): 29 | return "Imported from Apple Note\nOriginal Create Time: %s\nImport Time: %s\n-----------------------\n%s\n-----------------------\nhttps://github.com/adamyi/notes_to_keep" % (note.date_created.strftime("%c"), datetime.now().strftime("%c"), parseHTML(note.data)) 30 | 31 | def uploadNote(keep, note, header, label, pfx, folders): 32 | log.info("Uploading note: " + note.title) 33 | gnote = g_node.Note() 34 | if pfx is not None: 35 | gnote.title = "[%s] %s" % (pfx, note.title) 36 | else: 37 | gnote.title = note.title 38 | if header: 39 | gnote.text = generateBody(note) 40 | else: 41 | gnote.text = parseHTML(note.data) 42 | # ts = g_node.NodeTimestamps() 43 | # ts.load({'created': note.date_created, 'edited': note.date_edited, 'updated': datetime.now()}) 44 | # gnote.timestamps = ts 45 | if label is not None: 46 | gnote.labels.add(label) 47 | if folders: 48 | if keep.findLabel(note.folder) is not None: 49 | gnote.labels.add(keep.findLabel(note.folder)) 50 | else: 51 | gnote.labels.add(keep.createLabel(note.folder)) 52 | keep.add(gnote) 53 | # make things slower to sync everytime instead of sync one time finally 54 | # however, syncing one time is more error-prone. 55 | # in this way, if a sync has an issue, it only affects one single note. 56 | keep.sync() 57 | 58 | def createLabel(keep): 59 | name = "notes_to_keep %s" % datetime.now().strftime("%y/%m/%d %H:%M:%S") 60 | log.info("Creating label: " + name) 61 | label = keep.createLabel(name) 62 | return label 63 | 64 | def start(gaia, pwd, notes, num, pfx, no_alter, no_label, folders): 65 | log.info("Logging in gaia account...") 66 | try: 67 | keep = login(gaia, pwd) 68 | except LoginException as e: 69 | log.error(e) 70 | log.info("If you get the \"ou must sign in on the web\" error, please check https://support.google.com/accounts/answer/2461835 to unlock your account for access without webpage (or generate an App Password if you have enabled 2SV) and try again.") 71 | sys.exit(1) 72 | log.info("Login completed.") 73 | 74 | if no_label: 75 | label = None 76 | log.info("Will not create a label.") 77 | else: 78 | label = createLabel(keep) 79 | 80 | if num != None: 81 | log.warning("As requested, we will only upload %s note(s)." % num) 82 | num = int(num) 83 | i = 0 84 | for note in notes: 85 | try: 86 | i += 1 87 | uploadNote(keep, note, no_alter, label, pfx, folders) 88 | if i == num: 89 | break 90 | except Exception as e: 91 | log.error(e) 92 | if note.title is not None: 93 | log.error("Error parsing/updating this note... Skip this note for now. Title: " + note.title) 94 | continue 95 | log.info("Done! Have fun~") 96 | 97 | if __name__ == '__main__': 98 | print("This is part of notes_to_keep, which cannot be called separately.") 99 | -------------------------------------------------------------------------------- /notes_to_keep/note.py: -------------------------------------------------------------------------------- 1 | class Note: 2 | 3 | def __init__(self, id, folder, title, snippet, data, att_id, att_path, acc_desc, acc_identifier, acc_username, created, edited, version, source): 4 | self.note_id = id 5 | self.folder = folder 6 | self.title = title 7 | self.snippet = snippet 8 | self.data = data 9 | self.attachment_id = att_id # DEPRECATED 10 | self.attachment_path = att_path # DEPRECATED 11 | self.account = acc_desc 12 | self.account_identifier = acc_identifier 13 | self.account_username = acc_username 14 | self.date_created = created 15 | self.date_edited = edited 16 | self.version = version 17 | self.source_file = source 18 | #self.folder_title_modified = folder_title_modified 19 | 20 | if __name__ == '__main__': 21 | print("This is part of notes_to_keep, which cannot be called separately.") 22 | -------------------------------------------------------------------------------- /notes_to_keep/notes_to_keep.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """notes_to_keep - export all your Apple Notes to Google Keep 4 | 5 | Usage: 6 | notes_to_keep [options] 7 | notes_to_keep --help 8 | notes_to_keep --version 9 | 10 | Arguments: 11 | Your Google account 12 | The password of your Google account 13 | 14 | Options: 15 | --num= The number of notes to be exported to Google 16 | Keep (default: all notes will be exported) 17 | --prefix= Append a prefix before the title of all notes. 18 | A pair of [] will be put around it 19 | automatically. (Default: empty) 20 | --meta-header Add a header message to the beginning of each 21 | note to include the original creation time 22 | of the note and the import time. 23 | --no-label Do not create a label for all imported notes. 24 | By default, we will create a new label for 25 | all imported notes. 26 | --folders Create labels that correspond to the folders 27 | in your Notes db. 28 | """ 29 | 30 | import logging 31 | from docopt import docopt 32 | from .scan_notes import ScanNotes 33 | from . import gkeep 34 | from . import __version__ 35 | 36 | log = logging.getLogger("main") 37 | logging.basicConfig(format='%(asctime)s: %(levelname)s: %(name)s (%(lineno)s): %(message)s') 38 | logging.root.setLevel(level=logging.INFO) 39 | 40 | def start(args): 41 | gaia = args[''] 42 | password = args[''] 43 | num = args['--num'] 44 | pfx = args['--prefix'] 45 | header = args['--meta-header'] 46 | no_label = args['--no-label'] 47 | folders = args['--folders'] 48 | 49 | notes = ScanNotes() 50 | gkeep.start(gaia, password, notes, num, pfx, header, no_label, folders) 51 | 52 | def main(): 53 | args = docopt(__doc__, version=__version__) 54 | start(args) 55 | 56 | 57 | if __name__ == '__main__': 58 | main() 59 | -------------------------------------------------------------------------------- /notes_to_keep/scan_notes.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This file was modified from mac_apt (macOS Artifact Parsing Tool). 3 | Changes are also subject to the terms of the MIT License. 4 | For the copyright information of the changes, please check the 5 | LICENSE file. 6 | 7 | 8 | Below is the original copyright information 9 | ------------------------------------------------------------- 10 | Copyright (c) 2017 Yogesh Khatri 11 | This file is part of mac_apt (macOS Artifact Parsing Tool). 12 | Usage or distribution of this software/code is subject to the 13 | terms of the MIT License. 14 | ''' 15 | 16 | #from __future__ import print_function 17 | #from __future__ import unicode_literals # Must disable for sqlite.row_factory 18 | 19 | import os 20 | import datetime 21 | import logging 22 | from biplist import * 23 | import binascii 24 | import sqlite3 25 | import zlib 26 | import struct 27 | from .note import Note 28 | import json 29 | from six import string_types 30 | 31 | log = logging.getLogger("scannode") 32 | 33 | def ConvertToInt(x): 34 | if type(x) == int: 35 | return x 36 | else: 37 | return int(struct.unpack(' 0xFFFFFFFF: # more than 32 bits, this should be nane-second resolution timestamp in HighSierra 46 | return datetime.datetime.utcfromtimestamp(mac_abs_time / 1000000000 + 978307200) 47 | return datetime.datetime.utcfromtimestamp(mac_abs_time + 978307200) 48 | except Exception as ex: 49 | log.error("ReadMacAbsoluteTime() Failed to convert timestamp from value " + str(mac_abs_time) + " Error was: " + str(ex)) 50 | return '' 51 | 52 | 53 | def ReadAttPathFromPlist(plist_blob): 54 | '''For NotesV2, read plist and get path''' 55 | try: 56 | plist = readPlistFromString(plist_blob) 57 | try: 58 | path = plist['$objects'][2] 59 | return path 60 | except: 61 | log.exception('Could not fetch attachment path from plist') 62 | except (InvalidPlistException, NotBinaryPlistException, Exception) as e: 63 | log.error ("Invalid plist in table." + str(e) ) 64 | return '' 65 | 66 | def GetUncompressedData(compressed): 67 | if compressed == None: 68 | return None 69 | data = None 70 | try: 71 | data = zlib.decompress(compressed, 15 + 32) 72 | except: 73 | log.exception('Zlib Decompression failed!') 74 | return data 75 | 76 | def ReadNotesV2_V4_V6(db, notes, version, source): 77 | '''Reads NotesVx.storedata, where x= 2,4,6,7''' 78 | try: 79 | # " att.ZCONTENTID as att_id, att.ZFILEURL as file_url " 80 | # " LEFT JOIN ZATTACHMENT as att ON att.ZNOTE = n.Z_PK " 81 | query = "SELECT n.Z_PK as note_id, n.ZDATECREATED as created, n.ZDATEEDITED as edited, n.ZTITLE as title, "\ 82 | " (SELECT ZNAME from ZFOLDER where n.ZFOLDER=ZFOLDER.Z_PK) as folder, "\ 83 | " (SELECT zf2.ZACCOUNT from ZFOLDER as zf1 LEFT JOIN ZFOLDER as zf2 on (zf1.ZPARENT=zf2.Z_PK) where n.ZFOLDER=zf1.Z_PK) as folder_parent_id, "\ 84 | " ac.ZEMAILADDRESS as email, ac.ZACCOUNTDESCRIPTION as acc_desc, ac.ZUSERNAME as username, b.ZHTMLSTRING as data "\ 85 | " FROM ZNOTE as n "\ 86 | " LEFT JOIN ZNOTEBODY as b ON b.ZNOTE = n.Z_PK "\ 87 | " LEFT JOIN ZACCOUNT as ac ON ac.Z_PK = folder_parent_id" 88 | db.row_factory = sqlite3.Row 89 | cursor = db.execute(query) 90 | for row in cursor: 91 | try: 92 | # att_path = '' 93 | # if row['file_url'] != None: 94 | # att_path = ReadAttPathFromPlist(row['file_url']) 95 | note = Note(row['note_id'], row['folder'], row['title'], '', row['data'], '', '', 96 | row['acc_desc'], row['email'], row['username'], 97 | ReadMacAbsoluteTime(row['created']), ReadMacAbsoluteTime(row['edited']), 98 | version, source) 99 | notes.append(note) 100 | except: 101 | log.exception('Error fetching row data') 102 | except: 103 | log.exception('Query execution failed. Query was: ' + query) 104 | 105 | def ReadLengthField(blob): 106 | '''Returns a tuple (length, skip) where skip is number of bytes read''' 107 | length = 0 108 | skip = 0 109 | try: 110 | data_length = ConvertToInt(blob[0]) 111 | length = data_length & 0x7F 112 | while data_length > 0x7F: 113 | skip += 1 114 | data_length = ConvertToInt(blob[skip]) 115 | length = ((data_length & 0x7F) << (skip * 7)) + length 116 | except: 117 | log.exception('Error trying to read length field in note data blob') 118 | skip += 1 119 | return length, skip 120 | 121 | def ProcessNoteBodyBlob(blob): 122 | data = '' 123 | if blob == None: return data 124 | try: 125 | pos = 0 126 | if blob[0:3] != b'\x08\x00\x12': # header 127 | log.error('Unexpected bytes in header pos 0 - {:x}{:x}{:x} Expected 080012'.format( 128 | ConvertToInt(blob[0]), ConvertToInt(blob[1]), ConvertToInt(blob[2]))) 129 | return '' 130 | pos += 3 131 | length, skip = ReadLengthField(blob[pos:]) 132 | pos += skip 133 | 134 | if blob[pos:pos+3] != b'\x08\x00\x10': # header 2 135 | log.error('Unexpected bytes in header pos {0}:{0}+3'.format(pos)) 136 | return '' 137 | pos += 3 138 | length, skip = ReadLengthField(blob[pos:]) 139 | pos += skip 140 | 141 | # Now text data begins 142 | if blob[pos] != b'\x1A' and blob[pos] != 26: 143 | log.error(blob[pos]) 144 | log.error('Unexpected byte in text header pos {} - byte is {:x}'.format(pos, ConvertToInt(blob[pos]))) 145 | return '' 146 | pos += 1 147 | length, skip = ReadLengthField(blob[pos:]) 148 | pos += skip 149 | # Read text tag next 150 | if blob[pos] != b'\x12' and blob[pos] != 18: 151 | log.error('Unexpected byte in pos {} - byte is {:x}'.format(pos, ConvertToInt(blob[pos]))) 152 | return '' 153 | pos += 1 154 | length, skip = ReadLengthField(blob[pos:]) 155 | pos += skip 156 | data = blob[pos : pos + length].decode('utf-8') 157 | # Skipping the formatting Tags 158 | except: 159 | log.exception('Error processing note data blob') 160 | return data 161 | 162 | def ReadNotesHighSierra(db, notes, source): 163 | '''Read Notestore.sqlite''' 164 | try: 165 | # " c3.ZFILESIZE, " 166 | # " c4.ZFILENAME, c4.ZIDENTIFIER as att_uuid, " 167 | # " LEFT JOIN ZICCLOUDSYNCINGOBJECT as c3 ON c3.ZNOTE= n.ZNOTE " 168 | # " LEFT JOIN ZICCLOUDSYNCINGOBJECT as c4 ON c4.ZATTACHMENT1= c3.Z_PK " 169 | query = " SELECT n.Z_PK, n.ZNOTE as note_id, n.ZDATA as data, " \ 170 | " c1.ZTITLE1 as title, c1.ZSNIPPET as snippet, c1.ZIDENTIFIER as noteID, "\ 171 | " c1.ZCREATIONDATE1 as created, c1.ZLASTVIEWEDMODIFICATIONDATE, c1.ZMODIFICATIONDATE1 as modified, "\ 172 | " c2.ZACCOUNT3, c2.ZTITLE2 as folderName, c2.ZIDENTIFIER as folderID, "\ 173 | " c5.ZNAME as acc_name, c5.ZIDENTIFIER as acc_identifier, c5.ZACCOUNTTYPE "\ 174 | " FROM ZICNOTEDATA as n "\ 175 | " LEFT JOIN ZICCLOUDSYNCINGOBJECT as c1 ON c1.ZNOTEDATA = n.Z_PK "\ 176 | " LEFT JOIN ZICCLOUDSYNCINGOBJECT as c2 ON c2.Z_PK = c1.ZFOLDER "\ 177 | " LEFT JOIN ZICCLOUDSYNCINGOBJECT as c5 ON c5.Z_PK = c1.ZACCOUNT2 "\ 178 | " ORDER BY note_id " 179 | db.row_factory = sqlite3.Row 180 | cursor = db.execute(query) 181 | for row in cursor: 182 | try: 183 | # att_path = '' 184 | # if row['att_uuid'] != None: 185 | # att_path = os.getenv("HOME") + '/Library/Group Containers/group.com.apple.notes/Media/' + row['att_uuid'] + '/' + row['ZFILENAME'] 186 | data = GetUncompressedData(row['data']) 187 | text_content = ProcessNoteBodyBlob(data) 188 | note = Note(row['note_id'], row['folderName'], row['title'], row['snippet'], text_content, '', '', 189 | row['acc_name'], row['acc_identifier'], '', 190 | ReadMacAbsoluteTime(row['created']), ReadMacAbsoluteTime(row['modified']), 191 | 'NoteStore', source) 192 | notes.append(note) 193 | except: 194 | log.exception('Error fetching row data') 195 | except: 196 | log.exception('Query execution failed. Query was: ' + query) 197 | 198 | def ReadNotes(db, notes, source): 199 | '''Read Notestore.sqlite''' 200 | cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='Z_12NOTES'") 201 | if cursor.fetchone() is None: 202 | ReadNotesHighSierra(db, notes, source) 203 | return 204 | try: 205 | # " c3.ZMEDIA as media_id, c3.ZFILESIZE as att_filesize, c3.ZMODIFICATIONDATE as att_modified, c3.ZPREVIEWUPDATEDATE as att_previewed, c3.ZTITLE as att_title, c3.ZTYPEUTI, c3.ZIDENTIFIER as att_uuid, " 206 | # " c4.ZFILENAME, c4.ZIDENTIFIER as media_uuid " 207 | # " LEFT JOIN ZICCLOUDSYNCINGOBJECT as c3 ON c3.ZNOTE = n.Z_9NOTES " 208 | # " LEFT JOIN ZICCLOUDSYNCINGOBJECT as c4 ON c3.ZMEDIA = c4.Z_PK " 209 | query = " SELECT n.Z_12FOLDERS as folder_id , n.Z_9NOTES as note_id, d.ZDATA as data, " \ 210 | " c2.ZTITLE2 as folder, c2.ZDATEFORLASTTITLEMODIFICATION as folder_title_modified, " \ 211 | " c1.ZCREATIONDATE as created, c1.ZMODIFICATIONDATE1 as modified, c1.ZSNIPPET as snippet, c1.ZTITLE1 as title, c1.ZACCOUNT2 as acc_id, " \ 212 | " c5.ZACCOUNTTYPE as acc_type, c5.ZIDENTIFIER as acc_identifier, c5.ZNAME as acc_name, " \ 213 | " FROM Z_12NOTES as n " \ 214 | " LEFT JOIN ZICNOTEDATA as d ON d.ZNOTE = n.Z_9NOTES " \ 215 | " LEFT JOIN ZICCLOUDSYNCINGOBJECT as c1 ON c1.Z_PK = n.Z_9NOTES " \ 216 | " LEFT JOIN ZICCLOUDSYNCINGOBJECT as c2 ON c2.Z_PK = n.Z_12FOLDERS " \ 217 | " LEFT JOIN ZICCLOUDSYNCINGOBJECT as c5 ON c5.Z_PK = c1.ZACCOUNT2 " \ 218 | " ORDER BY note_id " 219 | db.row_factory = sqlite3.Row 220 | cursor = db.execute(query) 221 | for row in cursor: 222 | try: 223 | # att_path = '' 224 | # if row['media_id'] != None: 225 | # att_path = row['ZFILENAME'] 226 | data = GetUncompressedData(row['data']) 227 | text_content = ProcessNoteBodyBlob(data) 228 | note = Note(row['note_id'], row['folder'], row['title'], row['snippet'], text_content, '', '', 229 | row['acc_name'], row['acc_identifier'], '', 230 | ReadMacAbsoluteTime(row['created']), ReadMacAbsoluteTime(row['modified']), 231 | 'NoteStore',source) 232 | notes.append(note) 233 | except: 234 | log.exception('Error fetching row data') 235 | except: 236 | log.exception('Query execution failed. Query was: ' + query) 237 | 238 | def OpenDb(inputPath): 239 | log.info ("Processing file " + inputPath) 240 | try: 241 | conn = sqlite3.connect(inputPath) 242 | log.debug ("Opened database successfully") 243 | return conn 244 | except Exception as ex: 245 | log.exeption ("Failed to open database, is it a valid Notes DB?") 246 | return None 247 | 248 | def ProcessNotesDbFromPath(notes, source_path, version=''): 249 | if os.path.isfile(source_path): 250 | db = OpenDb(source_path) 251 | if db != None: 252 | if version: 253 | ReadNotesV2_V4_V6(db, notes, version, source_path) 254 | else: 255 | ReadNotes(db, notes, source_path) 256 | db.close() 257 | 258 | def ScanNotes(): 259 | '''Main Entry point function''' 260 | log.info("Scanning notes now") 261 | notes = [] 262 | notes_v1_path = os.getenv("HOME") + '/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV1.storedata' # Mountain Lion 263 | notes_v2_path = os.getenv("HOME") + '/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV2.storedata' # Mavericks 264 | notes_v4_path = os.getenv("HOME") + '/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV4.storedata' # Yosemite 265 | notes_v6_path = os.getenv("HOME") + '/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV6.storedata' # Elcapitan 266 | notes_v7_path = os.getenv("HOME") + '/Library/Containers/com.apple.Notes/Data/Library/Notes/NotesV7.storedata' # HighSierra 267 | notes_path = os.getenv("HOME") + '/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite' # Elcapitan+ has this too! 268 | 269 | ProcessNotesDbFromPath(notes, notes_v1_path, 'V1') 270 | ProcessNotesDbFromPath(notes, notes_v2_path, 'V2') 271 | ProcessNotesDbFromPath(notes, notes_v4_path, 'V4') 272 | ProcessNotesDbFromPath(notes, notes_v6_path, 'V6') 273 | ProcessNotesDbFromPath(notes, notes_v7_path, 'V7') 274 | ProcessNotesDbFromPath(notes, notes_path) 275 | 276 | log.info(str(len(notes)) + " note(s) found") 277 | return notes 278 | 279 | if __name__ == '__main__': 280 | print("This is part of notes_to_keep, which cannot be called separately.") 281 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file = LICENSE 3 | 4 | [bdist_wheel] 5 | universal=1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Packaging settings.""" 2 | 3 | from setuptools import setup 4 | from codecs import open 5 | from os import path 6 | from notes_to_keep import __version__ 7 | 8 | here = path.abspath(path.dirname(__file__)) 9 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 10 | long_description = f.read() 11 | 12 | setup( 13 | name = 'notes_to_keep', 14 | version = __version__, 15 | description = 'Export all your Apple iCloud Notes to Google Keep', 16 | long_description = long_description, 17 | long_description_content_type = 'text/markdown', 18 | url = 'https://github.com/adamyi/notes_to_keep', 19 | author = 'Adam Yi', 20 | author_email = 'i@adamyi.com', 21 | license = 'MIT', 22 | classifiers = [ 23 | 'Development Status :: 3 - Alpha', 24 | 'Environment :: Console', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Intended Audience :: End Users/Desktop', 27 | 'Topic :: Utilities', 28 | 'Natural Language :: English', 29 | 'Operating System :: MacOS', 30 | 'Programming Language :: Python :: 2', 31 | 'Programming Language :: Python :: 2.7', 32 | 'Programming Language :: Python :: 3', 33 | 'Programming Language :: Python :: 3.6', 34 | ], 35 | keywords = 'apple icloud notes google keep', 36 | entry_points = { 37 | 'console_scripts': ['notes_to_keep = notes_to_keep.notes_to_keep:main'] 38 | }, 39 | packages = ['notes_to_keep'], 40 | install_requires = [ 41 | 'gkeepapi >= 0.11.2', 42 | 'biplist >= 1.0.3', 43 | 'beautifulsoup4 >= 4.6.0', 44 | 'docopt >= 0.6.2', 45 | 'html5lib >= 1.0.1' 46 | ], 47 | ) 48 | --------------------------------------------------------------------------------