├── requirements ├── .gitignore ├── com.gautampk.bear-sync.plist ├── README.md └── main.py /requirements: -------------------------------------------------------------------------------- 1 | sqlalchemy 2 | pandas 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .ropeproject/ 3 | -------------------------------------------------------------------------------- /com.gautampk.bear-sync.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | com.gautampk.bear-sync 7 | ProgramArguments 8 | 9 | /usr/local/bin/python3 10 | ~/Applications/bear-sync/main.py 11 | 12 | StandardErrorPath 13 | /dev/null 14 | StandardOutPath 15 | /dev/null 16 | RunAtLoad 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bear Notes App Exporter and Sync 2 | 3 | This script will export new files from the SQLite database where Bear stores all notes. It assumes that every note has exactly one tag and uses that as the filepath in the exported files. 4 | 5 | It will additionally sync files that already exist in the database, taking either the exported or the database version depending on the date they were last modified. 6 | 7 | It will NOT import new files into the database -- please use Bear's built-in import function for this. Once the file has been imported it can be synced as normal. 8 | 9 | ## Installation 10 | This script needs Python to run. I've only tested it on Python 3, but it may work with Python 2 as well. 11 | 12 | 1. Install Python requirements: `pip3 install -r requirements`. 13 | 2. Update the username and sync location in `main.py` to be what you want. 14 | 3. Update the path to `main.py` in the `.plist` file. By default, it is assumed that this is `~/Applications/bear-sync/main.py`. 15 | 4. Copy the `.plist` to the correct location: `cp com.gautampk.bear-sync.plist ~/Libaray/LaunchAgents/`. 16 | 5. Either logout and login to start the service, or run `launchctl start com.gautampk.bear-sync`. 17 | 18 | ## Licence 19 | Copyright 2018 gautampk, licensed under the MIT licence. 20 | 21 | 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: 22 | 23 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 24 | 25 | 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. 26 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Bear Notes App Exporter and Sync 3 | 4 | This script will export new files from the SQLite database where Bear stores 5 | all notes. It assumes that every note has exactly one tag and uses that as the 6 | filepath in the exported files. 7 | 8 | It will additionally sync files that already exist in the database, taking 9 | either the exported or the database version depending on the date they were 10 | last modified. 11 | 12 | It will NOT import new files into the database -- please use Bear's built-in 13 | import function for this. Once the file has been imported it can be synced as 14 | normal. 15 | ''' 16 | 17 | from sqlalchemy import create_engine 18 | import pandas as pd 19 | import os 20 | from datetime import datetime 21 | from time import sleep 22 | 23 | os.stat_float_times(True) 24 | 25 | MAC_USERNAME = 'yourUsernameHere' 26 | ROOT = '~/Applications/bear-sync/sync/' 27 | 28 | 29 | def main(): 30 | # Connect to Bear database. 31 | db = create_engine('sqlite:////Users/' + MAC_USERNAME + '/Library/' + 32 | 'Containers/net.shinyfrog.bear/Data/Documents/' + 33 | 'Application Data/database.sqlite') 34 | 35 | # Build list of files in the DB. 36 | dbNotes = pd.read_sql( 37 | "SELECT a.Z_PK as NID, a.ZTITLE || '.' || a.Z_PK || '.md' as FILE,\ 38 | a.ZTEXT as CONTENT, CAST(a.ZMODIFICATIONDATE as REAL) as DATE,\ 39 | c.ZTITLE as TAG_PATH, MAX(LENGTH(c.ZTITLE)) AS TAG_LEN,\ 40 | a.ZTRASHED as TRASHED\ 41 | FROM ( SELECT * FROM ZSFNOTE WHERE ZSKIPSYNC = 0 ) a\ 42 | LEFT JOIN Z_5TAGS b ON a.Z_PK = b.Z_5NOTES\ 43 | LEFT JOIN ZSFNOTETAG c ON b.Z_10TAGS = c.Z_PK\ 44 | GROUP BY FILE;", 45 | db 46 | ).set_index('NID') 47 | dbNotes.loc[dbNotes['TAG_PATH'].isnull(), 'TAG_PATH'] = '' 48 | dbNotes = dbNotes.to_dict(orient='index') 49 | 50 | # Build list of files on the FS. 51 | fsNotes = {} 52 | for path, _, files in os.walk(ROOT): 53 | # Include only .md Markdown files. 54 | files = [f for f in files if ( 55 | os.path.splitext(f)[1] == '.md' and 56 | f[0] != '.' 57 | )] 58 | 59 | # Go through every note in the folder. 60 | for note in files: 61 | # Get the unique note ID. 62 | nid = os.path.splitext(os.path.splitext(note)[0])[1][1:] 63 | 64 | # Test the note ID to make sure it's an integer. 65 | try: 66 | int(nid) 67 | except ValueError: 68 | # If it's not an integer, skip it. 69 | continue 70 | else: 71 | # Get the contents of the note. 72 | with open(os.path.join(path, note), encoding='utf-8') as f: 73 | content = f.read() 74 | 75 | # Add an entry. 76 | fsNotes[int(nid)] = { 77 | 'FILE': note, 78 | 'CONTENT': content, 79 | 'DATE': os.path.getmtime(os.path.join(path, note)) + 80 | datetime(1970, 1, 1).timestamp() - 81 | datetime(2001, 1, 1).timestamp(), 82 | 'TAG_PATH': os.path.relpath(path, ROOT), 83 | } 84 | 85 | # Go through every file in the DB and compare it with the current FS 86 | # version. 87 | for nid in dbNotes: 88 | try: 89 | fsNotes[nid] 90 | except KeyError: # Note doesn't exist on the FS, so create it. 91 | if dbNotes[nid]['TRASHED'] == 0: 92 | # Create the tag path. 93 | try: 94 | os.makedirs(os.path.join(ROOT, dbNotes[nid]['TAG_PATH'])) 95 | except OSError as e: 96 | pass 97 | 98 | # Make the file. 99 | with open( 100 | os.path.join( 101 | ROOT, 102 | dbNotes[nid]['TAG_PATH'], 103 | dbNotes[nid]['FILE'] 104 | ), 105 | 'w', encoding='utf-8' 106 | ) as f: 107 | f.write(dbNotes[nid]['CONTENT']) 108 | 109 | # Update the DB with new time. 110 | date = os.path.getmtime( 111 | os.path.join( 112 | ROOT, 113 | dbNotes[nid]['TAG_PATH'], 114 | dbNotes[nid]['FILE'] 115 | ) 116 | ) + datetime(1970, 1, 1).timestamp() - datetime(2001, 1, 1).timestamp() 117 | db.execute( 118 | "UPDATE ZSFNOTE\ 119 | SET ZMODIFICATIONDATE = " + str(date) + "\ 120 | WHERE Z_PK = " + str(nid) + ";" 121 | ) 122 | else: # Note already exists, so compare details. 123 | # First, make sure the note hasn't been trashed on the DB. 124 | # NB: trashing a note from the FS will not work, as it will be 125 | # re-added on the next sync. To trash a note you must use Bear. 126 | if dbNotes[nid]['TRASHED'] != 0: 127 | try: 128 | os.remove( 129 | os.path.join( 130 | ROOT, 131 | fsNotes[nid]['TAG_PATH'], 132 | fsNotes[nid]['FILE'] 133 | ) 134 | ) 135 | except FileNotFoundError: 136 | pass 137 | else: 138 | # Next, check if the DB filename or tag path has changed. 139 | if ( 140 | dbNotes[nid]['FILE'] != fsNotes[nid]['FILE'] or 141 | dbNotes[nid]['TAG_PATH'] != fsNotes[nid]['TAG_PATH'] 142 | ): 143 | # Update the FS filename and path to match the DB version. 144 | # NB: to change the path of a note on the FS, update the 145 | # tags inside the note, and wait for the sync with Bear 146 | # to complete. 147 | # Create the tag path. 148 | try: 149 | os.makedirs(os.path.join(ROOT, dbNotes[nid]['TAG_PATH'])) 150 | except OSError as e: 151 | pass 152 | 153 | os.rename( 154 | os.path.join( 155 | ROOT, 156 | fsNotes[nid]['TAG_PATH'], 157 | fsNotes[nid]['FILE'] 158 | ), 159 | os.path.join( 160 | ROOT, 161 | dbNotes[nid]['TAG_PATH'], 162 | dbNotes[nid]['FILE'] 163 | ) 164 | ) 165 | 166 | fsNotes[nid]['TAG_PATH'] = dbNotes[nid]['TAG_PATH'] 167 | fsNotes[nid]['FILE'] = dbNotes[nid]['FILE'] 168 | 169 | # Now compare the dates and sync. 170 | if dbNotes[nid]['DATE'] > fsNotes[nid]['DATE']: 171 | # Save DB to FS. 172 | with open( 173 | os.path.join( 174 | ROOT, 175 | fsNotes[nid]['TAG_PATH'], 176 | fsNotes[nid]['FILE'] 177 | ), 178 | 'w', 179 | encoding='utf-8' 180 | ) as f: 181 | f.write(dbNotes[nid]['CONTENT']) 182 | elif dbNotes[nid]['DATE'] < fsNotes[nid]['DATE']: 183 | # Save FS to DB. 184 | db.execute( 185 | "UPDATE ZSFNOTE\ 186 | SET ZTEXT = \"" + fsNotes[nid]['CONTENT'] + "\"\ 187 | WHERE Z_PK = " + str(nid) + ";" 188 | ) 189 | 190 | # Update the DB with new time. 191 | date = os.path.getmtime( 192 | os.path.join( 193 | ROOT, 194 | fsNotes[nid]['TAG_PATH'], 195 | fsNotes[nid]['FILE'] 196 | ) 197 | ) + datetime(1970, 1, 1).timestamp() - datetime(2001, 1, 1).timestamp() 198 | db.execute( 199 | "UPDATE ZSFNOTE\ 200 | SET ZMODIFICATIONDATE = " + str(date) + "\ 201 | WHERE Z_PK = " + str(nid) + ";" 202 | ) 203 | 204 | # Go back through the FS and remove empty folders. 205 | for path, folders, files in os.walk(ROOT): 206 | # Include only .md Markdown files. 207 | files = [f for f in files if ( 208 | os.path.splitext(f)[1] == '.md' and 209 | f[0] != '.' 210 | )] 211 | 212 | if len(files) == 0 and len(folders) == 0: 213 | os.rmdir(path) 214 | 215 | 216 | if __name__ == "__main__": 217 | while True: 218 | main() 219 | sleep(10) 220 | --------------------------------------------------------------------------------