├── .gitignore ├── LICENSE ├── README.md ├── ankdown ├── __init__.py ├── ankdown.py └── highlight.css ├── requirements.txt ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # Friggin Apple 104 | .DS_Store 105 | 106 | # PyCharm 107 | .idea/ 108 | 109 | # VSCode 110 | .vscode 111 | *.code-workspace -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ben Weinstein-Raun 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ankdown 2 | 3 | A simple way to write Anki decks in Markdown. 4 | 5 | ## What This Is 6 | 7 | [Anki](https://apps.ankiweb.net) is awesome, in many ways. 8 | However, its card editor is... a little bit uncomfortable. 9 | I really wanted to write Anki cards in Markdown. So I made 10 | a tool to convert Markdown (+ standard MathJAX math notation) 11 | into Anki decks that can be easily imported. This way, it's 12 | possible to use any fancy markdown (and MathJAX) tools to build 13 | your decks. 14 | 15 | ## How to use it 16 | 17 | **NOTE** This program requires _Python 3_, along with the 18 | packages in requirements.txt 19 | 20 | ### Installing 21 | 22 | Ankdown can be installed by doing `pip3 install --user ankdown`. 23 | 24 | ### Writing Cards 25 | 26 | Cards are written in the following format: 27 | 28 | ```markdown 29 | Expected Value of \(f(x)\) 30 | 31 | % 32 | 33 | \[\mathbb{E}[f(x)] = \sum_x p(x)f(x)\] 34 | 35 | % 36 | 37 | math, probability 38 | 39 | --- 40 | 41 | Variance of \(f(x)\) 42 | 43 | % 44 | 45 | \[\text{Var}(f(x)) = \mathbb{E}[(f(x) - \mathbb{E}[f(x)])^2]\] 46 | 47 | ``` 48 | 49 | Each of the solitary `%` signs is a field separator: the first 50 | field is the front of the card, the second field is 51 | the back of the card, and subsequent fields can contain whatever 52 | you want them to (all fields after the second are optional). 53 | 54 | `---` markers represent a card boundary. 55 | 56 | The tool needs these separators to be alone on their own lines, 57 | and most markdown editors will work better if you separate them from 58 | other text with empty lines, so that they're treated as their own 59 | paragraphs by the editor. 60 | 61 | ### Running Ankdown 62 | 63 | #### Method A: manually 64 | 65 | To compile your cards, put them in markdown files with `.md` extensions, 66 | inside of a directory that has the name of the deck you'd like to put 67 | the cards into. Then, run `ankdown -r [directory] -p [package filename]`. 68 | The package filename should end in `.apkg`. 69 | 70 | You can then import the package using the Anki import tool. To do this, 71 | go to `File > Import`, and then in the drop-down menu select "Packaged 72 | Anki Deck/Collection (*.apkg)". 73 | 74 | #### Method B: via the add-on 75 | 76 | Once you've installed ankdown, it can be a hassle to run it on all 77 | of your decks over and over again. There is an [`ankdown` 78 | Anki add-on](https://ankiweb.net/shared/info/109255569) that you 79 | can use to make this process simpler: If you put all of your decks 80 | in one megadirectory (mine is in `~/Flashcards`), you can re-import 81 | your decks in one swell foop by going to `Tools > Reload Markdown 82 | Decks` (or using the operating-system-dependent keybinding). 83 | 84 | 85 | ## Gotchas 86 | 87 | Ankdown has an unusually large number of known issues; my preferred method 88 | of discussing them is via github ticket. 89 | 90 | ### Multiple Decks 91 | 92 | Ankdown uses Genanki as a backend, which doesn't (as of this writing) handle 93 | multiple decks in a single package very well. If you point ankdown at a 94 | directory with multiple decks in subdirectories, it will do its best, and 95 | your cards will all be added to the package, but they won't be assigned 96 | to the correct decks. The ankdown plugin solves this problem by running 97 | the executable on each deck individually, and then importing all the 98 | resulting packages. 99 | 100 | ### Intentional feature removals 101 | 102 | There used to be other ways to run ankdown, but they were slowly making 103 | the code worse and worse as I tried to keep them all operational. If there's 104 | a particular method of operating ankdown that you used and miss, let me know 105 | in a github issue. 106 | 107 | ### Math separators 108 | 109 | Unfortunately, `$` and `$$` as math separators were not chosen by the anki 110 | developers for the desktop client's MathJax display, and so in order for math 111 | to work in both web and desktop, it became much simpler to use `\(\)` and 112 | `\[\]`. These separators should be configurable in most markdown editors 113 | (e.g. I use the VSCode Markdown+Math plugin). Older decks that were built 114 | for ankdown need to be modified to use the new separators. 115 | 116 | ### Media references 117 | 118 | Ankdown should work with media references that result in `src=""` appearing 119 | somewhere in the generated html (mainly images). If you need it to work with 120 | other media types (like sounds), let me know in a github issue and I may make 121 | time to fix this. 122 | 123 | ### Updating Cards 124 | 125 | When you want to modify a card, just run your deck through the above 126 | process after changing the markdown file. Anki should notice, and update 127 | the card. This is done by giving the cards in your deck unique IDs based on 128 | their filename and index in the file. 129 | 130 | This is the most robust solution I could come up with, but it has some downsides: 131 | 132 | 1. It's not possible to automatically remove cards from your anki decks, since 133 | the anki package importer never deletes cards. 134 | 2. If you delete a card from a markdown file, ankdown will give all of its 135 | successors off-by-one ID numbers, and so if they were different in important 136 | ways (like how much you needed to study them), anki will get confused. 137 | The best way to deal with this is to give each card its own markdown file. 138 | 139 | ### General code quality 140 | 141 | Lastly, the catch-all disclaimer: this is, as they say, alpha-quality software. 142 | I wrote this program (and the add-on) to work for me; it's pretty likely that 143 | you'll hit bugs in proportion to how different your desires are from mine. That 144 | said, I want it to be useful for other people as well; please submit github 145 | tickets if you do run into problems! 146 | 147 | -------------------------------------------------------------------------------- /ankdown/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benwr/ankdown/92d547546e390f6df7ebe2242bbf67b1e025b537/ankdown/__init__.py -------------------------------------------------------------------------------- /ankdown/ankdown.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Ankdown: Convert Markdown files into anki decks. 3 | 4 | This is a hacky script that I wrote because I wanted to use 5 | aesthetically pleasing editing tools to make anki cards, instead of 6 | the (somewhat annoying, imo) card editor in the anki desktop app. 7 | 8 | The math support is via MathJax, which is more full-featured (and 9 | much prettier) than Anki's builtin LaTeX support. 10 | 11 | The markdown inputs should look like this: 12 | 13 | ``` 14 | First Card Front ![alt_text](local_image.png) 15 | 16 | % 17 | 18 | First Card Back: \\(\\text{TeX inline math}\\) 19 | 20 | % 21 | 22 | first, card, tags 23 | 24 | --- 25 | 26 | Second Card Front: 27 | 28 | \\[\\text{TeX Math environment}\\] 29 | 30 | % 31 | 32 | Second Card Back (note that tags are optional) 33 | ``` 34 | 35 | Ankdown can be configured via yaml. A possible configuration file might look like this: 36 | 37 | ```yaml 38 | recur_dir: ~/ankdown_cards 39 | pkg_arg: ~/ankdown_cards.apkg 40 | card_model_name: CustomModelName 41 | card_model_css: ".card {font-family: 'Crimson Pro', 'Crimson Text', 'Cardo', 'Times', 'serif'; text-align: left; color: black; background-color: white;}" 42 | dollar: True 43 | ``` 44 | 45 | A configuration can also be passed as a string: `"{dollar: True, card_model_name: CustomModelName, card_model_css: \".card {text-align: left;}\"}"` 46 | 47 | Usage: 48 | ankdown.py [-r DIR] [-p PACKAGENAME] [--highlight] [--config CONFIG_STRING] [--configFile CONFIG_FILE_PATH] 49 | 50 | Options: 51 | -h --help Show this help message 52 | --version Show version 53 | 54 | -r DIR Recursively visit DIR, accumulating cards from `.md` files. 55 | 56 | -p PACKAGE Instead of a .txt file, produce a .apkg file. recommended. 57 | 58 | --highlight Enable syntax highlighting for code 59 | 60 | --config CONFIG_STRING ankdown configuration as YAML string 61 | 62 | --configFile CONFIG_FILE_PATH path to ankdown configuration as YAML file 63 | """ 64 | 65 | 66 | import hashlib 67 | import os 68 | import re 69 | import tempfile 70 | import textwrap 71 | 72 | from shutil import copyfile 73 | 74 | import misaka 75 | import genanki 76 | import yaml 77 | 78 | from docopt import docopt 79 | 80 | import houdini as h 81 | from pygments import highlight 82 | from pygments.formatters import HtmlFormatter, ClassNotFound 83 | from pygments.lexers import get_lexer_by_name 84 | 85 | 86 | class HighlighterRenderer(misaka.HtmlRenderer): 87 | def blockcode(self, text, lang): 88 | try: 89 | lexer = get_lexer_by_name(lang, stripall=True) 90 | except ClassNotFound: 91 | lexer = None 92 | 93 | if lexer: 94 | formatter = HtmlFormatter() 95 | return highlight(text, lexer, formatter) 96 | # default 97 | return '\n
{}
\n'.format( 98 | h.escape_html(text.strip())) 99 | 100 | 101 | renderer = HighlighterRenderer() 102 | highlight_markdown = misaka.Markdown(renderer, extensions=("fenced-code", "math")) 103 | 104 | 105 | VERSION = "0.7.1" 106 | 107 | # Anki 2.1 has mathjax built in, but ankidroid and other clients don't. 108 | CARD_MATHJAX_CONTENT = textwrap.dedent("""\ 109 | 120 | 133 | """) 134 | 135 | CONFIG = { 136 | 'pkg_arg': 'AnkdownPkg.apkg', 137 | 'recur_dir': '.', 138 | 'dollar': False, 139 | 'highlight': False, 140 | 'card_model_name': 'Ankdown Model 2', 141 | 'card_model_css': """ 142 | .card { 143 | font-family: 'Crimson Pro', 'Crimson Text', 'Cardo', 'Times', 'serif'; 144 | text-align: center; 145 | color: black; 146 | background-color: white; 147 | } 148 | """, 149 | 'card_model_fields': [ 150 | {"name": "Question"}, 151 | {"name": "Answer"}, 152 | {"name": "Tags"}, 153 | ], 154 | 'card_model_templates': [ 155 | { 156 | "name": "Ankdown Card", 157 | "qfmt": "{{{{Question}}}}\n{0}".format(CARD_MATHJAX_CONTENT), 158 | "afmt": "{{{{Question}}}}
{{{{Answer}}}}\n{0}".format(CARD_MATHJAX_CONTENT), 159 | } 160 | ] 161 | } 162 | 163 | 164 | def simple_hash(text): 165 | """MD5 of text, mod 2^63. Probably not a great hash function.""" 166 | h = hashlib.md5() 167 | h.update(text.encode("utf-8")) 168 | return int(h.hexdigest(), 16) % (1 << 63) 169 | 170 | 171 | class Card(object): 172 | """A single anki card.""" 173 | 174 | def __init__(self, filename, file_index): 175 | self.fields = [] 176 | self.filename = filename 177 | self.file_index = file_index 178 | self.model = genanki.Model( 179 | simple_hash(CONFIG['card_model_name']), 180 | CONFIG['card_model_name'], 181 | fields=CONFIG['card_model_fields'], 182 | templates=CONFIG['card_model_templates'], 183 | css=CONFIG['card_model_css'] 184 | ) 185 | 186 | def deckdir(self): 187 | return os.path.dirname(self.filename) 188 | 189 | def deckname(self): 190 | return os.path.basename(self.deckdir()) 191 | 192 | def basename(self): 193 | return os.path.basename(self.filename) 194 | 195 | def card_id(self): 196 | return "{}/{}{}".format(self.deckname(), self.basename(), self.file_index) 197 | 198 | def add_field(self, field): 199 | self.fields.append(field) 200 | 201 | def has_data(self): 202 | return len(self.fields) > 0 and any([s.strip() for s in self.fields]) 203 | 204 | def has_front_and_back(self): 205 | return len(self.fields) >= 2 206 | 207 | def finalize(self): 208 | """Ensure proper shape, for extraction into result formats.""" 209 | if len(self.fields) > 3: 210 | self.fields = self.fields[:3] 211 | else: 212 | while len(self.fields) < 3: 213 | self.fields.append('') 214 | 215 | def guid(self): 216 | return simple_hash(self.card_id()) 217 | 218 | def to_genanki_note(self): 219 | """Produce a genanki.Note with the specified guid.""" 220 | return genanki.Note(model=self.model, fields=self.fields, guid=self.guid()) 221 | 222 | def make_ref_pair(self, filename): 223 | """Take a filename relative to the card, and make it absolute.""" 224 | newname = '%'.join(filename.split(os.sep)) 225 | 226 | if os.path.isabs(filename): 227 | abspath = filename 228 | else: 229 | abspath = os.path.normpath(os.path.join(self.deckdir(), filename)) 230 | return (abspath, newname) 231 | 232 | def determine_media_references(self): 233 | """Find all media references in a card""" 234 | for i, field in enumerate(self.fields): 235 | current_stage = field 236 | for regex in [r'src="([^"]*?)"']: # TODO not sure how this should work:, r'\[sound:(.*?)\]']: 237 | results = [] 238 | 239 | def process_match(m): 240 | initial_contents = m.group(1) 241 | abspath, newpath = self.make_ref_pair(initial_contents) 242 | results.append((abspath, newpath)) 243 | return r'src="' + newpath + '"' 244 | 245 | current_stage = re.sub(regex, process_match, current_stage) 246 | 247 | for r in results: 248 | yield r 249 | 250 | # Anki seems to hate alt tags :( 251 | self.fields[i] = re.sub(r'alt="[^"]*?"', '', current_stage) 252 | 253 | 254 | class DeckCollection(dict): 255 | """Defaultdict for decks, but with stored name.""" 256 | def __getitem__(self, deckname): 257 | if deckname not in self: 258 | deck_id = simple_hash(deckname) 259 | self[deckname] = genanki.Deck(deck_id, deckname) 260 | return super(DeckCollection, self).__getitem__(deckname) 261 | 262 | 263 | def field_to_html(field): 264 | """Need to extract the math in brackets so that it doesn't get markdowned. 265 | If math is separated with dollar sign it is converted to brackets.""" 266 | if CONFIG['dollar']: 267 | for (sep, (op, cl)) in [("$$", (r"\\[", r"\\]")), ("$", (r"\\(", r"\\)"))]: 268 | escaped_sep = sep.replace(r"$", r"\$") 269 | # ignore escaped dollar signs when splitting the field 270 | field = re.split(r"(?=0.6.3', 'misaka>=2.1.0', 'docopt>=0.6.2', 'houdini.py>=0.1.0', 'Pygments>=2.4.0'], 43 | entry_points={ 44 | "console_scripts": [ 45 | "ankdown=ankdown.ankdown:main" 46 | ] 47 | } 48 | ) 49 | --------------------------------------------------------------------------------