Skip to content

Commit

Permalink
Fix computation of Model req field
Browse files Browse the repository at this point in the history
Also remove user ability to control which Cards a Note has. Anki doesn't
actually support this; it enforces that the set of Cards a Note has is
based on which Fields it has.
  • Loading branch information
kerrickstaley committed Feb 19, 2017
1 parent ccb3b77 commit b7a5821
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 37 deletions.
11 changes: 0 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,6 @@ class MyNote(genanki.Note):
return genanki.guid_for(self.fields[0], self.fields[1])
```

## Only Including Certain Cards
By default, all `Card`s are included when you add a `Note` to a deck. You can control which cards are included by
explicitly passing `cards=[]` to `Note()` and then calling `add_card` with the card number:

```python
my_note = Note(
...,
cards=[])
my_note.add_card(1) # add the card corresponding to the 2nd template
```

## sort_field
Anki has a value for each `Note` called the `sort_field`. Anki uses this value to sort the cards in the Browse
interface. Anki also is happier if you avoid having two notes with the same `sort_field`, although this isn't strictly
Expand Down
75 changes: 52 additions & 23 deletions genanki/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from cached_property import cached_property
from copy import copy
import json
import hashlib
import os
import random
import pystache
import sqlite3
import tempfile
import time
Expand Down Expand Up @@ -60,6 +62,44 @@ def set_templates(self, templates):
elif isinstance(templates, str):
self.templates = yaml.load(templates)

@cached_property
def _req(self):
"""
List of required fields for each template. Format is [tmpl_idx, "all", [req_field_1, req_field_2, ...]].
Partial reimplementation of req computing logic from Anki. We only support "all" style req, not "any".
The goal is to figure out which fields are "required", i.e. if they are missing then the front side of the note
doesn't contain any meaningful content.
"""
sentinel = 'SeNtInEl'

field_names = [field['name'] for field in self.fields]
field_values = {field: sentinel for field in field_names}

req = []
for template_ord, template in enumerate(self.templates):
required_fields = []
for field_ord, field in enumerate(field_names):
fvcopy = copy(field_values)
fvcopy[field] = ''

rendered = pystache.render(template['qfmt'], fvcopy)

if sentinel not in rendered:
# when this field is missing, there is no meaningful content (no field values) in the question, so this field
# is required
required_fields.append(field_ord)

if not required_fields:
raise Exception(
'Could not compute required fields for this template; please check the formatting of "qfmt": {}'.format(
template))

req.append([template_ord, 'all', required_fields])

return req

def to_json(self, now_ts, deck_id):
for ord_, tmpl in enumerate(self.templates):
tmpl['ord'] = ord_
Expand All @@ -75,10 +115,6 @@ def to_json(self, now_ts, deck_id):
field.setdefault('size', 20)
field.setdefault('sticky', False)

# TODO: figure out how req works
all_field_ords = list(range(len(self.fields)))
req = [[tmpl_ord, "all", all_field_ords] for tmpl_ord in range(len(self.templates))]

return {
"css": self.css,
"did": deck_id,
Expand All @@ -89,7 +125,7 @@ def to_json(self, now_ts, deck_id):
"\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
"mod": now_ts,
"name": self.name,
"req": req,
"req": self._req,
"sortf": 0,
"tags": [],
"tmpls": self.templates,
Expand All @@ -101,13 +137,13 @@ def to_json(self, now_ts, deck_id):

class Card:
def __init__(self, ord_):
self.ord_ = ord_
self.ord = ord_

def write_to_db(self, cursor, now_ts, deck_id, note_id):
cursor.execute('INSERT INTO cards VALUES(null,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);', (
note_id, # nid
deck_id, # did
self.ord_, # ord
self.ord, # ord
now_ts, # mod
-1, # usn
0, # type (=0 for non-Cloze)
Expand All @@ -131,15 +167,8 @@ def __init__(self, model=None, fields=None, sort_field=None, tags=None, cards=No
self.fields = fields
self.sort_field = sort_field
self.tags = tags or []
self.cards = cards
self.guid = guid

def add_card(self, *args, **kwargs):
if len(args) == 1 and not kwargs and isinstance(args[0], Card):
self.cards.append(args[0])
else:
self.cards.append(Card(*args, **kwargs))

@property
def sort_field(self):
return self._sort_field or self.fields[0]
Expand All @@ -148,15 +177,15 @@ def sort_field(self):
def sort_field(self, val):
self._sort_field = val

@property
# We use cached_property instead of initializing in the constructor so that the user can set the model after calling
# __init__ and it'll still work.
@cached_property
def cards(self):
if self._cards is None:
return [Card(i) for i in range(len(self.model.templates))]
return self._cards

@cards.setter
def cards(self, val):
self._cards = val
rv = []
for card_ord, _, required_field_ords in self.model._req:
if all(self.fields[ord_] for ord_ in required_field_ords):
rv.append(Card(card_ord))
return rv

@property
def guid(self):
Expand Down
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
packages=['genanki'],
zip_safe=False,
install_requires=[
'cached-property',
'frozendict',
'pystache',
'pyyaml',
],
keywords=[
Expand Down
44 changes: 41 additions & 3 deletions tests/test_genanki.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,27 @@
],
)

TEST_CN_MODEL = genanki.Model(
345678, 'Chinese',
fields=[{'name': 'Traditional'}, {'name': 'Simplified'}, {'name': 'English'}],
templates=[
{
'name': 'Traditional',
'qfmt': '{{Traditional}}',
'afmt': '{{FrontSide}}'
'<hr id="answer">'
'{{English}}',
},
{
'name': 'Simplified',
'qfmt': '{{Simplified}}',
'afmt': '{{FrontSide}}'
'<hr id="answer">'
'{{English}}',
},
],
)


class TestWithCollection:
def setup(self):
Expand All @@ -41,7 +62,6 @@ def setup(self):
def test_generated_deck_can_be_imported(self):
deck = genanki.Deck(123456, 'foodeck')
note = genanki.Note(TEST_MODEL, ['a', 'b'])
note.add_card(0)
deck.add_note(note)

outf = tempfile.NamedTemporaryFile(suffix='.apkg', delete=False)
Expand All @@ -58,11 +78,10 @@ def test_generated_deck_can_be_imported(self):

assert imported_deck['name'] == 'foodeck'

def test_card_isEmtpy__with_2_fields__succeeds(self):
def test_card_isEmpty__with_2_fields__succeeds(self):
"""Tests for a bug in an early version of genanki where notes with <4 fields were not supported."""
deck = genanki.Deck(123456, 'foodeck')
note = genanki.Note(TEST_MODEL, ['a', 'b'])
note.add_card(0)
deck.add_note(note)

outf = tempfile.NamedTemporaryFile(suffix='.apkg', delete=False)
Expand All @@ -78,3 +97,22 @@ def test_card_isEmtpy__with_2_fields__succeeds(self):

# test passes if this doesn't raise an exception
anki_card.isEmpty()

def test_Model_req(self):
assert TEST_MODEL._req == [[0, 'all', [0]]]

def test_Model_req__cn(self):
assert TEST_CN_MODEL._req == [[0, 'all', [0]], [1, 'all', [1]]]

def test_notes_generate_cards_based_on_req(self):
# has 'Simplified' field, will generate a 'Simplified' card
n1 = genanki.Note(model=TEST_CN_MODEL, fields=['中國', '中国', 'China'])
# no 'Simplified' field, so it won't generate a 'Simplified' card
n2 = genanki.Note(model=TEST_CN_MODEL, fields=['你好', '', 'hello'])

assert len(n1.cards) == 2
assert n1.cards[0].ord == 0
assert n1.cards[1].ord == 1

assert len(n2.cards) == 1
assert n2.cards[0].ord == 0

0 comments on commit b7a5821

Please sign in to comment.