├── tests ├── __init__.py ├── test_builtin_models.py ├── test_cloze.py ├── test_note.py └── test_genanki.py ├── MANIFEST.in ├── genanki ├── version.py ├── __init__.py ├── card.py ├── util.py ├── apkg_col.py ├── package.py ├── apkg_schema.py ├── deck.py ├── builtin_models.py ├── model.py └── note.py ├── setup.cfg ├── run_tests.sh ├── Makefile ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── ci.yml ├── LICENSE.txt ├── setup.py ├── .gitignore ├── setup_tests.sh └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | -------------------------------------------------------------------------------- /genanki/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.13.1' 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # TODO make this cleaner / platform-independent 3 | set -e 4 | 5 | # enter venv if needed 6 | [[ -z "$VIRTUAL_ENV" ]] && source tests_venv/bin/activate 7 | 8 | exec python -m pytest tests/ 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install 2 | install: 3 | python3 setup.py install --user 4 | 5 | publish_test: 6 | rm -rf dist 7 | python3 setup.py sdist bdist_wheel 8 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* 9 | 10 | publish_real: 11 | rm -rf dist 12 | python3 setup.py sdist bdist_wheel 13 | twine upload dist/* 14 | -------------------------------------------------------------------------------- /genanki/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .version import __version__ 3 | 4 | from .card import Card 5 | from .deck import Deck 6 | from .model import Model 7 | from .note import Note 8 | from .package import Package 9 | 10 | from .util import guid_for 11 | 12 | from .builtin_models import BASIC_MODEL 13 | from .builtin_models import BASIC_AND_REVERSED_CARD_MODEL 14 | from .builtin_models import BASIC_OPTIONAL_REVERSED_CARD_MODEL 15 | from .builtin_models import BASIC_TYPE_IN_THE_ANSWER_MODEL 16 | from .builtin_models import CLOZE_MODEL 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE REQUEST] " 5 | labels: enhancement, help wanted 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug in genanki 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Description** 11 | Give a short description of the bug here. 12 | 13 | **To reproduce** 14 | Describe how to make the bug happen. Include any Python code that you are running. 15 | 16 | **Expected behavior** 17 | Describe what you expected to happen when you followed the instructions in "To reproduce". 18 | 19 | **Actual behavior** 20 | Describe what actually happened when you followed the instructions in "To reproduce". 21 | 22 | **genanki version** 23 | Look at the output of `pip show genanki` and enter the version here. 24 | 25 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /genanki/card.py: -------------------------------------------------------------------------------- 1 | class Card: 2 | def __init__(self, ord, suspend=False): 3 | self.ord = ord 4 | self.suspend = suspend 5 | 6 | def write_to_db(self, cursor, timestamp: float, deck_id, note_id, id_gen, due=0): 7 | queue = -1 if self.suspend else 0 8 | cursor.execute('INSERT INTO cards VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);', ( 9 | next(id_gen), # id 10 | note_id, # nid 11 | deck_id, # did 12 | self.ord, # ord 13 | int(timestamp), # mod 14 | -1, # usn 15 | 0, # type (=0 for non-Cloze) 16 | queue, # queue 17 | due, # due 18 | 0, # ivl 19 | 0, # factor 20 | 0, # reps 21 | 0, # lapses 22 | 0, # left 23 | 0, # odue 24 | 0, # odid 25 | 0, # flags 26 | "", # data 27 | )) 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2017 Kerrick Staley. 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /genanki/util.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | BASE91_TABLE = [ 4 | 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 5 | 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 6 | 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', 7 | '5', '6', '7', '8', '9', '!', '#', '$', '%', '&', '(', ')', '*', '+', ',', '-', '.', '/', ':', 8 | ';', '<', '=', '>', '?', '@', '[', ']', '^', '_', '`', '{', '|', '}', '~'] 9 | 10 | 11 | def guid_for(*values): 12 | hash_str = '__'.join(str(val) for val in values) 13 | 14 | # get the first 8 bytes of the SHA256 of hash_str as an int 15 | m = hashlib.sha256() 16 | m.update(hash_str.encode('utf-8')) 17 | hash_bytes = m.digest()[:8] 18 | hash_int = 0 19 | for b in hash_bytes: 20 | hash_int <<= 8 21 | hash_int += b 22 | 23 | # convert to the weird base91 format that Anki uses 24 | rv_reversed = [] 25 | while hash_int > 0: 26 | rv_reversed.append(BASE91_TABLE[hash_int % len(BASE91_TABLE)]) 27 | hash_int //= len(BASE91_TABLE) 28 | 29 | return ''.join(reversed(rv_reversed)) 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from pathlib import Path 3 | 4 | version = {} 5 | with open('genanki/version.py') as fp: 6 | exec(fp.read(), version) 7 | 8 | this_directory = Path(__file__).parent 9 | long_description = (this_directory / "README.md").read_text() 10 | 11 | setup(name='genanki', 12 | version=version['__version__'], 13 | description='Generate Anki decks programmatically', 14 | long_description=long_description, 15 | long_description_content_type='text/markdown', 16 | url='http://github.com/kerrickstaley/genanki', 17 | author='Kerrick Staley', 18 | author_email='k@kerrickstaley.com', 19 | license='MIT', 20 | packages=['genanki'], 21 | zip_safe=False, 22 | include_package_data=True, 23 | python_requires='>=3.8', 24 | install_requires=[ 25 | 'cached-property', 26 | 'frozendict', 27 | 'chevron', 28 | 'pyyaml', 29 | ], 30 | setup_requires=[ 31 | 'pytest-runner', 32 | ], 33 | tests_require=[ 34 | 'pytest>=6.0.2', 35 | ], 36 | keywords=[ 37 | 'anki', 38 | 'flashcards', 39 | 'memorization', 40 | ]) 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ${{ matrix.os }}-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu, macos, windows] 18 | python-version: [3.8, 3.11] 19 | anki-version: [ed8340a4e3a2006d6285d7adf9b136c735ba2085] 20 | steps: 21 | - uses: actions/checkout@v2 22 | - if: ${{ matrix.os == 'ubuntu' }} 23 | run: sudo apt-get install portaudio19-dev python3-dev 24 | - if: ${{ matrix.os == 'macos' }} 25 | run: brew install portaudio 26 | - uses: actions/setup-python@v2 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - run: python3 -m pip install --upgrade pip wheel setuptools 30 | - if: ${{ matrix.os == 'windows' }} 31 | run: python3 -m pip install pywin32 32 | - name: install package and tests_require from setup.py 33 | run: python3 -m pip install -e ".[test]" pytest-cov codecov 34 | - run: | 35 | git clone https://github.com/ankitects/anki.git anki_upstream 36 | cd anki_upstream 37 | git reset --hard ${{ matrix.anki-version }} 38 | python3 -m pip install -r requirements.txt 39 | - run: | 40 | python3 -m pytest tests -vv --cov genanki --cov-report term-missing:skip-covered --no-cov-on-fail 41 | - run: codecov 42 | -------------------------------------------------------------------------------- /tests/test_builtin_models.py: -------------------------------------------------------------------------------- 1 | import genanki 2 | import os 3 | import pytest 4 | import tempfile 5 | import warnings 6 | 7 | 8 | def test_builtin_models(): 9 | my_deck = genanki.Deck( 10 | 1598559905, 11 | 'Country Capitals') 12 | 13 | my_deck.add_note(genanki.Note( 14 | model=genanki.BASIC_MODEL, 15 | fields=['Capital of Argentina', 'Buenos Aires'])) 16 | 17 | my_deck.add_note(genanki.Note( 18 | model=genanki.BASIC_AND_REVERSED_CARD_MODEL, 19 | fields=['Costa Rica', 'San José'])) 20 | 21 | my_deck.add_note(genanki.Note( 22 | model=genanki.BASIC_OPTIONAL_REVERSED_CARD_MODEL, 23 | fields=['France', 'Paris', 'y'])) 24 | 25 | my_deck.add_note(genanki.Note( 26 | model=genanki.BASIC_TYPE_IN_THE_ANSWER_MODEL, 27 | fields=['Taiwan', 'Taipei'])) 28 | 29 | my_deck.add_note(genanki.Note( 30 | model=genanki.CLOZE_MODEL, 31 | fields=[ 32 | '{{c1::Ottawa}} is the capital of {{c2::Canada}}', 33 | 'Ottawa is in Ontario province.'])) 34 | 35 | # Just try writing the notes to a .apkg file; if there is no Exception and no Warnings, we assume 36 | # things are good. 37 | fnode, fpath = tempfile.mkstemp() 38 | os.close(fnode) 39 | 40 | with warnings.catch_warnings(record=True) as warning_list: 41 | my_deck.write_to_file(fpath) 42 | 43 | assert not warning_list 44 | 45 | os.unlink(fpath) 46 | 47 | def test_cloze_with_single_field_warns(): 48 | my_deck = genanki.Deck( 49 | 1598559905, 50 | 'Country Capitals') 51 | 52 | my_deck.add_note(genanki.Note( 53 | model=genanki.CLOZE_MODEL, 54 | fields=['{{c1::Rome}} is the capital of {{c2::Italy}}'])) 55 | 56 | fnode, fpath = tempfile.mkstemp() 57 | os.close(fnode) 58 | 59 | with pytest.warns(DeprecationWarning): 60 | my_deck.write_to_file(fpath) 61 | 62 | os.unlink(fpath) 63 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 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 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # PyCharm 107 | .idea/ 108 | 109 | # Used in testing 110 | /anki_upstream/ 111 | -------------------------------------------------------------------------------- /setup_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # TODO make this cleaner / platform-independent 3 | set -e 4 | anki_test_revision=ed8340a4e3a2006d6285d7adf9b136c735ba2085 5 | 6 | # Checks if the first argument is a valid executable in a POSIX-compatible way 7 | command_exists() { 8 | command -v "$1" >/dev/null 9 | } 10 | 11 | # fallback to doas if sudo is not installed 12 | if command_exists sudo; then 13 | elevator="sudo" 14 | elif command_exists doas; then 15 | elevator="doas" 16 | else 17 | echo "error: neither sudo nor doas is installed" 1>&2 18 | exit 1 19 | fi 20 | 21 | # install pyaudio system deps 22 | if command_exists apt-get; then 23 | $elevator apt-get -y update 24 | $elevator apt-get install -y python-all-dev portaudio19-dev ripgrep moreutils 25 | elif command_exists pacman; then 26 | $elevator pacman -S pacman -S --noconfirm --needed portaudio python-virtualenv ripgrep moreutils 27 | elif command_exists xbps-install; then 28 | $elevator xbps-install --sync --yes portaudio python3-virtualenv ripgrep moreutils 29 | else 30 | echo "error: couldn't find a suitable package manager" 31 | exit 1 32 | fi 33 | 34 | # enter venv if needed 35 | if [[ -z "$VIRTUAL_ENV" ]]; then 36 | rm -rf tests_venv 37 | virtualenv -p python3 tests_venv 38 | source tests_venv/bin/activate 39 | fi 40 | pip --isolated install . 41 | pip --isolated install 'pytest>=6.0.2' 42 | 43 | # clone Anki upstream at specific revision, install dependencies 44 | rm -rf anki_upstream 45 | git clone https://github.com/ankitects/anki.git anki_upstream 46 | ( cd anki_upstream 47 | git reset --hard $anki_test_revision 48 | pip --isolated install -r requirements.txt 49 | # patch in commit d261d1e 50 | rg --replace 'from shutil import which as find_executable' --passthru --no-line-number --multiline 'from distutils\.spawn import[^)]*\)' anki/mpv.py | sponge anki/mpv.py 51 | ) 52 | -------------------------------------------------------------------------------- /genanki/apkg_col.py: -------------------------------------------------------------------------------- 1 | APKG_COL = r''' 2 | INSERT INTO col VALUES( 3 | null, 4 | 1411124400, 5 | 1425279151694, 6 | 1425279151690, 7 | 11, 8 | 0, 9 | 0, 10 | 0, 11 | '{ 12 | "activeDecks": [ 13 | 1 14 | ], 15 | "addToCur": true, 16 | "collapseTime": 1200, 17 | "curDeck": 1, 18 | "curModel": "1425279151691", 19 | "dueCounts": true, 20 | "estTimes": true, 21 | "newBury": true, 22 | "newSpread": 0, 23 | "nextPos": 1, 24 | "sortBackwards": false, 25 | "sortType": "noteFld", 26 | "timeLim": 0 27 | }', 28 | '{}', 29 | '{ 30 | "1": { 31 | "collapsed": false, 32 | "conf": 1, 33 | "desc": "", 34 | "dyn": 0, 35 | "extendNew": 10, 36 | "extendRev": 50, 37 | "id": 1, 38 | "lrnToday": [ 39 | 0, 40 | 0 41 | ], 42 | "mod": 1425279151, 43 | "name": "Default", 44 | "newToday": [ 45 | 0, 46 | 0 47 | ], 48 | "revToday": [ 49 | 0, 50 | 0 51 | ], 52 | "timeToday": [ 53 | 0, 54 | 0 55 | ], 56 | "usn": 0 57 | } 58 | }', 59 | '{ 60 | "1": { 61 | "autoplay": true, 62 | "id": 1, 63 | "lapse": { 64 | "delays": [ 65 | 10 66 | ], 67 | "leechAction": 0, 68 | "leechFails": 8, 69 | "minInt": 1, 70 | "mult": 0 71 | }, 72 | "maxTaken": 60, 73 | "mod": 0, 74 | "name": "Default", 75 | "new": { 76 | "bury": true, 77 | "delays": [ 78 | 1, 79 | 10 80 | ], 81 | "initialFactor": 2500, 82 | "ints": [ 83 | 1, 84 | 4, 85 | 7 86 | ], 87 | "order": 1, 88 | "perDay": 20, 89 | "separate": true 90 | }, 91 | "replayq": true, 92 | "rev": { 93 | "bury": true, 94 | "ease4": 1.3, 95 | "fuzz": 0.05, 96 | "ivlFct": 1, 97 | "maxIvl": 36500, 98 | "minSpace": 1, 99 | "perDay": 100 100 | }, 101 | "timer": 0, 102 | "usn": 0 103 | } 104 | }', 105 | '{}' 106 | ); 107 | ''' 108 | -------------------------------------------------------------------------------- /genanki/package.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import json 3 | import os 4 | import sqlite3 5 | import tempfile 6 | import time 7 | import zipfile 8 | 9 | from .apkg_col import APKG_COL 10 | from .apkg_schema import APKG_SCHEMA 11 | from .deck import Deck 12 | 13 | from typing import Optional 14 | 15 | class Package: 16 | def __init__(self, deck_or_decks=None, media_files=None): 17 | if isinstance(deck_or_decks, Deck): 18 | self.decks = [deck_or_decks] 19 | else: 20 | self.decks = deck_or_decks 21 | 22 | self.media_files = list(set(media_files or [])) 23 | 24 | def write_to_file(self, file, timestamp: Optional[float] = None): 25 | """ 26 | :param file: File path to write to. 27 | :param timestamp: Timestamp (float seconds since Unix epoch) to assign to generated notes/cards. Can be used to 28 | make build hermetic. Defaults to time.time(). 29 | """ 30 | dbfile, dbfilename = tempfile.mkstemp() 31 | os.close(dbfile) 32 | 33 | conn = sqlite3.connect(dbfilename) 34 | cursor = conn.cursor() 35 | 36 | if timestamp is None: 37 | timestamp = time.time() 38 | 39 | id_gen = itertools.count(int(timestamp * 1000)) 40 | self.write_to_db(cursor, timestamp, id_gen) 41 | 42 | conn.commit() 43 | conn.close() 44 | 45 | with zipfile.ZipFile(file, 'w') as outzip: 46 | outzip.write(dbfilename, 'collection.anki2') 47 | 48 | media_file_idx_to_path = dict(enumerate(self.media_files)) 49 | media_json = {idx: os.path.basename(path) for idx, path in media_file_idx_to_path.items()} 50 | outzip.writestr('media', json.dumps(media_json)) 51 | 52 | for idx, path in media_file_idx_to_path.items(): 53 | outzip.write(path, str(idx)) 54 | 55 | def write_to_db(self, cursor, timestamp: float, id_gen): 56 | cursor.executescript(APKG_SCHEMA) 57 | cursor.executescript(APKG_COL) 58 | 59 | for deck in self.decks: 60 | deck.write_to_db(cursor, timestamp, id_gen) 61 | 62 | def write_to_collection_from_addon(self): 63 | """ 64 | Write to local collection. *Only usable when running inside an Anki addon!* Only tested on Anki 2.1. 65 | 66 | This writes to a temporary file and then calls the code that Anki uses to import packages. 67 | 68 | Note: the caller may want to use mw.checkpoint and mw.reset as follows: 69 | 70 | # creates a menu item called "Undo Add Notes From MyAddon" after this runs 71 | mw.checkpoint('Add Notes From MyAddon') 72 | # run import 73 | my_package.write_to_collection_from_addon() 74 | # refreshes main view so new deck is visible 75 | mw.reset() 76 | 77 | Tip: if your deck has the same name and ID as an existing deck, then the notes will get placed in that deck rather 78 | than a new deck being created. 79 | """ 80 | from aqt import mw # main window 81 | from anki.importing.apkg import AnkiPackageImporter 82 | 83 | tmpfilename = tempfile.NamedTemporaryFile(delete=False).name 84 | self.write_to_file(tmpfilename) 85 | AnkiPackageImporter(mw.col, tmpfilename).run() 86 | -------------------------------------------------------------------------------- /genanki/apkg_schema.py: -------------------------------------------------------------------------------- 1 | APKG_SCHEMA = ''' 2 | CREATE TABLE col ( 3 | id integer primary key, 4 | crt integer not null, 5 | mod integer not null, 6 | scm integer not null, 7 | ver integer not null, 8 | dty integer not null, 9 | usn integer not null, 10 | ls integer not null, 11 | conf text not null, 12 | models text not null, 13 | decks text not null, 14 | dconf text not null, 15 | tags text not null 16 | ); 17 | CREATE TABLE notes ( 18 | id integer primary key, /* 0 */ 19 | guid text not null, /* 1 */ 20 | mid integer not null, /* 2 */ 21 | mod integer not null, /* 3 */ 22 | usn integer not null, /* 4 */ 23 | tags text not null, /* 5 */ 24 | flds text not null, /* 6 */ 25 | sfld integer not null, /* 7 */ 26 | csum integer not null, /* 8 */ 27 | flags integer not null, /* 9 */ 28 | data text not null /* 10 */ 29 | ); 30 | CREATE TABLE cards ( 31 | id integer primary key, /* 0 */ 32 | nid integer not null, /* 1 */ 33 | did integer not null, /* 2 */ 34 | ord integer not null, /* 3 */ 35 | mod integer not null, /* 4 */ 36 | usn integer not null, /* 5 */ 37 | type integer not null, /* 6 */ 38 | queue integer not null, /* 7 */ 39 | due integer not null, /* 8 */ 40 | ivl integer not null, /* 9 */ 41 | factor integer not null, /* 10 */ 42 | reps integer not null, /* 11 */ 43 | lapses integer not null, /* 12 */ 44 | left integer not null, /* 13 */ 45 | odue integer not null, /* 14 */ 46 | odid integer not null, /* 15 */ 47 | flags integer not null, /* 16 */ 48 | data text not null /* 17 */ 49 | ); 50 | CREATE TABLE revlog ( 51 | id integer primary key, 52 | cid integer not null, 53 | usn integer not null, 54 | ease integer not null, 55 | ivl integer not null, 56 | lastIvl integer not null, 57 | factor integer not null, 58 | time integer not null, 59 | type integer not null 60 | ); 61 | CREATE TABLE graves ( 62 | usn integer not null, 63 | oid integer not null, 64 | type integer not null 65 | ); 66 | CREATE INDEX ix_notes_usn on notes (usn); 67 | CREATE INDEX ix_cards_usn on cards (usn); 68 | CREATE INDEX ix_revlog_usn on revlog (usn); 69 | CREATE INDEX ix_cards_nid on cards (nid); 70 | CREATE INDEX ix_cards_sched on cards (did, queue, due); 71 | CREATE INDEX ix_revlog_cid on revlog (cid); 72 | CREATE INDEX ix_notes_csum on notes (csum); 73 | ''' 74 | -------------------------------------------------------------------------------- /genanki/deck.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | class Deck: 4 | def __init__(self, deck_id=None, name=None, description=''): 5 | self.deck_id = deck_id 6 | self.name = name 7 | self.description = description 8 | self.notes = [] 9 | self.models = {} # map of model id to model 10 | 11 | def add_note(self, note): 12 | self.notes.append(note) 13 | 14 | def add_model(self, model): 15 | self.models[model.model_id] = model 16 | 17 | def to_json(self): 18 | return { 19 | "collapsed": False, 20 | "conf": 1, 21 | "desc": self.description, 22 | "dyn": 0, 23 | "extendNew": 0, 24 | "extendRev": 50, 25 | "id": self.deck_id, 26 | "lrnToday": [ 27 | 163, 28 | 2 29 | ], 30 | "mod": 1425278051, 31 | "name": self.name, 32 | "newToday": [ 33 | 163, 34 | 2 35 | ], 36 | "revToday": [ 37 | 163, 38 | 0 39 | ], 40 | "timeToday": [ 41 | 163, 42 | 23598 43 | ], 44 | "usn": -1 45 | } 46 | 47 | def write_to_db(self, cursor, timestamp: float, id_gen): 48 | if not isinstance(self.deck_id, int): 49 | raise TypeError('Deck .deck_id must be an integer, not {}.'.format(self.deck_id)) 50 | if not isinstance(self.name, str): 51 | raise TypeError('Deck .name must be a string, not {}.'.format(self.name)) 52 | 53 | decks_json_str, = cursor.execute('SELECT decks FROM col').fetchone() 54 | decks = json.loads(decks_json_str) 55 | decks.update({str(self.deck_id): self.to_json()}) 56 | cursor.execute('UPDATE col SET decks = ?', (json.dumps(decks),)) 57 | 58 | models_json_str, = cursor.execute('SELECT models from col').fetchone() 59 | models = json.loads(models_json_str) 60 | for note in self.notes: 61 | self.add_model(note.model) 62 | models.update( 63 | {model.model_id: model.to_json(timestamp, self.deck_id) for model in self.models.values()}) 64 | cursor.execute('UPDATE col SET models = ?', (json.dumps(models),)) 65 | 66 | for note in self.notes: 67 | note.write_to_db(cursor, timestamp, self.deck_id, id_gen) 68 | 69 | def write_to_file(self, file): 70 | """ 71 | Write this deck to a .apkg file. 72 | """ 73 | from .package import Package 74 | Package(self).write_to_file(file) 75 | 76 | def write_to_collection_from_addon(self): 77 | """ 78 | Write to local collection. *Only usable when running inside an Anki addon!* Only tested on Anki 2.1. 79 | 80 | This writes to a temporary file and then calls the code that Anki uses to import packages. 81 | 82 | Note: the caller may want to use mw.checkpoint and mw.reset as follows: 83 | 84 | # creates a menu item called "Undo Add Notes From MyAddon" after this runs 85 | mw.checkpoint('Add Notes From MyAddon') 86 | # run import 87 | my_package.write_to_collection_from_addon() 88 | # refreshes main view so new deck is visible 89 | mw.reset() 90 | 91 | Tip: if your deck has the same name and ID as an existing deck, then the notes will get placed in that deck rather 92 | than a new deck being created. 93 | """ 94 | from .package import Package 95 | Package(self).write_to_collection_from_addon() 96 | -------------------------------------------------------------------------------- /tests/test_cloze.py: -------------------------------------------------------------------------------- 1 | """Test creating Cloze cards""" 2 | # https://apps.ankiweb.net/docs/manual20.html#cloze-deletion 3 | 4 | import sys 5 | from genanki import Model 6 | from genanki import Note 7 | from genanki import Deck 8 | from genanki import Package 9 | 10 | 11 | CSS = """.card { 12 | font-family: arial; 13 | font-size: 20px; 14 | text-align: center; 15 | color: black; 16 | background-color: white; 17 | } 18 | 19 | .cloze { 20 | font-weight: bold; 21 | color: blue; 22 | } 23 | .nightMode .cloze { 24 | color: lightblue; 25 | } 26 | """ 27 | 28 | MY_CLOZE_MODEL = Model( 29 | 998877661, 30 | 'My Cloze Model', 31 | fields=[ 32 | {'name': 'Text'}, 33 | {'name': 'Extra'}, 34 | ], 35 | templates=[{ 36 | 'name': 'My Cloze Card', 37 | 'qfmt': '{{cloze:Text}}', 38 | 'afmt': '{{cloze:Text}}
{{Extra}}', 39 | },], 40 | css=CSS, 41 | model_type=Model.CLOZE) 42 | 43 | # This doesn't seem to be very useful but Anki supports it and so do we *shrug* 44 | MULTI_FIELD_CLOZE_MODEL = Model( 45 | 1047194615, 46 | 'Multi Field Cloze Model', 47 | fields=[ 48 | {'name': 'Text1'}, 49 | {'name': 'Text2'}, 50 | ], 51 | templates=[{ 52 | 'name': 'Cloze', 53 | 'qfmt': '{{cloze:Text1}} and {{cloze:Text2}}', 54 | 'afmt': '{{cloze:Text1}} and {{cloze:Text2}}', 55 | }], 56 | css=CSS, 57 | model_type=Model.CLOZE, 58 | ) 59 | 60 | 61 | def test_cloze(write_to_test_apkg=False): 62 | """Test Cloze model""" 63 | notes = [] 64 | assert MY_CLOZE_MODEL.to_json(0, 0)["type"] == 1 65 | 66 | # Question: NOTE ONE: [...] 67 | # Answer: NOTE ONE: single deletion 68 | fields = ['NOTE ONE: {{c1::single deletion}}', ''] 69 | my_cloze_note = Note(model=MY_CLOZE_MODEL, fields=fields) 70 | assert {card.ord for card in my_cloze_note.cards} == {0} 71 | notes.append(my_cloze_note) 72 | 73 | # Question: NOTE TWO: [...] 2nd deletion 3rd deletion 74 | # Answer: NOTE TWO: **1st deletion** 2nd deletion 3rd deletion 75 | # 76 | # Question: NOTE TWO: 1st deletion [...] 3rd deletion 77 | # Answer: NOTE TWO: 1st deletion **2nd deletion** 3rd deletion 78 | # 79 | # Question: NOTE TWO: 1st deletion 2nd deletion [...] 80 | # Answer: NOTE TWO: 1st deletion 2nd deletion **3rd deletion** 81 | fields = ['NOTE TWO: {{c1::1st deletion}} {{c2::2nd deletion}} {{c3::3rd deletion}}', ''] 82 | my_cloze_note = Note(model=MY_CLOZE_MODEL, fields=fields) 83 | assert sorted(card.ord for card in my_cloze_note.cards) == [0, 1, 2] 84 | notes.append(my_cloze_note) 85 | 86 | # Question: NOTE THREE: C1-CLOZE 87 | # Answer: NOTE THREE: 1st deletion 88 | fields = ['NOTE THREE: {{c1::1st deletion::C1-CLOZE}}', ''] 89 | my_cloze_note = Note(model=MY_CLOZE_MODEL, fields=fields) 90 | assert {card.ord for card in my_cloze_note.cards} == {0} 91 | notes.append(my_cloze_note) 92 | 93 | # Question: NOTE FOUR: [...] foo 2nd deletion bar [...] 94 | # Answer: NOTE FOUR: 1st deletion foo 2nd deletion bar 3rd deletion 95 | fields = ['NOTE FOUR: {{c1::1st deletion}} foo {{c2::2nd deletion}} bar {{c1::3rd deletion}}', ''] 96 | my_cloze_note = Note(model=MY_CLOZE_MODEL, fields=fields) 97 | assert sorted(card.ord for card in my_cloze_note.cards) == [0, 1] 98 | notes.append(my_cloze_note) 99 | 100 | if write_to_test_apkg: 101 | _wr_apkg(notes) 102 | 103 | 104 | def _wr_apkg(notes): 105 | """Write cloze cards to an Anki apkg file""" 106 | deckname = 'mtherieau' 107 | deck = Deck(deck_id=0, name=deckname) 108 | for note in notes: 109 | deck.add_note(note) 110 | fout_anki = '{NAME}.apkg'.format(NAME=deckname) 111 | Package(deck).write_to_file(fout_anki) 112 | print(' {N} Notes WROTE: {APKG}'.format(N=len(notes), APKG=fout_anki)) 113 | 114 | 115 | def test_cloze_multi_field(): 116 | fields = [ 117 | '{{c1::Berlin}} is the capital of {{c2::Germany}}', 118 | '{{c3::Paris}} is the capital of {{c4::France}}'] 119 | 120 | note = Note(model=MULTI_FIELD_CLOZE_MODEL, fields=fields) 121 | assert sorted(card.ord for card in note.cards) == [0, 1, 2, 3] 122 | 123 | 124 | def test_cloze_indicies_do_not_start_at_1(): 125 | fields = ['{{c2::Mitochondria}} are the {{c3::powerhouses}} of the cell', ''] 126 | note = Note(model=MY_CLOZE_MODEL, fields=fields) 127 | assert sorted(card.ord for card in note.cards) == [1, 2] 128 | 129 | 130 | def test_cloze_newlines_in_deletion(): 131 | fields = ['{{c1::Washington, D.C.}} is the capital of {{c2::the\nUnited States\nof America}}', ''] 132 | note = Note(model=MY_CLOZE_MODEL, fields=fields) 133 | assert sorted(card.ord for card in note.cards) == [0, 1] 134 | 135 | 136 | if __name__ == '__main__': 137 | test_cloze(len(sys.argv) != 1) 138 | -------------------------------------------------------------------------------- /genanki/builtin_models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Models that behave the same as Anki's built-in models ("Basic", "Basic (and reversed card)", "Cloze", etc.). 3 | 4 | Note: Anki does not assign consistent IDs to its built-in models (see 5 | https://github.com/kerrickstaley/genanki/issues/55#issuecomment-717687667 and 6 | https://forums.ankiweb.net/t/exported-basic-cards-create-duplicate-card-types-when-imported-by-other-users/959 ). 7 | Because of this, we cannot simply call these models "Basic" etc. If we did, then when importing a genanki-generated 8 | deck, Anki would see a model called "Basic" which has a different model ID than its internal "Basic" model, and it 9 | would rename the imported model to something like "Basic-123abc". Instead, we name the models "Basic (genanki)" 10 | etc., which is less confusing. 11 | """ 12 | 13 | import warnings 14 | 15 | from .model import Model 16 | 17 | 18 | BASIC_MODEL = Model( 19 | 1559383000, 20 | 'Basic (genanki)', 21 | fields=[ 22 | { 23 | 'name': 'Front', 24 | 'font': 'Arial', 25 | }, 26 | { 27 | 'name': 'Back', 28 | 'font': 'Arial', 29 | }, 30 | ], 31 | templates=[ 32 | { 33 | 'name': 'Card 1', 34 | 'qfmt': '{{Front}}', 35 | 'afmt': '{{FrontSide}}\n\n
\n\n{{Back}}', 36 | }, 37 | ], 38 | css='.card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n', 39 | ) 40 | 41 | BASIC_AND_REVERSED_CARD_MODEL = Model( 42 | 1485830179, 43 | 'Basic (and reversed card) (genanki)', 44 | fields=[ 45 | { 46 | 'name': 'Front', 47 | 'font': 'Arial', 48 | }, 49 | { 50 | 'name': 'Back', 51 | 'font': 'Arial', 52 | }, 53 | ], 54 | templates=[ 55 | { 56 | 'name': 'Card 1', 57 | 'qfmt': '{{Front}}', 58 | 'afmt': '{{FrontSide}}\n\n
\n\n{{Back}}', 59 | }, 60 | { 61 | 'name': 'Card 2', 62 | 'qfmt': '{{Back}}', 63 | 'afmt': '{{FrontSide}}\n\n
\n\n{{Front}}', 64 | }, 65 | ], 66 | css='.card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n', 67 | ) 68 | 69 | BASIC_OPTIONAL_REVERSED_CARD_MODEL = Model( 70 | 1382232460, 71 | 'Basic (optional reversed card) (genanki)', 72 | fields=[ 73 | { 74 | 'name': 'Front', 75 | 'font': 'Arial', 76 | }, 77 | { 78 | 'name': 'Back', 79 | 'font': 'Arial', 80 | }, 81 | { 82 | 'name': 'Add Reverse', 83 | 'font': 'Arial', 84 | }, 85 | ], 86 | templates=[ 87 | { 88 | 'name': 'Card 1', 89 | 'qfmt': '{{Front}}', 90 | 'afmt': '{{FrontSide}}\n\n
\n\n{{Back}}', 91 | }, 92 | { 93 | 'name': 'Card 2', 94 | 'qfmt': '{{#Add Reverse}}{{Back}}{{/Add Reverse}}', 95 | 'afmt': '{{FrontSide}}\n\n
\n\n{{Front}}', 96 | }, 97 | ], 98 | css='.card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n', 99 | ) 100 | 101 | BASIC_TYPE_IN_THE_ANSWER_MODEL = Model( 102 | 1305534440, 103 | 'Basic (type in the answer) (genanki)', 104 | fields=[ 105 | { 106 | 'name': 'Front', 107 | 'font': 'Arial', 108 | }, 109 | { 110 | 'name': 'Back', 111 | 'font': 'Arial', 112 | }, 113 | ], 114 | templates=[ 115 | { 116 | 'name': 'Card 1', 117 | 'qfmt': '{{Front}}\n\n{{type:Back}}', 118 | 'afmt': '{{Front}}\n\n
\n\n{{type:Back}}', 119 | }, 120 | ], 121 | css='.card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n', 122 | ) 123 | 124 | CLOZE_MODEL = Model( 125 | 1550428389, 126 | 'Cloze (genanki)', 127 | model_type=Model.CLOZE, 128 | fields=[ 129 | { 130 | 'name': 'Text', 131 | 'font': 'Arial', 132 | }, 133 | { 134 | 'name': 'Back Extra', 135 | 'font': 'Arial', 136 | }, 137 | ], 138 | templates=[ 139 | { 140 | 'name': 'Cloze', 141 | 'qfmt': '{{cloze:Text}}', 142 | 'afmt': '{{cloze:Text}}
\n{{Back Extra}}', 143 | }, 144 | ], 145 | css='.card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n\n' 146 | '.cloze {\n font-weight: bold;\n color: blue;\n}\n.nightMode .cloze {\n color: lightblue;\n}', 147 | ) 148 | 149 | def _fix_deprecated_builtin_models_and_warn(model, fields): 150 | if model is CLOZE_MODEL and len(fields) == 1: 151 | fixed_fields = fields + [''] 152 | warnings.warn( 153 | 'Using CLOZE_MODEL with a single field is deprecated.' 154 | + ' Please pass two fields, e.g. {} .'.format(repr(fixed_fields)) 155 | + ' See https://github.com/kerrickstaley/genanki#cloze_model-deprecationwarning .', 156 | DeprecationWarning) 157 | return fixed_fields 158 | 159 | return fields 160 | -------------------------------------------------------------------------------- /genanki/model.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from cached_property import cached_property 3 | import chevron 4 | import yaml 5 | 6 | class Model: 7 | 8 | FRONT_BACK = 0 9 | CLOZE = 1 10 | DEFAULT_LATEX_PRE = ('\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n' 11 | + '\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n' 12 | + '\\begin{document}\n') 13 | DEFAULT_LATEX_POST = '\\end{document}' 14 | 15 | def __init__(self, model_id=None, name=None, fields=None, templates=None, css='', model_type=FRONT_BACK, 16 | latex_pre=DEFAULT_LATEX_PRE, latex_post=DEFAULT_LATEX_POST, sort_field_index=0): 17 | self.model_id = model_id 18 | self.name = name 19 | self.set_fields(fields) 20 | self.set_templates(templates) 21 | self.css = css 22 | self.model_type = model_type 23 | self.latex_pre = latex_pre 24 | self.latex_post = latex_post 25 | self.sort_field_index = sort_field_index 26 | 27 | def set_fields(self, fields): 28 | if isinstance(fields, list): 29 | self.fields = fields 30 | elif isinstance(fields, str): 31 | self.fields = yaml.full_load(fields) 32 | 33 | def set_templates(self, templates): 34 | if isinstance(templates, list): 35 | self.templates = templates 36 | elif isinstance(templates, str): 37 | self.templates = yaml.full_load(templates) 38 | 39 | @cached_property 40 | def _req(self): 41 | """ 42 | List of required fields for each template. Format is [tmpl_idx, "all"|"any", [req_field_1, req_field_2, ...]]. 43 | 44 | Partial reimplementation of req computing logic from Anki. We use chevron instead of Anki's custom mustache 45 | implementation. 46 | 47 | The goal is to figure out which fields are "required", i.e. if they are missing then the front side of the note 48 | doesn't contain any meaningful content. 49 | """ 50 | sentinel = 'SeNtInEl' 51 | field_names = [field['name'] for field in self.fields] 52 | 53 | req = [] 54 | for template_ord, template in enumerate(self.templates): 55 | required_fields = [] 56 | for field_ord, field in enumerate(field_names): 57 | field_values = {field: sentinel for field in field_names} 58 | field_values[field] = '' 59 | 60 | rendered = chevron.render(template['qfmt'], field_values) 61 | 62 | if sentinel not in rendered: 63 | # when this field is missing, there is no meaningful content (no field values) in the question, so this field 64 | # is required 65 | required_fields.append(field_ord) 66 | 67 | if required_fields: 68 | req.append([template_ord, 'all', required_fields]) 69 | continue 70 | 71 | # there are no required fields, so an "all" is not appropriate, switch to checking for "any" 72 | for field_ord, field in enumerate(field_names): 73 | field_values = {field: '' for field in field_names} 74 | field_values[field] = sentinel 75 | 76 | rendered = chevron.render(template['qfmt'], field_values) 77 | 78 | if sentinel in rendered: 79 | # when this field is present, there is meaningful content in the question 80 | required_fields.append(field_ord) 81 | 82 | if not required_fields: 83 | raise Exception( 84 | 'Could not compute required fields for this template; please check the formatting of "qfmt": {}'.format( 85 | template)) 86 | 87 | req.append([template_ord, 'any', required_fields]) 88 | 89 | return req 90 | 91 | def to_json(self, timestamp: float, deck_id): 92 | for ord_, tmpl in enumerate(self.templates): 93 | tmpl['ord'] = ord_ 94 | tmpl.setdefault('bafmt', '') 95 | tmpl.setdefault('bqfmt', '') 96 | tmpl.setdefault('bfont', '') 97 | tmpl.setdefault('bsize', 0) 98 | tmpl.setdefault('did', None) # TODO None works just fine here, but should it be deck_id? 99 | 100 | for ord_, field in enumerate(self.fields): 101 | field['ord'] = ord_ 102 | field.setdefault('font', 'Liberation Sans') 103 | field.setdefault('media', []) 104 | field.setdefault('rtl', False) 105 | field.setdefault('size', 20) 106 | field.setdefault('sticky', False) 107 | 108 | return { 109 | "css": self.css, 110 | "did": deck_id, 111 | "flds": self.fields, 112 | "id": str(self.model_id), 113 | "latexPost": self.latex_post, 114 | "latexPre": self.latex_pre, 115 | "latexsvg": False, 116 | "mod": int(timestamp), 117 | "name": self.name, 118 | "req": self._req, 119 | "sortf": self.sort_field_index, 120 | "tags": [], 121 | "tmpls": self.templates, 122 | "type": self.model_type, 123 | "usn": -1, 124 | "vers": [] 125 | } 126 | 127 | def __repr__(self): 128 | attrs = ['model_id', 'name', 'fields', 'templates', 'css', 'model_type'] 129 | pieces = ['{}={}'.format(attr, repr(getattr(self, attr))) for attr in attrs] 130 | return '{}({})'.format(self.__class__.__name__, ', '.join(pieces)) 131 | -------------------------------------------------------------------------------- /genanki/note.py: -------------------------------------------------------------------------------- 1 | import re 2 | import warnings 3 | from cached_property import cached_property 4 | 5 | from .builtin_models import _fix_deprecated_builtin_models_and_warn 6 | from .card import Card 7 | from .util import guid_for 8 | 9 | 10 | class _TagList(list): 11 | @staticmethod 12 | def _validate_tag(tag): 13 | if ' ' in tag: 14 | raise ValueError('Tag "{}" contains a space; this is not allowed!'.format(tag)) 15 | 16 | def __init__(self, tags=()): 17 | super().__init__() 18 | self.extend(tags) 19 | 20 | def __repr__(self): 21 | return '{}({})'.format(self.__class__.__name__, super().__repr__()) 22 | 23 | def __setitem__(self, key, val): 24 | if isinstance(key, slice): 25 | # val may be an iterator, convert to a list so we can iterate multiple times 26 | val = list(val) 27 | for tag in val: 28 | self._validate_tag(tag) 29 | else: 30 | self._validate_tag(val) 31 | 32 | super().__setitem__(key, val) 33 | 34 | def append(self, tag): 35 | self._validate_tag(tag) 36 | super().append(tag) 37 | 38 | def extend(self, tags): 39 | # tags may be an iterator, convert to list so we can iterate multiple times 40 | tags = list(tags) 41 | for tag in tags: 42 | self._validate_tag(tag) 43 | super().extend(tags) 44 | 45 | def insert(self, i, tag): 46 | self._validate_tag(tag) 47 | super().insert(i, tag) 48 | 49 | 50 | class Note: 51 | _INVALID_HTML_TAG_RE = re.compile(r'<(?!/?[a-zA-Z0-9]+(?: .*|/?)>|!--|!\[CDATA\[)(?:.|\n)*?>') 52 | 53 | def __init__(self, model=None, fields=None, sort_field=None, tags=None, guid=None, due=0): 54 | self.model = model 55 | self.fields = fields 56 | self.sort_field = sort_field 57 | self.tags = tags or [] 58 | self.due = due 59 | try: 60 | self.guid = guid 61 | except AttributeError: 62 | # guid was defined as a property 63 | pass 64 | 65 | @property 66 | def sort_field(self): 67 | return self._sort_field or self.fields[self.model.sort_field_index] 68 | 69 | @sort_field.setter 70 | def sort_field(self, val): 71 | self._sort_field = val 72 | 73 | @property 74 | def tags(self): 75 | return self._tags 76 | 77 | @tags.setter 78 | def tags(self, val): 79 | self._tags = _TagList() 80 | self._tags.extend(val) 81 | 82 | # We use cached_property instead of initializing in the constructor so that the user can set the model after calling 83 | # __init__ and it'll still work. 84 | @cached_property 85 | def cards(self): 86 | if self.model.model_type == self.model.FRONT_BACK: 87 | return self._front_back_cards() 88 | elif self.model.model_type == self.model.CLOZE: 89 | return self._cloze_cards() 90 | raise ValueError('Expected model_type CLOZE or FRONT_BACK') 91 | 92 | def _cloze_cards(self): 93 | """Returns a Card with unique ord for each unique cloze reference.""" 94 | card_ords = set() 95 | # find cloze replacements in first template's qfmt, e.g "{{cloze::Text}}" 96 | cloze_replacements = set( 97 | re.findall(r"{{[^}]*?cloze:(?:[^}]?:)*(.+?)}}", self.model.templates[0]['qfmt']) + 98 | re.findall("<%cloze:(.+?)%>", self.model.templates[0]['qfmt'])) 99 | for field_name in cloze_replacements: 100 | field_index = next((i for i, f in enumerate(self.model.fields) if f['name'] == field_name), -1) 101 | field_value = self.fields[field_index] if field_index >= 0 else "" 102 | # update card_ords with each cloze reference N, e.g. "{{cN::...}}" 103 | card_ords.update(int(m)-1 for m in re.findall(r"{{c(\d+)::.+?}}", field_value, re.DOTALL) if int(m) > 0) 104 | if not card_ords: 105 | card_ords = {0} 106 | return([Card(ord) for ord in card_ords]) 107 | 108 | def _front_back_cards(self): 109 | """Create Front/Back cards""" 110 | rv = [] 111 | for card_ord, any_or_all, required_field_ords in self.model._req: 112 | op = {'any': any, 'all': all}[any_or_all] 113 | if op(self.fields[ord_] for ord_ in required_field_ords): 114 | rv.append(Card(card_ord)) 115 | return rv 116 | 117 | @property 118 | def guid(self): 119 | if self._guid is None: 120 | return guid_for(*self.fields) 121 | return self._guid 122 | 123 | @guid.setter 124 | def guid(self, val): 125 | self._guid = val 126 | 127 | def _check_number_model_fields_matches_num_fields(self): 128 | if len(self.model.fields) != len(self.fields): 129 | raise ValueError( 130 | 'Number of fields in Model does not match number of fields in Note: ' 131 | '{} has {} fields, but {} has {} fields.'.format( 132 | self.model, len(self.model.fields), self, len(self.fields))) 133 | 134 | @classmethod 135 | def _find_invalid_html_tags_in_field(cls, field): 136 | return cls._INVALID_HTML_TAG_RE.findall(field) 137 | 138 | def _check_invalid_html_tags_in_fields(self): 139 | for idx, field in enumerate(self.fields): 140 | invalid_tags = self._find_invalid_html_tags_in_field(field) 141 | if invalid_tags: 142 | # You can disable the below warning by calling warnings.filterwarnings: 143 | # 144 | # warnings.filterwarnings('ignore', module='genanki', message='^Field contained the following invalid HTML tags') 145 | # 146 | # If you think you're getting a false positive for this warning, please file an issue at 147 | # https://github.com/kerrickstaley/genanki/issues 148 | warnings.warn("Field contained the following invalid HTML tags. Make sure you are calling html.escape() if" 149 | " your field data isn't already HTML-encoded: {}".format(' '.join(invalid_tags))) 150 | 151 | def write_to_db(self, cursor, timestamp: float, deck_id, id_gen): 152 | self.fields = _fix_deprecated_builtin_models_and_warn(self.model, self.fields) 153 | self._check_number_model_fields_matches_num_fields() 154 | self._check_invalid_html_tags_in_fields() 155 | cursor.execute('INSERT INTO notes VALUES(?,?,?,?,?,?,?,?,?,?,?);', ( 156 | next(id_gen), # id 157 | self.guid, # guid 158 | self.model.model_id, # mid 159 | int(timestamp), # mod 160 | -1, # usn 161 | self._format_tags(), # TODO tags 162 | self._format_fields(), # flds 163 | self.sort_field, # sfld 164 | 0, # csum, can be ignored 165 | 0, # flags 166 | '', # data 167 | )) 168 | 169 | note_id = cursor.lastrowid 170 | for card in self.cards: 171 | card.write_to_db(cursor, timestamp, deck_id, note_id, id_gen, self.due) 172 | 173 | def _format_fields(self): 174 | return '\x1f'.join(self.fields) 175 | 176 | def _format_tags(self): 177 | return ' ' + ' '.join(self.tags) + ' ' 178 | 179 | def __repr__(self): 180 | attrs = ['model', 'fields', 'sort_field', 'tags', 'guid'] 181 | pieces = ['{}={}'.format(attr, repr(getattr(self, attr))) for attr in attrs] 182 | return '{}({})'.format(self.__class__.__name__, ', '.join(pieces)) 183 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # genanki: A Library for Generating Anki Decks 2 | 3 | `genanki` allows you to programatically generate decks in Python 3 for Anki, a popular spaced-repetition flashcard 4 | program. Please see below for concepts and usage. 5 | 6 | *This library and its author(s) are not affiliated/associated with the main Anki project in any way.* 7 | 8 | [![CI](https://github.com/kerrickstaley/genanki/actions/workflows/ci.yml/badge.svg)](https://github.com/kerrickstaley/genanki/actions/workflows/ci.yml) 9 | 10 | ## Notes 11 | The basic unit in Anki is the `Note`, which contains a fact to memorize. `Note`s correspond to one or more `Card`s. 12 | 13 | Here's how you create a `Note`: 14 | 15 | ```python 16 | my_note = genanki.Note( 17 | model=my_model, 18 | fields=['Capital of Argentina', 'Buenos Aires']) 19 | ``` 20 | 21 | You pass in a `Model`, discussed below, and a set of `fields` (encoded as HTML). 22 | 23 | ## Models 24 | A `Model` defines the fields and cards for a type of `Note`. For example: 25 | 26 | ```python 27 | my_model = genanki.Model( 28 | 1607392319, 29 | 'Simple Model', 30 | fields=[ 31 | {'name': 'Question'}, 32 | {'name': 'Answer'}, 33 | ], 34 | templates=[ 35 | { 36 | 'name': 'Card 1', 37 | 'qfmt': '{{Question}}', 38 | 'afmt': '{{FrontSide}}
{{Answer}}', 39 | }, 40 | ]) 41 | ``` 42 | 43 | This note-type has two fields and one card. The card displays the `Question` field on the front and the `Question` and 44 | `Answer` fields on the back, separated by a `
`. You can also pass a `css` argument to `Model()` to supply custom 45 | CSS. 46 | 47 | You need to pass a `model_id` so that Anki can keep track of your model. It's important that you use a unique `model_id` 48 | for each `Model` you define. Use `import random; random.randrange(1 << 30, 1 << 31)` to generate a suitable model_id, and hardcode it 49 | into your `Model` definition. You can print one at the command line with 50 | 51 | ```bash 52 | python3 -c "import random; print(random.randrange(1 << 30, 1 << 31))" 53 | ``` 54 | 55 | ## Generating a Deck/Package 56 | To import your notes into Anki, you need to add them to a `Deck`: 57 | 58 | ```python 59 | my_deck = genanki.Deck( 60 | 2059400110, 61 | 'Country Capitals') 62 | 63 | my_deck.add_note(my_note) 64 | ``` 65 | 66 | Once again, you need a unique `deck_id` that you should generate once and then hardcode into your `.py` file. 67 | 68 | Then, create a `Package` for your `Deck` and write it to a file: 69 | 70 | ```python 71 | genanki.Package(my_deck).write_to_file('output.apkg') 72 | ``` 73 | 74 | You can then load `output.apkg` into Anki using File -> Import... 75 | 76 | ## Media Files 77 | To add sounds or images, set the `media_files` attribute on your `Package`: 78 | 79 | ```python 80 | my_package = genanki.Package(my_deck) 81 | my_package.media_files = ['my_sound_file.mp3', 'images/my_image_file.jpg'] 82 | ``` 83 | 84 | `media_files` should have the path (relative or absolute) to each file. To use them in notes, first add a field to your model, and reference that field in your template: 85 | 86 | ```python 87 | my_model = genanki.Model( 88 | 1091735104, 89 | 'Simple Model with Media', 90 | fields=[ 91 | {'name': 'Question'}, 92 | {'name': 'Answer'}, 93 | {'name': 'MyMedia'}, # ADD THIS 94 | ], 95 | templates=[ 96 | { 97 | 'name': 'Card 1', 98 | 'qfmt': '{{Question}}
{{MyMedia}}', # AND THIS 99 | 'afmt': '{{FrontSide}}
{{Answer}}', 100 | }, 101 | ]) 102 | ``` 103 | 104 | Then, set the `MyMedia` field on your card to `[sound:my_sound_file.mp3]` for audio and `` for images. 105 | 106 | You *cannot* put `` in the template and `my_image_file.jpg` in the field. See these sections in the Anki manual for more information: [Importing Media](https://docs.ankiweb.net/importing/text-files.html#importing-media) and [Media & LaTeX](https://docs.ankiweb.net/templates/fields.html#media--latex). 107 | 108 | You should only put the filename (aka basename) and not the full path in the field; `` will *not* work. Media files should have unique filenames. 109 | 110 | ## Note GUIDs 111 | `Note`s have a `guid` property that uniquely identifies the note. If you import a new note that has the same GUID as an 112 | existing note, the new note will overwrite the old one (as long as their models have the same fields). 113 | 114 | This is an important feature if you want to be able to tweak the design/content of your notes, regenerate your deck, and 115 | import the updated version into Anki. Your notes need to have stable GUIDs in order for the new note to replace the 116 | existing one. 117 | 118 | By default, the GUID is a hash of all the field values. This may not be desirable if, for example, you add a new field 119 | with additional info that doesn't change the identity of the note. You can create a custom GUID implementation to hash 120 | only the fields that identify the note: 121 | 122 | ```python 123 | class MyNote(genanki.Note): 124 | @property 125 | def guid(self): 126 | return genanki.guid_for(self.fields[0], self.fields[1]) 127 | ``` 128 | 129 | ## sort_field 130 | Anki has a value for each `Note` called the `sort_field`. Anki uses this value to sort the cards in the Browse 131 | interface. Anki also is happier if you avoid having two notes with the same `sort_field`, although this isn't strictly 132 | necessary. By default, the `sort_field` is the first field, but you can change it by passing `sort_field=` to `Note()` 133 | or implementing `sort_field` as a property in a subclass (similar to `guid`). 134 | 135 | You can also pass `sort_field_index=` to `Model()` to change the sort field. `0` means the first field in the Note, `1` means the second, etc. 136 | 137 | ## YAML for Templates (and Fields) 138 | You can create your template definitions in the YAML format and pass them as a `str` to `Model()`. You can also do this 139 | for fields. 140 | 141 | ## Using genanki inside an Anki addon 142 | `genanki` supports adding generated notes to the local collection when running inside an Anki 2.1 addon (Anki 2.0 143 | may work but has not been tested). See the [`.write_to_collection_from_addon() method`]( 144 | https://github.com/kerrickstaley/genanki/blob/0c2cf8fea9c5e382e2fae9cd6d5eb440e267c637/genanki/__init__.py#L275). 145 | 146 | ## CLOZE_MODEL DeprecationWarning 147 | Due to a mistake, in genanki versions before 0.13.0, `builtin_models.CLOZE_MODEL` only had a single field, whereas the real Cloze model that is built into Anki has two fields. If you get a `DeprecationWarning` when using `CLOZE_MODEL`, simply add another field (it can be an empty string) when creating your `Note`, e.g. 148 | 149 | ```python 150 | my_note = genanki.Note( 151 | model=genanki.CLOZE_MODEL, 152 | fields=['{{c1::Rome}} is the capital of {{c2::Italy}}', '']) 153 | ``` 154 | 155 | ## FAQ 156 | ### My field data is getting garbled 157 | If fields in your notes contain literal `<`, `>`, or `&` characters, you need to HTML-encode them: field data is HTML, not plain text. You can use the [`html.escape`](https://docs.python.org/3/library/html.html#html.escape) function. 158 | 159 | For example, you should write 160 | ``` 161 | fields=['AT&T was originally called', 'Bell Telephone Company'] 162 | ``` 163 | or 164 | ``` 165 | fields=[html.escape(f) for f in ['AT&T was originally called', 'Bell Telephone Company']] 166 | ``` 167 | 168 | This applies even if the content is LaTeX; for example, you should write 169 | ``` 170 | fields=['Piketty calls this the "central contradiction of capitalism".', '[latex]r > g[/latex]'] 171 | ``` 172 | 173 | ## Publishing to PyPI 174 | If your name is Kerrick, you can publish the `genanki` package to PyPI by running these commands from the root of the `genanki` repo: 175 | ``` 176 | rm -rf dist/* 177 | python3 setup.py sdist bdist_wheel 178 | python3 -m twine upload dist/* 179 | ``` 180 | Note that this directly uploads to prod PyPI and skips uploading to test PyPI. 181 | -------------------------------------------------------------------------------- /tests/test_note.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import pytest 3 | import time 4 | import genanki 5 | import os 6 | from unittest import mock 7 | import tempfile 8 | import textwrap 9 | import warnings 10 | 11 | 12 | def test_ok(): 13 | my_model = genanki.Model( 14 | 1376484377, 15 | 'Simple Model', 16 | fields=[ 17 | {'name': 'Question'}, 18 | {'name': 'Answer'}, 19 | ], 20 | templates=[ 21 | { 22 | 'name': 'Card 1', 23 | 'qfmt': '{{Question}}', 24 | 'afmt': '{{FrontSide}}
{{Answer}}', 25 | }, 26 | ]) 27 | 28 | my_note = genanki.Note( 29 | model=my_model, 30 | fields=['Capital of Argentina', 'Buenos Aires']) 31 | 32 | # https://stackoverflow.com/a/45671804 33 | with warnings.catch_warnings(): 34 | warnings.simplefilter('error') 35 | my_note.write_to_db(mock.MagicMock(), mock.MagicMock(), mock.MagicMock(), itertools.count(int(time.time() * 1000))) 36 | 37 | 38 | class TestTags: 39 | def test_init(self): 40 | n = genanki.Note(tags=['foo', 'bar', 'baz']) 41 | with pytest.raises(ValueError): 42 | n = genanki.Note(tags=['foo', 'b ar', 'baz']) 43 | 44 | def test_assign(self): 45 | n = genanki.Note() 46 | n.tags = ['foo', 'bar', 'baz'] 47 | with pytest.raises(ValueError): 48 | n.tags = ['foo', 'bar', ' baz'] 49 | 50 | def test_assign_element(self): 51 | n = genanki.Note(tags=['foo', 'bar', 'baz']) 52 | n.tags[0] = 'dankey_kang' 53 | with pytest.raises(ValueError): 54 | n.tags[0] = 'dankey kang' 55 | 56 | def test_assign_slice(self): 57 | n = genanki.Note(tags=['foo', 'bar', 'baz']) 58 | n.tags[1:3] = ['bowser', 'daisy'] 59 | with pytest.raises(ValueError): 60 | n.tags[1:3] = ['bowser', 'princess peach'] 61 | 62 | def test_append(self): 63 | n = genanki.Note(tags=['foo', 'bar', 'baz']) 64 | n.tags.append('sheik_hashtag_melee') 65 | with pytest.raises(ValueError): 66 | n.tags.append('king dedede') 67 | 68 | def test_extend(self): 69 | n = genanki.Note(tags=['foo', 'bar', 'baz']) 70 | n.tags.extend(['palu', 'wolf']) 71 | with pytest.raises(ValueError): 72 | n.tags.extend(['dat fox doe']) 73 | 74 | def test_insert(self): 75 | n = genanki.Note(tags=['foo', 'bar', 'baz']) 76 | n.tags.insert(0, 'lucina') 77 | with pytest.raises(ValueError): 78 | n.tags.insert(0, 'nerf joker pls') 79 | 80 | 81 | def test_num_fields_equals_model_ok(): 82 | m = genanki.Model( 83 | 1894808898, 84 | 'Test Model', 85 | fields=[ 86 | {'name': 'Question'}, 87 | {'name': 'Answer'}, 88 | {'name': 'Extra'}, 89 | ], 90 | templates=[ 91 | { 92 | 'name': 'Card 1', 93 | 'qfmt': '{{Question}}', 94 | 'afmt': '{{FrontSide}}
{{Answer}}', 95 | }, 96 | ]) 97 | 98 | n = genanki.Note( 99 | model=m, 100 | fields=['What is the capital of Taiwan?', 'Taipei', 101 | 'Taipei was originally inhabitied by the Ketagalan people prior to the arrival of Han settlers in 1709.']) 102 | 103 | n.write_to_db(mock.MagicMock(), mock.MagicMock(), mock.MagicMock(), itertools.count(int(time.time() * 1000))) 104 | # test passes if code gets to here without raising 105 | 106 | 107 | def test_num_fields_less_than_model_raises(): 108 | m = genanki.Model( 109 | 1894808898, 110 | 'Test Model', 111 | fields=[ 112 | {'name': 'Question'}, 113 | {'name': 'Answer'}, 114 | {'name': 'Extra'}, 115 | ], 116 | templates=[ 117 | { 118 | 'name': 'Card 1', 119 | 'qfmt': '{{Question}}', 120 | 'afmt': '{{FrontSide}}
{{Answer}}', 121 | }, 122 | ]) 123 | 124 | n = genanki.Note(model=m, fields=['What is the capital of Taiwan?', 'Taipei']) 125 | 126 | with pytest.raises(ValueError): 127 | n.write_to_db(mock.MagicMock(), mock.MagicMock(), mock.MagicMock(), itertools.count(int(time.time() * 1000))) 128 | 129 | 130 | def test_num_fields_more_than_model_raises(): 131 | m = genanki.Model( 132 | 1894808898, 133 | 'Test Model', 134 | fields=[ 135 | {'name': 'Question'}, 136 | {'name': 'Answer'}, 137 | ], 138 | templates=[ 139 | { 140 | 'name': 'Card 1', 141 | 'qfmt': '{{Question}}', 142 | 'afmt': '{{FrontSide}}
{{Answer}}', 143 | }, 144 | ]) 145 | 146 | n = genanki.Note( 147 | model=m, 148 | fields=['What is the capital of Taiwan?', 'Taipei', 149 | 'Taipei was originally inhabitied by the Ketagalan people prior to the arrival of Han settlers in 1709.']) 150 | 151 | with pytest.raises(ValueError): 152 | n.write_to_db(mock.MagicMock(), mock.MagicMock(), mock.MagicMock(), itertools.count(int(time.time() * 1000))) 153 | 154 | 155 | class TestFindInvalidHtmlTagsInField: 156 | def test_ok(self): 157 | assert genanki.Note._find_invalid_html_tags_in_field('

') == [] 158 | 159 | def test_ok_with_space(self): 160 | assert genanki.Note._find_invalid_html_tags_in_field('

') == [] 161 | 162 | def test_ok_multiple(self): 163 | assert genanki.Note._find_invalid_html_tags_in_field('

test

') == [] 164 | 165 | def test_ok_br(self): 166 | assert genanki.Note._find_invalid_html_tags_in_field('
') == [] 167 | 168 | def test_ok_br2(self): 169 | assert genanki.Note._find_invalid_html_tags_in_field('
') == [] 170 | 171 | def test_ok_br3(self): 172 | assert genanki.Note._find_invalid_html_tags_in_field('
') == [] 173 | 174 | def test_ok_attrs(self): 175 | assert genanki.Note._find_invalid_html_tags_in_field('

STOP

') == [] 176 | 177 | def test_ok_uppercase(self): 178 | assert genanki.Note._find_invalid_html_tags_in_field('') == [] 179 | 180 | def test_ng_empty(self): 181 | assert genanki.Note._find_invalid_html_tags_in_field(' hello <> goodbye') == ['<>'] 182 | 183 | def test_ng_empty_space(self): 184 | assert genanki.Note._find_invalid_html_tags_in_field(' hello < > goodbye') == ['< >'] 185 | 186 | def test_ng_invalid_characters(self): 187 | assert genanki.Note._find_invalid_html_tags_in_field('<@h1>') == ['<@h1>'] 188 | 189 | def test_ng_invalid_characters_end(self): 190 | assert genanki.Note._find_invalid_html_tags_in_field('') == [''] 191 | 192 | def test_ng_issue_28(self): 193 | latex_code = r''' 194 | [latex] 195 | \schemestart 196 | \chemfig{*6(--([?]} 198 | \chemfig{*6(--(<[:30]{O}?)(<:H)-?[,{>},](<:H)---)} 199 | \schemestop 200 | [/latex] 201 | ''' 202 | latex_code = textwrap.dedent(latex_code[1:]) 203 | 204 | expected_invalid_tags = [ 205 | '', 206 | '<[:30]{O}?)(<:H)-?[,{>', 207 | ] 208 | 209 | assert genanki.Note._find_invalid_html_tags_in_field(latex_code) == expected_invalid_tags 210 | 211 | def test_ok_html_comment(self): 212 | # see https://github.com/kerrickstaley/genanki/issues/108 213 | assert genanki.Note._find_invalid_html_tags_in_field('') == [] 214 | 215 | def test_ok_cdata(self): 216 | # see https://github.com/kerrickstaley/genanki/issues/108 217 | assert genanki.Note._find_invalid_html_tags_in_field('') == [] 218 | 219 | 220 | def test_warns_on_invalid_html_tags(): 221 | my_model = genanki.Model( 222 | 1376484377, 223 | 'Simple Model', 224 | fields=[ 225 | {'name': 'Question'}, 226 | {'name': 'Answer'}, 227 | ], 228 | templates=[ 229 | { 230 | 'name': 'Card 1', 231 | 'qfmt': '{{Question}}', 232 | 'afmt': '{{FrontSide}}
{{Answer}}', 233 | }, 234 | ]) 235 | 236 | my_note = genanki.Note( 237 | model=my_model, 238 | fields=['Capital of <$> Argentina', 'Buenos Aires']) 239 | 240 | with pytest.warns(UserWarning, match='^Field contained the following invalid HTML tags.*$'): 241 | my_note.write_to_db(mock.MagicMock(), mock.MagicMock(), mock.MagicMock(), itertools.count(int(time.time() * 1000))) 242 | 243 | 244 | def test_suppress_warnings(recwarn): 245 | my_model = genanki.Model( 246 | 1376484377, 247 | 'Simple Model', 248 | fields=[ 249 | {'name': 'Question'}, 250 | {'name': 'Answer'}, 251 | ], 252 | templates=[ 253 | { 254 | 'name': 'Card 1', 255 | 'qfmt': '{{Question}}', 256 | 'afmt': '{{FrontSide}}
{{Answer}}', 257 | }, 258 | ]) 259 | 260 | my_note = genanki.Note( 261 | model=my_model, 262 | fields=['Capital of <$> Argentina', 'Buenos Aires']) 263 | 264 | with warnings.catch_warnings(): 265 | warnings.simplefilter('error') 266 | warnings.filterwarnings('ignore', message='^Field contained the following invalid HTML tags', module='genanki') 267 | my_note.write_to_db(mock.MagicMock(), mock.MagicMock(), mock.MagicMock(), itertools.count(int(time.time() * 1000))) 268 | 269 | 270 | # https://github.com/kerrickstaley/genanki/issues/121 271 | def test_does_not_warn_on_html_tags_in_guid(): 272 | my_note = genanki.Note( 273 | model=genanki.BASIC_MODEL, 274 | fields=['Capital of Iowa', 'Des Moines'], 275 | guid='GtZ') 276 | 277 | with warnings.catch_warnings(): 278 | warnings.simplefilter('error') 279 | my_note.write_to_db(mock.MagicMock(), mock.MagicMock(), mock.MagicMock(), itertools.count(int(time.time() * 1000))) 280 | 281 | 282 | def test_furigana_field(): 283 | # Fields like {{furigana:Reading}} are supported by the Japanese Support plugin: 284 | # https://ankiweb.net/shared/info/3918629684 285 | # Japanese Support is quasi-official (it was created by Damien Elmes, the creator of Anki) and so 286 | # we support it in genanki. 287 | my_model = genanki.Model( 288 | 1523004567, 289 | 'Japanese', 290 | fields=[ 291 | {'name': 'Question'}, 292 | {'name': 'Answer'}, 293 | ], 294 | templates=[ 295 | { 296 | 'name': 'Card 1', 297 | 'qfmt': '{{Question}}', 298 | 'afmt': '{{FrontSide}}
{{furigana:Answer}}', 299 | }, 300 | ]) 301 | 302 | my_note = genanki.Note( 303 | model=my_model, 304 | fields=['kanji character', '漢字[かんじ]']) 305 | 306 | my_deck = genanki.Deck(1702181380, 'Japanese') 307 | my_deck.add_note(my_note) 308 | 309 | with tempfile.NamedTemporaryFile(delete=False) as tempf: 310 | pass 311 | 312 | my_deck.write_to_file(tempf.name) 313 | os.remove(tempf.name) 314 | 315 | # test passes if there is no exception 316 | -------------------------------------------------------------------------------- /tests/test_genanki.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | sys.path.append(os.path.join(os.getcwd(), 'anki_upstream')) 4 | 5 | import pytest 6 | import tempfile 7 | 8 | import anki 9 | import anki.importing.apkg 10 | 11 | import genanki 12 | 13 | TEST_MODEL = genanki.Model( 14 | 234567, 'foomodel', 15 | fields=[ 16 | { 17 | 'name': 'AField', 18 | }, 19 | { 20 | 'name': 'BField', 21 | }, 22 | ], 23 | templates=[ 24 | { 25 | 'name': 'card1', 26 | 'qfmt': '{{AField}}', 27 | 'afmt': '{{FrontSide}}' 28 | '
' 29 | '{{BField}}', 30 | } 31 | ], 32 | ) 33 | 34 | TEST_CN_MODEL = genanki.Model( 35 | 345678, 'Chinese', 36 | fields=[{'name': 'Traditional'}, {'name': 'Simplified'}, {'name': 'English'}], 37 | templates=[ 38 | { 39 | 'name': 'Traditional', 40 | 'qfmt': '{{Traditional}}', 41 | 'afmt': '{{FrontSide}}' 42 | '
' 43 | '{{English}}', 44 | }, 45 | { 46 | 'name': 'Simplified', 47 | 'qfmt': '{{Simplified}}', 48 | 'afmt': '{{FrontSide}}' 49 | '
' 50 | '{{English}}', 51 | }, 52 | ], 53 | ) 54 | 55 | TEST_MODEL_WITH_HINT = genanki.Model( 56 | 456789, 'with hint', 57 | fields=[{'name': 'Question'}, {'name': 'Hint'}, {'name': 'Answer'}], 58 | templates=[ 59 | { 60 | 'name': 'card1', 61 | 'qfmt': '{{Question}}' 62 | '{{#Hint}}
Hint: {{Hint}}{{/Hint}}', 63 | 'afmt': '{{Answer}}', 64 | }, 65 | ], 66 | ) 67 | 68 | # Same as default latex_pre but we include amsfonts package 69 | CUSTOM_LATEX_PRE = ('\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n' 70 | + '\\usepackage{amssymb,amsmath,amsfonts}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n' 71 | + '\\begin{document}\n') 72 | # Same as default latex_post but we add a comment. (What is a real-world use-case for customizing latex_post?) 73 | CUSTOM_LATEX_POST = '% here is a great comment\n\\end{document}' 74 | 75 | TEST_MODEL_WITH_LATEX = genanki.Model( 76 | 567890, 'with latex', 77 | fields=[ 78 | { 79 | 'name': 'AField', 80 | }, 81 | { 82 | 'name': 'BField', 83 | }, 84 | ], 85 | templates=[ 86 | { 87 | 'name': 'card1', 88 | 'qfmt': '{{AField}}', 89 | 'afmt': '{{FrontSide}}' 90 | '
' 91 | '{{BField}}', 92 | } 93 | ], 94 | latex_pre=CUSTOM_LATEX_PRE, 95 | latex_post=CUSTOM_LATEX_POST, 96 | ) 97 | 98 | CUSTOM_SORT_FIELD_INDEX = 1 # Anki default value is 0 99 | TEST_MODEL_WITH_SORT_FIELD_INDEX = genanki.Model( 100 | 987123, 'with sort field index', 101 | fields=[ 102 | { 103 | 'name': 'AField', 104 | }, 105 | { 106 | 'name': 'BField', 107 | }, 108 | ], 109 | templates=[ 110 | { 111 | 'name': 'card1', 112 | 'qfmt': '{{AField}}', 113 | 'afmt': '{{FrontSide}}' 114 | '
' 115 | '{{BField}}', 116 | } 117 | ], 118 | sort_field_index=CUSTOM_SORT_FIELD_INDEX, 119 | ) 120 | 121 | # VALID_MP3 and VALID_JPG courtesy of https://github.com/mathiasbynens/small 122 | VALID_MP3 = ( 123 | b'\xff\xe3\x18\xc4\x00\x00\x00\x03H\x00\x00\x00\x00LAME3.98.2\x00\x00\x00' 124 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 125 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 126 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00') 127 | 128 | VALID_JPG = ( 129 | b'\xff\xd8\xff\xdb\x00C\x00\x03\x02\x02\x02\x02\x02\x03\x02\x02\x02\x03\x03' 130 | b'\x03\x03\x04\x06\x04\x04\x04\x04\x04\x08\x06\x06\x05\x06\t\x08\n\n\t\x08\t' 131 | b'\t\n\x0c\x0f\x0c\n\x0b\x0e\x0b\t\t\r\x11\r\x0e\x0f\x10\x10\x11\x10\n\x0c' 132 | b'\x12\x13\x12\x10\x13\x0f\x10\x10\x10\xff\xc9\x00\x0b\x08\x00\x01\x00\x01' 133 | b'\x01\x01\x11\x00\xff\xcc\x00\x06\x00\x10\x10\x05\xff\xda\x00\x08\x01\x01' 134 | b'\x00\x00?\x00\xd2\xcf \xff\xd9') 135 | 136 | 137 | class TestWithCollection: 138 | def setup_method(self): 139 | # TODO make this less messy 140 | colf = tempfile.NamedTemporaryFile(suffix='.anki2') 141 | colf_name = colf.name 142 | colf.close() # colf is deleted 143 | self.col = anki.Collection(colf_name) 144 | 145 | def import_package(self, pkg, timestamp=None): 146 | """ 147 | Imports `pkg` into self.col. 148 | 149 | :param genanki.Package pkg: 150 | """ 151 | outf = tempfile.NamedTemporaryFile(suffix='.apkg', delete=False) 152 | outf.close() 153 | 154 | pkg.write_to_file(outf.name, timestamp=timestamp) 155 | 156 | importer = anki.importing.apkg.AnkiPackageImporter(self.col, outf.name) 157 | importer.run() 158 | 159 | def check_media(self): 160 | # col.media.check seems to assume that the cwd is the media directory. So this helper function 161 | # chdirs to the media dir before running check and then goes back to the original cwd. 162 | orig_cwd = os.getcwd() 163 | os.chdir(self.col.media.dir()) 164 | ret = self.col.media.check() 165 | os.chdir(orig_cwd) 166 | return ret 167 | 168 | def test_generated_deck_can_be_imported(self): 169 | deck = genanki.Deck(123456, 'foodeck') 170 | note = genanki.Note(TEST_MODEL, ['a', 'b']) 171 | deck.add_note(note) 172 | 173 | self.import_package(genanki.Package(deck)) 174 | 175 | all_imported_decks = self.col.decks.all() 176 | assert len(all_imported_decks) == 2 # default deck and foodeck 177 | imported_deck = all_imported_decks[1] 178 | 179 | assert imported_deck['name'] == 'foodeck' 180 | 181 | def test_generated_deck_has_valid_cards(self): 182 | """ 183 | Generates a deck with several notes and verifies that the nid/ord combinations on the generated cards make sense. 184 | 185 | Catches a bug that was fixed in 08d8a139. 186 | """ 187 | deck = genanki.Deck(123456, 'foodeck') 188 | deck.add_note(genanki.Note(TEST_CN_MODEL, ['a', 'b', 'c'])) # 2 cards 189 | deck.add_note(genanki.Note(TEST_CN_MODEL, ['d', 'e', 'f'])) # 2 cards 190 | deck.add_note(genanki.Note(TEST_CN_MODEL, ['g', 'h', 'i'])) # 2 cards 191 | 192 | self.import_package(genanki.Package(deck)) 193 | 194 | cards = [self.col.getCard(i) for i in self.col.findCards('')] 195 | 196 | # the bug causes us to fail to generate certain cards (e.g. the second card for the second note) 197 | assert len(cards) == 6 198 | 199 | def test_multi_deck_package(self): 200 | deck1 = genanki.Deck(123456, 'foodeck') 201 | deck2 = genanki.Deck(654321, 'bardeck') 202 | 203 | note = genanki.Note(TEST_MODEL, ['a', 'b']) 204 | 205 | deck1.add_note(note) 206 | deck2.add_note(note) 207 | 208 | self.import_package(genanki.Package([deck1, deck2])) 209 | 210 | all_imported_decks = self.col.decks.all() 211 | assert len(all_imported_decks) == 3 # default deck, foodeck, and bardeck 212 | 213 | def test_card_isEmpty__with_2_fields__succeeds(self): 214 | """Tests for a bug in an early version of genanki where notes with <4 fields were not supported.""" 215 | deck = genanki.Deck(123456, 'foodeck') 216 | note = genanki.Note(TEST_MODEL, ['a', 'b']) 217 | deck.add_note(note) 218 | 219 | self.import_package(genanki.Package(deck)) 220 | 221 | anki_note = self.col.getNote(self.col.findNotes('')[0]) 222 | anki_card = anki_note.cards()[0] 223 | 224 | # test passes if this doesn't raise an exception 225 | anki_card.isEmpty() 226 | 227 | def test_Model_req(self): 228 | assert TEST_MODEL._req == [[0, 'all', [0]]] 229 | 230 | def test_Model_req__cn(self): 231 | assert TEST_CN_MODEL._req == [[0, 'all', [0]], [1, 'all', [1]]] 232 | 233 | def test_Model_req__with_hint(self): 234 | assert TEST_MODEL_WITH_HINT._req == [[0, 'any', [0, 1]]] 235 | 236 | def test_notes_generate_cards_based_on_req__cn(self): 237 | # has 'Simplified' field, will generate a 'Simplified' card 238 | n1 = genanki.Note(model=TEST_CN_MODEL, fields=['中國', '中国', 'China']) 239 | # no 'Simplified' field, so it won't generate a 'Simplified' card 240 | n2 = genanki.Note(model=TEST_CN_MODEL, fields=['你好', '', 'hello']) 241 | 242 | assert len(n1.cards) == 2 243 | assert n1.cards[0].ord == 0 244 | assert n1.cards[1].ord == 1 245 | 246 | assert len(n2.cards) == 1 247 | assert n2.cards[0].ord == 0 248 | 249 | def test_notes_generate_cards_based_on_req__with_hint(self): 250 | # both of these notes will generate one card 251 | n1 = genanki.Note(model=TEST_MODEL_WITH_HINT, fields=['capital of California', '', 'Sacramento']) 252 | n2 = genanki.Note(model=TEST_MODEL_WITH_HINT, fields=['capital of Iowa', 'French for "The Moines"', 'Des Moines']) 253 | 254 | assert len(n1.cards) == 1 255 | assert n1.cards[0].ord == 0 256 | assert len(n2.cards) == 1 257 | assert n2.cards[0].ord == 0 258 | 259 | def test_Note_with_guid_property(self): 260 | class MyNote(genanki.Note): 261 | @property 262 | def guid(self): 263 | return '3' 264 | 265 | # test passes if this doesn't raise an exception 266 | MyNote() 267 | 268 | def test_media_files(self): 269 | # change to a scratch directory so we can write files 270 | os.chdir(tempfile.mkdtemp()) 271 | 272 | deck = genanki.Deck(123456, 'foodeck') 273 | note = genanki.Note(TEST_MODEL, [ 274 | 'question [sound:present.mp3] [sound:missing.mp3]', 275 | 'answer ']) 276 | deck.add_note(note) 277 | 278 | # populate files with data 279 | with open('present.mp3', 'wb') as h: 280 | h.write(VALID_MP3) 281 | with open('present.jpg', 'wb') as h: 282 | h.write(VALID_JPG) 283 | 284 | package = genanki.Package(deck, media_files=['present.mp3', 'present.jpg']) 285 | self.import_package(package) 286 | 287 | os.remove('present.mp3') 288 | os.remove('present.jpg') 289 | 290 | missing, unused, invalid = self.check_media() 291 | assert set(missing) == {'missing.mp3', 'missing.jpg'} 292 | 293 | def test_media_files_in_subdirs(self): 294 | # change to a scratch directory so we can write files 295 | os.chdir(tempfile.mkdtemp()) 296 | 297 | deck = genanki.Deck(123456, 'foodeck') 298 | note = genanki.Note(TEST_MODEL, [ 299 | 'question [sound:present.mp3] [sound:missing.mp3]', 300 | 'answer ']) 301 | deck.add_note(note) 302 | 303 | # populate files with data 304 | os.mkdir('subdir1') 305 | with open('subdir1/present.mp3', 'wb') as h: 306 | h.write(VALID_MP3) 307 | os.mkdir('subdir2') 308 | with open('subdir2/present.jpg', 'wb') as h: 309 | h.write(VALID_JPG) 310 | 311 | package = genanki.Package(deck, media_files=['subdir1/present.mp3', 'subdir2/present.jpg']) 312 | self.import_package(package) 313 | 314 | os.remove('subdir1/present.mp3') 315 | os.remove('subdir2/present.jpg') 316 | 317 | missing, unused, invalid = self.check_media() 318 | assert set(missing) == {'missing.mp3', 'missing.jpg'} 319 | 320 | def test_media_files_absolute_paths(self): 321 | # change to a scratch directory so we can write files 322 | os.chdir(tempfile.mkdtemp()) 323 | media_dir = tempfile.mkdtemp() 324 | 325 | deck = genanki.Deck(123456, 'foodeck') 326 | note = genanki.Note(TEST_MODEL, [ 327 | 'question [sound:present.mp3] [sound:missing.mp3]', 328 | 'answer ']) 329 | deck.add_note(note) 330 | 331 | # populate files with data 332 | present_mp3_path = os.path.join(media_dir, 'present.mp3') 333 | present_jpg_path = os.path.join(media_dir, 'present.jpg') 334 | with open(present_mp3_path, 'wb') as h: 335 | h.write(VALID_MP3) 336 | with open(present_jpg_path, 'wb') as h: 337 | h.write(VALID_JPG) 338 | 339 | package = genanki.Package(deck, media_files=[present_mp3_path, present_jpg_path]) 340 | self.import_package(package) 341 | 342 | missing, unused, invalid = self.check_media() 343 | assert set(missing) == {'missing.mp3', 'missing.jpg'} 344 | 345 | def test_write_deck_without_deck_id_fails(self): 346 | # change to a scratch directory so we can write files 347 | os.chdir(tempfile.mkdtemp()) 348 | 349 | deck = genanki.Deck() 350 | deck.name = 'foodeck' 351 | 352 | with pytest.raises(TypeError): 353 | deck.write_to_file('foodeck.apkg') 354 | 355 | def test_write_deck_without_name_fails(self): 356 | # change to a scratch directory so we can write files 357 | os.chdir(tempfile.mkdtemp()) 358 | 359 | deck = genanki.Deck() 360 | deck.deck_id = 123456 361 | 362 | with pytest.raises(TypeError): 363 | deck.write_to_file('foodeck.apkg') 364 | 365 | def test_card_suspend(self): 366 | deck = genanki.Deck(123456, 'foodeck') 367 | note = genanki.Note(model=TEST_CN_MODEL, fields=['中國', '中国', 'China']) 368 | assert len(note.cards) == 2 369 | 370 | note.cards[1].suspend = True 371 | 372 | deck.add_note(note) 373 | 374 | self.import_package(genanki.Package(deck), timestamp=0) 375 | 376 | assert self.col.findCards('') == [1, 2] 377 | assert self.col.findCards('is:suspended') == [2] 378 | 379 | def test_deck_with_description(self): 380 | deck = genanki.Deck(112233, 'foodeck', description='This is my great deck.\nIt is so so great.') 381 | note = genanki.Note(TEST_MODEL, ['a', 'b']) 382 | deck.add_note(note) 383 | 384 | self.import_package(genanki.Package(deck)) 385 | 386 | all_decks = self.col.decks.all() 387 | assert len(all_decks) == 2 # default deck and foodeck 388 | imported_deck = all_decks[1] 389 | 390 | assert imported_deck['desc'] == 'This is my great deck.\nIt is so so great.' 391 | 392 | def test_card_added_date_is_recent(self): 393 | """ 394 | Checks for a bug where cards were assigned the creation date 1970-01-01 (i.e. the Unix epoch). 395 | 396 | See https://github.com/kerrickstaley/genanki/issues/29 . 397 | 398 | The "Added" date is encoded in the card.id field; see 399 | https://github.com/ankitects/anki/blob/ed8340a4e3a2006d6285d7adf9b136c735ba2085/anki/stats.py#L28 400 | 401 | TODO implement a fix so that this test passes. 402 | """ 403 | deck = genanki.Deck(1104693946, 'foodeck') 404 | note = genanki.Note(TEST_MODEL, ['a', 'b']) 405 | deck.add_note(note) 406 | 407 | self.import_package(genanki.Package(deck)) 408 | 409 | anki_note = self.col.getNote(self.col.findNotes('')[0]) 410 | anki_card = anki_note.cards()[0] 411 | 412 | assert anki_card.id > 1577836800000 # Jan 1 2020 UTC (milliseconds since epoch) 413 | 414 | def test_model_with_latex_pre_and_post(self): 415 | deck = genanki.Deck(1681249286, 'foodeck') 416 | note = genanki.Note(TEST_MODEL_WITH_LATEX, ['a', 'b']) 417 | deck.add_note(note) 418 | 419 | self.import_package(genanki.Package(deck)) 420 | 421 | anki_note = self.col.getNote(self.col.findNotes('')[0]) 422 | assert anki_note.model()['latexPre'] == CUSTOM_LATEX_PRE 423 | assert anki_note.model()['latexPost'] == CUSTOM_LATEX_POST 424 | 425 | def test_model_with_sort_field_index(self): 426 | deck = genanki.Deck(332211, 'foodeck') 427 | note = genanki.Note(TEST_MODEL_WITH_SORT_FIELD_INDEX, ['a', '3.A']) 428 | deck.add_note(note) 429 | 430 | self.import_package(genanki.Package(deck)) 431 | 432 | anki_note = self.col.getNote(self.col.findNotes('')[0]) 433 | assert anki_note.model()['sortf'] == CUSTOM_SORT_FIELD_INDEX 434 | 435 | def test_notes_with_due1(self): 436 | deck = genanki.Deck(4145273926, 'foodeck') 437 | deck.add_note(genanki.Note( 438 | TEST_MODEL, 439 | ['Capital of Washington', 'Olympia'], 440 | due=1)) 441 | deck.add_note(genanki.Note( 442 | TEST_MODEL, 443 | ['Capital of Oregon', 'Salem'], 444 | due=2)) 445 | 446 | self.import_package(genanki.Package(deck)) 447 | 448 | self.col.decks.select(self.col.decks.id('foodeck')) 449 | self.col.sched.reset() 450 | next_card = self.col.sched.getCard() 451 | next_note = self.col.getNote(next_card.nid) 452 | 453 | # Next card is the one with lowest due value. 454 | assert next_note.fields == ['Capital of Washington', 'Olympia'] 455 | 456 | def test_notes_with_due2(self): 457 | # Same as test_notes_with_due1, but we switch the due values 458 | # for the two notes. 459 | deck = genanki.Deck(4145273927, 'foodeck') 460 | deck.add_note(genanki.Note( 461 | TEST_MODEL, 462 | ['Capital of Washington', 'Olympia'], 463 | due=2)) 464 | deck.add_note(genanki.Note( 465 | TEST_MODEL, 466 | ['Capital of Oregon', 'Salem'], 467 | due=1)) 468 | 469 | self.import_package(genanki.Package(deck)) 470 | 471 | self.col.decks.select(self.col.decks.id('foodeck')) 472 | self.col.sched.reset() 473 | next_card = self.col.sched.getCard() 474 | next_note = self.col.getNote(next_card.nid) 475 | 476 | # Next card changes to "Capital of Oregon", because it has lower 477 | # due value. 478 | assert next_note.fields == ['Capital of Oregon', 'Salem'] 479 | --------------------------------------------------------------------------------