├── .gitignore ├── .gitattributes ├── LICENSE ├── Bear Import.md ├── README.md ├── bear_import.py └── bear_export_sync.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 rovest 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Bear Import.md: -------------------------------------------------------------------------------- 1 | ## Bear Markdown and textbundle import – with tags from file and folder. 2 | 3 | ***bear_import.py*** 4 | *Version 1.0.0 - 2018-02-10 at 17:37 EST* 5 | 6 | *See also:* **[bear_export_sync.py](https://github.com/rovest/Bear-Markdown-Export/blob/master/README.md)** *for export with sync-back.* 7 | 8 | 9 | ### Features 10 | 11 | * Imports markdown or textbundles from nested folders under a `BearImport/input/' folder 12 | * Foldernames are converted to Bear tags 13 | * Also imports MacOS file tags as Bear tags 14 | * Imported notes are also tagged with `#.imported/yyyy-MM-dd` for convenience. 15 | * Import-files are then cleared to a `BearImport/done/' folder 16 | * Use for email input to Bear with Zapier's "Gmail to Dropbox" zap. 17 | * Or for import of nested groups and sheets from Ulysses, images and keywords included. 18 | 19 | 20 | ### Trigger script with Automator Folder Action 21 | 22 | 1. New Automator file as `Folder Action` 23 | 2. Set `Folder action receives files and folders added to`: `{user}/Dropbox/BearImport/Input` 24 | 3. Add action: `Run Shell Script` choose `bin/bash` 25 | 4. Insert one line with full paths to python and script (Use "" if spaces in paths!): 26 | `/Library/Frameworks/Python.framework/Versions/3.6/bin/python3.6 "/Users/username/scripts/bear_import.py"` 27 | 5. Save as `Bear Import` or whatever you choose. 28 | 29 | Or skip all this and run it manually :) 30 | 31 | 32 | ### Get mail to Bear with "Zapier Gmail to Dropbox" action 33 | 34 | 1. Create a free zapier.com account. 35 | 2. Use a dedicated gmail account or setup a filter assigning a label used by zapier. 36 | 3. Make a Zapier zap. See: [Add new Gmail emails to Dropbox as text files](https://zapier.com/apps/dropbox/integrations/gmail/10323/add-new-gmail-emails-to-dropbox-as-text-files) 37 | 1. Set zap to monitor inbox with label (assigned by filter in step 2.) 38 | 2. Set zap Dropbox output to `{user}/Dropbox/BearImport/Input` 39 | 40 | - Zap will now check for new email (with matching gmail label) every 15 minutes and script above will import to Bear. 41 | - Alternately on iOS: use this workflow (import to Bear from same Dropbox folder): [Gmail-DB zap to Bear](https://workflow.is/workflows/827b9b2518d5476ca0158a67d5b492fa) 42 | 43 | ### Import from Ulysses’ external folders on Mac 44 | 45 | 1. Add `{user}/Dropbox/BearImport/Input` as external folder 46 | 2. Edit folder settings to `.textbundle` and `Inline Links`! 47 | 3. Drag any library group to this folder in Ulysses' sidebar. 48 | 4. Voilà – Imports to Bear with images and tags (both from group names and keywords). 49 | 50 | 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Markdown export and sync of Bear notes 2 | 3 | ***bear_export_sync.py*** 4 | *Version 1.4, 2020-01-11* 5 | 6 | Python script for export and roundtrip sync of Bear's notes to OneDrive, Dropbox, etc. and edit online with [StackEdit](https://stackedit.io/app), or use a markdown editor like *Typora* on Windows or a suitable app on Android. Remote edits and new notes get synced back into Bear with this script. 7 | 8 | **See also: [Bear Markdown and textbundle import – with tags from file and folder](https://github.com/rovest/Bear-Markdown-Export/blob/master/Bear%20Import.md)** 9 | 10 | Set up seamless syncing with Ulysses’ external folders on Mac, with images included! 11 | Write and add photos in Bear, then reorder, glue, and publish, export, or print with styles in Ulysses— 12 | bears and butterflies are best friends ;) 13 | (PS. The manual order you set for notes in Ulysses' external folder, is maintained during syncs, unless title is changed.) 14 | 15 | Suitable for use with https://github.com/andymatuschak/note-link-janitor. 16 | 17 | BEAR IN MIND! This is a free to use version, and please improve or modify as needed. But do be careful! both `rsync` and `shutil.rmtree` used here, are powerful commands that can wipe clean a whole folder tree or even your complete HD if paths are set incorrectly! Also, be safe, take a fresh backup of both Bear and your Mac before first run. 18 | 19 | *See also: [Bear Power Pack](https://github.com/rovest/Bear-Power-Pack/blob/master/README.md)* 20 | 21 | ## Usage 22 | 23 | ``` 24 | python bear_export_sync.py --out ~/Notes/Bear --backup ~/Notes/Backup 25 | ``` 26 | 27 | See `--help` for more. 28 | 29 | ## Features 30 | 31 | * Bear notes exported as plain Markdown or Textbundles with images. 32 | * Syncs external edits back to Bear with original image links intact. 33 | * New external `.md` files or `.textbundles` are added. 34 | (Tags created from sub folder name) 35 | * Export option: Make nested folders from tags. 36 | For first tag only, or all tags (duplicates notes) 37 | * Export option: Include or exclude export of notes with specific tags. 38 | * Export option: Export as `.textbundles` with images included. 39 | * Or as: `.md` with links to common image repository 40 | * Export option: Hide tags in HTML comments like: `` if `hide_tags_in_comment_block = True` 41 | * **NEW** Hybrid export: `.textbundles` of notes with images, otherwise regular `.md` (Makes it easier to browse and edit on other platforms.) 42 | * **NEW** Writes log to `bear_export_sync_log.txt` in `BearSyncBackup` folder. 43 | 44 | Edit your Bear notes online in browser on [OneDrive.com](https://onedrive.live.com). It has a ok editor for plain text/markdown. Or with [StackEdit](https://stackedit.io/app), an amazing online markdown editor that can sync with *Dropbox* or *Google Drive* 45 | 46 | Read and edit your Bear notes on *Windows* or *Android* with any markdown editor of choice. Remote edits or new notes will be synced back into Bear again. *Typora* works great on Windows, and displays images of text bundles as well. 47 | 48 | NOTE! If syncing with Ulysses’ external folders on Mac, remember to edit that folder settings to `.textbundle` and `Inline Links`! 49 | 50 | Run script manually or add it to a cron job for automatic syncing (every 5 – 15 minutes, or whatever you prefer). 51 | ([LaunchD Task Scheduler](https://itunes.apple.com/us/app/launchd-task-scheduler/id620249105?mt=12) Is easy to configure and works very well for this) 52 | 53 | 54 | ### Syncs external edits back into Bear 55 | Script first checks for external edits in Markdown files or textbundles (previously exported from Bear as described below): 56 | 57 | * It replaces text in original note with `bear://x-callback-url/add-text?mode=replace` command 58 | (That way keeping original note ID and creation date) 59 | If any changes to title, new title will be added just below original title. 60 | (`mode=replace` does not replace title) 61 | * Original note in `sqlite` database and external edit are both backed up as markdown-files to BearSyncBackup folder before import to bear. 62 | * If a sync conflict, both original and new version will be in Bear (the new one with a sync conflict message and link to original). 63 | * New notes created online, are just added to Bear 64 | (with the `bear://x-callback-url/create` command) 65 | * If a textbundle gets new images from an external app, it will be opened and imported as a new note in Bear, with message and link to original note. 66 | (The `subprocess.call(['open', '-a', '/applications/bear.app', bundle])` command is used for this) 67 | 68 | 69 | ### Markdown export to Dropbox, OneDrive, or other: 70 | Then exports all notes from Bear's database.sqlite as plain markdown files: 71 | 72 | * Checks modified timestamp on database.sqlite, so exports only when needed. 73 | * Sets Bear note's modification date on exported markdown files. 74 | * Appends Bear note's creation date to filename to avoid “title-filename-collisions” 75 | * Note IDs are included at bottom of markdown files to match original note on sync back: 76 | {BearID:730A5BD2-0245-4EF7-BE16-A5217468DF0E-33519-0000429ADFD9221A} 77 | (these ID's are striped off again when synced back into Bear) 78 | * Uses rsync for copying (from a temp folder), so only changed notes will be synced to Dropbox (or other sync services) 79 | * rsync also takes care of deleting trashed notes 80 | * "Hides” tags from being displayed as H1 in other markdown apps by adding `period+space` in front of first tag on a line: 81 | `. #bear #idea #python` 82 | * Or hide tags in HTML comment blocks like: `` if `hide_tags_in_comment_block = True` 83 | (these are striped off again when synced back into Bear) 84 | * Makes subfolders named with first tag in note if `make_tag_folders = True` 85 | * Files can now be copied to multiple tag-folders if `multi_tags = True` 86 | * Export can now be restricted to a list of spesific tags: `limit_export_to_tags = ['bear/github', 'writings']` 87 | or leave list empty for all notes: `limit_export_to_tags = []` 88 | * Can export and link to images in common image repository 89 | * Or export as textbundles with images included 90 | 91 | 92 | You have Bear on Mac but also want your notes on your Android phone, on Linux or Windows machine at your office. Or you want them available online in a browser from any desktop computer. Here is a solution (or call it workaround) for now, until Bear comes with an online, Windows, or Android solution ;) 93 | 94 | Happy syncing! ;) 95 | -------------------------------------------------------------------------------- /bear_import.py: -------------------------------------------------------------------------------- 1 | # encoding=utf-8 2 | # python3.6 3 | # bear_import.py 4 | # Developed with Visual Studio Code with MS Python Extension. 5 | 6 | ''' 7 | # Markdown import to Bear from folder 8 | Version 1.0.0 - 2018-02-10 at 17:37 EST 9 | github/rovest, rorves@twitter 10 | 11 | ## NEW import function: 12 | * Imports markdown or textbundles from nested folders under a `BearImport/input/' folder 13 | * Foldernames are converted to Bear tags 14 | * Also imports MacOS file tags as Bear tags 15 | * Imported notes are also tagged with `#.imported/yyyy-MM-dd` for convenience. 16 | * Import-files are then cleared to a `BearImport/done/' folder 17 | * Use for email input to Bear with Zapier's "Gmail to Dropbox" zap. 18 | * Or for import of nested groups and sheets from Ulysses, images and keywords included. 19 | ''' 20 | 21 | my_sync_service = 'Dropbox' # Change 'Dropbox' to 'Box', 'Onedrive', 22 | # or whatever folder of sync service you need. 23 | # Your user "Home" folder is added below. 24 | 25 | use_filename_as_title = False # Set to `True` if importing Simplenotes synced with nvALT. 26 | set_logging_on = True 27 | 28 | # This tag is added for convenience (easy deletion of imported notes they are not wanted.) 29 | # (Easier to delete one tag, than finding a bunch of tagless imported notes.) 30 | 31 | import datetime 32 | import re 33 | import subprocess 34 | import urllib.parse 35 | import os 36 | import time 37 | import shutil 38 | import fnmatch 39 | import json 40 | 41 | import_tag = '#.imported/' + datetime.datetime.now().strftime('%Y-%m-%d') 42 | # import_tag = '' # Blank if not needed 43 | 44 | HOME = os.getenv('HOME', '') 45 | 46 | # Import folder for files from other apps, 47 | # or incoming emails via "Gmail to Dropbox" Zapier zap or IFTTT 48 | bear_import = os.path.join(HOME, my_sync_service, 'BearImport') 49 | import_path = os.path.join(bear_import, 'input') 50 | import_done = os.path.join(bear_import, 'done') 51 | 52 | gettag_sh = os.path.join(HOME, 'temp/gettag.sh') 53 | gettag_txt = os.path.join(HOME, 'temp/gettag.txt') 54 | 55 | 56 | def main(): 57 | if not os.path.exists(import_path): 58 | os.makedirs(import_path) 59 | print('New path, use it for import to Bear:', import_path) 60 | return False 61 | if not os.path.exists(import_done): 62 | os.makedirs(import_done) 63 | init_gettag_script() 64 | count = import_external_files() 65 | print(str(count), 'files imported. Job done!') 66 | 67 | 68 | def import_external_files(): 69 | files_found = False 70 | file_types = ('*.md', '*.txt', '*.markdown') 71 | count = 0 72 | time.sleep(3) # Wait a little bit after being triggered by Automator Folder Action 73 | for (root, dirnames, filenames) in os.walk(import_path): 74 | ''' 75 | This step walks down into all sub folders, if any. 76 | ''' 77 | for pattern in file_types: 78 | for filename in fnmatch.filter(filenames, pattern): 79 | if not files_found: # Yet 80 | # Wait 5 sec at first for external files to finish downloading from dropbox. 81 | # Otherwise images in textbundles might be missing in import: 82 | time.sleep(5) 83 | files_found = True 84 | md_file = os.path.join(root, filename) 85 | mod_dt = os.path.getmtime(md_file) 86 | md_text = read_file(md_file) 87 | if pattern == '*.txt': 88 | # Replace rich text bullets to markdown: 89 | # (When using with IFTTT or Zapier and Gmail to Dropbox zap.) 90 | md_text = md_text.replace('\n• ', '\n- ') 91 | md_text = md_text.replace('\n • ', '\n\t- ') 92 | md_text = md_text.replace('\n • ', '\n\t\t- ') 93 | if re.search(r'!\[.*?\]\(assets/.+?\)', md_text) \ 94 | and '.textbundle/' in md_file: 95 | # New textbundle with images: 96 | bundle = os.path.split(md_file)[0] 97 | md_text = get_tag_from_path(md_text, bundle, import_path, False) 98 | write_file(md_file, md_text, mod_dt) 99 | os.utime(bundle, (-1, mod_dt)) 100 | subprocess.call(['open', '-a', '/applications/bear.app', bundle]) 101 | time.sleep(0.5) 102 | move_import_to_done(bundle, import_path, import_done) 103 | else: 104 | title = '' 105 | # No images, import markdown only even if textbundle: 106 | if '.textbundle/' in md_file: 107 | file_bundle = os.path.split(md_file)[0] 108 | else: 109 | file_bundle = md_file 110 | if use_filename_as_title: 111 | title = os.path.splitext(os.path.split(md_file)[1])[0] 112 | md_text = get_tag_from_path(md_text, file_bundle, import_path, False) 113 | x_create = 'bear://x-callback-url/create?show_window=no' 114 | bear_x_callback(x_create, md_text, title) 115 | move_import_to_done(file_bundle, import_path, import_done) 116 | write_log('Imported to Bear: ', file_bundle) 117 | count += 1 118 | if files_found: 119 | # cleanup empty input sub folders here ??? 120 | # But quite tricky since new files may appear. Bette to do that manually when needed. 121 | # Recursive call to look for leftovers/newly downloaded files: 122 | count += import_external_files() 123 | return count 124 | 125 | 126 | def move_import_to_done(file_bundle, import_path, import_done): 127 | file_path = file_bundle.replace(import_path + '/', '') 128 | sub_path = os.path.split(file_path)[0] 129 | dest_path = os.path.join(import_done, sub_path) 130 | if not os.path.exists(dest_path): 131 | os.makedirs(dest_path) 132 | count = 2 133 | file_name = os.path.split(file_bundle)[1] 134 | dest_file = os.path.join(dest_path, file_name) 135 | (file_raw, ext) = os.path.splitext(file_name) 136 | while os.path.exists(dest_file): 137 | # Adding sequence number to identical filenames, preventing overwrite: 138 | dest_file = os.path.join(dest_path, file_raw + " - " + str(count).zfill(2) + ext) 139 | count += 1 140 | # dest_path = os.path.split(dest_file)[0] 141 | shutil.move(file_bundle, dest_file) 142 | 143 | 144 | def get_tag_from_path(md_text, file_bundle, root_path, inbox_for_root=True): 145 | path = file_bundle.replace(root_path, '')[1:] 146 | sub_path = os.path.split(path)[0] 147 | tags = [] 148 | if sub_path == '': 149 | if inbox_for_root: 150 | tag = '#.inbox' 151 | else: 152 | tag = '' 153 | elif sub_path.startswith('_'): 154 | tag = '#.' + sub_path[1:].strip() 155 | else: 156 | tag = '#' + sub_path.strip() 157 | if ' ' in tag: 158 | tag += "#" 159 | if tag != '': 160 | tags.append(tag) 161 | if import_tag != '': 162 | tags.append(import_tag) 163 | for tag in get_file_tags(file_bundle): 164 | tag = '#' + tag.strip() 165 | if ' ' in tag: tag += "#" 166 | tags.append(tag) 167 | return md_text.strip() + '\n\n' + ' '.join(tags) + '\n' 168 | 169 | 170 | def get_file_tags(file_bundle): 171 | try: 172 | subprocess.call([gettag_sh, file_bundle, gettag_txt]) 173 | tags_raw = read_file(gettag_txt) 174 | tags_text = re.sub(r'\\n\d{1,2}', r'', tags_raw) 175 | tag_list = json.loads(tags_text) 176 | return tag_list 177 | except: 178 | return [] 179 | 180 | 181 | def bear_x_callback(x_command, md_text, title): 182 | if title != '' and not title.startswith("#"): 183 | md_text = '# ' + title + '\n' + md_text 184 | x_command_text = x_command + '&text=' + urllib.parse.quote(md_text) 185 | subprocess.call(["open", x_command_text]) 186 | time.sleep(.2) 187 | 188 | 189 | def init_gettag_script(): 190 | gettag_script = \ 191 | '''#!/bin/bash 192 | if [[ ! -e $1 ]] ; then 193 | echo 'file missing or not specified' 194 | exit 0 195 | fi 196 | JSON="$(xattr -p com.apple.metadata:_kMDItemUserTags "$1" | xxd -r -p | plutil -convert json - -o -)" 197 | echo $JSON > "$2" 198 | ''' 199 | temp = os.path.join(HOME, 'temp') 200 | if not os.path.exists(temp): 201 | os.makedirs(temp) 202 | write_file(gettag_sh, gettag_script, 0) 203 | subprocess.call(['chmod', '777', gettag_sh]) 204 | 205 | 206 | def write_log(message, file_bundle): 207 | if set_logging_on == True: 208 | log_file = os.path.join(import_done, 'bear_import_log.txt') 209 | time_stamp = datetime.datetime.now().strftime("%Y-%m-%d at %H:%M:%S") 210 | # file_name = os.path.split(file_path)[1] 211 | file_path = file_bundle.replace(import_path + '/', '') 212 | with open(log_file, 'a', encoding='utf-8') as f: 213 | f.write(time_stamp + ': ' + message + file_path +'\n') 214 | 215 | 216 | def write_file(filename, file_content, modified): 217 | with open(filename, "w", encoding='utf-8') as f: 218 | f.write(file_content) 219 | if modified > 0: 220 | os.utime(filename, (-1, modified)) 221 | 222 | 223 | def read_file(file_name): 224 | with open(file_name, "r", encoding='utf-8') as f: 225 | file_content = f.read() 226 | return file_content 227 | 228 | 229 | if __name__ == '__main__': 230 | main() 231 | -------------------------------------------------------------------------------- /bear_export_sync.py: -------------------------------------------------------------------------------- 1 | # encoding=utf-8 2 | # python3.6 3 | # bear_export_sync.py 4 | # Developed with Visual Studio Code with MS Python Extension. 5 | 6 | import shlex 7 | import objc 8 | from AppKit import NSWorkspace, NSWorkspaceOpenConfiguration, NSURL 9 | 10 | ''' 11 | # Markdown export from Bear sqlite database 12 | Version 1.4, 2020-01-11 13 | modified by: github/andymatuschak, andy_matuschak@twitter 14 | original author: github/rovest, rorves@twitter 15 | 16 | See also: bear_import.py for auto import to bear script. 17 | 18 | ## Sync external updates: 19 | First checks for changes in external Markdown files (previously exported from Bear) 20 | * Replacing text in original note with callback-url replace command 21 | (Keeping original creation date) 22 | If changes in title it will be added just below original title 23 | * New notes are added to Bear (with x-callback-url command) 24 | * New notes get tags from sub folder names, or `#.inbox` if root 25 | * Backing up original note as file to BearSyncBackup folder 26 | (unless a sync conflict, then both notes will be there) 27 | 28 | ## Export: 29 | Then exporting Markdown from Bear sqlite db. 30 | * check_if_modified() on database.sqlite to see if export is needed 31 | * Uses rsync for copying, so only markdown files of changed sheets will be updated 32 | and synced by Dropbox (or other sync services) 33 | * "Hides" tags with `period+space` on beginning of line: `. #tag` not appear as H1 in other apps. 34 | (This is removed if sync-back above) 35 | * Or instead hide tags in HTML comment blocks like: `` if `hide_tags_in_comment_block = True` 36 | * Makes subfolders named with first tag in note if `make_tag_folders = True` 37 | * Files can now be copied to multiple tag-folders if `multi_tags = True` 38 | * Export can now be restricted to a list of spesific tags: `limit_export_to_tags = ['bear/github', 'writings']` 39 | or leave list empty for all notes: `limit_export_to_tags = []` 40 | * Can export and link to images in common image repository 41 | * Or export as textbundles with images included 42 | ''' 43 | 44 | make_tag_folders = False # Exports to folders using first tag only, if `multi_tag_folders = False` 45 | multi_tag_folders = True # Copies notes to all 'tag-paths' found in note! 46 | # Only active if `make_tag_folders = True` 47 | hide_tags_in_comment_block = False # Hide tags in HTML comments: `` 48 | 49 | # The following two lists are more or less mutually exclusive, so use only one of them. 50 | # (You can use both if you have some nested tags where that makes sense) 51 | # Also, they only work if `make_tag_folders = True`. 52 | only_export_these_tags = [] # Leave this list empty for all notes! See below for sample 53 | # only_export_these_tags = ['bear/github', 'writings'] 54 | 55 | export_as_textbundles = False # Exports as Textbundles with images included 56 | export_as_hybrids = True # Exports as .textbundle only if images included, otherwise as .md 57 | # Only used if `export_as_textbundles = True` 58 | export_image_repository = True # Export all notes as md but link images to 59 | # a common repository exported to: `assets_path` 60 | # Only used if `export_as_textbundles = False` 61 | 62 | import os 63 | HOME = os.getenv('HOME', '') 64 | default_out_folder = os.path.join(HOME, "Work", "BearNotes") 65 | default_backup_folder = os.path.join(HOME, "Work", "BearSyncBackup") 66 | 67 | # NOTE! Your user 'HOME' path and '/BearNotes' is added below! 68 | # NOTE! So do not change anything below here!!! 69 | 70 | import sqlite3 71 | import datetime 72 | import re 73 | import subprocess 74 | import urllib.parse 75 | import time 76 | import shutil 77 | import fnmatch 78 | import json 79 | import argparse 80 | 81 | parser = argparse.ArgumentParser(description="Sync Bear notes") 82 | parser.add_argument("--out", default=default_out_folder, help="Path where Bear notes will be synced") 83 | parser.add_argument("--backup", default=default_backup_folder, help="Path where conflicts will be backed up (must be outside of --out)") 84 | parser.add_argument("--images", default=None, help="Path where images will be stored") 85 | parser.add_argument("--skipImport", action="store_const", const=True, default=False, help="When present, the script only exports from Bear to Markdown; it skips the import step.") 86 | parser.add_argument("--excludeTag", action="append", default=[], help="Don't export notes with this tag. Can be used multiple times.") 87 | parser.add_argument("--hideTags", action="store_const", const=True, default=False, help="Wrap tags in ") 88 | 89 | parsed_args = vars(parser.parse_args()) 90 | 91 | 92 | set_logging_on = True 93 | 94 | # NOTE! if 'BearNotes' is left blank, all other files in my_sync_service will be deleted!! 95 | export_path = parsed_args.get("out") 96 | no_export_tags = parsed_args.get("excludeTag") # If a tag in note matches one in this list, it will not be exported. 97 | hide_tags_in_comment_block = parsed_args.get("hideTags"); 98 | 99 | # NOTE! "export_path" is used for sync-back to Bear, so don't change this variable name! 100 | multi_export = [(export_path, True)] # only one folder output here. 101 | # Use if you want export to severa places like: Dropbox and OneDrive, etc. See below 102 | # Sample for multi folder export: 103 | # export_path_aux1 = os.path.join(HOME, 'OneDrive', 'BearNotes') 104 | # export_path_aux2 = os.path.join(HOME, 'Box', 'BearNotes') 105 | 106 | # NOTE! All files in export path not in Bear will be deleted if delete flag is "True"! 107 | # Set this flag fo False only for folders to keep old deleted versions of notes 108 | # multi_export = [(export_path, True), (export_path_aux1, False), (export_path_aux2, True)] 109 | 110 | temp_path = os.path.join(HOME, 'Temp', 'BearExportTemp') # NOTE! Do not change the "BearExportTemp" folder name!!! 111 | bear_db = os.path.join(HOME, 'Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/database.sqlite') 112 | sync_backup = parsed_args.get("backup") # Backup of original note before sync to Bear. 113 | log_file = os.path.join(sync_backup, 'bear_export_sync_log.txt') 114 | 115 | # Paths used in image exports: 116 | bear_image_path = os.path.join(HOME, 117 | 'Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/Local Files/Note Images') 118 | assets_path = parsed_args.get("images") if parsed_args.get("images") else os.path.join(export_path, 'BearImages') 119 | 120 | sync_ts = '.sync-time.log' 121 | export_ts = '.export-time.log' 122 | 123 | sync_ts_file = os.path.join(export_path, sync_ts) 124 | sync_ts_file_temp = os.path.join(temp_path, sync_ts) 125 | export_ts_file_exp = os.path.join(export_path, export_ts) 126 | export_ts_file = os.path.join(temp_path, export_ts) 127 | 128 | gettag_sh = os.path.join(HOME, 'temp/gettag.sh') 129 | gettag_txt = os.path.join(HOME, 'temp/gettag.txt') 130 | 131 | 132 | def main(): 133 | init_gettag_script() 134 | if not parsed_args.get("skipImport"): 135 | sync_md_updates() 136 | if check_db_modified(): 137 | delete_old_temp_files() 138 | note_count = export_markdown() 139 | write_time_stamp() 140 | rsync_files_from_temp() 141 | if export_image_repository and not export_as_textbundles: 142 | copy_bear_images() 143 | # notify('Export completed') 144 | write_log(str(note_count) + ' notes exported to: ' + export_path) 145 | exit(1) 146 | else: 147 | print('*** No notes needed exports') 148 | exit(0) 149 | 150 | 151 | def write_log(message): 152 | if set_logging_on == True: 153 | if not os.path.exists(sync_backup): 154 | os.makedirs(sync_backup) 155 | time_stamp = datetime.datetime.now().strftime("%Y-%m-%d at %H:%M:%S") 156 | message = message.replace(export_path + '/', '') 157 | with open(log_file, 'a', encoding='utf-8') as f: 158 | f.write(time_stamp + ': ' + message + '\n') 159 | 160 | 161 | def check_db_modified(): 162 | if not os.path.exists(sync_ts_file): 163 | return True 164 | db_ts = get_file_date(bear_db) 165 | last_export_ts = get_file_date(export_ts_file_exp) 166 | return db_ts > last_export_ts 167 | 168 | 169 | def export_markdown(): 170 | with sqlite3.connect(bear_db) as conn: 171 | conn.row_factory = sqlite3.Row 172 | query = "SELECT * FROM `ZSFNOTE` WHERE `ZTRASHED` LIKE '0' AND `ZARCHIVED` LIKE '0'" 173 | c = conn.execute(query) 174 | note_count = 0 175 | for row in c: 176 | title = row['ZTITLE'] 177 | md_text = row['ZTEXT'].rstrip() 178 | creation_date = row['ZCREATIONDATE'] 179 | modified = row['ZMODIFICATIONDATE'] 180 | uuid = row['ZUNIQUEIDENTIFIER'] 181 | pk = row['Z_PK'] 182 | filename = clean_title(title) 183 | file_list = [] 184 | if make_tag_folders: 185 | file_list = sub_path_from_tag(temp_path, filename, md_text) 186 | else: 187 | is_excluded = False 188 | for no_tag in no_export_tags: 189 | if ("#" + no_tag) in md_text: 190 | is_excluded = True 191 | break 192 | if not is_excluded: 193 | file_list.append(os.path.join(temp_path, filename)) 194 | if file_list: 195 | mod_dt = dt_conv(modified) 196 | md_text = hide_tags(md_text) 197 | md_text += '\n\n\n' 198 | for filepath in file_list: 199 | note_count += 1 200 | # print(filepath) 201 | if export_as_textbundles: 202 | if check_image_hybrid(md_text): 203 | make_text_bundle(md_text, filepath, mod_dt) 204 | else: 205 | write_file(filepath + '.md', md_text, mod_dt, creation_date) 206 | elif export_image_repository: 207 | md_proc_text = process_image_links(md_text, filepath, conn, pk) 208 | write_file(filepath + '.md', md_proc_text, mod_dt, creation_date) 209 | else: 210 | write_file(filepath + '.md', md_text, mod_dt, creation_date) 211 | return note_count 212 | 213 | 214 | def check_image_hybrid(md_text): 215 | if export_as_hybrids: 216 | if re.search(r'\[image:(.+?)\]', md_text): 217 | return True 218 | else: 219 | return False 220 | else: 221 | return True 222 | 223 | 224 | def make_text_bundle(md_text, filepath, mod_dt): 225 | ''' 226 | Exports as Textbundles with images included 227 | ''' 228 | bundle_path = filepath + '.textbundle' 229 | assets_path = os.path.join(bundle_path, 'assets') 230 | if not os.path.exists(bundle_path): 231 | os.makedirs(bundle_path) 232 | os.makedirs(assets_path) 233 | 234 | info = '''{ 235 | "transient" : true, 236 | "type" : "net.daringfireball.markdown", 237 | "creatorIdentifier" : "net.shinyfrog.bear", 238 | "version" : 2 239 | }''' 240 | matches = re.findall(r'\[image:(.+?)\]', md_text) 241 | for match in matches: 242 | image_name = match 243 | new_name = image_name.replace('/', '_') 244 | source = os.path.join(bear_image_path, image_name) 245 | target = os.path.join(assets_path, new_name) 246 | shutil.copy2(source, target) 247 | 248 | md_text = re.sub(r'\[image:(.+?)/(.+?)\]', r'![](assets/\1_\2)', md_text) 249 | write_file(bundle_path + '/text.md', md_text, mod_dt, 0) 250 | write_file(bundle_path + '/info.json', info, mod_dt, 0) 251 | os.utime(bundle_path, (-1, mod_dt)) 252 | 253 | 254 | def sub_path_from_tag(temp_path, filename, md_text): 255 | # Get tags in note: 256 | pattern1 = r'(?', md_text) 377 | return md_text 378 | 379 | 380 | def restore_tags(md_text): 381 | # Tags back to normal Bear tags, stripping the `period+space` at start of line: 382 | if hide_tags_in_comment_block: 383 | md_text = re.sub(r'(\n)', r'\1\2', md_text) 384 | return md_text 385 | 386 | 387 | def clean_title(title): 388 | title = title[:225].strip() 389 | if title == "": 390 | title = "Untitled" 391 | title = re.sub(r'[\/\\:]', r'-', title) 392 | title = re.sub(r'-$', r'', title) 393 | return title.strip() 394 | 395 | 396 | def write_file(filename, file_content, modified, created): 397 | with open(filename, "w", encoding='utf-8') as f: 398 | f.write(file_content) 399 | if modified > 0: 400 | os.utime(filename, (-1, modified)) 401 | if created > 0: 402 | newnum = dt_conv(created) 403 | dtdate = datetime.datetime.fromtimestamp(newnum) 404 | datestring = dtdate.strftime("%m/%d/%Y %H:%M:%S") 405 | command = 'SetFile -d "' + datestring + '" ' + shlex.quote(filename) 406 | subprocess.call(command, shell=True) 407 | 408 | 409 | def read_file(file_name): 410 | with open(file_name, "r", encoding='utf-8') as f: 411 | file_content = f.read() 412 | return file_content 413 | 414 | 415 | def get_file_date(filename): 416 | try: 417 | t = os.path.getmtime(filename) 418 | return t 419 | except: 420 | return 0 421 | 422 | 423 | def dt_conv(dtnum): 424 | # Formula for date offset based on trial and error: 425 | hour = 3600 # seconds 426 | year = 365.25 * 24 * hour 427 | offset = year * 31 + hour * 6 428 | return dtnum + offset 429 | 430 | 431 | def date_time_conv(dtnum): 432 | newnum = dt_conv(dtnum) 433 | dtdate = datetime.datetime.fromtimestamp(newnum) 434 | #print(newnum, dtdate) 435 | return dtdate.strftime(' - %Y-%m-%d_%H%M') 436 | 437 | 438 | def time_stamp_ts(ts): 439 | dtdate = datetime.datetime.fromtimestamp(ts) 440 | return dtdate.strftime('%Y-%m-%d at %H:%M') 441 | 442 | 443 | def date_conv(dtnum): 444 | dtdate = datetime.datetime.fromtimestamp(dtnum) 445 | return dtdate.strftime('%Y-%m-%d') 446 | 447 | 448 | def delete_old_temp_files(): 449 | # Deletes all files in temp folder before new export using "shutil.rmtree()": 450 | # NOTE! CAUTION! Do not change this function unless you really know shutil.rmtree() well! 451 | if os.path.exists(temp_path) and "BearExportTemp" in temp_path: 452 | # *** NOTE! Double checking that temp_path folder actually contains "BearExportTemp" 453 | # *** Because if temp_path is accidentally empty or root, 454 | # *** shutil.rmtree() will delete all files on your complete Hard Drive ;( 455 | shutil.rmtree(temp_path) 456 | # *** NOTE: USE rmtree() WITH EXTREME CAUTION! 457 | os.makedirs(temp_path) 458 | 459 | 460 | def rsync_files_from_temp(): 461 | # Moves markdown files to new folder using rsync: 462 | # This is a very important step! 463 | # By first exporting all Bear notes to an emptied temp folder, 464 | # rsync will only update destination if modified or size have changed. 465 | # So only changed notes will be synced by Dropbox or OneDrive destinations. 466 | # Rsync will also delete notes on destination if deleted in Bear. 467 | # So doing it this way saves a lot of otherwise very complex programing. 468 | # Thank you very much, Rsync! ;) 469 | for (dest_path, delete) in multi_export: 470 | if not os.path.exists(dest_path): 471 | os.makedirs(dest_path) 472 | if delete: 473 | subprocess.call(['rsync', '-r', '-t', '--crtimes', '-E', '--delete', 474 | '--exclude', 'BearImages/', 475 | '--exclude', '.obsidian/', 476 | '--exclude', '.Ulysses*', 477 | '--exclude', '*.Ulysses_Public_Filter', 478 | temp_path + "/", dest_path]) 479 | else: 480 | subprocess.call(['rsync', '-r', '-t', '-E', 481 | temp_path + "/", dest_path]) 482 | 483 | 484 | def sync_md_updates(): 485 | updates_found = False 486 | if not os.path.exists(sync_ts_file) or not os.path.exists(export_ts_file): 487 | return False 488 | ts_last_sync = os.path.getmtime(sync_ts_file) 489 | ts_last_export = os.path.getmtime(export_ts_file) 490 | # Update synced timestamp file: 491 | update_sync_time_file(0) 492 | file_types = ('*.md', '*.txt', '*.markdown') 493 | for (root, dirnames, filenames) in os.walk(export_path): 494 | if '.obsidian' in dirnames: 495 | dirnames.remove('.obsidian') 496 | ''' 497 | This step walks down into all sub folders, if any. 498 | ''' 499 | for pattern in file_types: 500 | for filename in fnmatch.filter(filenames, pattern): 501 | md_file = os.path.join(root, filename) 502 | ts = os.path.getmtime(md_file) 503 | if ts > ts_last_sync: 504 | if not updates_found: # Yet 505 | # Wait 5 sec at first for external files to finish downloading from dropbox. 506 | # Otherwise images in textbundles might be missing in import: 507 | time.sleep(5) 508 | updates_found = True 509 | md_text = read_file(md_file) 510 | backup_ext_note(md_file) 511 | if check_if_image_added(md_text, md_file): 512 | textbundle_to_bear(md_text, md_file, ts) 513 | write_log('Imported to Bear: ' + md_file) 514 | else: 515 | update_bear_note(md_text, md_file, ts, ts_last_export) 516 | write_log('Bear Note Updated: ' + md_file) 517 | if updates_found: 518 | # Give Bear time to process updates: 519 | time.sleep(3) 520 | # Check again, just in case new updates synced from remote (OneDrive/Dropbox) 521 | # during this process! 522 | # The logic is not 100% fool proof, but should be close to 99.99% 523 | sync_md_updates() # Recursive call 524 | return updates_found 525 | 526 | 527 | def check_if_image_added(md_text, md_file): 528 | if not '.textbundle/' in md_file: 529 | return False 530 | matches = re.findall(r'!\[.*?\]\(assets/(.+?_).+?\)', md_text) 531 | for image_match in matches: 532 | 'F89CDA3D-3FCC-4E92-88C1-CC4AF46FA733-10097-00002BBE9F7FF804_IMG_2280.JPG' 533 | if not re.match(r'[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}-[0-9A-F]{3,5}-[0-9A-F]{16}_', image_match): 534 | return True 535 | return False 536 | 537 | 538 | def textbundle_to_bear(md_text, md_file, mod_dt): 539 | md_text = restore_tags(md_text) 540 | bundle = os.path.split(md_file)[0] 541 | match = re.search(r'\{BearID:(.+?)\}', md_text) 542 | if match: 543 | uuid = match.group(1) 544 | # Remove old BearID: from new note 545 | md_text = re.sub(r'\<\!-- ?\{BearID\:' + uuid + r'\} ?--\>', '', md_text).rstrip() + '\n' 546 | md_text = insert_link_top_note(md_text, 'Images added! Link to original note: ', uuid) 547 | else: 548 | # New textbundle (with images), add path as tag: 549 | md_text = get_tag_from_path(md_text, bundle, export_path) 550 | write_file(md_file, md_text, mod_dt, 0) 551 | os.utime(bundle, (-1, mod_dt)) 552 | subprocess.call(['open', '-a', '/applications/bear.app', bundle]) 553 | time.sleep(0.5) 554 | 555 | 556 | def backup_ext_note(md_file): 557 | if '.textbundle' in md_file: 558 | bundle_path = os.path.split(md_file)[0] 559 | bundle_name = os.path.split(bundle_path)[1] 560 | target = os.path.join(sync_backup, bundle_name) 561 | bundle_raw = os.path.splitext(target)[0] 562 | count = 2 563 | while os.path.exists(target): 564 | # Adding sequence number to identical filenames, preventing overwrite: 565 | target = bundle_raw + " - " + str(count).zfill(2) + ".textbundle" 566 | count += 1 567 | shutil.copytree(bundle_path, target) 568 | else: 569 | # Overwrite former bacups of incoming changes, only keeps last one: 570 | shutil.copy2(md_file, sync_backup + '/') 571 | 572 | 573 | def update_sync_time_file(ts): 574 | write_file(sync_ts_file, 575 | "Checked for Markdown updates to sync at: " + 576 | datetime.datetime.now().strftime("%Y-%m-%d at %H:%M:%S"), ts, 0) 577 | 578 | 579 | def update_bear_note(md_text, md_file, ts, ts_last_export): 580 | md_text = restore_tags(md_text) 581 | md_text = restore_image_links(md_text) 582 | uuid = '' 583 | match = re.search(r'\{BearID:(.+?)\}', md_text) 584 | sync_conflict = False 585 | if match: 586 | uuid = match.group(1) 587 | # Remove old BearID: from new note 588 | md_text = re.sub(r'\<\!-- ?\{BearID\:' + uuid + r'\} ?--\>', '', md_text).rstrip() + '\n' 589 | 590 | sync_conflict = check_sync_conflict(uuid, ts_last_export) 591 | if sync_conflict: 592 | link_original = 'bear://x-callback-url/open-note?id=' + uuid 593 | message = '::Sync conflict! External update: ' + time_stamp_ts(ts) + '::' 594 | message += '\n[Click here to see original Bear note](' + link_original + ')' 595 | x_create = 'bear://x-callback-url/create?show_window=no&open_note=no' 596 | bear_x_callback(x_create, md_text, message, '') 597 | else: 598 | # Regular external update 599 | orig_title = backup_bear_note(uuid) 600 | # message = '::External update: ' + time_stamp_ts(ts) + '::' 601 | x_replace = 'bear://x-callback-url/add-text?show_window=no&open_note=no&mode=replace&id=' + uuid 602 | bear_x_callback(x_replace, md_text, '', orig_title) 603 | # # Trash old original note: 604 | # x_trash = 'bear://x-callback-url/trash?show_window=no&id=' + uuid 605 | # subprocess.call(["open", x_trash]) 606 | # time.sleep(.2) 607 | else: 608 | # New external md Note, since no Bear uuid found in text: 609 | # message = '::New external Note - ' + time_stamp_ts(ts) + '::' 610 | md_text = get_tag_from_path(md_text, md_file, export_path) 611 | x_create = 'bear://x-callback-url/create?show_window=no' 612 | bear_x_callback(x_create, md_text, '', '') 613 | return 614 | 615 | 616 | def get_tag_from_path(md_text, md_file, root_path, inbox_for_root=False, extra_tag=''): 617 | # extra_tag should be passed as '#tag' or '#space tag#' 618 | path = md_file.replace(root_path, '')[1:] 619 | sub_path = os.path.split(path)[0] 620 | tags = [] 621 | if '.textbundle' in sub_path: 622 | sub_path = os.path.split(sub_path)[0] 623 | if sub_path == '': 624 | if inbox_for_root: 625 | tag = '#.inbox' 626 | else: 627 | tag = '' 628 | elif sub_path.startswith('_'): 629 | tag = '#.' + sub_path[1:].strip() 630 | else: 631 | tag = '#' + sub_path.strip() 632 | if ' ' in tag: 633 | tag += "#" 634 | if tag != '': 635 | tags.append(tag) 636 | if extra_tag != '': 637 | tags.append(extra_tag) 638 | for tag in get_file_tags(md_file): 639 | tag = '#' + tag.strip() 640 | if ' ' in tag: tag += "#" 641 | tags.append(tag) 642 | return md_text.strip() + '\n\n' + ' '.join(tags) + '\n' 643 | 644 | 645 | def get_file_tags(md_file): 646 | try: 647 | subprocess.call([gettag_sh, md_file, gettag_txt]) 648 | text = re.sub(r'\\n\d{1,2}', r'', read_file(gettag_txt)) 649 | tag_list = json.loads(text) 650 | return tag_list 651 | except: 652 | return [] 653 | 654 | 655 | open_config = NSWorkspaceOpenConfiguration.alloc().init() 656 | open_config.setActivates_(False) 657 | 658 | def bear_x_callback(x_command, md_text, message, orig_title): 659 | if message != '': 660 | lines = md_text.splitlines() 661 | lines.insert(1, message) 662 | md_text = '\n'.join(lines) 663 | if orig_title != '': 664 | lines = md_text.splitlines() 665 | title = re.sub(r'^#+ ', r'', lines[0]) 666 | if title != orig_title: 667 | md_text = '\n'.join(lines) 668 | else: 669 | md_text = '\n'.join(lines[1:]) 670 | x_command_text = x_command + '&text=' + urllib.parse.quote(md_text) 671 | url = NSURL.URLWithString_(x_command_text) 672 | NSWorkspace.sharedWorkspace().openURL_configuration_completionHandler_(url, open_config, None) 673 | time.sleep(.2) 674 | 675 | 676 | def check_sync_conflict(uuid, ts_last_export): 677 | conflict = False 678 | # Check modified date of original note in Bear sqlite db! 679 | with sqlite3.connect(bear_db) as conn: 680 | conn.row_factory = sqlite3.Row 681 | query = "SELECT * FROM `ZSFNOTE` WHERE `ZTRASHED` LIKE '0' AND `ZUNIQUEIDENTIFIER` LIKE '" + uuid + "'" 682 | c = conn.execute(query) 683 | for row in c: 684 | modified = row['ZMODIFICATIONDATE'] 685 | uuid = row['ZUNIQUEIDENTIFIER'] 686 | mod_dt = dt_conv(modified) 687 | conflict = mod_dt > ts_last_export 688 | return conflict 689 | 690 | 691 | def backup_bear_note(uuid): 692 | # Get single note from Bear sqlite db! 693 | with sqlite3.connect(bear_db) as conn: 694 | conn.row_factory = sqlite3.Row 695 | query = "SELECT * FROM `ZSFNOTE` WHERE `ZUNIQUEIDENTIFIER` LIKE '" + uuid + "'" 696 | c = conn.execute(query) 697 | title = '' 698 | for row in c: # Will only get one row if uuid is found! 699 | title = row['ZTITLE'] 700 | md_text = row['ZTEXT'].rstrip() 701 | modified = row['ZMODIFICATIONDATE'] 702 | mod_dt = dt_conv(modified) 703 | created = row['ZCREATIONDATE'] 704 | cre_dt = dt_conv(created) 705 | md_text = insert_link_top_note(md_text, 'Link to updated note: ', uuid) 706 | dtdate = datetime.datetime.fromtimestamp(cre_dt) 707 | filename = clean_title(title) + dtdate.strftime(' - %Y-%m-%d_%H%M') 708 | if not os.path.exists(sync_backup): 709 | os.makedirs(sync_backup) 710 | file_part = os.path.join(sync_backup, filename) 711 | # This is a Bear text file, not exactly markdown. 712 | backup_file = file_part + ".txt" 713 | count = 2 714 | while os.path.exists(backup_file): 715 | # Adding sequence number to identical filenames, preventing overwrite: 716 | backup_file = file_part + " - " + str(count).zfill(2) + ".txt" 717 | count += 1 718 | write_file(backup_file, md_text, mod_dt, created) 719 | filename2 = os.path.split(backup_file)[1] 720 | write_log('Original to sync_backup: ' + filename2) 721 | return title 722 | 723 | 724 | def insert_link_top_note(md_text, message, uuid): 725 | lines = md_text.split('\n') 726 | title = re.sub(r'^#{1,6} ', r'', lines[0]) 727 | link = '::' + message + '[' + title + '](bear://x-callback-url/open-note?id=' + uuid + ')::' 728 | lines.insert(1, link) 729 | return '\n'.join(lines) 730 | 731 | 732 | def init_gettag_script(): 733 | gettag_script = \ 734 | '''#!/bin/bash 735 | if [[ ! -e $1 ]] ; then 736 | echo 'file missing or not specified' 737 | exit 0 738 | fi 739 | JSON="$(xattr -p com.apple.metadata:_kMDItemUserTags "$1" | xxd -r -p | plutil -convert json - -o -)" 740 | echo $JSON > "$2" 741 | ''' 742 | temp = os.path.join(HOME, 'temp') 743 | if not os.path.exists(temp): 744 | os.makedirs(temp) 745 | write_file(gettag_sh, gettag_script, 0, 0) 746 | subprocess.call(['chmod', '777', gettag_sh]) 747 | 748 | 749 | def notify(message): 750 | title = "ul_sync_md.py" 751 | try: 752 | # Uses "terminal-notifier", download at: 753 | # https://github.com/julienXX/terminal-notifier/releases/download/2.0.0/terminal-notifier-2.0.0.zip 754 | # Only works with MacOS 10.11+ 755 | subprocess.call(['/Applications/terminal-notifier.app/Contents/MacOS/terminal-notifier', 756 | '-message', message, "-title", title, '-sound', 'default']) 757 | except: 758 | write_log('"terminal-notifier.app" is missing!') 759 | return 760 | 761 | 762 | if __name__ == '__main__': 763 | main() 764 | --------------------------------------------------------------------------------