├── requirements.txt ├── test-data ├── file with .dot.md ├── file with space.md ├── file title name not same.md ├── subfoler │ └── subfile.md ├── front_matter not dict.md ├── assets folder with space │ ├── image with space.png │ └── image with space2.png └── test-file.md ├── .gitignore ├── README_zh.md ├── README_jp.md ├── README.md └── md-to-bundle.py /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML==6.0 2 | -------------------------------------------------------------------------------- /test-data/file with .dot.md: -------------------------------------------------------------------------------- 1 | # file with .dot -------------------------------------------------------------------------------- /test-data/file with space.md: -------------------------------------------------------------------------------- 1 | # file with space -------------------------------------------------------------------------------- /test-data/file title name not same.md: -------------------------------------------------------------------------------- 1 | # what ever the title is -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .obsidian 3 | __pycache__ 4 | test-data-export -------------------------------------------------------------------------------- /test-data/subfoler/subfile.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | # subfile 5 | 6 | -------------------------------------------------------------------------------- /test-data/front_matter not dict.md: -------------------------------------------------------------------------------- 1 | --- 2 | haha 3 | --- 4 | # front_matter not dict -------------------------------------------------------------------------------- /test-data/assets folder with space/image with space.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devinhurry/markdown-to-textbundle-for-bear/HEAD/test-data/assets folder with space/image with space.png -------------------------------------------------------------------------------- /test-data/assets folder with space/image with space2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devinhurry/markdown-to-textbundle-for-bear/HEAD/test-data/assets folder with space/image with space2.png -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 | # Markdown笔记转换成Bear Notes文本包 2 | ## 环境 3 | - 需要Python 3 4 | - 运行pip install -r requirements.txt 安装依赖 5 | ## 使用方法 6 | 1. 运行`python md-to-bundle.py 你的Markdown文件夹路径` 7 | 2. 使用子文件夹名称作为标签:`python md-to-bundle.py 你的Markdown文件夹路径 --tags` 8 | 9 | 导出的文件将在`你的Markdown文件夹路径-export`目录中。 10 | # 功能 11 | - 将附件复制到文本包中 12 | - 支持Obsidian的`![[file]]`格式 13 | - 保留修改时间 14 | - 可选地使用子文件夹名称作为标签(使用`--tags`选项) 15 | - 从front matter中获取信息 16 | - 测试过的应用程序 17 | - Obsidian 18 | - Joplin(Markdown和Markdown + Front Matter) -------------------------------------------------------------------------------- /test-data/test-file.md: -------------------------------------------------------------------------------- 1 | --- 2 | tags: 3 | - tag1 4 | - tag2/tag3 5 | --- 6 | # test-file 7 | ![img 1](assets%20folder%20with%20space/image%20with%20space.png) 8 | ![file with space](file%20with%20space.md) 9 | [file with space](file%20with%20space.md) 10 | ![file with space](./file%20with%20space.md) 11 | 12 | [[file with space]] 13 | [[file with .dot]] 14 | ![[assets folder with space/image with space2.png]] 15 | ![[file with space]] 16 | ![[file with .dot]] 17 | 18 | [[file title name not same]] 19 | 20 | ![](filenotexist.md) 21 | 22 | ![[subfile]] 23 | 24 | ![](Image (4).png) -------------------------------------------------------------------------------- /README_jp.md: -------------------------------------------------------------------------------- 1 | # MarkdownノートをBearノートのテキストバンドルに変換する 2 | ## 環境 3 | - Python 3が必要です 4 | - `pip install -r requirements.txt`を実行して依存関係をインストールしてください。 5 | ## 使い方 6 | 1. `python md-to-bundle.py your-markdown-folder`を実行します。 7 | 2. サブフォルダ名をタグとして使用する場合: `python md-to-bundle.py your-markdown-folder --tags` 8 | 9 | エクスポートされたファイルは`your-markdown-folder-export`に保存されます。 10 | ## 機能 11 | - 添付ファイルをテキストバンドルにコピーする 12 | - Obsidianの`![[file]]`フォーマットをサポート 13 | - 変更日時を保持する 14 | - サブフォルダ名をオプションでタグとして使用できる(`--tags`オプションで指定) 15 | - front matterから情報を取得する 16 | - テスト済みアプリケーション 17 | - Obsidian 18 | - Joplin(MarkdownとMarkdown + Front Matter) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markdown notes to bear notes text bundle 2 | 3 | [中文](README_zh.md) [日本語](README_jp.md) 4 | 5 | ## Environment 6 | - python 3 required 7 | - pip install -r requirements.txt 8 | 9 | ## Summary 10 | Takes as input the name of a folder containing markdown files 11 | and their associated images and attachments, and creates a `.textbundle` for each markdown file. 12 | Each `.textbundle` also contains any images or attachments used by the markdown file. 13 | It can handle both markdown and images/attachments held in subfolders. 14 | 15 | 16 | ## Usage 17 | 1. `python md-to-bundle.py your-markdown-folder` 18 | 2. use subfolder names as tags: `python md-to-bundle.py your-markdown-folder --tags` 19 | 20 | 21 | The exported `textbundle` files will be in `your-markdown-folder-export` 22 | 23 | # Features 24 | - Copy attachments to the text bundle 25 | - Support obsidian's `![[file]]` format 26 | - Convert `[](markdown.md)` to `[[markdown]]` 27 | - Insert file name(or title in front matter) as title to first line of document (because bear takes first line as title by default) 28 | - Preserve modification time (take modify time from front matter if has one) 29 | - Optionally use subfolder name as tags (with `--tags`) 30 | - Retrieve information from front matter 31 | - Tested apps 32 | - Obsidian 33 | - Joplin (exported as Markdown or Markdown + Front Matter) 34 | - Upnote (exported as Markdown) 35 | - Use the `--tags` option in order to capture Upnote 'Notebooks' as nested tags 36 | -------------------------------------------------------------------------------- /md-to-bundle.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import re 4 | import shutil 5 | import urllib.parse 6 | import argparse 7 | import yaml 8 | 9 | files_map = {} 10 | 11 | def read_file(file_path): 12 | with open(file_path, 'r', encoding='utf-8') as f: 13 | return f.read() 14 | 15 | 16 | def write_file(file_path, content): 17 | with open(file_path, 'w', encoding='utf-8') as f: 18 | f.write(content) 19 | 20 | 21 | def get_files(dir_path): 22 | files = [] 23 | global files_map 24 | for root, dirs, file_names in os.walk(dir_path): 25 | for file_name in file_names: 26 | file_path = os.path.join(root, file_name) 27 | files_map[file_name] = file_path 28 | files.append(file_path) 29 | return files 30 | 31 | 32 | def find_file(file_path): 33 | if os.path.exists(file_path): 34 | return file_path 35 | global files_map 36 | file_name = os.path.basename(file_path) 37 | if file_name in files_map: 38 | found_file = files_map[file_name] 39 | return found_file 40 | return None 41 | 42 | 43 | def parse_front_matter(content): 44 | front_matter = None 45 | end_index = 0 46 | if content.startswith('---'): 47 | end_index = content.find('---', 3) 48 | if end_index > 0: 49 | front_matter_content = content[3:end_index] 50 | front_matter = yaml.load( 51 | front_matter_content, Loader=yaml.FullLoader) 52 | end_index += 3 53 | return front_matter, end_index 54 | 55 | 56 | def md_to_bundle(md_path, add_tag, tag, export_dir): 57 | content = read_file(md_path) 58 | front_matter, end_index = parse_front_matter(content) 59 | basename = os.path.basename(md_path) 60 | 61 | # Dealing with tags 62 | if isinstance(front_matter, dict): 63 | tags = front_matter.get('tags', []) 64 | else: 65 | tags = [] 66 | if isinstance(tags, str): 67 | if tags == '': 68 | tags = [] 69 | else: 70 | tags = [tags] 71 | if add_tag and len(tag) > 0: 72 | tags.append(tag) 73 | 74 | if tags is not None and len(tags) > 0: 75 | fm_tags_str = '# #'.join(tags) 76 | # insert tag in end of file 77 | content = content + '\n#{}#'.format(fm_tags_str) 78 | 79 | 80 | # Find all links in the markdown 81 | # find patter like this ![somefile](somefile) 82 | # links_pattern = re.compile(r'(\[.*?\]\((.*?)\))') 83 | # Dealing with nested paranthesis 84 | links_pattern = re.compile(r'(\[.*?\]\(((?:[^()]|\((?:[^()]*\)))+)\))') 85 | 86 | # find patter like this ![[somefile]] 87 | wiki_links_pattern = re.compile(r'(\!\[\[(.*?)\]\])') 88 | links = links_pattern.finditer(content) 89 | wiki_links = wiki_links_pattern.finditer(content) 90 | 91 | # Because bear use document first line as title by default, 92 | # we need to insert title in front of markdown content if the first line is not title 93 | md_title = os.path.splitext(basename)[0] 94 | if front_matter is not None and 'title' in front_matter: 95 | md_title = front_matter['title'] 96 | # find the title of content(first no empty line) after front matter(end_index) 97 | lines = content[end_index:].splitlines() 98 | line = "" 99 | # print("end index is {}".format(end_index)) 100 | for line in lines: 101 | if line.strip(): 102 | line = line.strip() 103 | break 104 | # remove #+\s 105 | line = re.sub(r'^#+\s', '', line) 106 | if line == "": 107 | content = content + '\n#{}'.format(md_title) 108 | elif line != md_title: 109 | # insert title in front of content 110 | if end_index == 0: 111 | content = '# {}\n'.format(md_title) + content 112 | else: 113 | content = content[:end_index] + \ 114 | '\n# {}\n'.format(md_title) + content[end_index:] 115 | 116 | # Create textbundle dir 117 | bundle_path = os.path.join( 118 | export_dir, basename.replace('.md', '.textbundle')) 119 | if not os.path.exists(bundle_path): 120 | os.makedirs(bundle_path) 121 | 122 | # Create assets dir 123 | assetsPath = os.path.join(bundle_path, 'assets') 124 | if not os.path.exists(assetsPath): 125 | os.makedirs(assetsPath) 126 | 127 | # Create info.json 128 | info_path = os.path.join(bundle_path, 'info.json') 129 | info_content = '{"type":"net.daringfireball.markdown","version":2}' 130 | write_file(info_path, info_content) 131 | 132 | # Create text.md 133 | markdown_content = content 134 | 135 | # Many cases included 136 | # Case 1: ![](./markdown file.md) => [[markdown file]] [](./markdown file.md) => [[markdown file]] 137 | # Case 2: ![[markdown file]] => [[markdown file]] 138 | # Othercase: ![](./media file.png) => ![](./assets/media file.png) 139 | # ![[media file.png/jpeg/pdf...so on]] => ![](./assets/media file.png) 140 | # params: 141 | # full_link example: ![](./markdown file.md) 142 | # original_link example: "./markdown file.md" 143 | def copy_and_replace_assets(full_link, original_link, is_wiki_link): 144 | nonlocal markdown_content, assetsPath 145 | # replace path to assets/file_name 146 | if original_link == '': 147 | return 148 | if original_link.startswith('#'): 149 | return 150 | if original_link.startswith('http://') or original_link.startswith('https://'): 151 | return 152 | # if starts with [a-zA-Z]+:, eg. app: tel: return 153 | if re.match(r'^[a-zA-Z\-0-9\.]+:', original_link): 154 | return 155 | unquoted_filename = urllib.parse.unquote(original_link) 156 | 157 | # if it is a markdown file, replace it with wiki link 158 | # Case 1: ![](./markdown file.md) => [[markdown file]] [](./markdown file.md) => [[markdown file]] 159 | basename = os.path.basename(unquoted_filename) 160 | ext = os.path.splitext(basename)[1] 161 | title = os.path.splitext(basename)[0] 162 | if ext.lower() == '.md': 163 | # reg replace ![.*](./markdown file.md) to ![[markdown file]] 164 | escaped_file_name = re.escape(original_link) 165 | markdown_content = re.sub( 166 | rf'!\[.*\]\({escaped_file_name}\)', '[[{}]]'.format(title), markdown_content) 167 | markdown_content = re.sub( 168 | rf'\[.*\]\({escaped_file_name}\)', '[[{}]]'.format(title), markdown_content) 169 | return 170 | 171 | 172 | quotedfile_name = urllib.parse.quote( 173 | os.path.basename(unquoted_filename)) 174 | 175 | # the file link in markdown maybe urlencoded so we need to unquote to get true_file_name 176 | true_file_name = urllib.parse.unquote(original_link) 177 | # we guess the the link file is in same folder with markdown file(md_path), 178 | # if not, find globally (find_file(file_path)) 179 | file_path = os.path.join(os.path.dirname(md_path), true_file_name) 180 | backup_filepath = file_path 181 | file_path = find_file(file_path) 182 | 183 | # Case 2: ![[markdown file]] => [[markdown file]] 184 | # we cannot find "markdown file" but can find "markdown file.md" 185 | if is_wiki_link and not file_path and find_file(backup_filepath + '.md'): 186 | source_pattern = '![[{}]]' 187 | dest_pattern = '[[{}]]' 188 | # in wikilink, we need unquoted_filename 189 | markdown_content = markdown_content.replace(source_pattern.format(original_link), 190 | dest_pattern.format(unquoted_filename)) 191 | return 192 | 193 | # Othercase: ![](./media file.png) => ![](./assets/media file.png) 194 | # ![[media file.png/jpeg/pdf...so on]] => ![](./assets/media file.png) 195 | # all cases not link to a markdown file fallback to this case 196 | # copy file to textbundle/assets 197 | if file_path: 198 | if is_wiki_link: 199 | source_pattern = '![[{}]]' 200 | dest_pattern = '![]({})' 201 | else: 202 | source_pattern = '{}' 203 | dest_pattern = '{}' 204 | markdown_content = markdown_content.replace(source_pattern.format(original_link), 205 | dest_pattern.format(os.path.join('assets', quotedfile_name))) 206 | shutil.copyfile(file_path, os.path.join( 207 | assetsPath, os.path.basename(true_file_name))) 208 | else: 209 | # This case ![](media file.png) or ![[media file.png]] is not exist 210 | print('file in link not found: {} in Markdown file {}'.format( 211 | full_link, md_path)) 212 | pass 213 | 214 | # Copy files to assets dir and replace path 215 | for matches in links: 216 | copy_and_replace_assets(matches.group( 217 | 1), matches.group(2), is_wiki_link=False) 218 | 219 | # Copy files to assets dir and replace path (for ![[file]]) 220 | for matches in wiki_links: 221 | copy_and_replace_assets(matches.group( 222 | 1), matches.group(2), is_wiki_link=True) 223 | 224 | markdownContentPath = os.path.join(bundle_path, 'text.md') 225 | write_file(markdownContentPath, markdown_content) 226 | 227 | # Preserve creat time and modify time 228 | has_front_matter_time = False 229 | if isinstance(front_matter, dict): 230 | ctime = front_matter.get('created', None) 231 | mtime = front_matter.get('updated', None) 232 | if ctime is None: 233 | ctime = mtime 234 | if mtime is None: 235 | mtime = ctime 236 | # if is datetime 237 | if isinstance(ctime, datetime.datetime) and isinstance(mtime, datetime.datetime): 238 | os.utime(bundle_path, (ctime.timestamp(), mtime.timestamp())) 239 | has_front_matter_time = True 240 | if not has_front_matter_time: 241 | src_ctime = os.path.getctime(md_path) 242 | src_mtime = os.path.getmtime(md_path) 243 | os.utime(bundle_path, (src_ctime, src_mtime)) 244 | 245 | 246 | def main(): 247 | # get named arg --tags 248 | add_tag = False 249 | argparser = argparse.ArgumentParser() 250 | argparser.add_argument("markdown_notes_dir", help="markdown notes dir") 251 | argparser.add_argument('--tags', action='store_true', 252 | help='add tags to bear') 253 | args = argparser.parse_args() 254 | if args.tags: 255 | add_tag = True 256 | 257 | markdown_notes_dir = args.markdown_notes_dir 258 | markdown_notes_dir = markdown_notes_dir.rstrip(os.sep) 259 | print('Using markdown_notes_dir: {}'.format(markdown_notes_dir)) 260 | 261 | # export_dir is the same level with markdown_notes_dir and append '-export' 262 | export_dir = os.path.join(os.path.dirname( 263 | markdown_notes_dir), os.path.basename(markdown_notes_dir) + '-export') 264 | 265 | # Doing clean 266 | if os.path.exists(export_dir): 267 | # rm dir 268 | shutil.rmtree(export_dir) 269 | os.makedirs(export_dir) 270 | 271 | # Find all markdown files 272 | # trim the last separator 273 | markdown_notes_dir = markdown_notes_dir.rstrip(os.sep) 274 | files = get_files(markdown_notes_dir) 275 | 276 | count = 0 277 | for file in files: 278 | if file.endswith(".md"): 279 | relative_path = os.path.relpath(file, markdown_notes_dir) 280 | tag = os.path.dirname(relative_path).strip(os.path.sep) 281 | md_to_bundle(file, add_tag, tag, export_dir) 282 | count += 1 283 | print('Exported to {}, Total count: {}'.format(export_dir, count)) 284 | 285 | if __name__ == '__main__': 286 | main() 287 | --------------------------------------------------------------------------------