Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
722 lines (621 sloc)
27.6 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # encoding=utf-8 | |
| # python3.6 | |
| # bear_export_sync.py | |
| # Developed with Visual Studio Code with MS Python Extension. | |
| ''' | |
| # Markdown export from Bear sqlite database | |
| Version 1.4, 2020-01-11 | |
| modified by: github/andymatuschak, andy_matuschak@twitter | |
| original author: github/rovest, rorves@twitter | |
| See also: bear_import.py for auto import to bear script. | |
| ## Sync external updates: | |
| First checks for changes in external Markdown files (previously exported from Bear) | |
| * Replacing text in original note with callback-url replace command | |
| (Keeping original creation date) | |
| If changes in title it will be added just below original title | |
| * New notes are added to Bear (with x-callback-url command) | |
| * New notes get tags from sub folder names, or `#.inbox` if root | |
| * Backing up original note as file to BearSyncBackup folder | |
| (unless a sync conflict, then both notes will be there) | |
| ## Export: | |
| Then exporting Markdown from Bear sqlite db. | |
| * check_if_modified() on database.sqlite to see if export is needed | |
| * Uses rsync for copying, so only markdown files of changed sheets will be updated | |
| and synced by Dropbox (or other sync services) | |
| * "Hides" tags with `period+space` on beginning of line: `. #tag` not appear as H1 in other apps. | |
| (This is removed if sync-back above) | |
| * Or instead hide tags in HTML comment blocks like: `<!-- #mytag -->` if `hide_tags_in_comment_block = True` | |
| * Makes subfolders named with first tag in note if `make_tag_folders = True` | |
| * Files can now be copied to multiple tag-folders if `multi_tags = True` | |
| * Export can now be restricted to a list of spesific tags: `limit_export_to_tags = ['bear/github', 'writings']` | |
| or leave list empty for all notes: `limit_export_to_tags = []` | |
| * Can export and link to images in common image repository | |
| * Or export as textbundles with images included | |
| ''' | |
| make_tag_folders = True # Exports to folders using first tag only, if `multi_tag_folders = False` | |
| multi_tag_folders = True # Copies notes to all 'tag-paths' found in note! | |
| # Only active if `make_tag_folders = True` | |
| hide_tags_in_comment_block = True # Hide tags in HTML comments: `<!-- #mytag -->` | |
| # The following two lists are more or less mutually exclusive, so use only one of them. | |
| # (You can use both if you have some nested tags where that makes sense) | |
| # Also, they only work if `make_tag_folders = True`. | |
| only_export_these_tags = [] # Leave this list empty for all notes! See below for sample | |
| # only_export_these_tags = ['bear/github', 'writings'] | |
| export_as_textbundles = False # Exports as Textbundles with images included | |
| export_as_hybrids = True # Exports as .textbundle only if images included, otherwise as .md | |
| # Only used if `export_as_textbundles = True` | |
| export_image_repository = True # Export all notes as md but link images to | |
| # a common repository exported to: `assets_path` | |
| # Only used if `export_as_textbundles = False` | |
| import os | |
| HOME = os.getenv('HOME', '') | |
| default_out_folder = os.path.join(HOME, "Work", "BearNotes") | |
| default_backup_folder = os.path.join(HOME, "Work", "BearSyncBackup") | |
| # NOTE! Your user 'HOME' path and '/BearNotes' is added below! | |
| # NOTE! So do not change anything below here!!! | |
| import sqlite3 | |
| import datetime | |
| import re | |
| import subprocess | |
| import urllib.parse | |
| import time | |
| import shutil | |
| import fnmatch | |
| import json | |
| import argparse | |
| parser = argparse.ArgumentParser(description="Sync Bear notes") | |
| parser.add_argument("--out", default=default_out_folder, help="Path where Bear notes will be synced") | |
| parser.add_argument("--backup", default=default_backup_folder, help="Path where conflicts will be backed up (must be outside of --out)") | |
| parser.add_argument("--images", default=None, help="Path where images will be stored") | |
| 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.") | |
| parser.add_argument("--excludeTag", action="append", default=[], help="Don't export notes with this tag. Can be used multiple times.") | |
| parsed_args = vars(parser.parse_args()) | |
| set_logging_on = True | |
| # NOTE! if 'BearNotes' is left blank, all other files in my_sync_service will be deleted!! | |
| export_path = parsed_args.get("out") | |
| no_export_tags = parsed_args.get("excludeTag") # If a tag in note matches one in this list, it will not be exported. | |
| # NOTE! "export_path" is used for sync-back to Bear, so don't change this variable name! | |
| multi_export = [(export_path, True)] # only one folder output here. | |
| # Use if you want export to severa places like: Dropbox and OneDrive, etc. See below | |
| # Sample for multi folder export: | |
| # export_path_aux1 = os.path.join(HOME, 'OneDrive', 'BearNotes') | |
| # export_path_aux2 = os.path.join(HOME, 'Box', 'BearNotes') | |
| # NOTE! All files in export path not in Bear will be deleted if delete flag is "True"! | |
| # Set this flag fo False only for folders to keep old deleted versions of notes | |
| # multi_export = [(export_path, True), (export_path_aux1, False), (export_path_aux2, True)] | |
| temp_path = os.path.join(HOME, 'Temp', 'BearExportTemp') # NOTE! Do not change the "BearExportTemp" folder name!!! | |
| bear_db = os.path.join(HOME, 'Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/database.sqlite') | |
| sync_backup = parsed_args.get("backup") # Backup of original note before sync to Bear. | |
| log_file = os.path.join(sync_backup, 'bear_export_sync_log.txt') | |
| # Paths used in image exports: | |
| bear_image_path = os.path.join(HOME, | |
| 'Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/Local Files/Note Images') | |
| assets_path = parsed_args.get("images") if parsed_args.get("images") else os.path.join(export_path, 'BearImages') | |
| sync_ts = '.sync-time.log' | |
| export_ts = '.export-time.log' | |
| sync_ts_file = os.path.join(export_path, sync_ts) | |
| sync_ts_file_temp = os.path.join(temp_path, sync_ts) | |
| export_ts_file_exp = os.path.join(export_path, export_ts) | |
| export_ts_file = os.path.join(temp_path, export_ts) | |
| gettag_sh = os.path.join(HOME, 'temp/gettag.sh') | |
| gettag_txt = os.path.join(HOME, 'temp/gettag.txt') | |
| def main(): | |
| init_gettag_script() | |
| if not parsed_args.get("skipImport"): | |
| sync_md_updates() | |
| if check_db_modified(): | |
| delete_old_temp_files() | |
| note_count = export_markdown() | |
| write_time_stamp() | |
| rsync_files_from_temp() | |
| if export_image_repository and not export_as_textbundles: | |
| copy_bear_images() | |
| # notify('Export completed') | |
| write_log(str(note_count) + ' notes exported to: ' + export_path) | |
| exit(1) | |
| else: | |
| print('*** No notes needed exports') | |
| exit(0) | |
| def write_log(message): | |
| if set_logging_on == True: | |
| if not os.path.exists(sync_backup): | |
| os.makedirs(sync_backup) | |
| time_stamp = datetime.datetime.now().strftime("%Y-%m-%d at %H:%M:%S") | |
| message = message.replace(export_path + '/', '') | |
| with open(log_file, 'a', encoding='utf-8') as f: | |
| f.write(time_stamp + ': ' + message + '\n') | |
| def check_db_modified(): | |
| if not os.path.exists(sync_ts_file): | |
| return True | |
| db_ts = get_file_date(bear_db) | |
| last_export_ts = get_file_date(export_ts_file_exp) | |
| return db_ts > last_export_ts | |
| def export_markdown(): | |
| with sqlite3.connect(bear_db) as conn: | |
| conn.row_factory = sqlite3.Row | |
| query = "SELECT * FROM `ZSFNOTE` WHERE `ZTRASHED` LIKE '0' AND `ZARCHIVED` LIKE '0'" | |
| c = conn.execute(query) | |
| note_count = 0 | |
| for row in c: | |
| title = row['ZTITLE'] | |
| md_text = row['ZTEXT'].rstrip() | |
| creation_date = row['ZCREATIONDATE'] | |
| modified = row['ZMODIFICATIONDATE'] | |
| uuid = row['ZUNIQUEIDENTIFIER'] | |
| filename = clean_title(title) | |
| file_list = [] | |
| if make_tag_folders: | |
| file_list = sub_path_from_tag(temp_path, filename, md_text) | |
| else: | |
| file_list.append(os.path.join(temp_path, filename)) | |
| if file_list: | |
| mod_dt = dt_conv(modified) | |
| md_text = hide_tags(md_text) | |
| md_text += '\n\n<!-- {BearID:' + uuid + '} -->\n' | |
| for filepath in file_list: | |
| note_count += 1 | |
| # print(filepath) | |
| if export_as_textbundles: | |
| if check_image_hybrid(md_text): | |
| make_text_bundle(md_text, filepath, mod_dt) | |
| else: | |
| write_file(filepath + '.md', md_text, mod_dt) | |
| elif export_image_repository: | |
| md_proc_text = process_image_links(md_text, filepath) | |
| write_file(filepath + '.md', md_proc_text, mod_dt) | |
| else: | |
| write_file(filepath + '.md', md_text, mod_dt) | |
| return note_count | |
| def check_image_hybrid(md_text): | |
| if export_as_hybrids: | |
| if re.search(r'\[image:(.+?)\]', md_text): | |
| return True | |
| else: | |
| return False | |
| else: | |
| return True | |
| def make_text_bundle(md_text, filepath, mod_dt): | |
| ''' | |
| Exports as Textbundles with images included | |
| ''' | |
| bundle_path = filepath + '.textbundle' | |
| assets_path = os.path.join(bundle_path, 'assets') | |
| if not os.path.exists(bundle_path): | |
| os.makedirs(bundle_path) | |
| os.makedirs(assets_path) | |
| info = '''{ | |
| "transient" : true, | |
| "type" : "net.daringfireball.markdown", | |
| "creatorIdentifier" : "net.shinyfrog.bear", | |
| "version" : 2 | |
| }''' | |
| matches = re.findall(r'\[image:(.+?)\]', md_text) | |
| for match in matches: | |
| image_name = match | |
| new_name = image_name.replace('/', '_') | |
| source = os.path.join(bear_image_path, image_name) | |
| target = os.path.join(assets_path, new_name) | |
| shutil.copy2(source, target) | |
| md_text = re.sub(r'\[image:(.+?)/(.+?)\]', r'', md_text) | |
| write_file(bundle_path + '/text.md', md_text, mod_dt) | |
| write_file(bundle_path + '/info.json', info, mod_dt) | |
| os.utime(bundle_path, (-1, mod_dt)) | |
| def sub_path_from_tag(temp_path, filename, md_text): | |
| # Get tags in note: | |
| pattern1 = r'(?<!\S)\#([.\w\/\-]+)[ \n]?(?!([\/ \w]+\w[#]))' | |
| pattern2 = r'(?<![\S])\#([^ \d][.\w\/ ]+?)\#([ \n]|$)' | |
| if multi_tag_folders: | |
| # Files copied to all tag-folders found in note | |
| tags = [] | |
| for matches in re.findall(pattern1, md_text): | |
| tag = matches[0] | |
| tags.append(tag) | |
| for matches2 in re.findall(pattern2, md_text): | |
| tag2 = matches2[0] | |
| tags.append(tag2) | |
| if len(tags) == 0: | |
| # No tags found, copy to root level only | |
| return [os.path.join(temp_path, filename)] | |
| else: | |
| # Only folder for first tag | |
| match1 = re.search(pattern1, md_text) | |
| match2 = re.search(pattern2, md_text) | |
| if match1 and match2: | |
| if match1.start(1) < match2.start(1): | |
| tag = match1.group(1) | |
| else: | |
| tag = match2.group(1) | |
| elif match1: | |
| tag = match1.group(1) | |
| elif match2: | |
| tag = match2.group(1) | |
| else: | |
| # No tags found, copy to root level only | |
| return [os.path.join(temp_path, filename)] | |
| tags = [tag] | |
| paths = [os.path.join(temp_path, filename)] | |
| for tag in tags: | |
| if tag == '/': | |
| continue | |
| if only_export_these_tags: | |
| export = False | |
| for export_tag in only_export_these_tags: | |
| if tag.lower().startswith(export_tag.lower()): | |
| export = True | |
| break | |
| if not export: | |
| continue | |
| for no_tag in no_export_tags: | |
| if tag.lower().startswith(no_tag.lower()): | |
| return [] | |
| if tag.startswith('.'): | |
| # Avoid hidden path if it starts with a '.' | |
| sub_path = '_' + tag[1:] | |
| else: | |
| sub_path = tag | |
| tag_path = os.path.join(temp_path, sub_path) | |
| if not os.path.exists(tag_path): | |
| os.makedirs(tag_path) | |
| paths.append(os.path.join(tag_path, filename)) | |
| return paths | |
| def process_image_links(md_text, filepath): | |
| ''' | |
| Bear image links converted to MD links | |
| ''' | |
| root = filepath.replace(temp_path, '') | |
| level = len(root.split('/')) - 2 | |
| parent = '../' * level | |
| md_text = re.sub(r'\[image:(.+?)\]', r'', md_text) | |
| return md_text | |
| def restore_image_links(md_text): | |
| ''' | |
| MD image links restored back to Bear links | |
| ''' | |
| #if not re.search(r'!\[.*?\]\(assets/.+?\)', md_text): | |
| # No image links in note, return unchanged: | |
| # return md_text | |
| if export_as_textbundles: | |
| md_text = re.sub(r'!\[(.*?)\]\(assets/(.+?)_(.+?)( ".+?")?\) ?', r'[image:\2/\3]\4 \1', md_text) | |
| elif export_image_repository : | |
| # md_text = re.sub(r'\[image:(.+?)\]', r'', md_text) | |
| md_text = re.sub(r'!\[\]\((\.\./)*BearImages/(.+?)\)', r'[image:\2]', md_text) | |
| return md_text | |
| def copy_bear_images(): | |
| # Image files copied to a common image repository | |
| subprocess.call(['rsync', '-r', '-t', '--delete', | |
| bear_image_path + "/", assets_path]) | |
| def write_time_stamp(): | |
| # write to time-stamp.txt file (used during sync) | |
| write_file(export_ts_file, "Markdown from Bear written at: " + | |
| datetime.datetime.now().strftime("%Y-%m-%d at %H:%M:%S"), 0) | |
| write_file(sync_ts_file_temp, "Markdown from Bear written at: " + | |
| datetime.datetime.now().strftime("%Y-%m-%d at %H:%M:%S"), 0) | |
| def hide_tags(md_text): | |
| # Hide tags from being seen as H1, by placing `period+space` at start of line: | |
| if hide_tags_in_comment_block: | |
| md_text = re.sub(r'(\n)[ \t]*(\#[^\s#].*)', r'\1<!-- \2 -->', md_text) | |
| else: | |
| md_text = re.sub(r'(\n)[ \t]*(\#[^\s#]+)', r'\1. \2', md_text) | |
| return md_text | |
| def restore_tags(md_text): | |
| # Tags back to normal Bear tags, stripping the `period+space` at start of line: | |
| # if hide_tags_in_comment_block: | |
| md_text = re.sub(r'(\n)<!--[ \t]*(\#[^\s#].*?) -->', r'\1\2', md_text) | |
| # else: | |
| md_text = re.sub(r'(\n)\.[ \t]*(\#[^\s#]+)', r'\1\2', md_text) | |
| return md_text | |
| def clean_title(title): | |
| title = title[:256].strip() | |
| if title == "": | |
| title = "Untitled" | |
| title = re.sub(r'[/\\*?$@!^&\|~:\.]', r'-', title) | |
| title = re.sub(r'-$', r'', title) | |
| return title.strip() | |
| def write_file(filename, file_content, modified): | |
| with open(filename, "w", encoding='utf-8') as f: | |
| f.write(file_content) | |
| if modified > 0: | |
| os.utime(filename, (-1, modified)) | |
| def read_file(file_name): | |
| with open(file_name, "r", encoding='utf-8') as f: | |
| file_content = f.read() | |
| return file_content | |
| def get_file_date(filename): | |
| try: | |
| t = os.path.getmtime(filename) | |
| return t | |
| except: | |
| return 0 | |
| def dt_conv(dtnum): | |
| # Formula for date offset based on trial and error: | |
| hour = 3600 # seconds | |
| year = 365.25 * 24 * hour | |
| offset = year * 31 + hour * 6 | |
| return dtnum + offset | |
| def date_time_conv(dtnum): | |
| newnum = dt_conv(dtnum) | |
| dtdate = datetime.datetime.fromtimestamp(newnum) | |
| #print(newnum, dtdate) | |
| return dtdate.strftime(' - %Y-%m-%d_%H%M') | |
| def time_stamp_ts(ts): | |
| dtdate = datetime.datetime.fromtimestamp(ts) | |
| return dtdate.strftime('%Y-%m-%d at %H:%M') | |
| def date_conv(dtnum): | |
| dtdate = datetime.datetime.fromtimestamp(dtnum) | |
| return dtdate.strftime('%Y-%m-%d') | |
| def delete_old_temp_files(): | |
| # Deletes all files in temp folder before new export using "shutil.rmtree()": | |
| # NOTE! CAUTION! Do not change this function unless you really know shutil.rmtree() well! | |
| if os.path.exists(temp_path) and "BearExportTemp" in temp_path: | |
| # *** NOTE! Double checking that temp_path folder actually contains "BearExportTemp" | |
| # *** Because if temp_path is accidentally empty or root, | |
| # *** shutil.rmtree() will delete all files on your complete Hard Drive ;( | |
| shutil.rmtree(temp_path) | |
| # *** NOTE: USE rmtree() WITH EXTREME CAUTION! | |
| os.makedirs(temp_path) | |
| def rsync_files_from_temp(): | |
| # Moves markdown files to new folder using rsync: | |
| # This is a very important step! | |
| # By first exporting all Bear notes to an emptied temp folder, | |
| # rsync will only update destination if modified or size have changed. | |
| # So only changed notes will be synced by Dropbox or OneDrive destinations. | |
| # Rsync will also delete notes on destination if deleted in Bear. | |
| # So doing it this way saves a lot of otherwise very complex programing. | |
| # Thank you very much, Rsync! ;) | |
| for (dest_path, delete) in multi_export: | |
| if not os.path.exists(dest_path): | |
| os.makedirs(dest_path) | |
| if delete: | |
| subprocess.call(['rsync', '-r', '-t', '-E', '--delete', | |
| '--exclude', 'BearImages/', | |
| '--exclude', '.Ulysses*', | |
| '--exclude', '*.Ulysses_Public_Filter', | |
| temp_path + "/", dest_path]) | |
| else: | |
| subprocess.call(['rsync', '-r', '-t', '-E', | |
| temp_path + "/", dest_path]) | |
| def sync_md_updates(): | |
| updates_found = False | |
| if not os.path.exists(sync_ts_file) or not os.path.exists(export_ts_file): | |
| return False | |
| ts_last_sync = os.path.getmtime(sync_ts_file) | |
| ts_last_export = os.path.getmtime(export_ts_file) | |
| # Update synced timestamp file: | |
| update_sync_time_file(0) | |
| file_types = ('*.md', '*.txt', '*.markdown') | |
| for (root, dirnames, filenames) in os.walk(export_path): | |
| ''' | |
| This step walks down into all sub folders, if any. | |
| ''' | |
| for pattern in file_types: | |
| for filename in fnmatch.filter(filenames, pattern): | |
| md_file = os.path.join(root, filename) | |
| ts = os.path.getmtime(md_file) | |
| if ts > ts_last_sync: | |
| if not updates_found: # Yet | |
| # Wait 5 sec at first for external files to finish downloading from dropbox. | |
| # Otherwise images in textbundles might be missing in import: | |
| time.sleep(5) | |
| updates_found = True | |
| md_text = read_file(md_file) | |
| backup_ext_note(md_file) | |
| if check_if_image_added(md_text, md_file): | |
| textbundle_to_bear(md_text, md_file, ts) | |
| write_log('Imported to Bear: ' + md_file) | |
| else: | |
| update_bear_note(md_text, md_file, ts, ts_last_export) | |
| write_log('Bear Note Updated: ' + md_file) | |
| if updates_found: | |
| # Give Bear time to process updates: | |
| time.sleep(3) | |
| # Check again, just in case new updates synced from remote (OneDrive/Dropbox) | |
| # during this process! | |
| # The logic is not 100% fool proof, but should be close to 99.99% | |
| sync_md_updates() # Recursive call | |
| return updates_found | |
| def check_if_image_added(md_text, md_file): | |
| if not '.textbundle/' in md_file: | |
| return False | |
| matches = re.findall(r'!\[.*?\]\(assets/(.+?_).+?\)', md_text) | |
| for image_match in matches: | |
| 'F89CDA3D-3FCC-4E92-88C1-CC4AF46FA733-10097-00002BBE9F7FF804_IMG_2280.JPG' | |
| 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): | |
| return True | |
| return False | |
| def textbundle_to_bear(md_text, md_file, mod_dt): | |
| md_text = restore_tags(md_text) | |
| bundle = os.path.split(md_file)[0] | |
| match = re.search(r'\{BearID:(.+?)\}', md_text) | |
| if match: | |
| uuid = match.group(1) | |
| # Remove old BearID: from new note | |
| md_text = re.sub(r'\<\!-- ?\{BearID\:' + uuid + r'\} ?--\>', '', md_text).rstrip() + '\n' | |
| md_text = insert_link_top_note(md_text, 'Images added! Link to original note: ', uuid) | |
| else: | |
| # New textbundle (with images), add path as tag: | |
| md_text = get_tag_from_path(md_text, bundle, export_path) | |
| write_file(md_file, md_text, mod_dt) | |
| os.utime(bundle, (-1, mod_dt)) | |
| subprocess.call(['open', '-a', '/applications/bear.app', bundle]) | |
| time.sleep(0.5) | |
| def backup_ext_note(md_file): | |
| if '.textbundle' in md_file: | |
| bundle_path = os.path.split(md_file)[0] | |
| bundle_name = os.path.split(bundle_path)[1] | |
| target = os.path.join(sync_backup, bundle_name) | |
| bundle_raw = os.path.splitext(target)[0] | |
| count = 2 | |
| while os.path.exists(target): | |
| # Adding sequence number to identical filenames, preventing overwrite: | |
| target = bundle_raw + " - " + str(count).zfill(2) + ".textbundle" | |
| count += 1 | |
| shutil.copytree(bundle_path, target) | |
| else: | |
| # Overwrite former bacups of incoming changes, only keeps last one: | |
| shutil.copy2(md_file, sync_backup + '/') | |
| def update_sync_time_file(ts): | |
| write_file(sync_ts_file, | |
| "Checked for Markdown updates to sync at: " + | |
| datetime.datetime.now().strftime("%Y-%m-%d at %H:%M:%S"), ts) | |
| def update_bear_note(md_text, md_file, ts, ts_last_export): | |
| md_text = restore_tags(md_text) | |
| md_text = restore_image_links(md_text) | |
| uuid = '' | |
| match = re.search(r'\{BearID:(.+?)\}', md_text) | |
| sync_conflict = False | |
| if match: | |
| uuid = match.group(1) | |
| # Remove old BearID: from new note | |
| md_text = re.sub(r'\<\!-- ?\{BearID\:' + uuid + r'\} ?--\>', '', md_text).rstrip() + '\n' | |
| sync_conflict = check_sync_conflict(uuid, ts_last_export) | |
| if sync_conflict: | |
| link_original = 'bear://x-callback-url/open-note?id=' + uuid | |
| message = '::Sync conflict! External update: ' + time_stamp_ts(ts) + '::' | |
| message += '\n[Click here to see original Bear note](' + link_original + ')' | |
| x_create = 'bear://x-callback-url/create?show_window=no&open_note=no' | |
| bear_x_callback(x_create, md_text, message, '') | |
| else: | |
| # Regular external update | |
| orig_title = backup_bear_note(uuid) | |
| # message = '::External update: ' + time_stamp_ts(ts) + '::' | |
| x_replace = 'bear://x-callback-url/add-text?show_window=no&open_note=no&mode=replace&id=' + uuid | |
| bear_x_callback(x_replace, md_text, '', orig_title) | |
| # # Trash old original note: | |
| # x_trash = 'bear://x-callback-url/trash?show_window=no&id=' + uuid | |
| # subprocess.call(["open", x_trash]) | |
| # time.sleep(.2) | |
| else: | |
| # New external md Note, since no Bear uuid found in text: | |
| # message = '::New external Note - ' + time_stamp_ts(ts) + '::' | |
| md_text = get_tag_from_path(md_text, md_file, export_path) | |
| x_create = 'bear://x-callback-url/create?show_window=no' | |
| bear_x_callback(x_create, md_text, '', '') | |
| return | |
| def get_tag_from_path(md_text, md_file, root_path, inbox_for_root=True, extra_tag=''): | |
| # extra_tag should be passed as '#tag' or '#space tag#' | |
| path = md_file.replace(root_path, '')[1:] | |
| sub_path = os.path.split(path)[0] | |
| tags = [] | |
| if '.textbundle' in sub_path: | |
| sub_path = os.path.split(sub_path)[0] | |
| if sub_path == '': | |
| if inbox_for_root: | |
| tag = '#.inbox' | |
| else: | |
| tag = '' | |
| elif sub_path.startswith('_'): | |
| tag = '#.' + sub_path[1:].strip() | |
| else: | |
| tag = '#' + sub_path.strip() | |
| if ' ' in tag: | |
| tag += "#" | |
| if tag != '': | |
| tags.append(tag) | |
| if extra_tag != '': | |
| tags.append(extra_tag) | |
| for tag in get_file_tags(md_file): | |
| tag = '#' + tag.strip() | |
| if ' ' in tag: tag += "#" | |
| tags.append(tag) | |
| return md_text.strip() + '\n\n' + ' '.join(tags) + '\n' | |
| def get_file_tags(md_file): | |
| try: | |
| subprocess.call([gettag_sh, md_file, gettag_txt]) | |
| text = re.sub(r'\\n\d{1,2}', r'', read_file(gettag_txt)) | |
| tag_list = json.loads(text) | |
| return tag_list | |
| except: | |
| return [] | |
| def bear_x_callback(x_command, md_text, message, orig_title): | |
| if message != '': | |
| lines = md_text.splitlines() | |
| lines.insert(1, message) | |
| md_text = '\n'.join(lines) | |
| if orig_title != '': | |
| lines = md_text.splitlines() | |
| title = re.sub(r'^#+ ', r'', lines[0]) | |
| if title != orig_title: | |
| md_text = '\n'.join(lines) | |
| else: | |
| md_text = '\n'.join(lines[1:]) | |
| x_command_text = x_command + '&text=' + urllib.parse.quote(md_text) | |
| subprocess.call(["open", "-g", x_command_text]) | |
| time.sleep(.2) | |
| def check_sync_conflict(uuid, ts_last_export): | |
| conflict = False | |
| # Check modified date of original note in Bear sqlite db! | |
| with sqlite3.connect(bear_db) as conn: | |
| conn.row_factory = sqlite3.Row | |
| query = "SELECT * FROM `ZSFNOTE` WHERE `ZTRASHED` LIKE '0' AND `ZUNIQUEIDENTIFIER` LIKE '" + uuid + "'" | |
| c = conn.execute(query) | |
| for row in c: | |
| modified = row['ZMODIFICATIONDATE'] | |
| uuid = row['ZUNIQUEIDENTIFIER'] | |
| mod_dt = dt_conv(modified) | |
| conflict = mod_dt > ts_last_export | |
| return conflict | |
| def backup_bear_note(uuid): | |
| # Get single note from Bear sqlite db! | |
| with sqlite3.connect(bear_db) as conn: | |
| conn.row_factory = sqlite3.Row | |
| query = "SELECT * FROM `ZSFNOTE` WHERE `ZUNIQUEIDENTIFIER` LIKE '" + uuid + "'" | |
| c = conn.execute(query) | |
| title = '' | |
| for row in c: # Will only get one row if uuid is found! | |
| title = row['ZTITLE'] | |
| md_text = row['ZTEXT'].rstrip() | |
| modified = row['ZMODIFICATIONDATE'] | |
| mod_dt = dt_conv(modified) | |
| created = row['ZCREATIONDATE'] | |
| cre_dt = dt_conv(created) | |
| md_text = insert_link_top_note(md_text, 'Link to updated note: ', uuid) | |
| dtdate = datetime.datetime.fromtimestamp(cre_dt) | |
| filename = clean_title(title) + dtdate.strftime(' - %Y-%m-%d_%H%M') | |
| if not os.path.exists(sync_backup): | |
| os.makedirs(sync_backup) | |
| file_part = os.path.join(sync_backup, filename) | |
| # This is a Bear text file, not exactly markdown. | |
| backup_file = file_part + ".txt" | |
| count = 2 | |
| while os.path.exists(backup_file): | |
| # Adding sequence number to identical filenames, preventing overwrite: | |
| backup_file = file_part + " - " + str(count).zfill(2) + ".txt" | |
| count += 1 | |
| write_file(backup_file, md_text, mod_dt) | |
| filename2 = os.path.split(backup_file)[1] | |
| write_log('Original to sync_backup: ' + filename2) | |
| return title | |
| def insert_link_top_note(md_text, message, uuid): | |
| lines = md_text.split('\n') | |
| title = re.sub(r'^#{1,6} ', r'', lines[0]) | |
| link = '::' + message + '[' + title + '](bear://x-callback-url/open-note?id=' + uuid + ')::' | |
| lines.insert(1, link) | |
| return '\n'.join(lines) | |
| def init_gettag_script(): | |
| gettag_script = \ | |
| '''#!/bin/bash | |
| if [[ ! -e $1 ]] ; then | |
| echo 'file missing or not specified' | |
| exit 0 | |
| fi | |
| JSON="$(xattr -p com.apple.metadata:_kMDItemUserTags "$1" | xxd -r -p | plutil -convert json - -o -)" | |
| echo $JSON > "$2" | |
| ''' | |
| temp = os.path.join(HOME, 'temp') | |
| if not os.path.exists(temp): | |
| os.makedirs(temp) | |
| write_file(gettag_sh, gettag_script, 0) | |
| subprocess.call(['chmod', '777', gettag_sh]) | |
| def notify(message): | |
| title = "ul_sync_md.py" | |
| try: | |
| # Uses "terminal-notifier", download at: | |
| # https://github.com/julienXX/terminal-notifier/releases/download/2.0.0/terminal-notifier-2.0.0.zip | |
| # Only works with MacOS 10.11+ | |
| subprocess.call(['/Applications/terminal-notifier.app/Contents/MacOS/terminal-notifier', | |
| '-message', message, "-title", title, '-sound', 'default']) | |
| except: | |
| write_log('"terminal-notifier.app" is missing!') | |
| return | |
| if __name__ == '__main__': | |
| main() |