From 28766a33244291011783841b88c3aa0ad89764ea Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Thu, 17 Aug 2017 14:22:02 -0700 Subject: [PATCH 01/14] Add deck options via new OptionsGroup class. * Switch to named placeholders in SQL commands for sqlite3 to maintain readability as number of deck options increases. * Add lots of deck options which are maintained through new OptionsGroup class. The OptionsGroup encapsulates the corresponding concept in Anki so multiple decks can share the same set of options. --- genanki/__init__.py | 66 ++++++++++++++++++++++++++++++++++-- genanki/apkg_col.py | 81 +++++++++++++++++++++------------------------ 2 files changed, 102 insertions(+), 45 deletions(-) diff --git a/genanki/__init__.py b/genanki/__init__.py index b5f3333..83954b9 100644 --- a/genanki/__init__.py +++ b/genanki/__init__.py @@ -243,12 +243,65 @@ def _format_tags(self): return ' ' + ' '.join(self.tags) + ' ' +class OptionsGroup: + def __init__(self, options_id=1, name='Default'): + self.options_id = options_id + self.options_group_name = name + # General. + self.max_time_per_answer = 60 + self.show_timer = False # 'false' + self.autoplay_audio = True + self.replay_audio_for_answer = True + # New. + self.new_steps = [1, 10] + self.order = 1 + self.new_cardsperday = 20 + self.graduating_interval = 1 + self.easy_interval = 4 + self.starting_ease = 2500 + self.new_bury_related_cards = True # 'true' + # Reviews. + self.max_reviews_per_day = 100 + self.easy_bonus = 1.3 + self.interval_modifier = 1 + self.max_interval = 36500 + self.review_bury_related_cards = True + # Lapses. + self.lapse_steps = [10] + self.leech_interval_multiplier = 0 + self.lapse_min_interval = 1 + self.leech_threshold = 8 + self.leech_action = 0 + + # Used for adding arbitrary options via JSON string. Useful for + # addons. + self.misc = '' + + def validate(self): + if self.misc and self.misc[-1] != ',': + self.misc += ',' + + def _format_fields(self): + self.validate() + fields = {} + for key, value in self.__dict__.items(): + if key.startswith('__') or callable(key): + continue + if type(value) is bool: + fields[key] = str(value).lower() + else: + fields[key] = str(value) + return fields + + class Deck: - def __init__(self, deck_id=None, name=None): + def __init__(self, deck_id=None, name=None, options=None): self.deck_id = deck_id self.name = name self.notes = [] self.models = {} # map of model id to model + self.description = '' + self.options = options def add_note(self, note): self.notes.append(note) @@ -261,7 +314,16 @@ def write_to_db(self, cursor, now_ts): self.add_model(note.model) models = {model.model_id: model.to_json(now_ts, self.deck_id) for model in self.models.values()} - cursor.execute(APKG_COL, [self.name, self.deck_id, json.dumps(models)]) + params = self.options._format_fields() + + params.update({ + 'name': self.name, + 'deck_id': self.deck_id, + 'models': json.dumps(models), + 'description': self.description, + }) + + cursor.execute(APKG_COL, params) for note in self.notes: note.write_to_db(cursor, now_ts, self.deck_id) diff --git a/genanki/apkg_col.py b/genanki/apkg_col.py index fe00e25..cddd89d 100644 --- a/genanki/apkg_col.py +++ b/genanki/apkg_col.py @@ -25,7 +25,7 @@ "sortType": "noteFld", "timeLim": 0 }', - ?3, + :models, '{ "1": { "collapsed": false, @@ -55,78 +55,73 @@ ], "usn": 0 }, - "' || ?2 || '": { + "' || :deck_id || '": { "collapsed": false, - "conf": 1, - "desc": "", + "conf": ' || :options_id || ', + "desc": "' || :description || '", "dyn": 0, - "extendNew": 0, + "extendNew": 10, "extendRev": 50, - "id": ' || ?2 || ', + "id": ' || :deck_id || ', "lrnToday": [ - 163, - 2 + 5, + 0 ], "mod": 1425278051, - "name": "' || ?1 || '", + "name": "' || :name || '", "newToday": [ - 163, - 2 + 5, + 0 ], "revToday": [ - 163, + 5, 0 ], "timeToday": [ - 163, - 23598 + 5, + 0 ], "usn": -1 } }', '{ - "1": { - "autoplay": true, - "id": 1, + "' || :options_id || '": { + "autoplay": ' || :autoplay_audio || ', + "id": ' || :options_id || ', "lapse": { - "delays": [ - 10 - ], - "leechAction": 0, - "leechFails": 8, - "minInt": 1, - "mult": 0 + "delays": ' || :lapse_steps || ', + "leechAction": ' || :leech_action || ', + "leechFails": ' || :leech_threshold || ', + "minInt": ' || :lapse_min_interval || ', + "mult": ' || :leech_interval_multiplier || ' }, - "maxTaken": 60, + "maxTaken": ' || :max_time_per_answer || ', "mod": 0, - "name": "Default", + "name": "' || :options_group_name || '", "new": { - "bury": true, - "delays": [ - 1, - 10 - ], - "initialFactor": 2500, + "bury": ' || :new_bury_related_cards || ', + "delays": ' || :new_steps || ', + "initialFactor": ' || :starting_ease || ', "ints": [ - 1, - 4, + ' || :graduating_interval || ', + ' || :easy_interval || ', 7 ], - "order": 1, - "perDay": 20, + "order": ' || :order || ', + "perDay": ' || :new_cardsperday || ', "separate": true }, - "replayq": true, + "replayq": ' || :replay_audio_for_answer || ', "rev": { - "bury": true, - "ease4": 1.3, + "bury": ' || :review_bury_related_cards || ', + "ease4": ' || :easy_bonus || ', "fuzz": 0.05, - "ivlFct": 1, - "maxIvl": 36500, + "ivlFct": ' || :interval_modifier || ', + "maxIvl": ' || :max_interval || ', "minSpace": 1, - "perDay": 100 + "perDay": ' || :max_reviews_per_day || ' }, - "timer": 0, + "timer": ' || :show_timer || ', "usn": 0 } }', From eab7246ee80a67512067a0cda73433609d290514 Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Fri, 18 Aug 2017 00:49:06 -0700 Subject: [PATCH 02/14] Add per-note options and a deck creation time. * A correct deck creation time is necessary since card due dates are defined relative to it. --- genanki/__init__.py | 61 +++++++++++++++++++++++++++++---------------- genanki/apkg_col.py | 8 +++--- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/genanki/__init__.py b/genanki/__init__.py index 83954b9..4631ffd 100644 --- a/genanki/__init__.py +++ b/genanki/__init__.py @@ -1,7 +1,8 @@ from cached_property import cached_property from copy import copy -import json +from datetime import datetime import hashlib +import json import os import pystache import sqlite3 @@ -155,25 +156,30 @@ class Card: def __init__(self, ord_): self.ord = ord_ - def write_to_db(self, cursor, now_ts, deck_id, note_id): + self.interval = 0 + + def write_to_db(self, cursor, now_ts, deck_id, note_id, + level, due, interval, ease, reps_til_grad): cursor.execute('INSERT INTO cards VALUES(null,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);', ( - note_id, # nid - deck_id, # did - self.ord, # ord - now_ts, # mod - -1, # usn - 0, # type (=0 for non-Cloze) - 0, # queue - 0, # due - 0, # ivl - 0, # factor - 0, # reps - 0, # lapses - 0, # left - 0, # odue - 0, # odid - 0, # flags - "", # data + note_id, # nid - note ID + deck_id, # did - deck ID + self.ord, # ord - which card template it corresponds + now_ts, # mod - modification time as epoch seconds + -1, # usn - value of -1 indicates need to push to server + level, # type - 0=new, 1=learning, 2=review + level, # queue - same as type unless buried + due, # due - new: unused + # learning: due time as integer seconds since epoch + # review: integer days relative to deck creation + interval, # ivl - positive days, negative seconds + ease, # factor - integer ease factor used by SRS, 2500 = 250% + 0, # reps - number of reviews + 0, # lapses - # times card went from "answered correctly" to "answered incorrectly" + reps_til_grad, # left - reps left until graduation + 0, # odue - only used when card is in filtered deck + 0, # odid - only used when card is in filtered deck + 0, # flags - currently unused + "", # data - currently unused )) @@ -189,6 +195,12 @@ def __init__(self, model=None, fields=None, sort_field=None, tags=None, guid=Non # guid was defined as a property pass + self.level = 0 + self.due = 0 + self.interval = 0 + self.ease = 1000 + self.reps_til_grad = 0 + @property def sort_field(self): return self._sort_field or self.fields[0] @@ -234,7 +246,9 @@ def write_to_db(self, cursor, now_ts, deck_id): note_id = cursor.lastrowid for card in self.cards: - card.write_to_db(cursor, now_ts, deck_id, note_id) + card.write_to_db(cursor, now_ts, deck_id, note_id, + self.level, self.due, self.interval, + self.ease, self.reps_til_grad) def _format_fields(self): return '\x1f'.join(self.fields) @@ -249,7 +263,7 @@ def __init__(self, options_id=1, name='Default'): self.options_group_name = name # General. self.max_time_per_answer = 60 - self.show_timer = False # 'false' + self.show_timer = False self.autoplay_audio = True self.replay_audio_for_answer = True # New. @@ -259,7 +273,7 @@ def __init__(self, options_id=1, name='Default'): self.graduating_interval = 1 self.easy_interval = 4 self.starting_ease = 2500 - self.new_bury_related_cards = True # 'true' + self.new_bury_related_cards = True # Reviews. self.max_reviews_per_day = 100 self.easy_bonus = 1.3 @@ -302,6 +316,7 @@ def __init__(self, deck_id=None, name=None, options=None): self.models = {} # map of model id to model self.description = '' self.options = options + self.creation_time = datetime.now() def add_note(self, note): self.notes.append(note) @@ -317,6 +332,8 @@ def write_to_db(self, cursor, now_ts): params = self.options._format_fields() params.update({ + 'creation_time': self.creation_time.timestamp(), + 'modification_time': self.creation_time.timestamp() * 1000, 'name': self.name, 'deck_id': self.deck_id, 'models': json.dumps(models), diff --git a/genanki/apkg_col.py b/genanki/apkg_col.py index cddd89d..f7ff007 100644 --- a/genanki/apkg_col.py +++ b/genanki/apkg_col.py @@ -1,9 +1,9 @@ APKG_COL = r''' INSERT INTO col VALUES( null, - 1411124400, - 1425279151694, - 1425279151690, + :creation_time, + :modification_time, + :modification_time, 11, 0, 0, @@ -15,7 +15,7 @@ "addToCur": true, "collapseTime": 1200, "curDeck": 1, - "curModel": "1425279151691", + "curModel": "' || :modification_time || '", "dueCounts": true, "estTimes": true, "newBury": true, From 685d962c4cb0a3309128e320b50ce9ea81839bd8 Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Fri, 18 Aug 2017 01:02:25 -0700 Subject: [PATCH 03/14] Ensure deck creation time is an integer. --- genanki/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/genanki/__init__.py b/genanki/__init__.py index 4631ffd..c25a60d 100644 --- a/genanki/__init__.py +++ b/genanki/__init__.py @@ -332,8 +332,8 @@ def write_to_db(self, cursor, now_ts): params = self.options._format_fields() params.update({ - 'creation_time': self.creation_time.timestamp(), - 'modification_time': self.creation_time.timestamp() * 1000, + 'creation_time': int(self.creation_time.timestamp()), + 'modification_time': int(self.creation_time.timestamp()) * 1000, 'name': self.name, 'deck_id': self.deck_id, 'models': json.dumps(models), From ebacc3cc27941d7a92385676a459381b7980df1b Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Fri, 18 Aug 2017 12:46:34 -0700 Subject: [PATCH 04/14] Fix a few typos and a minor interface inconsistency. --- genanki/__init__.py | 8 ++++---- genanki/apkg_col.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/genanki/__init__.py b/genanki/__init__.py index c25a60d..a28ab99 100644 --- a/genanki/__init__.py +++ b/genanki/__init__.py @@ -163,7 +163,7 @@ def write_to_db(self, cursor, now_ts, deck_id, note_id, cursor.execute('INSERT INTO cards VALUES(null,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);', ( note_id, # nid - note ID deck_id, # did - deck ID - self.ord, # ord - which card template it corresponds + self.ord, # ord - which card template it corresponds to now_ts, # mod - modification time as epoch seconds -1, # usn - value of -1 indicates need to push to server level, # type - 0=new, 1=learning, 2=review @@ -258,7 +258,7 @@ def _format_tags(self): class OptionsGroup: - def __init__(self, options_id=1, name='Default'): + def __init__(self, options_id=None, name=None): self.options_id = options_id self.options_group_name = name # General. @@ -269,7 +269,7 @@ def __init__(self, options_id=1, name='Default'): # New. self.new_steps = [1, 10] self.order = 1 - self.new_cardsperday = 20 + self.new_cards_per_day = 20 self.graduating_interval = 1 self.easy_interval = 4 self.starting_ease = 2500 @@ -315,7 +315,7 @@ def __init__(self, deck_id=None, name=None, options=None): self.notes = [] self.models = {} # map of model id to model self.description = '' - self.options = options + self.options = options if options else OptionsGroup() self.creation_time = datetime.now() def add_note(self, note): diff --git a/genanki/apkg_col.py b/genanki/apkg_col.py index f7ff007..1be0363 100644 --- a/genanki/apkg_col.py +++ b/genanki/apkg_col.py @@ -86,8 +86,8 @@ }', '{ "' || :options_id || '": { - "autoplay": ' || :autoplay_audio || ', "id": ' || :options_id || ', + "autoplay": ' || :autoplay_audio || ', "lapse": { "delays": ' || :lapse_steps || ', "leechAction": ' || :leech_action || ', @@ -108,7 +108,7 @@ 7 ], "order": ' || :order || ', - "perDay": ' || :new_cardsperday || ', + "perDay": ' || :new_cards_per_day || ', "separate": true }, "replayq": ' || :replay_audio_for_answer || ', From cd261da7463ef92153ae9beda74a3b481e57ea37 Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Fri, 18 Aug 2017 14:01:40 -0700 Subject: [PATCH 05/14] Rename note.level -> stage to reduce ambiguity. * Anki does not use a consistent nomenclature for the SRS stages. 'Type' cannot be used like it is in the database because that is a reserved word in Python. 'Level' could be taken as a generic measure, like 'interval' is for due days. It is hard(er) to mistake 'stage' for any of the other parameters. --- genanki/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/genanki/__init__.py b/genanki/__init__.py index a28ab99..6feec4b 100644 --- a/genanki/__init__.py +++ b/genanki/__init__.py @@ -159,15 +159,15 @@ def __init__(self, ord_): self.interval = 0 def write_to_db(self, cursor, now_ts, deck_id, note_id, - level, due, interval, ease, reps_til_grad): + stage, due, interval, ease, reps_til_grad): cursor.execute('INSERT INTO cards VALUES(null,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);', ( note_id, # nid - note ID deck_id, # did - deck ID self.ord, # ord - which card template it corresponds to now_ts, # mod - modification time as epoch seconds -1, # usn - value of -1 indicates need to push to server - level, # type - 0=new, 1=learning, 2=review - level, # queue - same as type unless buried + stage, # type - 0=new, 1=learning, 2=review + stage, # queue - same as type unless buried due, # due - new: unused # learning: due time as integer seconds since epoch # review: integer days relative to deck creation @@ -195,10 +195,10 @@ def __init__(self, model=None, fields=None, sort_field=None, tags=None, guid=Non # guid was defined as a property pass - self.level = 0 + self.stage = 0 self.due = 0 self.interval = 0 - self.ease = 1000 + self.ease = 0 self.reps_til_grad = 0 @property @@ -247,7 +247,7 @@ def write_to_db(self, cursor, now_ts, deck_id): note_id = cursor.lastrowid for card in self.cards: card.write_to_db(cursor, now_ts, deck_id, note_id, - self.level, self.due, self.interval, + self.stage, self.due, self.interval, self.ease, self.reps_til_grad) def _format_fields(self): From 6eb44fa74d7f7b20f732e2ef55697101e3eab49d Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Sat, 19 Aug 2017 11:51:40 -0700 Subject: [PATCH 06/14] Minor coding style improvement. --- genanki/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/genanki/__init__.py b/genanki/__init__.py index 6feec4b..c57440c 100644 --- a/genanki/__init__.py +++ b/genanki/__init__.py @@ -315,7 +315,7 @@ def __init__(self, deck_id=None, name=None, options=None): self.notes = [] self.models = {} # map of model id to model self.description = '' - self.options = options if options else OptionsGroup() + self.options = options or OptionsGroup() self.creation_time = datetime.now() def add_note(self, note): From 96606ecbbba34c8648ed842040757d2149f21ab2 Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Thu, 24 Aug 2017 12:45:44 -0700 Subject: [PATCH 07/14] Document deck and note options. --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 5d37429..53d0504 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,35 @@ interface. Anki also is happier if you avoid having two notes with the same `sor necessary. By default, the `sort_field` is the first field, but you can change it by passing `sort_field=` to `Note()` or implementing `sort_field` as a property in a subclass (similar to `guid`). +## Deck and Note Options +`Deck`s and `Note`s have options, many of which influence the Anki SRS algorithm. These can change when and how often cards are due, how leech cards are handled, how cards transition between SRS stages, and more. + +Deck options are primarily provided through the `OptionGroup` which is closely modeled after the Anki deck options window. In addition, the `Deck` has `description` and `creation_time` attributes. Note that Anki defines card due dates relative to deck creation time. + +``` python +options = genanki.OptionsGroup() +options.autoplay_audio = False +options.new_cards_per_day = 10 +options.max_reviews_per_day = 200 +options.review_bury_related_cards = False +options.interval_modifier = 0.85 +my_deck = genanki.Deck( + 2059400110, + 'Country Capitals', + options=options) +my_deck.description = r'The capitals of the 100 most populous countries. \nCreated on {}.'.format(deck.creation_time.date().isoformat()) +``` + +`Note` options typically vary significantly across notes and are thus set directly in the note. + +``` python +my_note.stage = 1 # SRS learning stage: 0 = new, 1 = learning, 2 = review. +my_note.interval = 20 # Days between next review and the one following. +``` + +Decks and notes have more options which are documented in `genanki/__init__.py`. For full details on their meaning and relation to the Anki SRS algorithm, please see the [official Anki documentation](https://apps.ankiweb.net/docs/manual.html#what-spaced-repetition-algorithm-does-anki-use). + + ## YAML for Templates (and Fields) You can create your template definitions in the YAML format and pass them as a `str` to `Model()`. You can also do this for fields. From 0fa1870e77dd9de15931558c8bd6351383a22e9a Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Thu, 24 Aug 2017 12:46:59 -0700 Subject: [PATCH 08/14] Internally document note and deck options further. --- genanki/__init__.py | 53 +++++++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/genanki/__init__.py b/genanki/__init__.py index c57440c..dd424c3 100644 --- a/genanki/__init__.py +++ b/genanki/__init__.py @@ -164,12 +164,12 @@ def write_to_db(self, cursor, now_ts, deck_id, note_id, note_id, # nid - note ID deck_id, # did - deck ID self.ord, # ord - which card template it corresponds to - now_ts, # mod - modification time as epoch seconds + now_ts, # mod - modification time as seconds since Unix epoch -1, # usn - value of -1 indicates need to push to server stage, # type - 0=new, 1=learning, 2=review stage, # queue - same as type unless buried due, # due - new: unused - # learning: due time as integer seconds since epoch + # learning: due time as integer seconds since Unix epoch # review: integer days relative to deck creation interval, # ivl - positive days, negative seconds ease, # factor - integer ease factor used by SRS, 2500 = 250% @@ -195,10 +195,24 @@ def __init__(self, model=None, fields=None, sort_field=None, tags=None, guid=Non # guid was defined as a property pass + ## Options ## + + """SRS learning stage. + 0 = new, 1 = learning, 2 = review.""" self.stage = 0 + """Behavior depends on learning stage of note. + new: unused. + learning: due time as integer seconds since Unix epoch. + review: integer days relative to deck creation timestamp.""" self.due = 0 + """Time between next review and the one following. + Positive values are in days, negative in seconds.""" self.interval = 0 + """Integer 'ease' factor used by SRS algorithm. + Example: 2500 = 250%.""" self.ease = 0 + """Repititions remaining until graduation from the learning stage. + Unused during other SRS stages.""" self.reps_til_grad = 0 @property @@ -261,26 +275,27 @@ class OptionsGroup: def __init__(self, options_id=None, name=None): self.options_id = options_id self.options_group_name = name - # General. - self.max_time_per_answer = 60 + # Organized according to options window tabs in Anki. + ## General ## + self.max_time_per_answer = 60 # minutes self.show_timer = False self.autoplay_audio = True self.replay_audio_for_answer = True - # New. - self.new_steps = [1, 10] - self.order = 1 - self.new_cards_per_day = 20 - self.graduating_interval = 1 - self.easy_interval = 4 - self.starting_ease = 2500 - self.new_bury_related_cards = True - # Reviews. + ## New Cards ## + self.new_steps = [1, 10] # list of minute intervals per learning stage + self.order = 1 # option selected in dropdown (0 = first, 1 = second) + self.new_cards_per_day = 20 # days + self.graduating_interval = 1 # days + self.easy_interval = 4 # days + self.starting_ease = 2500 # 2500 = 250% + self.bury_related_new_cards = True + ## Reviews ## self.max_reviews_per_day = 100 self.easy_bonus = 1.3 - self.interval_modifier = 1 - self.max_interval = 36500 - self.review_bury_related_cards = True - # Lapses. + self.interval_modifier = 1.0 + self.max_interval = 36500 # days + self.bury_related_review_cards = True + ## Lapses ## self.lapse_steps = [10] self.leech_interval_multiplier = 0 self.lapse_min_interval = 1 @@ -312,11 +327,11 @@ class Deck: def __init__(self, deck_id=None, name=None, options=None): self.deck_id = deck_id self.name = name + self.description = '' + self.creation_time = datetime.now() self.notes = [] self.models = {} # map of model id to model - self.description = '' self.options = options or OptionsGroup() - self.creation_time = datetime.now() def add_note(self, note): self.notes.append(note) From a5a8d00cd69b0b4c54343f0ea7c13c0dede9ffc6 Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Thu, 24 Aug 2017 14:06:50 -0700 Subject: [PATCH 09/14] Fix a couple field name typos in apgk_col. --- genanki/apkg_col.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/genanki/apkg_col.py b/genanki/apkg_col.py index 1be0363..856832f 100644 --- a/genanki/apkg_col.py +++ b/genanki/apkg_col.py @@ -99,7 +99,7 @@ "mod": 0, "name": "' || :options_group_name || '", "new": { - "bury": ' || :new_bury_related_cards || ', + "bury": ' || :bury_related_new_cards || ', "delays": ' || :new_steps || ', "initialFactor": ' || :starting_ease || ', "ints": [ @@ -113,7 +113,7 @@ }, "replayq": ' || :replay_audio_for_answer || ', "rev": { - "bury": ' || :review_bury_related_cards || ', + "bury": ' || :bury_related_review_cards || ', "ease4": ' || :easy_bonus || ', "fuzz": 0.05, "ivlFct": ' || :interval_modifier || ', From 4d0449a205204432e234339ad4a06a993ada60f5 Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Thu, 24 Aug 2017 15:19:57 -0700 Subject: [PATCH 10/14] Update tests to create OptionGroups. --- tests/test_genanki.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_genanki.py b/tests/test_genanki.py index 5db77bf..5b4d9be 100644 --- a/tests/test_genanki.py +++ b/tests/test_genanki.py @@ -103,7 +103,8 @@ def import_package(self, pkg): importer.run() def test_generated_deck_can_be_imported(self): - deck = genanki.Deck(123456, 'foodeck') + options = genanki.OptionsGroup(654321, 'foooptions') + deck = genanki.Deck(123456, 'foodeck', options) note = genanki.Note(TEST_MODEL, ['a', 'b']) deck.add_note(note) @@ -121,7 +122,8 @@ def test_generated_deck_has_valid_cards(self): Catches a bug that was fixed in 08d8a139. """ - deck = genanki.Deck(123456, 'foodeck') + options = genanki.OptionsGroup(654321, 'foooptions') + deck = genanki.Deck(123456, 'foodeck', options) deck.add_note(genanki.Note(TEST_CN_MODEL, ['a', 'b', 'c'])) # 2 cards deck.add_note(genanki.Note(TEST_CN_MODEL, ['d', 'e', 'f'])) # 2 cards deck.add_note(genanki.Note(TEST_CN_MODEL, ['g', 'h', 'i'])) # 2 cards @@ -135,7 +137,8 @@ def test_generated_deck_has_valid_cards(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') + options = genanki.OptionsGroup(654321, 'foooptions') + deck = genanki.Deck(123456, 'foodeck', options) note = genanki.Note(TEST_MODEL, ['a', 'b']) deck.add_note(note) @@ -192,7 +195,8 @@ def test_media_files(self): # change to a scratch directory so we can write files os.chdir(tempfile.mkdtemp()) - deck = genanki.Deck(123456, 'foodeck') + options = genanki.OptionsGroup(654321, 'foooptions') + deck = genanki.Deck(123456, 'foodeck', options) note = genanki.Note(TEST_MODEL, [ 'question [sound:present.mp3] [sound:missing.mp3]', 'answer ']) From 61b18b5ad043c2b4c0894ef147fad825b7ae4334 Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Mon, 28 Aug 2017 11:41:35 -0700 Subject: [PATCH 11/14] Add Card.status to allow suspending and burying cards. --- genanki/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/genanki/__init__.py b/genanki/__init__.py index dd424c3..9fb6f06 100644 --- a/genanki/__init__.py +++ b/genanki/__init__.py @@ -159,7 +159,7 @@ def __init__(self, ord_): self.interval = 0 def write_to_db(self, cursor, now_ts, deck_id, note_id, - stage, due, interval, ease, reps_til_grad): + stage, queue, due, interval, ease, reps_til_grad): cursor.execute('INSERT INTO cards VALUES(null,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);', ( note_id, # nid - note ID deck_id, # did - deck ID @@ -167,7 +167,8 @@ def write_to_db(self, cursor, now_ts, deck_id, note_id, now_ts, # mod - modification time as seconds since Unix epoch -1, # usn - value of -1 indicates need to push to server stage, # type - 0=new, 1=learning, 2=review - stage, # queue - same as type unless buried + queue, # queue - same as type, but + # -1=suspended, -2=user buried, -3=sched buried due, # due - new: unused # learning: due time as integer seconds since Unix epoch # review: integer days relative to deck creation @@ -200,6 +201,9 @@ def __init__(self, model=None, fields=None, sort_field=None, tags=None, guid=Non """SRS learning stage. 0 = new, 1 = learning, 2 = review.""" self.stage = 0 + """SRS queue status modifiers. + 0 = normal, 1 = suspended, 2 = user buried, 3 = scheduler buried""" + self.status = 0 """Behavior depends on learning stage of note. new: unused. learning: due time as integer seconds since Unix epoch. @@ -259,9 +263,10 @@ def write_to_db(self, cursor, now_ts, deck_id): )) note_id = cursor.lastrowid + queue = -self.status if self.status else self.stage for card in self.cards: card.write_to_db(cursor, now_ts, deck_id, note_id, - self.stage, self.due, self.interval, + self.stage, queue, self.due, self.interval, self.ease, self.reps_til_grad) def _format_fields(self): From bda709744026e401e0818489a5c4a0fb77e05acb Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Thu, 31 Aug 2017 22:30:51 -0700 Subject: [PATCH 12/14] Move note options to CardOptions and try to match database field names. --- genanki/__init__.py | 226 ++++++++++++++++++++++++++++---------------- 1 file changed, 147 insertions(+), 79 deletions(-) diff --git a/genanki/__init__.py b/genanki/__init__.py index 9fb6f06..06421dc 100644 --- a/genanki/__init__.py +++ b/genanki/__init__.py @@ -152,35 +152,88 @@ def to_json(self, now_ts, deck_id): } +class CardOptions: + def __init__(self, stage=0, status=0, due=0, interval=0, + ease_factor=0, reps_left_til_grad=0): + """SRS learning stage. + 0 = new, 1 = learning, 2 = review.""" + self.type = stage + """SRS queue status modifiers. + 0 = normal, 1 = suspended, 2 = user buried, 3 = scheduler buried""" + self.status = status + """Behavior depends on learning stage of note. + new: unused. + learning: due time as integer seconds since Unix epoch. + review: integer days relative to deck creation timestamp.""" + self.due = due + """Time between next review and the one following. + Positive values are in days, negative in seconds.""" + self.ivl = interval + """Integer 'ease' factor used by SRS algorithm. + Example: 2500 = 250%.""" + self.factor = ease_factor + """Repititions remaining until graduation from the learning stage. + Unused during other SRS stages.""" + self.left = reps_left_til_grad + + @property + def stage(self): + return self.type + @stage.setter + def stage(self, value): + self.type = value + @property + def interval(self): + return self.ivl + @interval.setter + def interval(self, value): + self.ivl = value + @property + def ease_factor(self): + return self.factor + @ease_factor.setter + def ease_factor(self, value): + self.factor = value + @property + def reps_left_til_grad(self): + return self.left + @reps_left_til_grad.setter + def reps_left_til_grad(self, value): + self.left = value + + class Card: - def __init__(self, ord_): + def __init__(self, ord_, options=None): self.ord = ord_ + self.options = options or CardOptions() - self.interval = 0 + def write_to_db(self, cursor, now_ts, deck_id, note_id): + if self.options.status: + queue = -self.options.status + else: + queue = self.options.type - def write_to_db(self, cursor, now_ts, deck_id, note_id, - stage, queue, due, interval, ease, reps_til_grad): cursor.execute('INSERT INTO cards VALUES(null,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);', ( - note_id, # nid - note ID - deck_id, # did - deck ID - self.ord, # ord - which card template it corresponds to - now_ts, # mod - modification time as seconds since Unix epoch - -1, # usn - value of -1 indicates need to push to server - stage, # type - 0=new, 1=learning, 2=review - queue, # queue - same as type, but - # -1=suspended, -2=user buried, -3=sched buried - due, # due - new: unused - # learning: due time as integer seconds since Unix epoch - # review: integer days relative to deck creation - interval, # ivl - positive days, negative seconds - ease, # factor - integer ease factor used by SRS, 2500 = 250% - 0, # reps - number of reviews - 0, # lapses - # times card went from "answered correctly" to "answered incorrectly" - reps_til_grad, # left - reps left until graduation - 0, # odue - only used when card is in filtered deck - 0, # odid - only used when card is in filtered deck - 0, # flags - currently unused - "", # data - currently unused + note_id, # nid - note ID + deck_id, # did - deck ID + self.ord, # ord - which card template it corresponds to + now_ts, # mod - modification time as seconds since Unix epoch + -1, # usn - value of -1 indicates need to push to server + self.options.type, # type - 0=new, 1=learning, 2=review + queue, # queue - same as type, but + # -1=suspended, -2=user buried, -3=sched buried + self.options.due, # due - new: unused + # learning: due time as integer seconds since Unix epoch + # review: integer days relative to deck creation + self.options.ivl, # ivl - positive days, negative seconds + self.options.factor, # factor - integer ease factor used by SRS, 2500 = 250% + 0, # reps - number of reviews + 0, # lapses - # times card went from "answered correctly" to "answered incorrectly" + self.options.left, # left - reps left until graduation + 0, # odue - only used when card is in filtered deck + 0, # odid - only used when card is in filtered deck + 0, # flags - currently unused + "", # data - currently unused )) @@ -196,29 +249,6 @@ def __init__(self, model=None, fields=None, sort_field=None, tags=None, guid=Non # guid was defined as a property pass - ## Options ## - - """SRS learning stage. - 0 = new, 1 = learning, 2 = review.""" - self.stage = 0 - """SRS queue status modifiers. - 0 = normal, 1 = suspended, 2 = user buried, 3 = scheduler buried""" - self.status = 0 - """Behavior depends on learning stage of note. - new: unused. - learning: due time as integer seconds since Unix epoch. - review: integer days relative to deck creation timestamp.""" - self.due = 0 - """Time between next review and the one following. - Positive values are in days, negative in seconds.""" - self.interval = 0 - """Integer 'ease' factor used by SRS algorithm. - Example: 2500 = 250%.""" - self.ease = 0 - """Repititions remaining until graduation from the learning stage. - Unused during other SRS stages.""" - self.reps_til_grad = 0 - @property def sort_field(self): return self._sort_field or self.fields[0] @@ -248,6 +278,18 @@ def guid(self): def guid(self, val): self._guid = val + def set_card_options(self, options): + """If `options` is a single CardOptions, apply it to all cards. If + `options` is a list of CardOptions, apply each CardOptions to the + card of the same index. + """ + try: + for i, card in enumerate(self.cards): + card.options = options[i] + except TypeError: + for card in self.cards: + card.options = options + def write_to_db(self, cursor, now_ts, deck_id): cursor.execute('INSERT INTO notes VALUES(null,?,?,?,?,?,?,?,?,?,?);', ( self.guid, # guid @@ -263,11 +305,8 @@ def write_to_db(self, cursor, now_ts, deck_id): )) note_id = cursor.lastrowid - queue = -self.status if self.status else self.stage for card in self.cards: - card.write_to_db(cursor, now_ts, deck_id, note_id, - self.stage, queue, self.due, self.interval, - self.ease, self.reps_til_grad) + card.write_to_db(cursor, now_ts, deck_id, note_id) def _format_fields(self): return '\x1f'.join(self.fields) @@ -277,39 +316,68 @@ def _format_tags(self): class OptionsGroup: - def __init__(self, options_id=None, name=None): + def __init__( + self, options_id=None, options_group_name=None, + # Organized according to options window tabs in Anki. + ## General ## + max_time_per_answer = 60, # minutes + show_timer = False, + autoplay_audio = True, + replay_audio_for_answer = True, + ## New Cards ## + new_steps = [1, 10], # list of minute intervals per learning stage + order = 1, # option selected in dropdown + # (0 = first, 1 = second) + new_cards_per_day = 20, # days + graduating_interval = 1, # days + easy_interval = 4, # days + starting_ease = 2500, # 2500 = 250% + bury_related_new_cards = True, + ## Reviews ## + max_reviews_per_day = 100, + easy_bonus = 1.3, + interval_modifier = 1.0, + max_interval = 36500, # days + bury_related_review_cards = True, + ## Lapses ## + lapse_steps = [10], + leech_interval_multiplier = 0, + lapse_min_interval = 1, + leech_threshold = 8, + leech_action = 0, + # Used for adding arbitrary options via JSON string. Useful for + # addons. + misc = '' + ): self.options_id = options_id - self.options_group_name = name - # Organized according to options window tabs in Anki. + self.options_group_name = options_group_name ## General ## - self.max_time_per_answer = 60 # minutes - self.show_timer = False - self.autoplay_audio = True - self.replay_audio_for_answer = True + self.max_time_per_answer = max_time_per_answer + self.show_timer = show_timer + self.autoplay_audio = autoplay_audio + self.replay_audio_for_answer = replay_audio_for_answer ## New Cards ## - self.new_steps = [1, 10] # list of minute intervals per learning stage - self.order = 1 # option selected in dropdown (0 = first, 1 = second) - self.new_cards_per_day = 20 # days - self.graduating_interval = 1 # days - self.easy_interval = 4 # days - self.starting_ease = 2500 # 2500 = 250% - self.bury_related_new_cards = True + self.new_steps = new_steps + self.order = order + self.new_cards_per_day = new_cards_per_day + self.graduating_interval = graduating_interval + self.easy_interval = easy_interval + self.starting_ease = starting_ease + self.bury_related_new_cards = bury_related_new_cards ## Reviews ## - self.max_reviews_per_day = 100 - self.easy_bonus = 1.3 - self.interval_modifier = 1.0 - self.max_interval = 36500 # days - self.bury_related_review_cards = True + self.max_reviews_per_day = max_reviews_per_day + self.easy_bonus = easy_bonus + self.interval_modifier = interval_modifier + self.max_interval = max_interval + self.bury_related_review_cards = bury_related_review_cards ## Lapses ## - self.lapse_steps = [10] - self.leech_interval_multiplier = 0 - self.lapse_min_interval = 1 - self.leech_threshold = 8 - self.leech_action = 0 - - # Used for adding arbitrary options via JSON string. Useful for - # addons. - self.misc = '' + self.lapse_steps = lapse_steps + self.leech_interval_multiplier = leech_interval_multiplier + self.lapse_min_interval = lapse_min_interval + self.leech_threshold = leech_threshold + self.leech_action = leech_action + + self.misc = misc def validate(self): if self.misc and self.misc[-1] != ',': From 0a13191f57ac2b64ce48b0207b32b8618585354a Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Fri, 1 Sep 2017 23:08:02 -0700 Subject: [PATCH 13/14] Remove CardOptions and set options directly on Card or via Note. --- genanki/__init__.py | 47 +++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/genanki/__init__.py b/genanki/__init__.py index 8651773..ddecf34 100644 --- a/genanki/__init__.py +++ b/genanki/__init__.py @@ -152,9 +152,14 @@ def to_json(self, now_ts, deck_id): } -class CardOptions: - def __init__(self, stage=0, status=0, due=0, interval=0, +class Card: + def __init__(self, ord, + stage=0, status=0, due=0, interval=0, ease_factor=0, reps_left_til_grad=0): + self.ord = ord + + ## Options ## + """SRS learning stage. 0 = new, 1 = learning, 2 = review.""" self.type = stage @@ -201,17 +206,11 @@ def reps_left_til_grad(self): def reps_left_til_grad(self, value): self.left = value - -class Card: - def __init__(self, ord, options=None): - self.ord = ord - self.options = options or CardOptions() - def write_to_db(self, cursor, now_ts, deck_id, note_id): - if self.options.status: - queue = -self.options.status + if self.status: + queue = -self.status else: - queue = self.options.type + queue = self.type cursor.execute('INSERT INTO cards VALUES(null,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);', ( note_id, # nid - note ID @@ -219,17 +218,17 @@ def write_to_db(self, cursor, now_ts, deck_id, note_id): self.ord, # ord - which card template it corresponds to now_ts, # mod - modification time as seconds since Unix epoch -1, # usn - value of -1 indicates need to push to server - self.options.type, # type - 0=new, 1=learning, 2=review + self.type, # type - 0=new, 1=learning, 2=review queue, # queue - same as type, but # -1=suspended, -2=user buried, -3=sched buried - self.options.due, # due - new: unused - # learning: due time as integer seconds since Unix epoch - # review: integer days relative to deck creation - self.options.ivl, # ivl - positive days, negative seconds - self.options.factor, # factor - integer ease factor used by SRS, 2500 = 250% + self.due, # due - new: unused + # learning: due time as integer seconds since Unix epoch + # review: integer days relative to deck creation + self.ivl, # ivl - positive days, negative seconds + self.factor, # factor - integer ease factor used by SRS, 2500 = 250% 0, # reps - number of reviews 0, # lapses - # times card went from "answered correctly" to "answered incorrectly" - self.options.left, # left - reps left until graduation + self.left, # left - reps left until graduation 0, # odue - only used when card is in filtered deck 0, # odid - only used when card is in filtered deck 0, # flags - currently unused @@ -279,16 +278,18 @@ def guid(self, val): self._guid = val def set_card_options(self, options): - """If `options` is a single CardOptions, apply it to all cards. If - `options` is a list of CardOptions, apply each CardOptions to the + """If `options` is a single dict of field-value pairs, apply it to all + cards. If `options` is a list of dicts, apply each dict to the card of the same index. """ try: for i, card in enumerate(self.cards): - card.options = options[i] - except TypeError: + for option, value in options[i].items(): + setattr(card, option, value) + except KeyError: for card in self.cards: - card.options = options + for option, value in options.items(): + setattr(card, option, value) def write_to_db(self, cursor, now_ts, deck_id): cursor.execute('INSERT INTO notes VALUES(null,?,?,?,?,?,?,?,?,?,?);', ( From d9fd1902e3f0ecd59d6495e1154e44cef87a98d9 Mon Sep 17 00:00:00 2001 From: Jesse Johnson Date: Sun, 16 Sep 2018 22:15:54 -0700 Subject: [PATCH 14/14] Avoid relative commits in __init__.py --- genanki/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/genanki/__init__.py b/genanki/__init__.py index ddecf34..0993fb1 100644 --- a/genanki/__init__.py +++ b/genanki/__init__.py @@ -11,8 +11,8 @@ import yaml import zipfile -from .apkg_col import APKG_COL -from .apkg_schema import APKG_SCHEMA +from genanki.apkg_col import APKG_COL +from genanki.apkg_schema import APKG_SCHEMA BASE91_TABLE = [ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',