├── 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
` for images.
105 |
106 | You *cannot* put `
` 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}}
'])
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 |
--------------------------------------------------------------------------------